#include "Global.h" #include "Performance_Monitor.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr std::uint64_t kMaxTileFileSize = 32ull * 1024ull * 1024ull; enum class Tile_Load_Status { Ready, Not_Found, Not_Modified }; struct Tile_Request { std::uint32_t z; std::uint32_t x; std::uint32_t y; std::string extension; }; struct Tile_Cache_Request { std::string if_none_match; std::string if_modified_since; bool include_body; }; struct File_Cache_Info { std::uint64_t size; std::time_t modified_time; std::string etag; std::string last_modified; }; struct Tile_Load_Result { Tile_Load_Status status; std::string path; std::string content; std::string content_type; File_Cache_Info cache_info; }; std::string format_http_date(std::time_t time) { std::tm tm{}; #ifdef _WIN32 gmtime_s(&tm, &time); #else gmtime_r(&time, &tm); #endif char buffer[64]; auto size = std::strftime(buffer, sizeof(buffer), "%a, %d %b %Y %H:%M:%S GMT", &tm); return std::string(buffer, size); } std::optional get_file_cache_info(const std::filesystem::path& path) { #ifdef _WIN32 struct _stati64 info{}; if (_wstati64(path.c_str(), &info) != 0 || (info.st_mode & _S_IFMT) != _S_IFREG || info.st_size < 0) { return std::nullopt; } #else struct stat info{}; if (::stat(path.c_str(), &info) != 0 || !S_ISREG(info.st_mode) || info.st_size < 0) { return std::nullopt; } #endif File_Cache_Info result; result.size = static_cast(info.st_size); result.modified_time = info.st_mtime; result.etag = std::format("W/\"{:x}-{:x}\"", static_cast(result.modified_time), result.size); result.last_modified = format_http_date(result.modified_time); return result; } std::string_view trim_http_ows(std::string_view value) { while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { value.remove_prefix(1); } while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) { value.remove_suffix(1); } return value; } std::string_view remove_weak_prefix(std::string_view etag) { if (etag.starts_with("W/")) { etag.remove_prefix(2); } return etag; } bool etag_matches(std::string_view values, std::string_view current_etag) { while (!values.empty()) { auto pos = values.find(','); auto value = trim_http_ows(values.substr(0, pos)); if (value == "*" || remove_weak_prefix(value) == remove_weak_prefix(current_etag)) { return true; } if (pos == std::string_view::npos) { break; } values.remove_prefix(pos + 1); } return false; } bool is_not_modified(const Tile_Cache_Request& request, const File_Cache_Info& info) { if (!request.if_none_match.empty()) { return etag_matches(request.if_none_match, info.etag); } return !request.if_modified_since.empty() && request.if_modified_since == info.last_modified; } void add_tile_cache_headers(const drogon::HttpResponsePtr& response, const File_Cache_Info& info, bool include_content_length) { response->addHeader("Cache-Control", "public, max-age=86400"); response->addHeader("ETag", info.etag); response->addHeader("Last-Modified", info.last_modified); if (include_content_length) { response->addHeader("Content-Length", std::to_string(info.size)); } } bool is_unsigned_path_component(std::string_view value) { if (value.empty()) { return false; } return std::ranges::all_of(value, [](unsigned char ch) { return std::isdigit(ch) != 0; }); } std::uint32_t parse_uint32_component(std::string_view value) { std::uint64_t result = 0; for (char ch : value) { result = result * 10 + static_cast(ch - '0'); if (result > std::numeric_limits::max()) { throw std::out_of_range("tile coordinate is too large"); } } return static_cast(result); } std::optional parse_tile_request(std::string_view path, std::string_view prefix, std::string_view default_extension) { if (!path.starts_with(prefix)) { return std::nullopt; } path.remove_prefix(prefix.size()); auto first = path.find('/'); auto second = first == std::string_view::npos ? std::string_view::npos : path.find('/', first + 1); if (first == std::string_view::npos || second == std::string_view::npos || path.find('/', second + 1) != std::string_view::npos) { return std::nullopt; } auto z = path.substr(0, first); auto x = path.substr(first + 1, second - first - 1); auto y_with_extension = path.substr(second + 1); auto dot = y_with_extension.rfind('.'); auto y = dot == std::string_view::npos ? y_with_extension : y_with_extension.substr(0, dot); auto extension = dot == std::string_view::npos ? std::string(default_extension) : std::string(y_with_extension.substr(dot)); if (!is_unsigned_path_component(z) || !is_unsigned_path_component(x) || !is_unsigned_path_component(y) || extension != default_extension) { return std::nullopt; } return Tile_Request{parse_uint32_component(z), parse_uint32_component(x), parse_uint32_component(y), extension}; } class Tile_Path_Resolver { public: explicit Tile_Path_Resolver(const Map_Tile_Config& config) : config_(config), directories_(config.get_true_directories()) { if (config_.path_layout != "z/x/y" && config_.path_layout != "z/y/x") { throw std::invalid_argument("invalid tile path_layout: " + config_.path_layout); } if (config_.extension.empty() || config_.extension.front() != '.') { throw std::invalid_argument("invalid tile extension: " + config_.extension); } } std::optional find_file(const Tile_Request& request) const { if (request.z < config_.minimum_level || request.z > config_.maximum_level) { return std::nullopt; } auto relative = relative_path(request); for (const auto& directory : directories_) { auto path = std::filesystem::path(directory) / relative; if (std::filesystem::is_regular_file(path)) { return path; } } return std::nullopt; } const Map_Tile_Config& config() const { return config_; } private: std::filesystem::path relative_path(const Tile_Request& request) const { auto z = std::to_string(request.z); auto x = std::to_string(request.x); auto y = std::to_string(request.y) + config_.extension; if (config_.path_layout == "z/x/y") { return std::filesystem::path(z) / x / y; } return std::filesystem::path(z) / std::to_string(request.y) / (std::to_string(request.x) + config_.extension); } Map_Tile_Config config_; std::vector directories_; }; class Tile_Source { public: explicit Tile_Source(Map_Tile_Config config) : resolver_(config) {} asio::awaitable load(const Tile_Request& request, const Tile_Cache_Request& cache_request) const { auto path = resolver_.find_file(request); if (!path) { co_return Tile_Load_Result{Tile_Load_Status::Not_Found}; } auto cache_info = get_file_cache_info(*path); if (!cache_info) { co_return Tile_Load_Result{Tile_Load_Status::Not_Found}; } if (cache_info->size > kMaxTileFileSize || cache_info->size > std::numeric_limits::max()) { throw std::runtime_error("tile file is too large: " + path->generic_string()); } auto generic_path = path->generic_string(); auto content_type = httplib::detail::find_content_type(generic_path, {}, "application/octet-stream"); if (is_not_modified(cache_request, *cache_info)) { co_return Tile_Load_Result{Tile_Load_Status::Not_Modified, std::move(generic_path), {}, std::move(content_type), *cache_info}; } std::string content; if (cache_request.include_body) { auto executor = co_await asio::this_coro::executor; asio::random_access_file file(executor, path->generic_string(), asio::file_base::read_only); content.resize(static_cast(cache_info->size)); if (!content.empty()) { co_await asio::async_read_at(file, 0, asio::buffer(content.data(), content.size()), asio::use_awaitable); } } co_return Tile_Load_Result{Tile_Load_Status::Ready, std::move(generic_path), std::move(content), std::move(content_type), *cache_info}; } const Map_Tile_Config& config() const { return resolver_.config(); } private: Tile_Path_Resolver resolver_; }; drogon::HttpResponsePtr make_tile_not_found_response(std::string_view request_path, bool include_body) { auto response = drogon::HttpResponse::newHttpResponse(); JSON ret = JSON::object(); ret.append({"LOG_POS", LOG_POS}); ret.append({"not_found_path", std::string(request_path)}); response->setStatusCode(drogon::k404NotFound); response->setContentTypeCode(drogon::CT_TEXT_PLAIN); if (include_body) { response->setBody(ret.to_json_string()); } response->addHeader("Cache-Control", "no-store"); return response; } drogon::HttpResponsePtr make_tile_not_modified_response(const File_Cache_Info& info) { auto response = drogon::HttpResponse::newHttpResponse(); response->setStatusCode(drogon::k304NotModified); response->setContentTypeCode(drogon::CT_NONE); add_tile_cache_headers(response, info, false); return response; } drogon::HttpResponsePtr make_tile_ok_response(Tile_Load_Result&& result, bool include_body) { auto response = drogon::HttpResponse::newHttpResponse(); response->setStatusCode(drogon::k200OK); response->setContentTypeString(result.content_type); add_tile_cache_headers(response, result.cache_info, !include_body); if (include_body) { response->setBody(std::move(result.content)); } return response; } drogon::HttpResponsePtr make_tile_response(Tile_Load_Result&& result, std::string_view request_path, bool include_body) { switch (result.status) { case Tile_Load_Status::Ready: return make_tile_ok_response(std::move(result), include_body); case Tile_Load_Status::Not_Modified: return make_tile_not_modified_response(result.cache_info); case Tile_Load_Status::Not_Found: return make_tile_not_found_response(request_path, include_body); } return make_tile_not_found_response(request_path, include_body); } drogon::HttpResponsePtr make_tile_read_failed_response(const std::exception& error, bool include_body) { auto response = drogon::HttpResponse::newHttpResponse(); response->setStatusCode(drogon::k500InternalServerError); response->setContentTypeCode(drogon::CT_APPLICATION_JSON); if (include_body) { response->setBody(Psc::JSON::object({{"error", Psc::platform_2_utf8(error.what())}}).to_json_string()); } return response; } Tile_Cache_Request make_cache_request(const drogon::HttpRequestPtr& req) { return Tile_Cache_Request{ req->getHeader("if-none-match"), req->getHeader("if-modified-since"), req->method() != drogon::Head }; } void register_tile_handler(std::string_view regex, std::string_view prefix, std::shared_ptr source) { drogon::app().registerHandlerViaRegex( std::string(regex), [prefix = std::string(prefix), source = std::move(source)]( const drogon::HttpRequestPtr& req, std::function&& callback) mutable { auto cache_request = make_cache_request(req); auto request = parse_tile_request(req->path(), prefix, source->config().extension); if (!request) { callback(make_tile_not_found_response(req->path(), cache_request.include_body)); return; } drogon::async_run( [callback = std::move(callback), source, request = std::move(*request), cache_request = std::move(cache_request), path = req->path()]() mutable -> drogon::Task<> { try { auto result = co_await Ecap_Coro::to_drogon(source->load(request, cache_request)); callback(make_tile_response(std::move(result), path, cache_request.include_body)); } catch (const std::exception& error) { if (server_logger) { server_logger->error("tiles", {}, LOG_POS + " " + Psc::platform_2_utf8(error.what())); } callback(make_tile_read_failed_response(error, cache_request.include_body)); } co_return; }); }, {drogon::Get, drogon::Head}); } void register_map_resources_handler() { drogon::app().registerHandlerViaRegex( R"(^/map/resources$)", [](const drogon::HttpRequestPtr& req, std::function&& callback) { auto global = Global::instance(); auto response = drogon::HttpResponse::newHttpResponse(); if (req->method() == drogon::Post) { auto parsed = Psc::try_parse_json(req->body().data()); if (!parsed.has_value()) { response->setStatusCode(drogon::k400BadRequest); response->setContentTypeCode(drogon::CT_TEXT_PLAIN); response->setBody("invalid current_imagery_key"); callback(response); return; } auto key = parsed.value().try_get_string("current_imagery_key"); if (!key.has_value() || !global->map_resources_config.is_imagery_key(key.value())) { response->setStatusCode(drogon::k400BadRequest); response->setContentTypeCode(drogon::CT_TEXT_PLAIN); response->setBody("invalid current_imagery_key"); callback(response); return; } global->map_resources_config.current_imagery_key = key.value(); Global::save(); } response->setStatusCode(drogon::k200OK); response->setContentTypeCode(drogon::CT_APPLICATION_JSON); response->setBody(global->map_resources_config.to_public_json().to_json_string()); response->addHeader("Cache-Control", "no-store"); callback(response); }, {drogon::Get, drogon::Post}); } void register_map_graphics_handler() { drogon::app().registerHandlerViaRegex( R"(^/map/graphics$)", [](const drogon::HttpRequestPtr& req, std::function&& callback) { auto global = Global::instance(); auto response = drogon::HttpResponse::newHttpResponse(); if (req->method() == drogon::Post) { auto parsed = Psc::try_parse_json(req->body().data()); if (!parsed.has_value()) { response->setStatusCode(drogon::k400BadRequest); response->setContentTypeCode(drogon::CT_TEXT_PLAIN); response->setBody("invalid cesium_graphics"); callback(response); return; } try { global->cesium_graphics_config.from_base_json(&parsed.value()); Global::save(); } catch (const std::exception& error) { response->setStatusCode(drogon::k400BadRequest); response->setContentTypeCode(drogon::CT_TEXT_PLAIN); response->setBody(Psc::platform_2_utf8(error.what())); callback(response); return; } } response->setStatusCode(drogon::k200OK); response->setContentTypeCode(drogon::CT_APPLICATION_JSON); response->setBody(global->cesium_graphics_config.to_base_json().to_json_string()); response->addHeader("Cache-Control", "no-store"); callback(response); }, {drogon::Get, drogon::Post}); } Psc::JSON make_map_view_response(Global* global) { auto ret = global->map_view_config.to_base_json(); ret.append({"current_imagery_key", global->map_resources_config.current_imagery_key}); return ret; } bool is_multipart_request(const drogon::HttpRequestPtr& req) { auto content_type = req->getHeader("content-type"); return content_type.find("multipart/form-data") != std::string::npos; } std::string normalize_upload_extension(std::string extension) { std::ranges::transform(extension, extension.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); if (!extension.empty() && extension.front() != '.') { extension.insert(extension.begin(), '.'); } return extension; } std::filesystem::path web_model_directory(Global* global) { return std::filesystem::path(global->web_server_config.get_true_webapp()) / "model"; } std::filesystem::path source_model_directory(Global* global) { return std::filesystem::path(global->web_server_config.get_true_webapp()).parent_path() / "model"; } bool is_safe_aircraft_model_key(std::string_view key) { return !key.empty() && std::ranges::all_of(key, [](unsigned char ch) { return std::isalnum(ch) != 0 || ch == '_'; }); } std::string model_file_name(std::string_view model_type, std::string_view aircraft_model_key) { if (model_type == "aircraft") { return "aircraft-custom.glb"; } if (model_type == "aircraft_type") { if (!is_safe_aircraft_model_key(aircraft_model_key)) { throw std::invalid_argument("invalid aircraft_model_key"); } auto normalized_key = std::string(aircraft_model_key); std::ranges::replace(normalized_key, '_', '-'); return "aircraft-" + normalized_key + ".glb"; } if (model_type == "base_station") { return "base-station-custom.glb"; } throw std::invalid_argument("invalid model_type"); } void save_uploaded_model_file(const drogon::HttpFile& upload, std::string_view model_type, std::string_view aircraft_model_key, Global* global) { auto extension = normalize_upload_extension(std::string(upload.getFileExtension())); if (extension != ".glb") { throw std::invalid_argument("model file must be .glb"); } if (model_type == "aircraft_type" && !global->map_model_config.aircraft_models.contains(std::string(aircraft_model_key))) { throw std::invalid_argument("unknown aircraft_model_key"); } auto upload_dir = std::filesystem::path(drogon::app().getUploadPath()); std::filesystem::create_directories(upload_dir); auto temp_name = std::string(model_type) + "_" + std::string(aircraft_model_key) + "_model_upload.tmp"; if (upload.saveAs(temp_name) != 0) { throw std::runtime_error("cannot save uploaded model"); } auto temp_path = upload_dir / temp_name; auto file_name = model_file_name(model_type, aircraft_model_key); auto web_dir = web_model_directory(global); auto source_dir = source_model_directory(global); std::filesystem::create_directories(web_dir); std::filesystem::create_directories(source_dir); std::filesystem::copy_file(temp_path, web_dir / file_name, std::filesystem::copy_options::overwrite_existing); std::filesystem::copy_file(temp_path, source_dir / file_name, std::filesystem::copy_options::overwrite_existing); std::filesystem::remove(temp_path); auto url = "/ui/model/" + file_name; if (model_type == "aircraft") { global->map_model_config.aircraft_model.url = url; } else if (model_type == "aircraft_type") { global->map_model_config.aircraft_models[std::string(aircraft_model_key)].url = url; } else { global->map_model_config.base_station_model.url = url; } } void handle_map_model_upload(const drogon::HttpRequestPtr& req, Global* global) { drogon::MultiPartParser parser; if (parser.parse(req) != 0) { throw std::invalid_argument("invalid multipart model upload"); } auto model_type = parser.getOptionalParameter("model_type"); if (!model_type || parser.getFiles().size() != 1) { throw std::invalid_argument("model_type or file"); } auto aircraft_model_key = parser.getOptionalParameter("aircraft_model_key").value_or(""); save_uploaded_model_file(parser.getFiles().front(), *model_type, aircraft_model_key, global); Global::save(); } void handle_map_model_config_update(const drogon::HttpRequestPtr& req, Global* global) { auto parsed = Psc::try_parse_json(req->body().data()); if (!parsed.has_value()) { throw std::invalid_argument("invalid map_models"); } global->map_model_config.from_base_json(&parsed.value()); Global::save(); } void register_map_models_handler() { drogon::app().registerHandlerViaRegex( R"(^/map/models$)", [](const drogon::HttpRequestPtr& req, std::function&& callback) { auto global = Global::instance(); auto response = drogon::HttpResponse::newHttpResponse(); try { if (req->method() == drogon::Post) { if (is_multipart_request(req)) { handle_map_model_upload(req, global); } else { handle_map_model_config_update(req, global); } } response->setStatusCode(drogon::k200OK); response->setContentTypeCode(drogon::CT_APPLICATION_JSON); response->setBody(global->map_model_config.to_base_json().to_json_string()); } catch (const std::exception& error) { response->setStatusCode(drogon::k400BadRequest); response->setContentTypeCode(drogon::CT_TEXT_PLAIN); response->setBody(Psc::platform_2_utf8(error.what())); } response->addHeader("Cache-Control", "no-store"); callback(response); }, {drogon::Get, drogon::Post}); } void register_map_view_handler() { drogon::app().registerHandlerViaRegex( R"(^/map/view$)", [](const drogon::HttpRequestPtr& req, std::function&& callback) { auto global = Global::instance(); auto response = drogon::HttpResponse::newHttpResponse(); if (req->method() == drogon::Post) { auto parsed = Psc::try_parse_json(req->body().data()); if (!parsed.has_value()) { response->setStatusCode(drogon::k400BadRequest); response->setContentTypeCode(drogon::CT_TEXT_PLAIN); response->setBody("invalid map_view"); callback(response); return; } auto key = parsed.value().try_get_string("current_imagery_key"); if (!key.has_value() || !global->map_resources_config.is_imagery_key(key.value())) { response->setStatusCode(drogon::k400BadRequest); response->setContentTypeCode(drogon::CT_TEXT_PLAIN); response->setBody("invalid current_imagery_key"); callback(response); return; } try { auto next_view = global->map_view_config; next_view.from_base_json(&parsed.value()); if (!global->map_resources_config.is_imagery_key(next_view.map2d.current_imagery_key) || !global->map_resources_config.is_imagery_key(next_view.map3d.current_imagery_key)) { throw std::invalid_argument("invalid current_imagery_key"); } global->map_resources_config.current_imagery_key = key.value(); global->map_view_config = next_view; Global::save(); } catch (const std::exception& error) { response->setStatusCode(drogon::k400BadRequest); response->setContentTypeCode(drogon::CT_TEXT_PLAIN); response->setBody(Psc::platform_2_utf8(error.what())); callback(response); return; } } response->setStatusCode(drogon::k200OK); response->setContentTypeCode(drogon::CT_APPLICATION_JSON); response->setBody(make_map_view_response(global).to_json_string()); response->addHeader("Cache-Control", "no-store"); callback(response); }, {drogon::Get, drogon::Post}); } } void Global::init_tiles() { auto imagery = std::make_shared(map_resources_config.imagery); auto google_imagery = std::make_shared(map_resources_config.google_imagery); auto terrain = std::make_shared(map_resources_config.terrain); register_map_resources_handler(); register_map_graphics_handler(); register_map_models_handler(); register_map_view_handler(); register_tile_handler(R"(^/map/imagery/.*$)", "/map/imagery/", imagery); register_tile_handler(R"(^/tiles/.*$)", "/tiles/", google_imagery); register_tile_handler(R"(^/map/terrain/.*$)", "/map/terrain/", terrain); }