From 9bacae87f5e8d0346dd0944bac158260523a1f0c Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:59:13 +0000 Subject: [PATCH 01/13] Add custom Map Styles support --- README.md | 4 +- docker/tiles-cache/Dockerfile | 2 +- docker/tiles-cache/nginx.conf | 31 +- pom.xml | 4 + .../UserSettingsControllerAdvice.java | 46 +- .../controller/api/MapStyleController.java | 444 ++++++++++++- .../controller/api/TileProxyController.java | 278 ++++++++- .../settings/MapStylesSettingsController.java | 78 +++ .../reitti/dto/map/ActiveMapStyleRequest.java | 4 + .../reitti/dto/map/MapStyleConfigDTO.java | 20 + .../reitti/dto/map/MapStyleSettingsDTO.java | 9 + .../reitti/dto/map/SaveMapStyleRequest.java | 17 + .../reitti/model/map/MapStyleDataSource.java | 14 + .../model/map/MapStyleVectorOptions.java | 8 + .../reitti/model/map/UserMapStyle.java | 24 + .../repository/UserMapStyleJdbcService.java | 459 ++++++++++++++ .../reitti/service/I18nService.java | 4 + .../service/RemoteTileUrlValidator.java | 208 +++++++ .../reitti/service/SafeHttpClient.java | 79 +++ .../service/TileProxySignatureService.java | 47 ++ .../reitti/service/TileUrlUtils.java | 39 ++ .../resources/application-docker.properties | 2 + src/main/resources/application.properties | 3 +- .../db/migration/V91__add_user_map_styles.sql | 42 ++ src/main/resources/messages.properties | 107 +++- src/main/resources/static/css/main.css | 283 ++++++++- .../resources/static/css/map-controls.css | 38 +- src/main/resources/static/js/map-controls.js | 46 ++ src/main/resources/static/js/map-renderer.js | 586 ++++++++++++++++-- .../resources/static/js/map-style-settings.js | 406 ++++++++++++ .../fragments/settings-navigation.html | 6 + src/main/resources/templates/index.html | 6 + .../templates/settings/map-styles.html | 243 ++++++++ .../service/RemoteTileUrlValidatorTest.java | 64 ++ .../TileProxySignatureServiceTest.java | 27 + .../reitti/service/TileUrlUtilsTest.java | 18 + 36 files changed, 3619 insertions(+), 77 deletions(-) create mode 100644 src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java create mode 100644 src/main/java/com/dedicatedcode/reitti/dto/map/ActiveMapStyleRequest.java create mode 100644 src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java create mode 100644 src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleSettingsDTO.java create mode 100644 src/main/java/com/dedicatedcode/reitti/dto/map/SaveMapStyleRequest.java create mode 100644 src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java create mode 100644 src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java create mode 100644 src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java create mode 100644 src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java create mode 100644 src/main/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidator.java create mode 100644 src/main/java/com/dedicatedcode/reitti/service/SafeHttpClient.java create mode 100644 src/main/java/com/dedicatedcode/reitti/service/TileProxySignatureService.java create mode 100644 src/main/java/com/dedicatedcode/reitti/service/TileUrlUtils.java create mode 100644 src/main/resources/db/migration/V91__add_user_map_styles.sql create mode 100644 src/main/resources/static/js/map-style-settings.js create mode 100644 src/main/resources/templates/settings/map-styles.html create mode 100644 src/test/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidatorTest.java create mode 100644 src/test/java/com/dedicatedcode/reitti/service/TileProxySignatureServiceTest.java create mode 100644 src/test/java/com/dedicatedcode/reitti/service/TileUrlUtilsTest.java diff --git a/README.md b/README.md index 945b5bc9a..72d84cabe 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,9 @@ The included `docker-compose.yml` provides a complete setup with: | `OIDC_SIGN_UP_ENABLED` | Whether new users should be signed up automatically if they first login via the OIDC Provider. (optional) | true | false | | `PROCESSING_WAIT_TIME` | How many seconds to wait after the last data input before starting to process all unprocessed data. (⚠️ This needs to be lower than your integrated app reports data in Reitti) | 15 | 15 | | `DANGEROUS_LIFE` | Enables data management features that can reset/delete all database data (⚠️ USE WITH CAUTION) | false | true | -| `TILES_CACHE` | The url of the tile caching proxy (Set to '' to disable the cache | http://tile-cache | | +| `TILES_CACHE` | The url of the tile caching proxy (Set to '' to disable the cache) | http://tile-cache | | +| `TILE_PROXY_SIGNATURE_SECRET` | Signing secret for proxied tile URLs. Set explicitly to share sessions across instances and persist them across restarts - otherwise regenerated per instance restart. | | SOME__EXAMPLE_SECRET_123 | +| `PROXY_LOCAL_TILE_URLS` | Allow the backend/tile-cache proxy to fetch local network tile URLs. Disable unless styles need proxy access to the local network; when disabled, browsers fetch local URLs directly. | false | true | | `PROCESSING_BATCH_SIZE` | How many geo points should we handle at once. For low-memory environment it could be needed to set this to 100. | 1000 | 100 | | `SERVER_PORT` | Application server port | 8080 | 8080 | | `APP_UID` | User ID to run the application as | 1000 | 1000 | diff --git a/docker/tiles-cache/Dockerfile b/docker/tiles-cache/Dockerfile index 937677a42..426f75192 100644 --- a/docker/tiles-cache/Dockerfile +++ b/docker/tiles-cache/Dockerfile @@ -1,7 +1,7 @@ FROM nginx:alpine # Create cache directories for different tile types -RUN mkdir -p /var/cache/nginx/osm /var/cache/nginx/vector /var/cache/nginx/terrain /var/cache/nginx/satellite +RUN mkdir -p /var/cache/nginx/osm /var/cache/nginx/vector /var/cache/nginx/custom /var/cache/nginx/terrain /var/cache/nginx/satellite COPY nginx.conf /etc/nginx/nginx.conf diff --git a/docker/tiles-cache/nginx.conf b/docker/tiles-cache/nginx.conf index 0e3cd8a7a..7750c0847 100644 --- a/docker/tiles-cache/nginx.conf +++ b/docker/tiles-cache/nginx.conf @@ -63,6 +63,11 @@ http { loader_threshold=300 loader_files=200 loader_sleep=50ms manager_threshold=300 manager_files=200 manager_sleep=50ms; + proxy_cache_path /var/cache/nginx/custom + levels=1:2 keys_zone=custom_tiles:512m max_size=2g inactive=180d use_temp_path=off + loader_threshold=300 loader_files=200 loader_sleep=50ms + manager_threshold=300 manager_files=200 manager_sleep=50ms; + proxy_cache_path /var/cache/nginx/terrain levels=1:2 keys_zone=terrain_tiles:512m max_size=2g inactive=180d use_temp_path=off loader_threshold=300 loader_files=200 loader_sleep=50ms @@ -88,6 +93,8 @@ http { access_log /var/log/nginx/access.log main; server_tokens off; + # Public fallback dns can leak internal hostnames if this proxy is ever used with untrusted upstream names. + resolver 127.0.0.11 1.1.1.1 8.8.8.8 ipv6=off valid=300s; server { listen 80; @@ -128,6 +135,28 @@ http { expires 1y; } + location /custom/ { + set $custom_upstream $http_x_reitti_upstream_url; + if ($custom_upstream = "") { + return 400; + } + + proxy_pass $custom_upstream; + proxy_set_header Host $proxy_host; + proxy_set_header User-Agent "Reitti/1.0"; + proxy_ssl_server_name on; + + proxy_cache custom_tiles; + proxy_cache_key "$custom_upstream"; + proxy_cache_valid 200 302 1y; + proxy_cache_valid 404 1m; + proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504; + + add_header X-Cache-Status $upstream_cache_status; + add_header Cache-Control $tile_client_cache_control; + expires 1y; + } + location /terrain/ { proxy_pass https://tiles.mapterhorn.com/; proxy_set_header Host tiles.mapterhorn.com; @@ -166,4 +195,4 @@ http { add_header Content-Type text/plain; } } -} \ No newline at end of file +} diff --git a/pom.xml b/pom.xml index df2b9f8a7..4a1edfce5 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,10 @@ org.springframework.boot spring-boot-starter-cache + + com.github.ben-manes.caffeine + caffeine + org.locationtech.jts jts-core diff --git a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java index 4faaef52c..5ffc90859 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java @@ -10,8 +10,12 @@ import com.dedicatedcode.reitti.model.security.UserSettings; import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; import com.dedicatedcode.reitti.repository.UserJdbcService; +import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService; import com.dedicatedcode.reitti.repository.UserSettingsJdbcService; +import com.dedicatedcode.reitti.service.ContextPathHolder; import com.dedicatedcode.reitti.service.TilesCustomizationProvider; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -30,17 +34,26 @@ public class UserSettingsControllerAdvice { public static final double DEFAULT_HOME_LONGITUDE = 24.9384; private final UserJdbcService userJdbcService; private final UserSettingsJdbcService userSettingsJdbcService; + private final UserMapStyleJdbcService userMapStyleJdbcService; private final TilesCustomizationProvider tilesCustomizationProvider; private final RawLocationPointJdbcService rawLocationPointJdbcService; + private final ContextPathHolder contextPathHolder; + private final ObjectMapper objectMapper; public UserSettingsControllerAdvice(UserJdbcService userJdbcService, UserSettingsJdbcService userSettingsJdbcService, + UserMapStyleJdbcService userMapStyleJdbcService, TilesCustomizationProvider tilesCustomizationProvider, - RawLocationPointJdbcService rawLocationPointJdbcService) { + RawLocationPointJdbcService rawLocationPointJdbcService, + ContextPathHolder contextPathHolder, + ObjectMapper objectMapper) { this.userJdbcService = userJdbcService; this.userSettingsJdbcService = userSettingsJdbcService; + this.userMapStyleJdbcService = userMapStyleJdbcService; this.tilesCustomizationProvider = tilesCustomizationProvider; this.rawLocationPointJdbcService = rawLocationPointJdbcService; + this.contextPathHolder = contextPathHolder; + this.objectMapper = objectMapper; } @ModelAttribute("userSettings") @@ -109,6 +122,37 @@ public UserSettingsDTO getCurrentUserSettings() { null); } + @ModelAttribute("mapStylesJson") + public String getCurrentUserMapStylesJson() { + return getCurrentUser().map(user -> { + try { + return objectMapper.writeValueAsString(userMapStyleJdbcService.getSettings(user, normalizedContextPath()).customStyles()); + } catch (JsonProcessingException e) { + return "[]"; + } + }).orElse("[]"); + } + + @ModelAttribute("activeMapStyleId") + public String getCurrentUserActiveMapStyleId() { + return getCurrentUser() + .map(userMapStyleJdbcService::getActiveStyleId) + .orElse("reitti"); + } + + private Optional getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { + return Optional.empty(); + } + return userJdbcService.findByUsername(authentication.getName()); + } + + private String normalizedContextPath() { + String contextPath = contextPathHolder.getContextPath(); + return "/".equals(contextPath) ? "" : contextPath; + } + private UserSettingsDTO.UIMode mapUserToUiMode(Authentication authentication) { List grantedRoles = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(); if (grantedRoles.contains("ROLE_ADMIN") || grantedRoles.contains("ROLE_USER") || grantedRoles.contains("ROLE_API_ACCESS")) { diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java index 9b0442801..3884a3ff0 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java @@ -1,8 +1,16 @@ package com.dedicatedcode.reitti.controller.api; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions; +import com.dedicatedcode.reitti.model.map.UserMapStyle; +import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService; import com.dedicatedcode.reitti.repository.UserSettingsJdbcService; import com.dedicatedcode.reitti.service.ContextPathHolder; +import com.dedicatedcode.reitti.service.RemoteTileUrlValidator; +import com.dedicatedcode.reitti.service.SafeHttpClient; +import com.dedicatedcode.reitti.service.TileProxySignatureService; +import com.dedicatedcode.reitti.service.TileUrlUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -16,30 +24,62 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.core.io.ClassPathResource; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Optional; import java.util.concurrent.TimeUnit; @RestController @RequestMapping("/map") public class MapStyleController { + private static final String VECTOR_TILEJSON_URL = "https://tiles.dedicatedcode.com/planet"; + private static final String TERRAIN_TILE_URL = "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp"; + private static final String SATELLITE_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"; + private static final String RUNTIME_TERRAIN_SOURCE = "reitti-terrain-source"; + private static final String RUNTIME_SATELLITE_SOURCE = "reitti-satellite-source"; + private static final String RUNTIME_BUILDING_SOURCE = "reitti-building-source"; private final ObjectMapper objectMapper; private final ContextPathHolder contextPathHolder; private final UserSettingsJdbcService userSettingsJdbcService; + private final UserMapStyleJdbcService userMapStyleJdbcService; + private final TileProxySignatureService tileProxySignatureService; + private final RemoteTileUrlValidator remoteTileUrlValidator; + private final SafeHttpClient safeHttpClient; + private final HttpClient httpClient; private final boolean tileCacheEnabled; public MapStyleController( ObjectMapper objectMapper, ContextPathHolder contextPathHolder, UserSettingsJdbcService userSettingsJdbcService, + UserMapStyleJdbcService userMapStyleJdbcService, + TileProxySignatureService tileProxySignatureService, + RemoteTileUrlValidator remoteTileUrlValidator, + SafeHttpClient safeHttpClient, @Value("${reitti.ui.tiles.cache.url:}") String cacheUrl) { this.objectMapper = objectMapper; this.contextPathHolder = contextPathHolder; this.userSettingsJdbcService = userSettingsJdbcService; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.tileProxySignatureService = tileProxySignatureService; + this.remoteTileUrlValidator = remoteTileUrlValidator; + this.safeHttpClient = safeHttpClient; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); this.tileCacheEnabled = StringUtils.hasText(cacheUrl); } @@ -55,6 +95,183 @@ public ResponseEntity getStyle(@AuthenticationPrincipal User user, Htt style = objectMapper.readTree(resource.getInputStream()); } + return buildStyleResponse(style, request); + } + + @GetMapping(value = "/custom/{id}.json", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getUserCustomStyle(@AuthenticationPrincipal User user, @PathVariable long id, HttpServletRequest request) throws IOException, InterruptedException { + Optional style = userMapStyleJdbcService.findById(user, id); + if (style.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + try { + JsonNode styleJson = readUserStyle(style.get()); + styleJson = applyVectorOptions(styleJson, style.get().vectorOptions()); + styleJson = applyCustomDataSource(styleJson, style.get().dataSource()); + return buildStyleResponse(styleJson, request); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } catch (IOException e) { + return ResponseEntity.notFound().build(); + } + } + + private JsonNode readUserStyle(UserMapStyle style) throws IOException, InterruptedException { + if ("raster".equals(style.mapType())) { + return buildRasterStyle(style); + } + + if (StringUtils.hasText(style.styleJson())) { + return objectMapper.readTree(style.styleJson()); + } + + HttpRequest request = HttpRequest.newBuilder(remoteTileUrlValidator.requirePublicHttpUrl(style.styleUrl(), "Vector style URL")) + .timeout(Duration.ofSeconds(20)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = safeHttpClient.sendFollowingPublicRedirects( + httpClient, + request, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8), + "Vector style URL" + ); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Unable to fetch map style: " + response.statusCode()); + } + + JsonNode json = objectMapper.readTree(response.body()); + if (json instanceof ObjectNode objectNode) { + ObjectNode metadata = objectNode.has("metadata") && objectNode.get("metadata") instanceof ObjectNode existing + ? existing + : objectMapper.createObjectNode(); + metadata.put("reitti:style-url", style.styleUrl()); + objectNode.set("metadata", metadata); + } + return json; + } + + private JsonNode buildRasterStyle(UserMapStyle style) { + MapStyleDataSource dataSource = style.dataSource(); + ObjectNode styleJson = objectMapper.createObjectNode(); + styleJson.put("version", 8); + styleJson.put("name", style.name()); + + ObjectNode sources = objectMapper.createObjectNode(); + ObjectNode source = objectMapper.createObjectNode(); + source.put("type", "raster"); + if (StringUtils.hasText(dataSource.tileJsonUrl())) { + source.put("url", dataSource.tileJsonUrl()); + } else if (StringUtils.hasText(dataSource.tileUrlTemplate())) { + ArrayNode tiles = objectMapper.createArrayNode(); + tiles.add(dataSource.tileUrlTemplate()); + source.set("tiles", tiles); + } + if (StringUtils.hasText(dataSource.attribution())) { + source.put("attribution", dataSource.attribution()); + } + source.put("minzoom", dataSource.minzoom() != null ? dataSource.minzoom() : 0); + source.put("maxzoom", dataSource.maxzoom() != null ? dataSource.maxzoom() : 19); + source.put("tileSize", effectiveRasterTileSize(dataSource)); + source.put("scheme", StringUtils.hasText(dataSource.scheme()) ? dataSource.scheme() : "xyz"); + sources.set("custom-raster-source", source); + styleJson.set("sources", sources); + + ArrayNode layers = objectMapper.createArrayNode(); + ObjectNode rasterLayer = objectMapper.createObjectNode(); + rasterLayer.put("id", "custom-raster-layer"); + rasterLayer.put("type", "raster"); + rasterLayer.put("source", "custom-raster-source"); + layers.add(rasterLayer); + styleJson.set("layers", layers); + + return styleJson; + } + + private int effectiveRasterTileSize(MapStyleDataSource dataSource) { + if (StringUtils.hasText(dataSource.tileUrlTemplate())) { + String tileUrlTemplate = dataSource.tileUrlTemplate(); + if (tileUrlTemplate.contains("{r}") || tileUrlTemplate.contains("@2x")) { + return 256; + } + } + return dataSource.tileSize() != null ? dataSource.tileSize() : 256; + } + + private JsonNode applyVectorOptions(JsonNode style, MapStyleVectorOptions options) { + if (options == null) { + return style; + } + + ObjectNode mutableStyle = style.deepCopy(); + if (StringUtils.hasText(options.glyphsUrlOverride())) { + mutableStyle.put("glyphs", options.glyphsUrlOverride()); + } + if (StringUtils.hasText(options.spriteUrlOverride())) { + mutableStyle.put("sprite", options.spriteUrlOverride()); + } + if (StringUtils.hasText(options.attributionOverride())) { + ObjectNode metadata = mutableStyle.has("metadata") && mutableStyle.get("metadata") instanceof ObjectNode existing + ? existing + : objectMapper.createObjectNode(); + metadata.put("reitti:attribution-override", options.attributionOverride()); + mutableStyle.set("metadata", metadata); + + JsonNode sources = mutableStyle.get("sources"); + if (sources instanceof ObjectNode sourcesObject) { + sourcesObject.fields().forEachRemaining(entry -> { + if (entry.getValue() instanceof ObjectNode source) { + source.put("attribution", options.attributionOverride()); + } + }); + } + } + return mutableStyle; + } + + private JsonNode applyCustomDataSource(JsonNode style, MapStyleDataSource dataSource) { + if (dataSource == null || !StringUtils.hasText(dataSource.sourceId())) { + return style; + } + if (!StringUtils.hasText(dataSource.tileJsonUrl()) && !StringUtils.hasText(dataSource.tileUrlTemplate())) { + return style; + } + + ObjectNode mutableStyle = style.deepCopy(); + ObjectNode sources = ensureSourcesNode(mutableStyle); + ObjectNode source = objectMapper.createObjectNode(); + source.put("type", StringUtils.hasText(dataSource.type()) ? dataSource.type() : "vector"); + if (StringUtils.hasText(dataSource.tileJsonUrl())) { + source.put("url", dataSource.tileJsonUrl()); + } + if (StringUtils.hasText(dataSource.tileUrlTemplate())) { + ArrayNode tiles = objectMapper.createArrayNode(); + tiles.add(dataSource.tileUrlTemplate()); + source.set("tiles", tiles); + } + if (StringUtils.hasText(dataSource.attribution())) { + source.put("attribution", dataSource.attribution()); + } + if (dataSource.minzoom() != null) { + source.put("minzoom", dataSource.minzoom()); + } + if (dataSource.maxzoom() != null) { + source.put("maxzoom", dataSource.maxzoom()); + } + if (dataSource.tileSize() != null) { + source.put("tileSize", dataSource.tileSize()); + } + if (StringUtils.hasText(dataSource.scheme())) { + source.put("scheme", dataSource.scheme()); + } + sources.set(dataSource.sourceId(), source); + return mutableStyle; + } + + private ResponseEntity buildStyleResponse(JsonNode style, HttpServletRequest request) { + style = ensureRuntimeSources(style, request); + if (this.tileCacheEnabled) { style = rewriteUrlsForProxy(style, request); } @@ -64,15 +281,79 @@ public ResponseEntity getStyle(@AuthenticationPrincipal User user, Htt } return ResponseEntity.ok() - .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)) - .body(style); + .cacheControl(CacheControl.noCache().cachePrivate()) + .body(style); + } + + private JsonNode ensureRuntimeSources(JsonNode style, HttpServletRequest request) { + ObjectNode mutableStyle = style.deepCopy(); + ObjectNode mutableSources = ensureSourcesNode(mutableStyle); + String baseUrl = getBaseUrl(request); + + if (!mutableSources.has(RUNTIME_TERRAIN_SOURCE)) { + ObjectNode terrainSource = objectMapper.createObjectNode(); + terrainSource.put("type", "raster-dem"); + ArrayNode tiles = objectMapper.createArrayNode(); + tiles.add(this.tileCacheEnabled ? baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp" : TERRAIN_TILE_URL); + terrainSource.set("tiles", tiles); + terrainSource.put("tileSize", 256); + terrainSource.put("encoding", "terrarium"); + terrainSource.put("maxzoom", 14); + terrainSource.put("attribution", "© Mapterhorn"); + mutableSources.set(RUNTIME_TERRAIN_SOURCE, terrainSource); + } + + if (!mutableSources.has(RUNTIME_SATELLITE_SOURCE)) { + ObjectNode satelliteSource = objectMapper.createObjectNode(); + satelliteSource.put("type", "raster"); + ArrayNode tiles = objectMapper.createArrayNode(); + tiles.add(this.tileCacheEnabled ? baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg" : SATELLITE_TILE_URL); + satelliteSource.set("tiles", tiles); + satelliteSource.put("tileSize", 256); + satelliteSource.put("maxzoom", 18); + satelliteSource.put("attribution", "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community"); + mutableSources.set(RUNTIME_SATELLITE_SOURCE, satelliteSource); + } + + if (!styleHasBuildingLayer(mutableStyle) && !mutableSources.has(RUNTIME_BUILDING_SOURCE)) { + ObjectNode buildingSource = objectMapper.createObjectNode(); + buildingSource.put("type", "vector"); + buildingSource.put("url", VECTOR_TILEJSON_URL); + buildingSource.put("minzoom", 0); + buildingSource.put("maxzoom", 14); + buildingSource.put("attribution", "© OpenFreeMap © OSM"); + mutableSources.set(RUNTIME_BUILDING_SOURCE, buildingSource); + } + + return mutableStyle; + } + + private boolean styleHasBuildingLayer(ObjectNode style) { + JsonNode layers = style.get("layers"); + if (!(layers instanceof ArrayNode layerArray)) { + return false; + } + + for (JsonNode layer : layerArray) { + String layerType = layer.path("type").asText(""); + if (!"fill".equals(layerType) && !"fill-extrusion".equals(layerType)) { + continue; + } + String layerId = layer.path("id").asText("").toLowerCase(); + String sourceLayer = layer.path("source-layer").asText("").toLowerCase(); + if (layerId.contains("building") || sourceLayer.contains("building")) { + return true; + } + } + return false; } private JsonNode rewriteResourceUrls(JsonNode style, HttpServletRequest request) { ObjectNode mutableStyle = style.deepCopy(); - // Rewrite sources - TextNode glyphs = (TextNode) mutableStyle.get("glyphs"); - mutableStyle.set("glyphs", new TextNode(this.contextPathHolder.getContextPath() + glyphs.asText())); + JsonNode glyphs = mutableStyle.get("glyphs"); + if (glyphs instanceof TextNode glyphsText && glyphsText.asText().startsWith("/")) { + mutableStyle.set("glyphs", new TextNode(this.contextPathHolder.getContextPath() + glyphsText.asText())); + } return mutableStyle; } @@ -80,31 +361,156 @@ private JsonNode rewriteUrlsForProxy(JsonNode style, HttpServletRequest request) ObjectNode mutableStyle = style.deepCopy(); String baseUrl = getBaseUrl(request); - // Rewrite sources JsonNode sources = mutableStyle.get("sources"); - if (sources != null) { + if (sources instanceof ObjectNode) { ObjectNode mutableSources = (ObjectNode) sources; + URI styleBaseUri = getStyleBaseUri(mutableStyle).orElse(null); - // Handle vector tiles (OpenFreeMap) - if (mutableSources.has("openmaptiles")) { - ObjectNode openMapTiles = (ObjectNode) mutableSources.get("openmaptiles"); - openMapTiles.remove("url"); - ArrayNode tiles = objectMapper.createArrayNode(); - tiles.add(baseUrl + "/api/v1/tiles/vector/{z}/{x}/{y}.pbf"); - openMapTiles.set("tiles", tiles); - } - - // Handle raster sources + mutableSources.fields().forEachRemaining(entry -> { + if (entry.getValue() instanceof ObjectNode source) { + String sourceType = source.path("type").asText(); + if ("vector".equals(sourceType)) { + rewriteVectorSource(source, baseUrl, styleBaseUri); + } else if ("raster".equals(sourceType)) { + rewriteCustomRasterSource(source, baseUrl, styleBaseUri); + } + } + }); + rewriteRasterSource(mutableSources, "terrain-source", baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"); rewriteRasterSource(mutableSources, "satellite-source", baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"); + rewriteRasterSource(mutableSources, RUNTIME_TERRAIN_SOURCE, baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"); + rewriteRasterSource(mutableSources, RUNTIME_SATELLITE_SOURCE, baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"); } return mutableStyle; } + private void rewriteVectorSource(ObjectNode source, String baseUrl, URI styleBaseUri) { + String sourceUrl = source.path("url").asText(""); + String firstTileUrl = ""; + JsonNode tiles = source.get("tiles"); + if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { + firstTileUrl = tileArray.get(0).asText(""); + } + + if (sourceUrl.contains("tiles.dedicatedcode.com") || firstTileUrl.contains("tiles.dedicatedcode.com")) { + source.remove("url"); + ArrayNode rewrittenTiles = objectMapper.createArrayNode(); + rewrittenTiles.add(baseUrl + "/api/v1/tiles/vector/{z}/{x}/{y}.pbf"); + source.set("tiles", rewrittenTiles); + return; + } + + if (sourceUrl.startsWith("http://") || sourceUrl.startsWith("https://")) { + if (!remoteTileUrlValidator.isServerFetchAllowedUrl(sourceUrl)) { + return; + } + String encodedSourceUrl = encodeTileTemplate(sourceUrl); + source.set("url", new TextNode(baseUrl + "/api/v1/tiles/custom/tilejson/" + encodedSourceUrl + "/" + tileProxySignatureService.sign(encodedSourceUrl) + ".json")); + return; + } + + if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { + ArrayNode rewrittenTiles = objectMapper.createArrayNode(); + tileArray.forEach(tile -> { + String tileUrl = tile.asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(tileUrl) ? customTileUrl(baseUrl, tileUrl) : tileUrl); + } else if (styleBaseUri != null && containsTilePlaceholders(tileUrl)) { + String resolvedTileUrl = styleBaseUri.resolve(tileUrl).toString(); + rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(resolvedTileUrl) ? customTileUrl(baseUrl, resolvedTileUrl) : tileUrl); + } else { + rewrittenTiles.add(tileUrl); + } + }); + source.set("tiles", rewrittenTiles); + } + } + + private void rewriteCustomRasterSource(ObjectNode source, String baseUrl, URI styleBaseUri) { + String sourceUrl = source.path("url").asText(""); + if (sourceUrl.startsWith("http://") || sourceUrl.startsWith("https://")) { + if (!remoteTileUrlValidator.isServerFetchAllowedUrl(sourceUrl)) { + return; + } + String encodedSourceUrl = encodeTileTemplate(sourceUrl); + source.set("url", new TextNode(baseUrl + "/api/v1/tiles/custom/tilejson/" + encodedSourceUrl + "/" + tileProxySignatureService.sign(encodedSourceUrl) + ".json")); + return; + } + + JsonNode tiles = source.get("tiles"); + if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { + ArrayNode rewrittenTiles = objectMapper.createArrayNode(); + tileArray.forEach(tile -> { + String tileUrl = tile.asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(tileUrl) ? customTileUrl(baseUrl, tileUrl) : tileUrl); + } else if (styleBaseUri != null && containsTilePlaceholders(tileUrl)) { + String resolvedTileUrl = styleBaseUri.resolve(tileUrl).toString(); + rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(resolvedTileUrl) ? customTileUrl(baseUrl, resolvedTileUrl) : tileUrl); + } else { + rewrittenTiles.add(tileUrl); + } + }); + source.set("tiles", rewrittenTiles); + } + } + + private String customTileUrl(String baseUrl, String tileUrl) { + String normalizedTileUrl = normalizeTileTemplateForProxy(tileUrl); + String encodedTileUrl = encodeTileTemplate(normalizedTileUrl); + return baseUrl + "/api/v1/tiles/custom/" + encodedTileUrl + "/" + tileProxySignatureService.sign(encodedTileUrl) + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + } + + private String normalizeTileTemplateForProxy(String tileUrl) { + return tileUrl.replace("{r}", ""); + } + + private String encodeTileTemplate(String tileUrl) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(tileUrl.getBytes(StandardCharsets.UTF_8)); + } + + private ObjectNode ensureSourcesNode(ObjectNode mutableStyle) { + JsonNode sources = mutableStyle.get("sources"); + if (sources instanceof ObjectNode sourcesObject) { + return sourcesObject; + } + + ObjectNode sourcesObject = objectMapper.createObjectNode(); + mutableStyle.set("sources", sourcesObject); + return sourcesObject; + } + + private Optional getStyleBaseUri(ObjectNode style) { + JsonNode metadata = style.get("metadata"); + if (!(metadata instanceof ObjectNode metadataObject)) { + return Optional.empty(); + } + + String styleUrl = metadataObject.path("reitti:style-url").asText(""); + if (!StringUtils.hasText(styleUrl)) { + return Optional.empty(); + } + + try { + return Optional.of(URI.create(styleUrl)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private boolean containsTilePlaceholders(String tileUrl) { + return tileUrl.contains("{z}") && tileUrl.contains("{x}") && tileUrl.contains("{y}"); + } + private void rewriteRasterSource(ObjectNode sources, String sourceName, String tileUrl) { - if (sources.has(sourceName)) { - ObjectNode source = (ObjectNode) sources.get(sourceName); + if (sources.has(sourceName) && sources.get(sourceName) instanceof ObjectNode source) { + if ("satellite-source".equals(sourceName) || RUNTIME_SATELLITE_SOURCE.equals(sourceName)) { + source.put("type", "raster"); + } ArrayNode tiles = objectMapper.createArrayNode(); tiles.add(tileUrl); source.set("tiles", tiles); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java index 838f21886..1de5eb583 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java @@ -1,6 +1,14 @@ package com.dedicatedcode.reitti.controller.api; import com.dedicatedcode.reitti.config.ConditionalOnPropertyNotEmpty; +import com.dedicatedcode.reitti.service.RemoteTileUrlValidator; +import com.dedicatedcode.reitti.service.SafeHttpClient; +import com.dedicatedcode.reitti.service.TileProxySignatureService; +import com.dedicatedcode.reitti.service.TileUrlUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -9,27 +17,40 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.Base64; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; @RestController @RequestMapping("/api/v1/tiles") @ConditionalOnPropertyNotEmpty("reitti.ui.tiles.cache.url") public class TileProxyController { private static final Logger log = LoggerFactory.getLogger(TileProxyController.class); + private static final String CUSTOM_UPSTREAM_HEADER = "X-Reitti-Upstream-Url"; + private static final Duration PRIVATE_TILE_DIRECT_TIMEOUT = Duration.ofSeconds(5); private final HttpClient httpClient; private final String tileCacheUrl; + private final ObjectMapper objectMapper; + private final TileProxySignatureService tileProxySignatureService; + private final RemoteTileUrlValidator remoteTileUrlValidator; + private final SafeHttpClient safeHttpClient; // Maps source names to internal paths and coordinate ordering private record SourceConfig(String path, boolean swapXY, String contentType) {} @@ -42,10 +63,20 @@ private record SourceConfig(String path, boolean swapXY, String contentType) {} "satellite", new SourceConfig("/satellite/", true, "image/jpeg") ); - public TileProxyController(@Value("${reitti.ui.tiles.cache.url}") String tileCacheUrl) { + public TileProxyController( + @Value("${reitti.ui.tiles.cache.url}") String tileCacheUrl, + ObjectMapper objectMapper, + TileProxySignatureService tileProxySignatureService, + RemoteTileUrlValidator remoteTileUrlValidator, + SafeHttpClient safeHttpClient) { this.tileCacheUrl = tileCacheUrl; + this.objectMapper = objectMapper; + this.tileProxySignatureService = tileProxySignatureService; + this.remoteTileUrlValidator = remoteTileUrlValidator; + this.safeHttpClient = safeHttpClient; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NEVER) .build(); } @@ -58,6 +89,89 @@ public ResponseEntity getTileLegacy( return getTile("raster", z, x, y, "png", request); } + @GetMapping("/custom/tilejson/{encodedTileJsonUrl}/{signature}.json") + public ResponseEntity getCustomTileJson( + @PathVariable String encodedTileJsonUrl, + @PathVariable String signature, + HttpServletRequest request) { + + try { + if (!tileProxySignatureService.isValid(encodedTileJsonUrl, signature)) { + return ResponseEntity.status(403).build(); + } + + String tileJsonUrl = decodeTemplate(encodedTileJsonUrl); + URI tileJsonUri = remoteTileUrlValidator.requirePublicHttpUrl(tileJsonUrl, "Custom TileJSON URL"); + + HttpResponse response = fetchPublicUrl(tileJsonUrl, "Custom TileJSON URL"); + if (response.statusCode() != 200) { + log.debug("Failed to fetch custom TileJSON [{}]: HTTP {}", tileJsonUri, response.statusCode()); + return ResponseEntity.notFound().build(); + } + + JsonNode tileJson = objectMapper.readTree(responseBody(response)); + if (tileJson instanceof ObjectNode mutableTileJson && mutableTileJson.get("tiles") instanceof ArrayNode tiles) { + ArrayNode rewrittenTiles = objectMapper.createArrayNode(); + tiles.forEach(tile -> { + String tileUrl = tile.asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(tileUrl) ? customTileUrl(request, tileUrl) : tileUrl); + } else if (!tileUrl.isBlank()) { + String resolvedTileUrl = tileJsonUri.resolve(tileUrl).toString(); + rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(resolvedTileUrl) ? customTileUrl(request, resolvedTileUrl) : tileUrl); + } else { + rewrittenTiles.add(tileUrl); + } + }); + mutableTileJson.set("tiles", rewrittenTiles); + } + + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().cachePrivate()) + .body(tileJson); + } catch (Exception e) { + log.warn("Failed to fetch custom TileJSON [{}]: {}", encodedTileJsonUrl, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/custom/{encodedTemplate}/{signature}/{z}/{x}/{y}.{ext}") + public ResponseEntity getCustomTile( + @PathVariable String encodedTemplate, + @PathVariable String signature, + @PathVariable int z, + @PathVariable int x, + @PathVariable int y, + @PathVariable String ext) { + + try { + if (!tileProxySignatureService.isValid(encodedTemplate, signature)) { + return ResponseEntity.status(403).build(); + } + + String template = decodeTemplate(encodedTemplate); + String upstreamTileUrl = template + .replace("{z}", String.valueOf(z)) + .replace("{x}", String.valueOf(x)) + .replace("{y}", String.valueOf(y)) + .replace("{r}", ""); + + URI upstreamTileUri = remoteTileUrlValidator.requirePublicHttpUrl(upstreamTileUrl, "Custom tile URL"); + + if (remoteTileUrlValidator.isValidLocalUrl(upstreamTileUrl)) { + log.trace("Fetching private custom tile directly [{}]: {}", encodedTemplate, upstreamTileUri); + return fetchPrivateTile(upstreamTileUrl, contentTypeForExtension(ext)); + } + + String tileUrl = tileCacheUrl + "/custom/"; + log.trace("Fetching custom tile [{}]: {}", encodedTemplate, upstreamTileUri); + return fetchTile(tileUrl, contentTypeForExtension(ext), "custom", Map.of(CUSTOM_UPSTREAM_HEADER, upstreamTileUrl)); + } catch (IllegalArgumentException e) { + log.warn("Failed to decode custom tile template [{}]: {}", encodedTemplate, e.getMessage()); + return ResponseEntity.badRequest().build(); + } + } + @GetMapping("/{source}/{z}/{x}/{y}.{ext}") public ResponseEntity getTile( @PathVariable String source, @@ -78,35 +192,173 @@ public ResponseEntity getTile( : String.format("%d/%d/%d.%s", z, x, y, ext); String tileUrl = tileCacheUrl + config.path() + coordPath; + if (StringUtils.hasText(request.getQueryString())) { + tileUrl += "?" + request.getQueryString(); + } log.trace("Fetching tile [{}]: {}", source, coordPath); - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(tileUrl)) - .timeout(Duration.ofSeconds(30)) - .GET(); + return fetchTile(tileUrl, config.contentType(), source); - HttpResponse response = httpClient.send( - requestBuilder.build(), - HttpResponse.BodyHandlers.ofByteArray() - ); + } catch (Exception e) { + log.warn("Failed to fetch tile {}/{}/{} from {}: {}", x, y, z, source, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } + + private ResponseEntity fetchTile(String tileUrl, String contentType, String source) { + return fetchTile(tileUrl, contentType, source, Map.of()); + } + + private ResponseEntity fetchTile(String tileUrl, String contentType, String source, Map requestHeaders) { + try { + HttpResponse response = fetchRaw(tileUrl, requestHeaders); if (response.statusCode() == 200) { HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType(config.contentType())); + headers.setContentType(MediaType.parseMediaType(contentType)); headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic()); headers.add("Access-Control-Allow-Origin", "*"); - + response.headers() + .firstValue(HttpHeaders.CONTENT_ENCODING) + .ifPresent(contentEncoding -> headers.add(HttpHeaders.CONTENT_ENCODING, contentEncoding)); + return ResponseEntity.ok() .headers(headers) .body(response.body()); } else { - log.debug("Failed to fetch tile {}/{}/{} from {}: HTTP {}", x, y, z, source, response.statusCode()); + log.debug("Failed to fetch tile from {}: HTTP {}", source, response.statusCode()); return ResponseEntity.notFound().build(); } + } catch (Exception e) { + log.warn("Failed to fetch tile from {}: {}", source, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } + + private ResponseEntity fetchPrivateTile(String upstreamTileUrl, String contentType) { + try { + HttpResponse response = fetchRaw(upstreamTileUrl, Map.of(), PRIVATE_TILE_DIRECT_TIMEOUT); + + if (response.statusCode() == 200) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(contentType)); + headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic()); + headers.add("Access-Control-Allow-Origin", "*"); + response.headers() + .firstValue(HttpHeaders.CONTENT_ENCODING) + .ifPresent(contentEncoding -> headers.add(HttpHeaders.CONTENT_ENCODING, contentEncoding)); + + return ResponseEntity.ok() + .headers(headers) + .body(response.body()); + } + log.debug("Failed to fetch private custom tile directly: HTTP {}", response.statusCode()); + return ResponseEntity.notFound().build(); } catch (Exception e) { - log.warn("Failed to fetch tile {}/{}/{} from {}: {}", x, y, z, source, e.getMessage()); + log.warn("Failed to fetch private custom tile directly: {}", e.getMessage()); return ResponseEntity.notFound().build(); } } + + private HttpResponse fetchRaw(String url) throws Exception { + return fetchRaw(url, Map.of()); + } + + private HttpResponse fetchRaw(String url, Map headers) throws Exception { + return fetchRaw(url, headers, Duration.ofSeconds(30)); + } + + private HttpResponse fetchRaw(String url, Map headers, Duration timeout) throws Exception { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(timeout) + .header(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate") + .GET(); + headers.forEach(requestBuilder::header); + + return httpClient.send( + requestBuilder.build(), + HttpResponse.BodyHandlers.ofByteArray() + ); + } + + private HttpResponse fetchPublicUrl(String url, String fieldName) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(30)) + .header(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate") + .GET() + .build(); + + return safeHttpClient.sendFollowingPublicRedirects( + httpClient, + request, + HttpResponse.BodyHandlers.ofByteArray(), + fieldName + ); + } + + private byte[] responseBody(HttpResponse response) throws IOException { + String contentEncoding = response.headers().firstValue(HttpHeaders.CONTENT_ENCODING).orElse(""); + if (contentEncoding.equalsIgnoreCase("gzip")) { + try (GZIPInputStream gzipInputStream = new GZIPInputStream(new ByteArrayInputStream(response.body()))) { + return gzipInputStream.readAllBytes(); + } + } + if (contentEncoding.equalsIgnoreCase("deflate")) { + try (InflaterInputStream inflaterInputStream = new InflaterInputStream(new ByteArrayInputStream(response.body()))) { + return inflaterInputStream.readAllBytes(); + } + } + return response.body(); + } + + private String customTileUrl(HttpServletRequest request, String tileUrl) { + String normalizedTileUrl = normalizeTileTemplateForProxy(tileUrl); + String encodedTemplate = encodeTemplate(normalizedTileUrl); + return getBaseUrl(request) + "/api/v1/tiles/custom/" + encodedTemplate + "/" + tileProxySignatureService.sign(encodedTemplate) + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + } + + private String normalizeTileTemplateForProxy(String tileUrl) { + return tileUrl.replace("{r}", ""); + } + + private String encodeTemplate(String template) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(template.getBytes(StandardCharsets.UTF_8)); + } + + private String decodeTemplate(String encodedTemplate) { + return new String(Base64.getUrlDecoder().decode(encodedTemplate), StandardCharsets.UTF_8); + } + + private String getBaseUrl(HttpServletRequest request) { + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + String contextPath = request.getContextPath(); + + StringBuilder url = new StringBuilder(); + url.append(scheme).append("://").append(serverName); + + if ((scheme.equals("http") && serverPort != 80) || + (scheme.equals("https") && serverPort != 443)) { + url.append(":").append(serverPort); + } + + url.append(contextPath); + return url.toString(); + } + + private String contentTypeForExtension(String ext) { + return switch (ext.toLowerCase()) { + case "pbf", "mvt" -> "application/x-protobuf"; + case "png" -> MediaType.IMAGE_PNG_VALUE; + case "jpg", "jpeg" -> MediaType.IMAGE_JPEG_VALUE; + case "webp" -> "image/webp"; + default -> MediaType.APPLICATION_OCTET_STREAM_VALUE; + }; + } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java new file mode 100644 index 000000000..b40b2a75d --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java @@ -0,0 +1,78 @@ +package com.dedicatedcode.reitti.controller.settings; + +import com.dedicatedcode.reitti.dto.map.ActiveMapStyleRequest; +import com.dedicatedcode.reitti.dto.map.MapStyleSettingsDTO; +import com.dedicatedcode.reitti.dto.map.SaveMapStyleRequest; +import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.map.UserMapStyle; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService; +import com.dedicatedcode.reitti.service.ContextPathHolder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +@Controller +@RequestMapping("/settings/map-styles") +public class MapStylesSettingsController { + private final boolean dataManagementEnabled; + private final UserMapStyleJdbcService userMapStyleJdbcService; + private final ContextPathHolder contextPathHolder; + + public MapStylesSettingsController( + @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, + UserMapStyleJdbcService userMapStyleJdbcService, + ContextPathHolder contextPathHolder) { + this.dataManagementEnabled = dataManagementEnabled; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.contextPathHolder = contextPathHolder; + } + + @GetMapping + public String getPage(@AuthenticationPrincipal User user, Model model) { + model.addAttribute("activeSection", "map-styles"); + model.addAttribute("dataManagementEnabled", dataManagementEnabled); + model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); + return "settings/map-styles"; + } + + @GetMapping("/api") + @ResponseBody + public MapStyleSettingsDTO getSettings(@AuthenticationPrincipal User user) { + return userMapStyleJdbcService.getSettings(user, normalizedContextPath()); + } + + @PostMapping("/api") + @ResponseBody + public MapStyleSettingsDTO saveStyle(@AuthenticationPrincipal User user, @RequestBody SaveMapStyleRequest request) { + UserMapStyle style = userMapStyleJdbcService.save(user, request); + userMapStyleJdbcService.setActiveStyleId(user, style.frontendId()); + return userMapStyleJdbcService.getSettings(user, normalizedContextPath()); + } + + @PostMapping("/api/active") + @ResponseBody + public MapStyleSettingsDTO setActiveStyle(@AuthenticationPrincipal User user, @RequestBody ActiveMapStyleRequest request) { + userMapStyleJdbcService.setActiveStyleId(user, request.activeStyleId()); + return userMapStyleJdbcService.getSettings(user, normalizedContextPath()); + } + + @DeleteMapping("/api/{id}") + public ResponseEntity deleteStyle(@AuthenticationPrincipal User user, @PathVariable long id) { + userMapStyleJdbcService.delete(user, id); + return ResponseEntity.noContent().build(); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleInvalidMapStyle(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + + private String normalizedContextPath() { + String contextPath = contextPathHolder.getContextPath(); + return "/".equals(contextPath) ? "" : contextPath; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/map/ActiveMapStyleRequest.java b/src/main/java/com/dedicatedcode/reitti/dto/map/ActiveMapStyleRequest.java new file mode 100644 index 000000000..c9a9790ac --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/map/ActiveMapStyleRequest.java @@ -0,0 +1,4 @@ +package com.dedicatedcode.reitti.dto.map; + +public record ActiveMapStyleRequest(String activeStyleId) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java b/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java new file mode 100644 index 000000000..398384011 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java @@ -0,0 +1,20 @@ +package com.dedicatedcode.reitti.dto.map; + +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions; + +public record MapStyleConfigDTO( + String id, + String label, + String mapType, + String styleInputType, + String rasterSourceInputType, + String styleUrl, + String styleInput, + boolean custom, + boolean shared, + boolean editable, + MapStyleDataSource dataSource, + MapStyleVectorOptions vectorOptions +) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleSettingsDTO.java b/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleSettingsDTO.java new file mode 100644 index 000000000..15b538421 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleSettingsDTO.java @@ -0,0 +1,9 @@ +package com.dedicatedcode.reitti.dto.map; + +import java.util.List; + +public record MapStyleSettingsDTO( + String activeStyleId, + List customStyles +) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/map/SaveMapStyleRequest.java b/src/main/java/com/dedicatedcode/reitti/dto/map/SaveMapStyleRequest.java new file mode 100644 index 000000000..97fe900d9 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/map/SaveMapStyleRequest.java @@ -0,0 +1,17 @@ +package com.dedicatedcode.reitti.dto.map; + +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions; + +public record SaveMapStyleRequest( + String id, + String label, + String mapType, + String styleInputType, + String rasterSourceInputType, + String styleInput, + boolean shared, + MapStyleDataSource dataSource, + MapStyleVectorOptions vectorOptions +) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java new file mode 100644 index 000000000..8292b53e7 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java @@ -0,0 +1,14 @@ +package com.dedicatedcode.reitti.model.map; + +public record MapStyleDataSource( + String sourceId, + String type, + String tileJsonUrl, + String tileUrlTemplate, + String attribution, + Integer minzoom, + Integer maxzoom, + Integer tileSize, + String scheme +) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java new file mode 100644 index 000000000..a1b894876 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java @@ -0,0 +1,8 @@ +package com.dedicatedcode.reitti.model.map; + +public record MapStyleVectorOptions( + String attributionOverride, + String glyphsUrlOverride, + String spriteUrlOverride +) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java b/src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java new file mode 100644 index 000000000..d769b5a0c --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java @@ -0,0 +1,24 @@ +package com.dedicatedcode.reitti.model.map; + +public record UserMapStyle( + Long id, + Long userId, + String name, + String mapType, + String styleInputType, + String rasterSourceInputType, + String styleJson, + String styleUrl, + MapStyleDataSource dataSource, + MapStyleVectorOptions vectorOptions, + boolean shared, + Long version +) { + public String frontendId() { + return "custom-" + id; + } + + public String styleInput() { + return styleJson != null ? styleJson : styleUrl; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java new file mode 100644 index 000000000..db50171e3 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java @@ -0,0 +1,459 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.dto.map.MapStyleConfigDTO; +import com.dedicatedcode.reitti.dto.map.MapStyleSettingsDTO; +import com.dedicatedcode.reitti.dto.map.SaveMapStyleRequest; +import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions; +import com.dedicatedcode.reitti.model.map.UserMapStyle; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.I18nService; +import com.dedicatedcode.reitti.service.RemoteTileUrlValidator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Optional; + +@Service +public class UserMapStyleJdbcService { + private static final String DEFAULT_STYLE_ID = "reitti"; + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + private final RemoteTileUrlValidator remoteTileUrlValidator; + private final I18nService i18nService; + + public UserMapStyleJdbcService( + JdbcTemplate jdbcTemplate, + ObjectMapper objectMapper, + RemoteTileUrlValidator remoteTileUrlValidator, + I18nService i18nService) { + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + this.remoteTileUrlValidator = remoteTileUrlValidator; + this.i18nService = i18nService; + } + + private final RowMapper rowMapper = (rs, _) -> new UserMapStyle( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getString("name"), + defaultText(rs.getString("map_type"), "vector"), + defaultText(rs.getString("style_input_type"), "url"), + defaultText(rs.getString("raster_source_input_type"), "tile_template"), + rs.getString("style_json"), + rs.getString("style_url"), + new MapStyleDataSource( + rs.getString("source_id"), + rs.getString("source_type"), + rs.getString("tilejson_url"), + rs.getString("tile_url_template"), + rs.getString("attribution"), + (Integer) rs.getObject("minzoom"), + (Integer) rs.getObject("maxzoom"), + (Integer) rs.getObject("tile_size"), + rs.getString("scheme") + ), + new MapStyleVectorOptions( + rs.getString("attribution_override"), + rs.getString("glyphs_url_override"), + rs.getString("sprite_url_override") + ), + rs.getBoolean("shared"), + rs.getLong("version") + ); + + public List findAll(User user) { + return jdbcTemplate.query( + "SELECT * FROM user_map_styles WHERE user_id = ? OR shared = TRUE ORDER BY shared, name, id", + rowMapper, + user.getId() + ); + } + + public Optional findById(User user, long id) { + List results = jdbcTemplate.query( + "SELECT * FROM user_map_styles WHERE id = ? AND (user_id = ? OR shared = TRUE)", + rowMapper, + id, + user.getId() + ); + return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); + } + + private Optional findOwnedById(User user, long id) { + List results = jdbcTemplate.query( + "SELECT * FROM user_map_styles WHERE user_id = ? AND id = ?", + rowMapper, + user.getId(), + id + ); + return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); + } + + public String getActiveStyleId(User user) { + List results = jdbcTemplate.queryForList( + "SELECT active_style_id FROM user_map_style_settings WHERE user_id = ?", + String.class, + user.getId() + ); + if (results.isEmpty()) { + return DEFAULT_STYLE_ID; + } + String activeStyleId = results.getFirst(); + if (isValidStyleId(user, activeStyleId)) { + return activeStyleId; + } + setActiveStyleId(user, DEFAULT_STYLE_ID); + return DEFAULT_STYLE_ID; + } + + @Transactional + public void setActiveStyleId(User user, String activeStyleId) { + String safeActiveStyleId = isValidStyleId(user, activeStyleId) + ? activeStyleId + : DEFAULT_STYLE_ID; + jdbcTemplate.update(""" + INSERT INTO user_map_style_settings (user_id, active_style_id) + VALUES (?, ?) + ON CONFLICT (user_id) DO UPDATE SET active_style_id = EXCLUDED.active_style_id, updated_at = CURRENT_TIMESTAMP + """, user.getId(), safeActiveStyleId); + } + + private boolean isValidStyleId(User user, String styleId) { + return DEFAULT_STYLE_ID.equals(styleId) || resolveCustomId(styleId).flatMap(id -> findById(user, id)).isPresent(); + } + + @Transactional + public UserMapStyle save(User user, SaveMapStyleRequest request) { + String label = clean(request.label()); + String mapType = normalizeChoice(request.mapType(), "vector", List.of("vector", "raster")); + String styleInputType = normalizeChoice(request.styleInputType(), "url", List.of("url", "json")); + String rasterSourceInputType = normalizeChoice(request.rasterSourceInputType(), "tile_template", List.of("tile_template", "tilejson")); + String styleInput = clean(request.styleInput()); + if (!StringUtils.hasText(label)) { + throw new IllegalArgumentException(message("error-name-required")); + } + + String styleJson = null; + String styleUrl = null; + if ("vector".equals(mapType)) { + if (!StringUtils.hasText(styleInput)) { + throw new IllegalArgumentException(message("json".equals(styleInputType) ? "error-style-json-required" : "error-style-url-required")); + } + styleJson = "json".equals(styleInputType) || styleInput.startsWith("{") ? styleInput : null; + styleUrl = styleJson == null ? styleInput : null; + if (styleJson != null) { + validateStyleJson(styleJson); + } else { + remoteTileUrlValidator.requireHttpUrl(styleUrl, label("style-json-url")); + } + } + MapStyleDataSource source = normalizeDataSource(request.dataSource()); + MapStyleVectorOptions vectorOptions = normalizeVectorOptions(request.vectorOptions()); + boolean shared = request.shared() && user.getRole() == Role.ADMIN; + if ("raster".equals(mapType)) { + validateRasterSource(rasterSourceInputType, source); + Integer tileSize = source.tileSize(); + String tileUrlTemplate = "tile_template".equals(rasterSourceInputType) ? normalizeRasterTileTemplate(source.tileUrlTemplate()) : null; + source = new MapStyleDataSource( + "custom-raster-source", + "raster", + "tilejson".equals(rasterSourceInputType) ? clean(source.tileJsonUrl()) : null, + tileUrlTemplate, + clean(source.attribution()), + source.minzoom(), + source.maxzoom(), + effectiveRasterTileSize(tileUrlTemplate, tileSize), + source.scheme() + ); + } + validateNoPrivateUrlsForStandardUser(user, styleUrl, styleJson, source, vectorOptions); + + Optional existingId = resolveCustomId(request.id()); + if (existingId.isPresent() && findOwnedById(user, existingId.get()).isPresent()) { + jdbcTemplate.update(""" + UPDATE user_map_styles + SET name = ?, map_type = ?, style_input_type = ?, raster_source_input_type = ?, + style_json = ?, style_url = ?, source_id = ?, source_type = ?, tilejson_url = ?, + tile_url_template = ?, attribution = ?, minzoom = ?, maxzoom = ?, tile_size = ?, scheme = ?, + attribution_override = ?, glyphs_url_override = ?, sprite_url_override = ?, shared = ?, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE user_id = ? AND id = ? + """, + label, + mapType, + styleInputType, + rasterSourceInputType, + styleJson, + styleUrl, + clean(source.sourceId()), + clean(source.type()), + clean(source.tileJsonUrl()), + clean(source.tileUrlTemplate()), + clean(source.attribution()), + source.minzoom(), + source.maxzoom(), + source.tileSize(), + source.scheme(), + clean(vectorOptions.attributionOverride()), + clean(vectorOptions.glyphsUrlOverride()), + clean(vectorOptions.spriteUrlOverride()), + shared, + user.getId(), + existingId.get()); + return findOwnedById(user, existingId.get()).orElseThrow(); + } + + Long id = jdbcTemplate.queryForObject(""" + INSERT INTO user_map_styles + (user_id, name, map_type, style_input_type, raster_source_input_type, style_json, style_url, + source_id, source_type, tilejson_url, tile_url_template, attribution, minzoom, maxzoom, tile_size, scheme, + attribution_override, glyphs_url_override, sprite_url_override, shared) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id + """, + Long.class, + user.getId(), + label, + mapType, + styleInputType, + rasterSourceInputType, + styleJson, + styleUrl, + clean(source.sourceId()), + clean(source.type()), + clean(source.tileJsonUrl()), + clean(source.tileUrlTemplate()), + clean(source.attribution()), + source.minzoom(), + source.maxzoom(), + source.tileSize(), + source.scheme(), + clean(vectorOptions.attributionOverride()), + clean(vectorOptions.glyphsUrlOverride()), + clean(vectorOptions.spriteUrlOverride()), + shared); + return findOwnedById(user, id).orElseThrow(); + } + + @Transactional + public void delete(User user, long id) { + jdbcTemplate.update("DELETE FROM user_map_styles WHERE user_id = ? AND id = ?", user.getId(), id); + if (("custom-" + id).equals(getActiveStyleId(user))) { + setActiveStyleId(user, DEFAULT_STYLE_ID); + } + } + + public MapStyleSettingsDTO getSettings(User user, String contextPath) { + return new MapStyleSettingsDTO( + getActiveStyleId(user), + findAll(user).stream().map(style -> toDto(user, style, contextPath)).toList() + ); + } + + public MapStyleConfigDTO toDto(User user, UserMapStyle style, String contextPath) { + return new MapStyleConfigDTO( + style.frontendId(), + style.name(), + style.mapType(), + StringUtils.hasText(style.styleJson()) ? "json" : style.styleInputType(), + style.rasterSourceInputType(), + styleUrlForClient(style, contextPath), + style.styleInput(), + true, + style.shared(), + style.userId().equals(user.getId()), + style.dataSource(), + style.vectorOptions() + ); + } + + public static Optional resolveCustomId(String frontendId) { + if (!StringUtils.hasText(frontendId) || !frontendId.startsWith("custom-")) { + return Optional.empty(); + } + try { + return Optional.of(Long.parseLong(frontendId.substring("custom-".length()))); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + private MapStyleDataSource normalizeDataSource(MapStyleDataSource dataSource) { + MapStyleDataSource source = dataSource != null ? dataSource : new MapStyleDataSource(null, "vector", null, null, null, null, null, null, null); + String type = normalizeChoice(source.type(), "vector", List.of("vector", "raster", "raster-dem")); + Integer tileSize = source.tileSize() == null || (source.tileSize() != 256 && source.tileSize() != 512) ? null : source.tileSize(); + String scheme = normalizeChoice(source.scheme(), null, List.of("xyz", "tms")); + return new MapStyleDataSource( + clean(source.sourceId()), + type, + clean(source.tileJsonUrl()), + clean(source.tileUrlTemplate()), + clean(source.attribution()), + source.minzoom(), + source.maxzoom(), + tileSize, + scheme + ); + } + + private MapStyleVectorOptions normalizeVectorOptions(MapStyleVectorOptions options) { + if (options == null) { + return new MapStyleVectorOptions(null, null, null); + } + return new MapStyleVectorOptions( + clean(options.attributionOverride()), + clean(options.glyphsUrlOverride()), + clean(options.spriteUrlOverride()) + ); + } + + private void validateRasterSource(String rasterSourceInputType, MapStyleDataSource source) { + if ("tile_template".equals(rasterSourceInputType)) { + String tileUrlTemplate = clean(source.tileUrlTemplate()); + if (!StringUtils.hasText(tileUrlTemplate) || !tileUrlTemplate.contains("{z}") || !tileUrlTemplate.contains("{x}") || !tileUrlTemplate.contains("{y}")) { + throw new IllegalArgumentException(message("error-tile-template-placeholders")); + } + remoteTileUrlValidator.requireHttpTemplate(tileUrlTemplate, label("tile-template")); + } else if (!StringUtils.hasText(source.tileJsonUrl())) { + throw new IllegalArgumentException(message("error-tilejson-required")); + } else { + remoteTileUrlValidator.requireHttpUrl(source.tileJsonUrl(), label("tilejson-url")); + } + validateZoom(source.minzoom(), label("minzoom")); + validateZoom(source.maxzoom(), label("maxzoom")); + if (source.minzoom() != null && source.maxzoom() != null && source.minzoom() >= source.maxzoom()) { + throw new IllegalArgumentException(message("error-zoom-order")); + } + } + + private void validateNoPrivateUrlsForStandardUser( + User user, + String styleUrl, + String styleJson, + MapStyleDataSource source, + MapStyleVectorOptions vectorOptions) { + if (user.getRole() == Role.ADMIN) { + return; + } + + rejectPrivateUrl(styleUrl); + rejectPrivateUrl(source.tileJsonUrl()); + rejectPrivateTemplate(source.tileUrlTemplate()); + rejectPrivateTemplate(vectorOptions.glyphsUrlOverride()); + rejectPrivateUrl(vectorOptions.spriteUrlOverride()); + + if (StringUtils.hasText(styleJson)) { + try { + rejectPrivateUrlsInJson(objectMapper.readTree(styleJson)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(message("error-json"), e); + } + } + } + + private void rejectPrivateUrlsInJson(JsonNode node) { + if (node == null || node.isNull()) { + return; + } + if (node.isTextual()) { + String value = node.asText(); + rejectPrivateUrl(value); + rejectPrivateTemplate(value); + return; + } + if (node.isContainerNode()) { + node.elements().forEachRemaining(this::rejectPrivateUrlsInJson); + } + } + + private void rejectPrivateUrl(String value) { + if (StringUtils.hasText(value) && startsWithHttp(value) && remoteTileUrlValidator.isValidLocalUrl(value)) { + throw new IllegalArgumentException(message("error-local-url")); + } + } + + private void rejectPrivateTemplate(String value) { + if (StringUtils.hasText(value) && startsWithHttp(value) && remoteTileUrlValidator.isValidLocalTemplate(value)) { + throw new IllegalArgumentException(message("error-local-url")); + } + } + + private boolean startsWithHttp(String value) { + String trimmed = value.trim().toLowerCase(); + return trimmed.startsWith("http://") || trimmed.startsWith("https://"); + } + + private void validateZoom(Integer zoom, String fieldName) { + if (zoom != null && (zoom < 0 || zoom > 24)) { + throw new IllegalArgumentException(message("error-zoom-range", fieldName)); + } + } + + private void validateStyleJson(String styleJson) { + try { + objectMapper.readTree(styleJson); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(message("error-json"), e); + } + } + + private String normalizeRasterTileTemplate(String tileUrlTemplate) { + String template = clean(tileUrlTemplate); + if (!StringUtils.hasText(template)) { + return null; + } + return template.replace("{r}", "@2x"); + } + + private String styleUrlForClient(UserMapStyle style, String contextPath) { + if ("vector".equals(style.mapType()) + && !StringUtils.hasText(style.styleJson()) + && StringUtils.hasText(style.styleUrl()) + && !remoteTileUrlValidator.isServerFetchAllowedUrl(style.styleUrl())) { + return style.styleUrl(); + } + return contextPath + "/map/custom/" + style.id() + ".json?v=" + style.version(); + } + + private Integer effectiveRasterTileSize(String tileUrlTemplate, Integer configuredTileSize) { + if (StringUtils.hasText(tileUrlTemplate) && tileUrlTemplate.contains("@2x")) { + return 256; + } + return configuredTileSize; + } + + private String normalizeChoice(String value, String defaultValue, List allowedValues) { + String normalized = clean(value); + if (!StringUtils.hasText(normalized)) { + return defaultValue; + } + normalized = normalized.toLowerCase(); + return allowedValues.contains(normalized) ? normalized : defaultValue; + } + + private String clean(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private String label(String key) { + return i18nService.translate("js.map.settings.dialog.map-styles." + key); + } + + private String message(String key, Object... args) { + return i18nService.translate("js.map.settings.dialog.map-styles." + key, args); + } + + private static String defaultText(String value, String defaultValue) { + return StringUtils.hasText(value) ? value : defaultValue; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/I18nService.java b/src/main/java/com/dedicatedcode/reitti/service/I18nService.java index e4d2819c3..a15355945 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/I18nService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/I18nService.java @@ -55,6 +55,10 @@ public String translateWithDefault(String messageKey, String defaultMessage) { return messageSource.getMessage(messageKey, null, defaultMessage, LocaleContextHolder.getLocale()); } + public String translateWithDefault(String messageKey, String defaultMessage, Object... args) { + return messageSource.getMessage(messageKey, args, defaultMessage, LocaleContextHolder.getLocale()); + } + public String translate(String messageKey, Object... args) { return messageSource.getMessage(messageKey, args, LocaleContextHolder.getLocale()); } diff --git a/src/main/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidator.java b/src/main/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidator.java new file mode 100644 index 000000000..8e1f43c15 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidator.java @@ -0,0 +1,208 @@ +package com.dedicatedcode.reitti.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.net.InetAddress; +import java.net.URI; +import java.time.Duration; +import java.util.Locale; + +@Service +public class RemoteTileUrlValidator { + private static final long HOST_SAFETY_CACHE_MAX_SIZE = 50_000; + private static final Duration HOST_SAFETY_CACHE_TTL = Duration.ofMinutes(10); + + private final boolean proxyLocalTileUrls; + private final Cache hostSafetyCache; + private final I18nService i18nService; + + @Autowired + public RemoteTileUrlValidator( + @Value("${reitti.ui.tiles.proxy-local-urls:false}") boolean proxyLocalTileUrls, + I18nService i18nService) { + this.proxyLocalTileUrls = proxyLocalTileUrls; + this.i18nService = i18nService; + this.hostSafetyCache = Caffeine.newBuilder() + .maximumSize(HOST_SAFETY_CACHE_MAX_SIZE) + .expireAfterWrite(HOST_SAFETY_CACHE_TTL) + .build(); + } + + public RemoteTileUrlValidator(@Value("${reitti.ui.tiles.proxy-local-urls:false}") boolean proxyLocalTileUrls) { + this.proxyLocalTileUrls = proxyLocalTileUrls; + this.i18nService = null; + this.hostSafetyCache = Caffeine.newBuilder() + .maximumSize(HOST_SAFETY_CACHE_MAX_SIZE) + .expireAfterWrite(HOST_SAFETY_CACHE_TTL) + .build(); + } + + public URI requirePublicHttpUrl(String value, String fieldName) { + URI uri = parseHttpUrl(value, fieldName); + validatePublicHost(uri, fieldName); + return uri; + } + + public URI requireHttpUrl(String value, String fieldName) { + return parseHttpUrl(value, fieldName); + } + + public URI requirePublicHttpTemplate(String value, String fieldName) { + return requirePublicHttpUrl(normalizeTemplateForParsing(value), fieldName); + } + + public URI requireHttpTemplate(String value, String fieldName) { + return requireHttpUrl(normalizeTemplateForParsing(value), fieldName); + } + + public boolean isServerFetchAllowedUrl(String value) { + try { + requirePublicHttpUrl(value, "URL"); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + public boolean isServerFetchAllowedTemplate(String value) { + try { + requirePublicHttpTemplate(value, "URL template"); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + public boolean isValidLocalUrl(String value) { + try { + URI uri = parseHttpUrl(value, "URL"); + return isLocalHost(uri.getHost().toLowerCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return false; + } + } + + public boolean isValidLocalTemplate(String value) { + return isValidLocalUrl(normalizeTemplateForParsing(value)); + } + + private URI parseHttpUrl(String value, String fieldName) { + if (!StringUtils.hasText(value)) { + throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-required", fieldName + " is required.", fieldName)); + } + + URI uri; + try { + uri = URI.create(value.trim()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-invalid", fieldName + " must be a valid URL.", fieldName), e); + } + + String scheme = uri.getScheme(); + if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) { + throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-scheme", fieldName + " must use HTTP or HTTPS.", fieldName)); + } + if (!StringUtils.hasText(uri.getHost())) { + throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-host", fieldName + " must include a host.", fieldName)); + } + if (StringUtils.hasText(uri.getUserInfo())) { + throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-credentials", fieldName + " must not contain embedded credentials.", fieldName)); + } + return uri; + } + + private void validatePublicHost(URI uri, String fieldName) { + if (proxyLocalTileUrls) { + return; + } + + String host = uri.getHost().toLowerCase(Locale.ROOT); + boolean isPublic = hostSafetyCache.get(host, this::isPublicHost); + if (!isPublic) { + throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-private", fieldName + " must not target local or private network addresses.", fieldName)); + } + } + + private boolean isPublicHost(String host) { + try { + InetAddress[] addresses = InetAddress.getAllByName(host); + if (addresses.length == 0) { + return false; + } + for (InetAddress address : addresses) { + if (!isPublicAddress(address)) { + return false; + } + } + return true; + } catch (Exception e) { + return false; + } + } + + private boolean isLocalHost(String host) { + if ("localhost".equals(host) || host.endsWith(".localhost") || host.endsWith(".local")) { + return true; + } + + try { + InetAddress[] addresses = InetAddress.getAllByName(host); + for (InetAddress address : addresses) { + if (!isPublicAddress(address)) { + return true; + } + } + return false; + } catch (Exception e) { + return false; + } + } + + private boolean isPublicAddress(InetAddress address) { + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return false; + } + + byte[] bytes = address.getAddress(); + if (bytes.length == 4) { + int first = Byte.toUnsignedInt(bytes[0]); + int second = Byte.toUnsignedInt(bytes[1]); + return !(first == 0 + || first == 10 + || first == 127 + || (first == 100 && second >= 64 && second <= 127) + || (first == 169 && second == 254) + || (first == 172 && second >= 16 && second <= 31) + || (first == 192 && second == 168) + || (first == 198 && (second == 18 || second == 19)) + || first >= 224); + } + if (bytes.length == 16) { + int first = Byte.toUnsignedInt(bytes[0]); + return (first & 0xfe) != 0xfc; + } + return false; + } + + private String normalizeTemplateForParsing(String value) { + if (!StringUtils.hasText(value)) { + return value; + } + return value.trim().replaceAll("\\{[^}]+}", "0"); + } + + private String message(String key, String defaultMessage, Object... args) { + return i18nService != null + ? i18nService.translateWithDefault(key, defaultMessage, args) + : defaultMessage; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/SafeHttpClient.java b/src/main/java/com/dedicatedcode/reitti/service/SafeHttpClient.java new file mode 100644 index 000000000..94ade28a5 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/SafeHttpClient.java @@ -0,0 +1,79 @@ +package com.dedicatedcode.reitti.service; + +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Optional; + +@Service +public class SafeHttpClient { + private static final int MAX_REDIRECTS = 5; + + private final RemoteTileUrlValidator remoteTileUrlValidator; + + public SafeHttpClient(RemoteTileUrlValidator remoteTileUrlValidator) { + this.remoteTileUrlValidator = remoteTileUrlValidator; + } + + public HttpResponse sendFollowingPublicRedirects( + HttpClient httpClient, + HttpRequest request, + HttpResponse.BodyHandler bodyHandler, + String fieldName) throws IOException, InterruptedException { + HttpRequest currentRequest = request; + URI currentUri = request.uri(); + + for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) { + HttpResponse response = httpClient.send(currentRequest, bodyHandler); + if (!isRedirect(response.statusCode())) { + return response; + } + + if (redirectCount == MAX_REDIRECTS) { + throw new IOException("Too many redirects for " + fieldName + "."); + } + + URI redirectUri = resolveRedirectUri(currentUri, response); + remoteTileUrlValidator.requirePublicHttpUrl(redirectUri.toString(), fieldName + " redirect URL"); + currentUri = redirectUri; + currentRequest = redirectRequest(currentRequest, response.statusCode(), redirectUri); + } + + throw new IOException("Too many redirects for " + fieldName + "."); + } + + private boolean isRedirect(int statusCode) { + return statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308; + } + + private URI resolveRedirectUri(URI currentUri, HttpResponse response) throws IOException { + Optional location = response.headers().firstValue(HttpHeaders.LOCATION); + if (location.isEmpty() || location.get().isBlank()) { + throw new IOException("Redirect response is missing a Location header."); + } + return currentUri.resolve(location.get().trim()); + } + + private HttpRequest redirectRequest(HttpRequest previousRequest, int statusCode, URI redirectUri) { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(redirectUri) + .GET(); + + previousRequest.timeout().ifPresent(builder::timeout); + previousRequest.headers().map().forEach((name, values) -> { + if (!HttpHeaders.HOST.equalsIgnoreCase(name)) { + values.forEach(value -> builder.header(name, value)); + } + }); + + if (statusCode == 303) { + builder.GET(); + } + return builder.build(); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/TileProxySignatureService.java b/src/main/java/com/dedicatedcode/reitti/service/TileProxySignatureService.java new file mode 100644 index 000000000..f705ef8ae --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/TileProxySignatureService.java @@ -0,0 +1,47 @@ +package com.dedicatedcode.reitti.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +@Service +public class TileProxySignatureService { + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + private final byte[] secret; + + public TileProxySignatureService(@Value("${reitti.ui.tiles.proxy.signature-secret:}") String configuredSecret) { + if (StringUtils.hasText(configuredSecret)) { + this.secret = configuredSecret.getBytes(StandardCharsets.UTF_8); + } else { + this.secret = new byte[32]; + new SecureRandom().nextBytes(this.secret); + } + } + + public String sign(String value) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(this.secret, HMAC_ALGORITHM)); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("Failed to sign tile proxy URL", e); + } + } + + public boolean isValid(String value, String signature) { + if (!StringUtils.hasText(signature)) { + return false; + } + return MessageDigest.isEqual(sign(value).getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/TileUrlUtils.java b/src/main/java/com/dedicatedcode/reitti/service/TileUrlUtils.java new file mode 100644 index 000000000..18fd360ec --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/TileUrlUtils.java @@ -0,0 +1,39 @@ +package com.dedicatedcode.reitti.service; + +public final class TileUrlUtils { + private TileUrlUtils() { + } + + public static String extractTileExtension(String url) { + String path = url.split("\\?", 2)[0]; + int placeholderIndex = path.indexOf("{y}"); + if (placeholderIndex < 0) { + return "pbf"; + } + + int extensionStart = placeholderIndex + 3; + while (extensionStart < path.length() && path.charAt(extensionStart) == '{') { + int tokenEnd = path.indexOf('}', extensionStart); + if (tokenEnd < 0) { + break; + } + extensionStart = tokenEnd + 1; + } + if (path.startsWith("@2x", extensionStart)) { + extensionStart += 3; + } + if (extensionStart >= path.length() || path.charAt(extensionStart) != '.') { + return "pbf"; + } + + int extensionEnd = extensionStart + 1; + while (extensionEnd < path.length() && Character.isLetterOrDigit(path.charAt(extensionEnd))) { + extensionEnd++; + } + + if (extensionEnd == extensionStart + 1) { + return "pbf"; + } + return path.substring(extensionStart + 1, extensionEnd); + } +} diff --git a/src/main/resources/application-docker.properties b/src/main/resources/application-docker.properties index 16874bfee..c2624cc0f 100644 --- a/src/main/resources/application-docker.properties +++ b/src/main/resources/application-docker.properties @@ -38,6 +38,8 @@ reitti.process-data.schedule=${REITTI_PROCESS_DATA_CRON:0 */10 * * * *} reitti.ui.tiles.custom.service=${CUSTOM_TILES_SERVICE:} reitti.ui.tiles.custom.attribution=${CUSTOM_TILES_ATTRIBUTION:} reitti.ui.tiles.cache.url=${TILES_CACHE:http://tile-cache} +reitti.ui.tiles.proxy.signature-secret=${TILE_PROXY_SIGNATURE_SECRET:} +reitti.ui.tiles.proxy-local-urls=${PROXY_LOCAL_TILE_URLS:false} reitti.import.batch-size=${PROCESSING_BATCH_SIZE:10000} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9ca257e37..f85a3dfee 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -84,6 +84,8 @@ reitti.geocoding.photon.base-url= # Tiles Configuration reitti.ui.tiles.cache.url= +reitti.ui.tiles.proxy-local-urls=false +reitti.ui.tiles.proxy.signature-secret= reitti.ui.tiles.default.service=https://tile.openstreetmap.org/{z}/{x}/{y}.png reitti.ui.tiles.default.attribution=© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France @@ -107,4 +109,3 @@ reitti.logging.max-buffer-size=10000 # For OIDC security configuration, create a separate oidc.properties file instead of configuring OIDC settings directly in this file. See the oidc.properties.example for the needed properties. spring.config.import=optional:oidc.properties - diff --git a/src/main/resources/db/migration/V91__add_user_map_styles.sql b/src/main/resources/db/migration/V91__add_user_map_styles.sql new file mode 100644 index 000000000..c09dcf807 --- /dev/null +++ b/src/main/resources/db/migration/V91__add_user_map_styles.sql @@ -0,0 +1,42 @@ +CREATE TABLE user_map_styles +( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + map_type VARCHAR(32) NOT NULL DEFAULT 'vector', + style_input_type VARCHAR(32) NOT NULL DEFAULT 'url', + raster_source_input_type VARCHAR(32) NOT NULL DEFAULT 'tile_template', + style_json TEXT, + style_url TEXT, + source_id VARCHAR(255), + source_type VARCHAR(32) NOT NULL DEFAULT 'vector', + tilejson_url TEXT, + tile_url_template TEXT, + attribution TEXT, + minzoom INTEGER, + maxzoom INTEGER, + tile_size INTEGER, + scheme VARCHAR(8), + attribution_override TEXT, + glyphs_url_override TEXT, + sprite_url_override TEXT, + shared BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 1, + CONSTRAINT user_map_styles_style_source CHECK ( + (map_type = 'vector' AND (style_json IS NOT NULL OR style_url IS NOT NULL)) + OR + (map_type = 'raster' AND (tilejson_url IS NOT NULL OR tile_url_template IS NOT NULL)) + ) +); + +CREATE INDEX idx_user_map_styles_user_id ON user_map_styles (user_id); +CREATE INDEX idx_user_map_styles_shared ON user_map_styles (shared) WHERE shared = TRUE; + +CREATE TABLE user_map_style_settings +( + user_id BIGINT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, + active_style_id VARCHAR(255) NOT NULL DEFAULT 'reitti', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 8b3abc432..54e57cbfd 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -1091,6 +1091,111 @@ js.map.settings.dialog.interface.title=Interface js.map.settings.dialog.interface.timeline-visible=Timeline Visible js.map.settings.dialog.interface.datepicker-visible=Date Selection Visible +js.map.settings.dialog.map-styles.active=Active Style +js.map.settings.dialog.map-styles.add=Add Style +js.map.settings.dialog.map-styles.add-title=Add Custom Style +js.map.settings.dialog.map-styles.edit-title=Edit Custom Style +js.map.settings.dialog.map-styles.name=Name +js.map.settings.dialog.map-styles.style-json=Style JSON +js.map.settings.dialog.map-styles.style-json-url=Style JSON URL +js.map.settings.dialog.map-styles.map-type=Map Type +js.map.settings.dialog.map-styles.map-type.vector=Vector Style +js.map.settings.dialog.map-styles.map-type.raster=Raster Tiles +js.map.settings.dialog.map-styles.vector-title=Vector Style +js.map.settings.dialog.map-styles.raster-title=Raster Tiles +js.map.settings.dialog.map-styles.style-input=Style Input +js.map.settings.dialog.map-styles.style-input.url=Style JSON URL +js.map.settings.dialog.map-styles.style-input.json=Paste Style JSON +js.map.settings.dialog.map-styles.source-input=Source Input +js.map.settings.dialog.map-styles.source-input.tile-template=Tile URL Template +js.map.settings.dialog.map-styles.source-input.tilejson=TileJSON URL +js.map.settings.dialog.map-styles.advanced-options=Advanced Options +js.map.settings.dialog.map-styles.attribution-override=Attribution Override +js.map.settings.dialog.map-styles.glyphs-url=Glyphs URL Override +js.map.settings.dialog.map-styles.sprite-url=Sprite URL Override +js.map.settings.dialog.map-styles.tile-settings=Tile Settings +js.map.settings.dialog.map-styles.tile-size=Tile Size +js.map.settings.dialog.map-styles.scheme=Scheme +js.map.settings.dialog.map-styles.default-256=Default (256) +js.map.settings.dialog.map-styles.default-xyz=Default (XYZ) +js.map.settings.dialog.map-styles.tilejson-url=TileJSON URL +js.map.settings.dialog.map-styles.tile-template=Tile URL Template +js.map.settings.dialog.map-styles.attribution=Attribution +js.map.settings.dialog.map-styles.minzoom=Min. Tile Zoom +js.map.settings.dialog.map-styles.maxzoom=Max. Tile Zoom +js.map.settings.dialog.map-styles.save=Save +js.map.settings.dialog.map-styles.cancel=Cancel +js.map.settings.dialog.map-styles.edit=Edit +js.map.settings.dialog.map-styles.remove=Remove +js.map.settings.dialog.map-styles.shared=Share with all users +js.map.settings.dialog.map-styles.shared-info=Other users can select this style, but only you can edit it. +js.map.settings.dialog.map-styles.shared-badge=Shared +js.map.settings.dialog.map-styles.remove-confirm=Remove this custom map style? +js.map.settings.dialog.map-styles.empty=No custom styles yet +js.map.settings.dialog.map-styles.error-name-required=Name is required. +js.map.settings.dialog.map-styles.error-map-type=Map type is required. +js.map.settings.dialog.map-styles.error-style-url-required=Style JSON URL is required. +js.map.settings.dialog.map-styles.error-style-json-required=Style JSON is required. +js.map.settings.dialog.map-styles.error-json=The pasted style JSON is invalid. +js.map.settings.dialog.map-styles.error-tile-template-required=Tile URL Template is required. +js.map.settings.dialog.map-styles.error-tile-template-placeholders=Tile URL Template must contain {z}, {x}, and {y}. +js.map.settings.dialog.map-styles.error-tilejson-required=TileJSON URL is required. +js.map.settings.dialog.map-styles.error-zoom-number=Min Zoom and Max Zoom must be numbers. +js.map.settings.dialog.map-styles.error-zoom-order=Min Zoom must be lower than Max Zoom. +js.map.settings.dialog.map-styles.error-tile-size=Tile Size must be 256 or 512. +js.map.settings.dialog.map-styles.error-scheme=Scheme must be XYZ or TMS. +js.map.settings.dialog.map-styles.error-save=Could not save this custom style. +js.map.settings.dialog.map-styles.error-save-status=Could not save this custom style (HTTP {0}). +js.map.settings.dialog.map-styles.error-delete=Could not delete this custom style. +js.map.settings.dialog.map-styles.error-active=Could not save the active map style. +js.map.settings.dialog.map-styles.error-local-url=Only admins can use local or private network map style URLs. +js.map.settings.dialog.map-styles.error-zoom-range={0} must be between 0 and 24. +js.map.settings.dialog.map-styles.error-url-required={0} is required. +js.map.settings.dialog.map-styles.error-url-invalid={0} must be a valid URL. +js.map.settings.dialog.map-styles.error-url-scheme={0} must use HTTP or HTTPS. +js.map.settings.dialog.map-styles.error-url-host={0} must include a host. +js.map.settings.dialog.map-styles.error-url-credentials={0} must not contain embedded credentials. +js.map.settings.dialog.map-styles.error-url-private="{0} must not target local or private network addresses. PROXY_LOCAL_TILE_URLS=true to enable." +settings.map.styles=Map Styles +settings.map.styles.description=Manage custom MapLibre styles and optional tile sources +map.settings.dialog.map-styles.active=Active Style +map.settings.dialog.map-styles.custom-title=Custom Styles +map.settings.dialog.map-styles.add=Add Style +map.settings.dialog.map-styles.add-title=Add Custom Style +map.settings.dialog.map-styles.name=Name +map.settings.dialog.map-styles.style-json=Style JSON +map.settings.dialog.map-styles.style-json-url=Style JSON URL +map.settings.dialog.map-styles.map-type=Map Type +map.settings.dialog.map-styles.map-type.vector=Vector Style +map.settings.dialog.map-styles.map-type.raster=Raster Tiles +map.settings.dialog.map-styles.vector-title=Vector Style +map.settings.dialog.map-styles.raster-title=Raster Tiles +map.settings.dialog.map-styles.style-input=Style Input +map.settings.dialog.map-styles.style-input.url=Style JSON URL +map.settings.dialog.map-styles.style-input.json=Paste Style JSON +map.settings.dialog.map-styles.source-input=Source Input +map.settings.dialog.map-styles.source-input.tile-template=Tile URL Template +map.settings.dialog.map-styles.source-input.tilejson=TileJSON URL +map.settings.dialog.map-styles.advanced-options=Advanced Options +map.settings.dialog.map-styles.attribution-override=Attribution Override +map.settings.dialog.map-styles.glyphs-url=Glyphs URL Override +map.settings.dialog.map-styles.sprite-url=Sprite URL Override +map.settings.dialog.map-styles.tile-settings=Tile Settings +map.settings.dialog.map-styles.tile-size=Tile Size +map.settings.dialog.map-styles.scheme=Scheme +map.settings.dialog.map-styles.default-256=Default (256) +map.settings.dialog.map-styles.default-xyz=Default (XYZ) +map.settings.dialog.map-styles.tilejson-url=TileJSON URL +map.settings.dialog.map-styles.tile-template=Tile URL Template +map.settings.dialog.map-styles.attribution=Attribution +map.settings.dialog.map-styles.minzoom=Min. Tile Zoom +map.settings.dialog.map-styles.maxzoom=Max. Tile Zoom +map.settings.dialog.map-styles.save=Save +map.settings.dialog.map-styles.cancel=Cancel +map.settings.dialog.map-styles.shared=Share with all users +map.settings.dialog.map-styles.shared-info=Other users can select this style, but only you can edit it. +map.settings.dialog.map-styles.shared-badge=Shared + map.settings.dialog.date-picker.title=Date Selection # Export Data @@ -1619,4 +1724,4 @@ common.distance.ft={0,number,#} ft common.actions.apply=Apply js.autoupdate.state.disable=Leave Auto-Update-Mode -js.autoupdate.state.enable=Enter Auto-Update-Mode \ No newline at end of file +js.autoupdate.state.enable=Enter Auto-Update-Mode diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 6f7d975cd..c013e12e6 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -342,6 +342,287 @@ header { margin-bottom: 8px; } +.settings-secondary-btn, +.settings-primary-btn, +.settings-icon-btn { + color: var(--color-highlight); + border: 1px solid var(--color-highlight); +} + +.settings-secondary-btn, +.settings-primary-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 10px; +} + +.settings-primary-btn { + background: var(--color-highlight); + color: var(--color-background-dark); +} + +.settings-primary-btn i, +.settings-primary-btn span { + color: var(--color-background-dark); +} + +.settings-icon-btn { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.settings-icon-btn.danger { + color: #ffb4ab; + border-color: #ffb4ab; +} + +.custom-map-style-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; +} + +.custom-map-style-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 1px solid var(--color-highlight); + border-radius: 6px; + padding: 8px; + background: var(--color-background-dark-light); +} + +.custom-map-style-item-main { + min-width: 0; +} + +.custom-map-style-item-name { + color: var(--color-text-white); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.custom-map-style-item-meta, +.custom-map-style-empty { + color: var(--color-text-gray); + font-size: 0.85rem; +} + +.custom-map-style-item-actions, +.custom-map-style-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.custom-map-style-form { + border-top: 1px solid var(--color-highlight); + margin-top: 12px; + padding-top: 12px; +} + +.custom-map-style-form.hidden { + display: none; +} + +.custom-map-style-mode-panel.hidden, +.map-styles-settings-page .form-group.hidden { + display: none; +} + +.custom-map-style-form-header { + color: var(--color-highlight); + font-weight: 600; + margin-bottom: 10px; +} + +.custom-map-style-mode-panel h4 { + color: var(--color-highlight); + margin: 12px 0 8px; +} + +.custom-map-style-choice { + border: 1px solid var(--color-highlight); + border-radius: 6px; + display: flex; + flex-wrap: wrap; + gap: 10px 18px; + margin: 0 0 10px; + padding: 10px; +} + +.custom-map-style-choice legend { + color: var(--color-highlight); + padding: 0 4px; +} + +.custom-map-style-choice label { + align-items: center; + color: var(--color-text-white); + display: inline-flex; + gap: 6px; +} + +.custom-map-style-choice input { + width: auto; +} + +.custom-map-style-advanced { + border: 1px solid var(--color-highlight); + border-radius: 6px; + padding: 8px; + margin-bottom: 10px; +} + +.custom-map-style-advanced summary { + color: var(--color-highlight); + cursor: pointer; +} + +.custom-map-style-share-panel { + align-items: center; + background: var(--color-background-dark-light); + border: 1px solid var(--color-highlight); + border-radius: 6px; + display: flex; + gap: 16px; + justify-content: space-between; + margin: 12px 0; + padding: 10px; +} + +.custom-map-style-share-copy { + min-width: 0; +} + +.custom-map-style-share-copy label { + color: var(--color-text-white); + font-weight: 600; +} + +.custom-map-style-share-copy p { + color: var(--color-text-gray); + font-size: 0.85rem; + margin: 3px 0 0; +} + +.settings-toggle-switch { + display: inline-flex; + flex: 0 0 auto; + position: relative; +} + +.settings-toggle-switch input { + opacity: 0; + position: absolute; +} + +.settings-toggle-switch span { + background: var(--color-background-dark); + border: 1px solid var(--color-highlight); + border-radius: 999px; + cursor: pointer; + display: inline-block; + height: 24px; + position: relative; + transition: background 0.15s ease; + width: 44px; +} + +.settings-toggle-switch span::before { + background: var(--color-text-gray); + border-radius: 50%; + content: ""; + height: 18px; + left: 3px; + position: absolute; + top: 50%; + transform: translateY(-50%); + transition: transform 0.15s ease, background 0.15s ease; + width: 18px; +} + +.settings-toggle-switch input:checked + span { + background: var(--color-highlight); +} + +.settings-toggle-switch input:checked + span::before { + background: var(--color-background-dark); + transform: translateY(-50%) translateX(20px); +} + +.custom-map-style-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.custom-map-style-error { + display: none; + color: #ffb4ab; + font-size: 0.9rem; + margin-bottom: 10px; +} + +.custom-map-style-error.visible { + display: block; +} + +.map-styles-settings-page .settings-card { + max-width: 900px; +} + +.map-styles-settings-page .settings-description { + color: var(--color-text-gray); + margin-top: -8px; + max-width: 900px; +} + +.map-styles-settings-page .settings-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.map-styles-settings-page .settings-card-header h3 { + margin: 0; +} + +.map-styles-settings-page select, +.map-styles-settings-page input, +.map-styles-settings-page textarea { + width: 100%; + color: var(--color-text-white); + background: var(--color-background-dark-light); + border: 1px solid var(--color-highlight); + border-radius: 4px; + padding: 8px; + font: inherit; +} + +.map-styles-settings-page .custom-map-style-choice input { + width: auto; +} + +.map-styles-settings-page textarea { + resize: vertical; + min-height: 180px; + font-family: monospace; + font-size: 0.9rem; +} + /* Responsive adjustments for settings menu */ @media (max-width: 768px) { .settings-menu { @@ -2223,5 +2504,3 @@ button:disabled { .timeband-item.selected:hover::before { opacity: 1; } - - diff --git a/src/main/resources/static/css/map-controls.css b/src/main/resources/static/css/map-controls.css index 9056fa95e..6627ee71e 100644 --- a/src/main/resources/static/css/map-controls.css +++ b/src/main/resources/static/css/map-controls.css @@ -43,10 +43,46 @@ white-space: nowrap; } -.map-controls button i { +.map-controls button i, +.map-style-selector i { font-size: 1.3rem; } +.map-style-selector { + display: flex; + gap: 4px; + align-items: center; + color: var(--color-primary); + padding: 4px 8px; + position: relative; + white-space: nowrap; +} + +.map-style-selector::after { + content: ""; + position: absolute; + right: 16px; + width: 7px; + height: 7px; + border-right: 2px solid rgba(245, 222, 179, 0.55); + border-bottom: 2px solid rgba(245, 222, 179, 0.55); + pointer-events: none; + transform: translateY(-2px) rotate(45deg); +} + +.map-style-selector select { + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + min-width: 150px; + color: var(--color-primary); + background: var(--color-background-dark-light); + border: 1px solid var(--color-primary); + border-radius: 4px; + padding: 4px 30px 4px 8px; + font: inherit; +} + .map-control-btn.active { background: var(--color-background-dark-light); } diff --git a/src/main/resources/static/js/map-controls.js b/src/main/resources/static/js/map-controls.js index 91feb2616..86db9461a 100644 --- a/src/main/resources/static/js/map-controls.js +++ b/src/main/resources/static/js/map-controls.js @@ -13,6 +13,11 @@ class MapControls {
+ + +
+ + `; + }).join(''); + } + + openForm(style = null) { + this.editingMapStyleId = style?.id || null; + this.setError(''); + this.form.reset(); + + this.formTitle.textContent = style + ? t('map.settings.dialog.map-styles.edit-title') + : t('map.settings.dialog.map-styles.add-title'); + + const mapType = style?.mapType || 'vector'; + const styleInputType = style?.styleInputType || (String(style?.styleInput || '').trim().startsWith('{') ? 'json' : 'url'); + const rasterSourceInputType = style?.rasterSourceInputType || (style?.dataSource?.tileJsonUrl ? 'tilejson' : 'tile_template'); + + this.setRadioValue('custom-map-style-map-type', mapType); + this.setRadioValue('custom-map-style-style-input', styleInputType); + this.setRadioValue('custom-map-style-raster-input', rasterSourceInputType); + + this.root.querySelector('#custom-map-style-name').value = style?.label || ''; + const sharedInput = this.root.querySelector('#custom-map-style-shared'); + if (sharedInput) { + sharedInput.checked = !!style?.shared; + } + this.root.querySelector('#custom-map-style-url').value = mapType === 'vector' && styleInputType === 'url' ? (style?.styleInput || style?.styleUrl || '') : ''; + this.root.querySelector('#custom-map-style-json').value = mapType === 'vector' && styleInputType === 'json' ? (style?.styleInput || '') : ''; + this.root.querySelector('#custom-map-style-tilejson-url').value = style?.dataSource?.tileJsonUrl || ''; + this.root.querySelector('#custom-map-style-tile-template').value = style?.dataSource?.tileUrlTemplate || ''; + this.root.querySelector('#custom-map-style-attribution').value = rasterSourceInputType === 'tile_template' ? (style?.dataSource?.attribution || '') : ''; + this.root.querySelector('#custom-map-style-raster-attribution-override').value = rasterSourceInputType === 'tilejson' ? (style?.dataSource?.attribution || '') : ''; + this.root.querySelector('#custom-map-style-minzoom').value = style?.dataSource?.minzoom ?? ''; + this.root.querySelector('#custom-map-style-maxzoom').value = style?.dataSource?.maxzoom ?? ''; + this.root.querySelector('#custom-map-style-tile-size').value = style?.dataSource?.tileSize ?? ''; + this.root.querySelector('#custom-map-style-scheme').value = style?.dataSource?.scheme || ''; + + const options = style?.vectorOptions || {}; + this.root.querySelector('#custom-map-style-attribution-override').value = options.attributionOverride || ''; + this.root.querySelector('#custom-map-style-glyphs-url').value = options.glyphsUrlOverride || ''; + this.root.querySelector('#custom-map-style-sprite-url').value = options.spriteUrlOverride || ''; + + this.syncFormMode(); + this.form.classList.remove('hidden'); + this.root.querySelector('#custom-map-style-name').focus(); + } + + closeForm() { + this.editingMapStyleId = null; + this.setError(''); + this.form.reset(); + this.syncFormMode(); + this.form.classList.add('hidden'); + } + + syncFormMode() { + const mapType = this.radioValue('custom-map-style-map-type') || 'vector'; + const styleInputType = this.radioValue('custom-map-style-style-input') || 'url'; + const rasterSourceInputType = this.radioValue('custom-map-style-raster-input') || 'tile_template'; + + this.root.querySelectorAll('[data-map-style-panel]').forEach(panel => { + panel.classList.toggle('hidden', panel.dataset.mapStylePanel !== mapType); + }); + this.root.querySelector('#custom-map-style-url').closest('.form-group').classList.toggle('hidden', styleInputType !== 'url'); + this.root.querySelector('[data-style-json-group]').classList.toggle('hidden', styleInputType !== 'json'); + this.root.querySelectorAll('[data-raster-template-group]').forEach(group => { + group.classList.toggle('hidden', rasterSourceInputType !== 'tile_template'); + }); + this.root.querySelectorAll('[data-raster-tilejson-group]').forEach(group => { + group.classList.toggle('hidden', rasterSourceInputType !== 'tilejson'); + }); + } + + async saveForm() { + const payload = this.buildPayload(); + const validationError = this.validatePayload(payload); + if (validationError) { + this.setError(validationError); + return; + } + + try { + const response = await fetch(`${window.contextPath || ''}/settings/map-styles/api`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || t('map.settings.dialog.map-styles.error-save-status', [response.status])); + } + this.applySettings(await response.json()); + this.closeForm(); + this.refresh(); + } catch (error) { + console.warn('Unable to save custom map style:', error); + this.setError(error.message || t('map.settings.dialog.map-styles.error-save')); + } + } + + buildPayload() { + const mapType = this.radioValue('custom-map-style-map-type') || 'vector'; + const styleInputType = this.radioValue('custom-map-style-style-input') || 'url'; + const rasterSourceInputType = this.radioValue('custom-map-style-raster-input') || 'tile_template'; + const rasterAttribution = rasterSourceInputType === 'tilejson' + ? this.value('#custom-map-style-raster-attribution-override') + : this.value('#custom-map-style-attribution'); + + return { + id: this.editingMapStyleId, + label: this.value('#custom-map-style-name'), + mapType, + styleInputType, + rasterSourceInputType, + shared: !!window.reittiIsAdmin && !!this.root.querySelector('#custom-map-style-shared')?.checked, + styleInput: mapType === 'vector' + ? (styleInputType === 'json' ? this.value('#custom-map-style-json') : this.value('#custom-map-style-url')) + : '', + dataSource: { + type: mapType === 'raster' ? 'raster' : 'vector', + tileJsonUrl: mapType === 'raster' && rasterSourceInputType === 'tilejson' ? this.value('#custom-map-style-tilejson-url') : '', + tileUrlTemplate: mapType === 'raster' && rasterSourceInputType === 'tile_template' ? this.value('#custom-map-style-tile-template') : '', + attribution: mapType === 'raster' ? rasterAttribution : '', + minzoom: mapType === 'raster' ? this.optionalNumberValue('#custom-map-style-minzoom') : null, + maxzoom: mapType === 'raster' ? this.optionalNumberValue('#custom-map-style-maxzoom') : null, + tileSize: mapType === 'raster' ? this.optionalNumberValue('#custom-map-style-tile-size') : null, + scheme: mapType === 'raster' ? this.value('#custom-map-style-scheme') : '' + }, + vectorOptions: { + attributionOverride: mapType === 'vector' ? this.value('#custom-map-style-attribution-override') : '', + glyphsUrlOverride: mapType === 'vector' ? this.value('#custom-map-style-glyphs-url') : '', + spriteUrlOverride: mapType === 'vector' ? this.value('#custom-map-style-sprite-url') : '' + } + }; + } + + validatePayload(payload) { + if (!payload.label) { + return t('map.settings.dialog.map-styles.error-name-required'); + } + if (!['vector', 'raster'].includes(payload.mapType)) { + return t('map.settings.dialog.map-styles.error-map-type'); + } + + if (payload.mapType === 'vector') { + return this.validateVectorPayload(payload); + } + return this.validateRasterPayload(payload); + } + + validateVectorPayload(payload) { + if (payload.styleInputType === 'url' && !payload.styleInput) { + return t('map.settings.dialog.map-styles.error-style-url-required'); + } + if (payload.styleInputType === 'json') { + if (!payload.styleInput) { + return t('map.settings.dialog.map-styles.error-style-json-required'); + } + try { + JSON.parse(payload.styleInput); + } catch (error) { + return t('map.settings.dialog.map-styles.error-json'); + } + } + + return ''; + } + + validateRasterPayload(payload) { + const source = payload.dataSource; + if (payload.rasterSourceInputType === 'tile_template') { + if (!source.tileUrlTemplate) { + return t('map.settings.dialog.map-styles.error-tile-template-required'); + } + if (!['{z}', '{x}', '{y}'].every(part => source.tileUrlTemplate.includes(part))) { + return t('map.settings.dialog.map-styles.error-tile-template-placeholders'); + } + } else if (!source.tileJsonUrl) { + return t('map.settings.dialog.map-styles.error-tilejson-required'); + } + + if (source.minzoom !== null && source.maxzoom !== null && source.minzoom >= source.maxzoom) { + return t('map.settings.dialog.map-styles.error-zoom-order'); + } + if ((source.minzoom !== null && !Number.isFinite(source.minzoom)) || (source.maxzoom !== null && !Number.isFinite(source.maxzoom))) { + return t('map.settings.dialog.map-styles.error-zoom-number'); + } + if (source.tileSize !== null && ![256, 512].includes(source.tileSize)) { + return t('map.settings.dialog.map-styles.error-tile-size'); + } + if (source.scheme && !['xyz', 'tms'].includes(source.scheme)) { + return t('map.settings.dialog.map-styles.error-scheme'); + } + return ''; + } + + async deleteStyle(styleId) { + if (!styleId || !confirm(t('map.settings.dialog.map-styles.remove-confirm'))) return; + const id = this.resolveCustomId(styleId); + if (!id) return; + + try { + const response = await fetch(`${window.contextPath || ''}/settings/map-styles/api/${id}`, { + method: 'DELETE' + }); + if (!response.ok) { + throw new Error(`Deleting map style failed with ${response.status}`); + } + await this.reloadSettings(); + this.closeForm(); + } catch (error) { + console.warn('Unable to delete custom map style:', error); + this.setError(t('map.settings.dialog.map-styles.error-delete')); + } + } + + async setActiveStyle(styleId) { + try { + const response = await fetch(`${window.contextPath || ''}/settings/map-styles/api/active`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({activeStyleId: styleId}) + }); + if (!response.ok) { + throw new Error(`Saving active map style failed with ${response.status}`); + } + this.applySettings(await response.json()); + this.refresh(); + } catch (error) { + console.warn('Unable to save active map style:', error); + this.setError(t('map.settings.dialog.map-styles.error-active')); + } + } + + async reloadSettings() { + const response = await fetch(`${window.contextPath || ''}/settings/map-styles/api`); + if (!response.ok) { + throw new Error(`Loading map styles failed with ${response.status}`); + } + this.applySettings(await response.json()); + this.refresh(); + } + + applySettings(settings) { + window.reittiCustomMapStyles = Array.isArray(settings.customStyles) ? settings.customStyles : []; + window.reittiActiveMapStyleId = settings.activeStyleId || MapRenderer.getDefaultMapStyleId(); + window.localStorage?.setItem('mapStyleId', window.reittiActiveMapStyleId); + MapRenderer.dispatchMapStylesChanged?.(window.reittiActiveMapStyleId); + } + + resolveCustomId(styleId) { + if (!styleId?.startsWith('custom-')) return null; + return styleId.substring('custom-'.length); + } + + setError(message) { + this.error.textContent = message || ''; + this.error.classList.toggle('visible', !!message); + } + + setRadioValue(name, value) { + const input = this.root.querySelector(`input[name="${name}"][value="${value}"]`); + if (input) { + input.checked = true; + } + } + + radioValue(name) { + return this.root.querySelector(`input[name="${name}"]:checked`)?.value || ''; + } + + value(selector) { + return this.root.querySelector(selector)?.value.trim() || ''; + } + + optionalNumberValue(selector) { + const rawValue = this.value(selector); + if (rawValue === '') return null; + const number = Number(rawValue); + return Number.isFinite(number) ? number : NaN; + } + + escapeHtml(value) { + return String(value).replace(/[&<>"']/g, character => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[character])); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const root = document.getElementById('map-style-settings-page'); + if (root) { + new MapStyleSettingsPage(root); + } +}); diff --git a/src/main/resources/templates/fragments/settings-navigation.html b/src/main/resources/templates/fragments/settings-navigation.html index 1dd5183dd..4f864c99c 100644 --- a/src/main/resources/templates/fragments/settings-navigation.html +++ b/src/main/resources/templates/fragments/settings-navigation.html @@ -55,6 +55,12 @@ th:title="#{settings.transportation-modes.description}" th:text="#{settings.transportation-modes}">Transportion Modes + Map Styles + + + + + + + Settings - Reitti + + + + + + + + + + + + + + + +
+
+
+
+
+

Map Styles

+

+ Manage custom MapLibre styles and optional data sources. +

+ +
+
+ + +
+
+ +
+
+

Custom Styles

+ +
+ +
+ + +
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/test/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidatorTest.java b/src/test/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidatorTest.java new file mode 100644 index 000000000..e547e6782 --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidatorTest.java @@ -0,0 +1,64 @@ +package com.dedicatedcode.reitti.service; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RemoteTileUrlValidatorTest { + + @Test + void acceptsPublicHttpUrlsAndTemplates() { + RemoteTileUrlValidator validator = new RemoteTileUrlValidator(false); + + assertDoesNotThrow(() -> validator.requirePublicHttpUrl("https://tile.openstreetmap.org/0/0/0.png", "Tile URL")); + assertDoesNotThrow(() -> validator.requirePublicHttpTemplate("https://tile.openstreetmap.org/{z}/{x}/{y}.png", "Tile URL template")); + } + + @Test + void rejectsPrivateAndLocalTargetsByDefault() { + RemoteTileUrlValidator validator = new RemoteTileUrlValidator(false); + + assertThrows(IllegalArgumentException.class, () -> validator.requirePublicHttpUrl("http://localhost:8080/test", "Tile URL")); + assertThrows(IllegalArgumentException.class, () -> validator.requirePublicHttpUrl("http://127.0.0.1/test", "Tile URL")); + assertThrows(IllegalArgumentException.class, () -> validator.requirePublicHttpUrl("http://10.0.0.10/test", "Tile URL")); + assertThrows(IllegalArgumentException.class, () -> validator.requirePublicHttpUrl("http://192.168.1.10/test", "Tile URL")); + } + + @Test + void acceptsPrivateAndLocalTargetsWhenOnlyValidatingUrlShape() { + RemoteTileUrlValidator validator = new RemoteTileUrlValidator(false); + + assertDoesNotThrow(() -> validator.requireHttpUrl("http://localhost:8080/style.json", "Style URL")); + assertDoesNotThrow(() -> validator.requireHttpTemplate("http://192.168.1.10/{z}/{x}/{y}.png", "Tile URL template")); + } + + @Test + void rejectsUnsupportedSchemesAndEmbeddedCredentials() { + RemoteTileUrlValidator validator = new RemoteTileUrlValidator(false); + + assertThrows(IllegalArgumentException.class, () -> validator.requirePublicHttpUrl("file:///etc/passwd", "Tile URL")); + assertThrows(IllegalArgumentException.class, () -> validator.requirePublicHttpUrl("https://user:secret@example.com/tile.png", "Tile URL")); + assertThrows(IllegalArgumentException.class, () -> validator.requireHttpUrl("file:///etc/passwd", "Tile URL")); + assertThrows(IllegalArgumentException.class, () -> validator.requireHttpUrl("https://user:secret@example.com/tile.png", "Tile URL")); + } + + @Test + void canProxyLocalUrlsForInternalDeployments() { + RemoteTileUrlValidator validator = new RemoteTileUrlValidator(true); + + assertDoesNotThrow(() -> validator.requirePublicHttpUrl("http://127.0.0.1/test", "Tile URL")); + } + + @Test + void detectsPrivateAndLocalHttpUrlsIndependentlyOfProxySetting() { + RemoteTileUrlValidator validator = new RemoteTileUrlValidator(true); + + assertTrue(validator.isValidLocalUrl("http://127.0.0.1/test")); + assertTrue(validator.isValidLocalUrl("http://192.168.1.10/test")); + assertTrue(validator.isValidLocalTemplate("http://192.168.1.10/{z}/{x}/{y}.png")); + assertFalse(validator.isValidLocalUrl("https://tile.openstreetmap.org/0/0/0.png")); + } +} diff --git a/src/test/java/com/dedicatedcode/reitti/service/TileProxySignatureServiceTest.java b/src/test/java/com/dedicatedcode/reitti/service/TileProxySignatureServiceTest.java new file mode 100644 index 000000000..259005f6f --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/service/TileProxySignatureServiceTest.java @@ -0,0 +1,27 @@ +package com.dedicatedcode.reitti.service; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TileProxySignatureServiceTest { + + @Test + void validatesSignaturesCreatedWithSameConfiguredSecret() { + TileProxySignatureService signer = new TileProxySignatureService("stable-secret"); + TileProxySignatureService verifier = new TileProxySignatureService("stable-secret"); + + String value = "https://tiles.example.test/{z}/{x}/{y}.pbf"; + assertTrue(verifier.isValid(value, signer.sign(value))); + } + + @Test + void rejectsSignaturesCreatedWithDifferentSecret() { + TileProxySignatureService signer = new TileProxySignatureService("first-secret"); + TileProxySignatureService verifier = new TileProxySignatureService("second-secret"); + + String value = "https://tiles.example.test/{z}/{x}/{y}.pbf"; + assertFalse(verifier.isValid(value, signer.sign(value))); + } +} diff --git a/src/test/java/com/dedicatedcode/reitti/service/TileUrlUtilsTest.java b/src/test/java/com/dedicatedcode/reitti/service/TileUrlUtilsTest.java new file mode 100644 index 000000000..7444a4874 --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/service/TileUrlUtilsTest.java @@ -0,0 +1,18 @@ +package com.dedicatedcode.reitti.service; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TileUrlUtilsTest { + + @Test + void extractsExtensionAfterRetinaSuffix() { + assertEquals("png", TileUrlUtils.extractTileExtension("http://192.168.178.51:8925/{z}/{x}/{y}@2x.png")); + } + + @Test + void extractsExtensionAfterPlaceholderSuffix() { + assertEquals("webp", TileUrlUtils.extractTileExtension("https://example.com/{z}/{x}/{y}{r}.webp")); + } +} From 262a7dbc3b3249c9a4bd98198d8bfec7b244ddd2 Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Mon, 4 May 2026 21:41:09 +0000 Subject: [PATCH 02/13] remove tile sign, improve user flow --- README.md | 2 - docker/tiles-cache/nginx.conf | 25 +- .../controller/api/MapStyleController.java | 452 ++++++++---------- .../controller/api/TileProxyController.java | 445 +++++++++++++---- .../settings/MapStylesSettingsController.java | 6 +- .../reitti/model/map/MapStyleDataSource.java | 3 +- .../repository/UserMapStyleJdbcService.java | 331 +++---------- .../reitti/service/I18nService.java | 8 - .../reitti/service/MapStylePathUtils.java | 25 + .../reitti/service/MapStyleUrlValidator.java | 64 +++ .../service/MemoryBlockGenerationService.java | 2 +- .../service/RemoteTileUrlValidator.java | 208 -------- .../reitti/service/SafeHttpClient.java | 79 --- .../service/TileProxySignatureService.java | 47 -- .../reitti/service/UserMapStyleValidator.java | 207 ++++++++ .../resources/application-docker.properties | 2 - src/main/resources/application.properties | 2 - .../V92__add_map_style_proxy_flag.sql | 2 + src/main/resources/messages.properties | 13 +- .../resources/static/js/map-style-settings.js | 10 +- .../templates/settings/map-styles.html | 13 +- .../api/MapStyleControllerTest.java | 76 +++ .../api/TileProxyControllerTest.java | 235 +++++++++ .../service/MapStyleUrlValidatorTest.java | 25 + .../service/RemoteTileUrlValidatorTest.java | 64 --- .../TileProxySignatureServiceTest.java | 27 -- 26 files changed, 1284 insertions(+), 1089 deletions(-) create mode 100644 src/main/java/com/dedicatedcode/reitti/service/MapStylePathUtils.java create mode 100644 src/main/java/com/dedicatedcode/reitti/service/MapStyleUrlValidator.java delete mode 100644 src/main/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidator.java delete mode 100644 src/main/java/com/dedicatedcode/reitti/service/SafeHttpClient.java delete mode 100644 src/main/java/com/dedicatedcode/reitti/service/TileProxySignatureService.java create mode 100644 src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java create mode 100644 src/main/resources/db/migration/V92__add_map_style_proxy_flag.sql create mode 100644 src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java create mode 100644 src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java create mode 100644 src/test/java/com/dedicatedcode/reitti/service/MapStyleUrlValidatorTest.java delete mode 100644 src/test/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidatorTest.java delete mode 100644 src/test/java/com/dedicatedcode/reitti/service/TileProxySignatureServiceTest.java diff --git a/README.md b/README.md index 72d84cabe..347e0a839 100644 --- a/README.md +++ b/README.md @@ -216,8 +216,6 @@ The included `docker-compose.yml` provides a complete setup with: | `PROCESSING_WAIT_TIME` | How many seconds to wait after the last data input before starting to process all unprocessed data. (⚠️ This needs to be lower than your integrated app reports data in Reitti) | 15 | 15 | | `DANGEROUS_LIFE` | Enables data management features that can reset/delete all database data (⚠️ USE WITH CAUTION) | false | true | | `TILES_CACHE` | The url of the tile caching proxy (Set to '' to disable the cache) | http://tile-cache | | -| `TILE_PROXY_SIGNATURE_SECRET` | Signing secret for proxied tile URLs. Set explicitly to share sessions across instances and persist them across restarts - otherwise regenerated per instance restart. | | SOME__EXAMPLE_SECRET_123 | -| `PROXY_LOCAL_TILE_URLS` | Allow the backend/tile-cache proxy to fetch local network tile URLs. Disable unless styles need proxy access to the local network; when disabled, browsers fetch local URLs directly. | false | true | | `PROCESSING_BATCH_SIZE` | How many geo points should we handle at once. For low-memory environment it could be needed to set this to 100. | 1000 | 100 | | `SERVER_PORT` | Application server port | 8080 | 8080 | | `APP_UID` | User ID to run the application as | 1000 | 1000 | diff --git a/docker/tiles-cache/nginx.conf b/docker/tiles-cache/nginx.conf index 7750c0847..34530b173 100644 --- a/docker/tiles-cache/nginx.conf +++ b/docker/tiles-cache/nginx.conf @@ -93,8 +93,7 @@ http { access_log /var/log/nginx/access.log main; server_tokens off; - # Public fallback dns can leak internal hostnames if this proxy is ever used with untrusted upstream names. - resolver 127.0.0.11 1.1.1.1 8.8.8.8 ipv6=off valid=300s; + resolver 127.0.0.11 ipv6=off valid=300s; server { listen 80; @@ -157,6 +156,28 @@ http { expires 1y; } + location /custom-vector/ { + set $custom_upstream $http_x_reitti_upstream_url; + if ($custom_upstream = "") { + return 400; + } + + proxy_pass $custom_upstream; + proxy_set_header Host $proxy_host; + proxy_set_header User-Agent "Reitti/1.0"; + proxy_ssl_server_name on; + + proxy_cache custom_tiles; + proxy_cache_key "$custom_upstream"; + proxy_cache_valid 200 302 1y; + proxy_cache_valid 404 1m; + proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504; + + add_header X-Cache-Status $upstream_cache_status; + add_header Cache-Control $tile_client_cache_control; + expires 1y; + } + location /terrain/ { proxy_pass https://tiles.mapterhorn.com/; proxy_set_header Host tiles.mapterhorn.com; diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java index 3884a3ff0..b859b5c74 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java @@ -7,9 +7,8 @@ import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService; import com.dedicatedcode.reitti.repository.UserSettingsJdbcService; import com.dedicatedcode.reitti.service.ContextPathHolder; -import com.dedicatedcode.reitti.service.RemoteTileUrlValidator; -import com.dedicatedcode.reitti.service.SafeHttpClient; -import com.dedicatedcode.reitti.service.TileProxySignatureService; +import com.dedicatedcode.reitti.service.MapStylePathUtils; +import com.dedicatedcode.reitti.service.MapStyleUrlValidator; import com.dedicatedcode.reitti.service.TileUrlUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -36,13 +35,12 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.Base64; import java.util.Optional; -import java.util.concurrent.TimeUnit; @RestController @RequestMapping("/map") public class MapStyleController { + private static final String VECTOR_TILEJSON_URL = "https://tiles.dedicatedcode.com/planet"; private static final String TERRAIN_TILE_URL = "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp"; private static final String SATELLITE_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"; @@ -54,48 +52,35 @@ public class MapStyleController { private final ContextPathHolder contextPathHolder; private final UserSettingsJdbcService userSettingsJdbcService; private final UserMapStyleJdbcService userMapStyleJdbcService; - private final TileProxySignatureService tileProxySignatureService; - private final RemoteTileUrlValidator remoteTileUrlValidator; - private final SafeHttpClient safeHttpClient; + private final MapStyleUrlValidator mapStyleUrlValidator; private final HttpClient httpClient; private final boolean tileCacheEnabled; - + public MapStyleController( ObjectMapper objectMapper, ContextPathHolder contextPathHolder, UserSettingsJdbcService userSettingsJdbcService, UserMapStyleJdbcService userMapStyleJdbcService, - TileProxySignatureService tileProxySignatureService, - RemoteTileUrlValidator remoteTileUrlValidator, - SafeHttpClient safeHttpClient, + MapStyleUrlValidator mapStyleUrlValidator, @Value("${reitti.ui.tiles.cache.url:}") String cacheUrl) { this.objectMapper = objectMapper; this.contextPathHolder = contextPathHolder; this.userSettingsJdbcService = userSettingsJdbcService; this.userMapStyleJdbcService = userMapStyleJdbcService; - this.tileProxySignatureService = tileProxySignatureService; - this.remoteTileUrlValidator = remoteTileUrlValidator; - this.safeHttpClient = safeHttpClient; + this.mapStyleUrlValidator = mapStyleUrlValidator; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) - .followRedirects(HttpClient.Redirect.NEVER) + .followRedirects(HttpClient.Redirect.NORMAL) .build(); this.tileCacheEnabled = StringUtils.hasText(cacheUrl); } @GetMapping(value = "/reitti.json", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity getStyle(@AuthenticationPrincipal User user, HttpServletRequest request) throws IOException { - ClassPathResource resource = new ClassPathResource("static/map/reitti.json"); - ClassPathResource coloredResource = new ClassPathResource("static/map/colored.json"); - - JsonNode style; - if (this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).isPreferColoredMap()) { - style = objectMapper.readTree(coloredResource.getInputStream()); - } else { - style = objectMapper.readTree(resource.getInputStream()); - } - - return buildStyleResponse(style, request); + boolean preferColored = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).isPreferColoredMap(); + String stylePath = preferColored ? "static/map/colored.json" : "static/map/reitti.json"; + JsonNode style = objectMapper.readTree(new ClassPathResource(stylePath).getInputStream()); + return buildStyleResponse(style, request, "reitti", true); } @GetMapping(value = "/custom/{id}.json", produces = MediaType.APPLICATION_JSON_VALUE) @@ -104,12 +89,11 @@ public ResponseEntity getUserCustomStyle(@AuthenticationPrincipal User if (style.isEmpty()) { return ResponseEntity.notFound().build(); } - try { JsonNode styleJson = readUserStyle(style.get()); styleJson = applyVectorOptions(styleJson, style.get().vectorOptions()); styleJson = applyCustomDataSource(styleJson, style.get().dataSource()); - return buildStyleResponse(styleJson, request); + return buildStyleResponse(styleJson, request, style.get().frontendId(), shouldProxyTiles(style.get())); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().build(); } catch (IOException e) { @@ -121,31 +105,25 @@ private JsonNode readUserStyle(UserMapStyle style) throws IOException, Interrupt if ("raster".equals(style.mapType())) { return buildRasterStyle(style); } - if (StringUtils.hasText(style.styleJson())) { return objectMapper.readTree(style.styleJson()); } + return fetchRemoteStyle(style); + } - HttpRequest request = HttpRequest.newBuilder(remoteTileUrlValidator.requirePublicHttpUrl(style.styleUrl(), "Vector style URL")) + private JsonNode fetchRemoteStyle(UserMapStyle style) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(mapStyleUrlValidator.requireHttpUrl(style.styleUrl(), "Vector style URL")) .timeout(Duration.ofSeconds(20)) .header("Accept", "application/json") .GET() .build(); - HttpResponse response = safeHttpClient.sendFollowingPublicRedirects( - httpClient, - request, - HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8), - "Vector style URL" - ); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() < 200 || response.statusCode() >= 300) { throw new IOException("Unable to fetch map style: " + response.statusCode()); } - JsonNode json = objectMapper.readTree(response.body()); if (json instanceof ObjectNode objectNode) { - ObjectNode metadata = objectNode.has("metadata") && objectNode.get("metadata") instanceof ObjectNode existing - ? existing - : objectMapper.createObjectNode(); + ObjectNode metadata = getOrCreateMetadata(objectNode); metadata.put("reitti:style-url", style.styleUrl()); objectNode.set("metadata", metadata); } @@ -154,56 +132,68 @@ private JsonNode readUserStyle(UserMapStyle style) throws IOException, Interrupt private JsonNode buildRasterStyle(UserMapStyle style) { MapStyleDataSource dataSource = style.dataSource(); - ObjectNode styleJson = objectMapper.createObjectNode(); - styleJson.put("version", 8); - styleJson.put("name", style.name()); - ObjectNode sources = objectMapper.createObjectNode(); ObjectNode source = objectMapper.createObjectNode(); source.put("type", "raster"); - if (StringUtils.hasText(dataSource.tileJsonUrl())) { - source.put("url", dataSource.tileJsonUrl()); - } else if (StringUtils.hasText(dataSource.tileUrlTemplate())) { - ArrayNode tiles = objectMapper.createArrayNode(); - tiles.add(dataSource.tileUrlTemplate()); - source.set("tiles", tiles); - } - if (StringUtils.hasText(dataSource.attribution())) { - source.put("attribution", dataSource.attribution()); - } - source.put("minzoom", dataSource.minzoom() != null ? dataSource.minzoom() : 0); - source.put("maxzoom", dataSource.maxzoom() != null ? dataSource.maxzoom() : 19); + populateDataSourceFields(source, dataSource); source.put("tileSize", effectiveRasterTileSize(dataSource)); source.put("scheme", StringUtils.hasText(dataSource.scheme()) ? dataSource.scheme() : "xyz"); + + ObjectNode sources = objectMapper.createObjectNode(); sources.set("custom-raster-source", source); - styleJson.set("sources", sources); - ArrayNode layers = objectMapper.createArrayNode(); ObjectNode rasterLayer = objectMapper.createObjectNode(); rasterLayer.put("id", "custom-raster-layer"); rasterLayer.put("type", "raster"); rasterLayer.put("source", "custom-raster-source"); + + ArrayNode layers = objectMapper.createArrayNode(); layers.add(rasterLayer); - styleJson.set("layers", layers); + ObjectNode styleJson = objectMapper.createObjectNode(); + styleJson.put("version", 8); + styleJson.put("name", style.name()); + styleJson.set("sources", sources); + styleJson.set("layers", layers); return styleJson; } private int effectiveRasterTileSize(MapStyleDataSource dataSource) { - if (StringUtils.hasText(dataSource.tileUrlTemplate())) { - String tileUrlTemplate = dataSource.tileUrlTemplate(); - if (tileUrlTemplate.contains("{r}") || tileUrlTemplate.contains("@2x")) { - return 256; - } + String tileUrlTemplate = dataSource.tileUrlTemplate(); + if (StringUtils.hasText(tileUrlTemplate) && (tileUrlTemplate.contains("{r}") || tileUrlTemplate.contains("@2x"))) { + return 256; } return dataSource.tileSize() != null ? dataSource.tileSize() : 256; } + private void populateDataSourceFields(ObjectNode source, MapStyleDataSource dataSource) { + if (StringUtils.hasText(dataSource.tileJsonUrl())) { + source.put("url", dataSource.tileJsonUrl()); + } + if (StringUtils.hasText(dataSource.tileUrlTemplate())) { + source.set("tiles", singleTileArray(dataSource.tileUrlTemplate())); + } + if (StringUtils.hasText(dataSource.attribution())) { + source.put("attribution", dataSource.attribution()); + } + if (dataSource.minzoom() != null) { + source.put("minzoom", dataSource.minzoom()); + } + if (dataSource.maxzoom() != null) { + source.put("maxzoom", dataSource.maxzoom()); + } + if (dataSource.tileSize() != null) { + source.put("tileSize", dataSource.tileSize()); + } + if (StringUtils.hasText(dataSource.scheme())) { + source.put("scheme", dataSource.scheme()); + } + } + private JsonNode applyVectorOptions(JsonNode style, MapStyleVectorOptions options) { if (options == null) { return style; } - ObjectNode mutableStyle = style.deepCopy(); if (StringUtils.hasText(options.glyphsUrlOverride())) { mutableStyle.put("glyphs", options.glyphsUrlOverride()); @@ -212,24 +202,26 @@ private JsonNode applyVectorOptions(JsonNode style, MapStyleVectorOptions option mutableStyle.put("sprite", options.spriteUrlOverride()); } if (StringUtils.hasText(options.attributionOverride())) { - ObjectNode metadata = mutableStyle.has("metadata") && mutableStyle.get("metadata") instanceof ObjectNode existing - ? existing - : objectMapper.createObjectNode(); - metadata.put("reitti:attribution-override", options.attributionOverride()); - mutableStyle.set("metadata", metadata); - - JsonNode sources = mutableStyle.get("sources"); - if (sources instanceof ObjectNode sourcesObject) { - sourcesObject.fields().forEachRemaining(entry -> { - if (entry.getValue() instanceof ObjectNode source) { - source.put("attribution", options.attributionOverride()); - } - }); - } + applyAttributionOverride(mutableStyle, options.attributionOverride()); } return mutableStyle; } + private void applyAttributionOverride(ObjectNode mutableStyle, String attribution) { + ObjectNode metadata = getOrCreateMetadata(mutableStyle); + metadata.put("reitti:attribution-override", attribution); + mutableStyle.set("metadata", metadata); + + JsonNode sources = mutableStyle.get("sources"); + if (sources instanceof ObjectNode sourcesObject) { + sourcesObject.fields().forEachRemaining(entry -> { + if (entry.getValue() instanceof ObjectNode source) { + source.put("attribution", attribution); + } + }); + } + } + private JsonNode applyCustomDataSource(JsonNode style, MapStyleDataSource dataSource) { if (dataSource == null || !StringUtils.hasText(dataSource.sourceId())) { return style; @@ -237,103 +229,84 @@ private JsonNode applyCustomDataSource(JsonNode style, MapStyleDataSource dataSo if (!StringUtils.hasText(dataSource.tileJsonUrl()) && !StringUtils.hasText(dataSource.tileUrlTemplate())) { return style; } - ObjectNode mutableStyle = style.deepCopy(); ObjectNode sources = ensureSourcesNode(mutableStyle); ObjectNode source = objectMapper.createObjectNode(); source.put("type", StringUtils.hasText(dataSource.type()) ? dataSource.type() : "vector"); - if (StringUtils.hasText(dataSource.tileJsonUrl())) { - source.put("url", dataSource.tileJsonUrl()); - } - if (StringUtils.hasText(dataSource.tileUrlTemplate())) { - ArrayNode tiles = objectMapper.createArrayNode(); - tiles.add(dataSource.tileUrlTemplate()); - source.set("tiles", tiles); - } - if (StringUtils.hasText(dataSource.attribution())) { - source.put("attribution", dataSource.attribution()); - } - if (dataSource.minzoom() != null) { - source.put("minzoom", dataSource.minzoom()); - } - if (dataSource.maxzoom() != null) { - source.put("maxzoom", dataSource.maxzoom()); - } - if (dataSource.tileSize() != null) { - source.put("tileSize", dataSource.tileSize()); - } - if (StringUtils.hasText(dataSource.scheme())) { - source.put("scheme", dataSource.scheme()); - } + populateDataSourceFields(source, dataSource); sources.set(dataSource.sourceId(), source); return mutableStyle; } - private ResponseEntity buildStyleResponse(JsonNode style, HttpServletRequest request) { + private ResponseEntity buildStyleResponse(JsonNode style, HttpServletRequest request, String styleId, boolean proxyCustomTiles) { style = ensureRuntimeSources(style, request); - - if (this.tileCacheEnabled) { - style = rewriteUrlsForProxy(style, request); + if (proxyCustomTiles) { + style = rewriteUrlsForProxy(style, request, styleId); } - - if (!this.contextPathHolder.getContextPath().equals("/")) { - style = rewriteResourceUrls(style, request); + if (!contextPathHolder.getContextPath().equals("/")) { + style = rewriteResourceUrls(style); } - return ResponseEntity.ok() - .cacheControl(CacheControl.noCache().cachePrivate()) - .body(style); + .cacheControl(CacheControl.noCache().cachePrivate()) + .body(style); } private JsonNode ensureRuntimeSources(JsonNode style, HttpServletRequest request) { ObjectNode mutableStyle = style.deepCopy(); - ObjectNode mutableSources = ensureSourcesNode(mutableStyle); + ObjectNode sources = ensureSourcesNode(mutableStyle); String baseUrl = getBaseUrl(request); - if (!mutableSources.has(RUNTIME_TERRAIN_SOURCE)) { - ObjectNode terrainSource = objectMapper.createObjectNode(); - terrainSource.put("type", "raster-dem"); - ArrayNode tiles = objectMapper.createArrayNode(); - tiles.add(this.tileCacheEnabled ? baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp" : TERRAIN_TILE_URL); - terrainSource.set("tiles", tiles); - terrainSource.put("tileSize", 256); - terrainSource.put("encoding", "terrarium"); - terrainSource.put("maxzoom", 14); - terrainSource.put("attribution", "©
Mapterhorn"); - mutableSources.set(RUNTIME_TERRAIN_SOURCE, terrainSource); - } - - if (!mutableSources.has(RUNTIME_SATELLITE_SOURCE)) { - ObjectNode satelliteSource = objectMapper.createObjectNode(); - satelliteSource.put("type", "raster"); - ArrayNode tiles = objectMapper.createArrayNode(); - tiles.add(this.tileCacheEnabled ? baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg" : SATELLITE_TILE_URL); - satelliteSource.set("tiles", tiles); - satelliteSource.put("tileSize", 256); - satelliteSource.put("maxzoom", 18); - satelliteSource.put("attribution", "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community"); - mutableSources.set(RUNTIME_SATELLITE_SOURCE, satelliteSource); - } - - if (!styleHasBuildingLayer(mutableStyle) && !mutableSources.has(RUNTIME_BUILDING_SOURCE)) { - ObjectNode buildingSource = objectMapper.createObjectNode(); - buildingSource.put("type", "vector"); - buildingSource.put("url", VECTOR_TILEJSON_URL); - buildingSource.put("minzoom", 0); - buildingSource.put("maxzoom", 14); - buildingSource.put("attribution", "© OpenFreeMap © OSM"); - mutableSources.set(RUNTIME_BUILDING_SOURCE, buildingSource); + if (!sources.has(RUNTIME_TERRAIN_SOURCE)) { + String tileUrl = tileCacheEnabled ? baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp" : TERRAIN_TILE_URL; + sources.set(RUNTIME_TERRAIN_SOURCE, buildTerrainSource(tileUrl)); + } + if (!sources.has(RUNTIME_SATELLITE_SOURCE)) { + String tileUrl = tileCacheEnabled ? baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg" : SATELLITE_TILE_URL; + sources.set(RUNTIME_SATELLITE_SOURCE, buildSatelliteSource(tileUrl)); + } + if (!styleHasBuildingLayer(mutableStyle) && !sources.has(RUNTIME_BUILDING_SOURCE)) { + sources.set(RUNTIME_BUILDING_SOURCE, buildBuildingSource()); } return mutableStyle; } + private ObjectNode buildTerrainSource(String tileUrl) { + ObjectNode source = objectMapper.createObjectNode(); + source.put("type", "raster-dem"); + source.set("tiles", singleTileArray(tileUrl)); + source.put("tileSize", 256); + source.put("encoding", "terrarium"); + source.put("maxzoom", 14); + source.put("attribution", "© Mapterhorn"); + return source; + } + + private ObjectNode buildSatelliteSource(String tileUrl) { + ObjectNode source = objectMapper.createObjectNode(); + source.put("type", "raster"); + source.set("tiles", singleTileArray(tileUrl)); + source.put("tileSize", 256); + source.put("maxzoom", 18); + source.put("attribution", "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community"); + return source; + } + + private ObjectNode buildBuildingSource() { + ObjectNode source = objectMapper.createObjectNode(); + source.put("type", "vector"); + source.put("url", VECTOR_TILEJSON_URL); + source.put("minzoom", 0); + source.put("maxzoom", 14); + source.put("attribution", "© OpenFreeMap © OSM"); + return source; + } + private boolean styleHasBuildingLayer(ObjectNode style) { JsonNode layers = style.get("layers"); if (!(layers instanceof ArrayNode layerArray)) { return false; } - for (JsonNode layer : layerArray) { String layerType = layer.path("type").asText(""); if (!"fill".equals(layerType) && !"fill-extrusion".equals(layerType)) { @@ -348,129 +321,102 @@ private boolean styleHasBuildingLayer(ObjectNode style) { return false; } - private JsonNode rewriteResourceUrls(JsonNode style, HttpServletRequest request) { + private JsonNode rewriteResourceUrls(JsonNode style) { ObjectNode mutableStyle = style.deepCopy(); JsonNode glyphs = mutableStyle.get("glyphs"); if (glyphs instanceof TextNode glyphsText && glyphsText.asText().startsWith("/")) { - mutableStyle.set("glyphs", new TextNode(this.contextPathHolder.getContextPath() + glyphsText.asText())); + mutableStyle.set("glyphs", new TextNode(contextPathHolder.getContextPath() + glyphsText.asText())); } return mutableStyle; } - private JsonNode rewriteUrlsForProxy(JsonNode style, HttpServletRequest request) { + private JsonNode rewriteUrlsForProxy(JsonNode style, HttpServletRequest request, String styleId) { ObjectNode mutableStyle = style.deepCopy(); String baseUrl = getBaseUrl(request); - JsonNode sources = mutableStyle.get("sources"); - if (sources instanceof ObjectNode) { - ObjectNode mutableSources = (ObjectNode) sources; - URI styleBaseUri = getStyleBaseUri(mutableStyle).orElse(null); - - mutableSources.fields().forEachRemaining(entry -> { - if (entry.getValue() instanceof ObjectNode source) { - String sourceType = source.path("type").asText(); - if ("vector".equals(sourceType)) { - rewriteVectorSource(source, baseUrl, styleBaseUri); - } else if ("raster".equals(sourceType)) { - rewriteCustomRasterSource(source, baseUrl, styleBaseUri); - } - } - }); - - rewriteRasterSource(mutableSources, "terrain-source", baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"); - rewriteRasterSource(mutableSources, "satellite-source", baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"); - rewriteRasterSource(mutableSources, RUNTIME_TERRAIN_SOURCE, baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"); - rewriteRasterSource(mutableSources, RUNTIME_SATELLITE_SOURCE, baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"); + if (!(sources instanceof ObjectNode mutableSources)) { + return mutableStyle; } - + URI styleBaseUri = getStyleBaseUri(mutableStyle).orElse(null); + mutableSources.fields().forEachRemaining(entry -> { + if (entry.getValue() instanceof ObjectNode source) { + rewriteTileSource(source, baseUrl, styleId, entry.getKey(), styleBaseUri); + } + }); + rewriteRasterSource(mutableSources, "terrain-source", baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"); + rewriteRasterSource(mutableSources, "satellite-source", baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"); + rewriteRasterSource(mutableSources, RUNTIME_TERRAIN_SOURCE, baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"); + rewriteRasterSource(mutableSources, RUNTIME_SATELLITE_SOURCE, baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"); return mutableStyle; } - private void rewriteVectorSource(ObjectNode source, String baseUrl, URI styleBaseUri) { + private void rewriteTileSource(ObjectNode source, String baseUrl, String styleId, String sourceId, URI styleBaseUri) { String sourceUrl = source.path("url").asText(""); - String firstTileUrl = ""; - JsonNode tiles = source.get("tiles"); - if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { - firstTileUrl = tileArray.get(0).asText(""); - } + String firstTileUrl = getFirstTileUrl(source); if (sourceUrl.contains("tiles.dedicatedcode.com") || firstTileUrl.contains("tiles.dedicatedcode.com")) { source.remove("url"); - ArrayNode rewrittenTiles = objectMapper.createArrayNode(); - rewrittenTiles.add(baseUrl + "/api/v1/tiles/vector/{z}/{x}/{y}.pbf"); - source.set("tiles", rewrittenTiles); + source.set("tiles", singleTileArray(baseUrl + "/api/v1/tiles/vector/{z}/{x}/{y}.pbf")); return; } - if (sourceUrl.startsWith("http://") || sourceUrl.startsWith("https://")) { - if (!remoteTileUrlValidator.isServerFetchAllowedUrl(sourceUrl)) { - return; - } - String encodedSourceUrl = encodeTileTemplate(sourceUrl); - source.set("url", new TextNode(baseUrl + "/api/v1/tiles/custom/tilejson/" + encodedSourceUrl + "/" + tileProxySignatureService.sign(encodedSourceUrl) + ".json")); + source.set("url", new TextNode(styleSourceTileJsonUrl(baseUrl, styleId, sourceId))); return; } + rewriteTileTemplates(source, baseUrl, styleId, sourceId, styleBaseUri); + } - if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { - ArrayNode rewrittenTiles = objectMapper.createArrayNode(); - tileArray.forEach(tile -> { - String tileUrl = tile.asText(""); - if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { - rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(tileUrl) ? customTileUrl(baseUrl, tileUrl) : tileUrl); - } else if (styleBaseUri != null && containsTilePlaceholders(tileUrl)) { - String resolvedTileUrl = styleBaseUri.resolve(tileUrl).toString(); - rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(resolvedTileUrl) ? customTileUrl(baseUrl, resolvedTileUrl) : tileUrl); - } else { - rewrittenTiles.add(tileUrl); - } - }); - source.set("tiles", rewrittenTiles); + private void rewriteTileTemplates(ObjectNode source, String baseUrl, String styleId, String sourceId, URI styleBaseUri) { + JsonNode tiles = source.get("tiles"); + if (!(tiles instanceof ArrayNode tileArray) || tileArray.isEmpty()) { + return; + } + ArrayNode rewrittenTiles = objectMapper.createArrayNode(); + for (int i = 0; i < tileArray.size(); i++) { + String tileUrl = tileArray.get(i).asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + rewrittenTiles.add(styleSourceTileUrl(baseUrl, styleId, sourceId, i, tileUrl)); + } else if (styleBaseUri != null && containsTilePlaceholders(tileUrl)) { + rewrittenTiles.add(styleSourceTileUrl(baseUrl, styleId, sourceId, i, styleBaseUri.resolve(tileUrl).toString())); + } else { + rewrittenTiles.add(tileUrl); + } } + source.set("tiles", rewrittenTiles); } - private void rewriteCustomRasterSource(ObjectNode source, String baseUrl, URI styleBaseUri) { - String sourceUrl = source.path("url").asText(""); - if (sourceUrl.startsWith("http://") || sourceUrl.startsWith("https://")) { - if (!remoteTileUrlValidator.isServerFetchAllowedUrl(sourceUrl)) { - return; - } - String encodedSourceUrl = encodeTileTemplate(sourceUrl); - source.set("url", new TextNode(baseUrl + "/api/v1/tiles/custom/tilejson/" + encodedSourceUrl + "/" + tileProxySignatureService.sign(encodedSourceUrl) + ".json")); + private void rewriteRasterSource(ObjectNode sources, String sourceName, String tileUrl) { + if (!(sources.get(sourceName) instanceof ObjectNode source)) { return; } - - JsonNode tiles = source.get("tiles"); - if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { - ArrayNode rewrittenTiles = objectMapper.createArrayNode(); - tileArray.forEach(tile -> { - String tileUrl = tile.asText(""); - if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { - rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(tileUrl) ? customTileUrl(baseUrl, tileUrl) : tileUrl); - } else if (styleBaseUri != null && containsTilePlaceholders(tileUrl)) { - String resolvedTileUrl = styleBaseUri.resolve(tileUrl).toString(); - rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(resolvedTileUrl) ? customTileUrl(baseUrl, resolvedTileUrl) : tileUrl); - } else { - rewrittenTiles.add(tileUrl); - } - }); - source.set("tiles", rewrittenTiles); + if ("satellite-source".equals(sourceName) || RUNTIME_SATELLITE_SOURCE.equals(sourceName)) { + source.put("type", "raster"); } + source.set("tiles", singleTileArray(tileUrl)); } - private String customTileUrl(String baseUrl, String tileUrl) { - String normalizedTileUrl = normalizeTileTemplateForProxy(tileUrl); - String encodedTileUrl = encodeTileTemplate(normalizedTileUrl); - return baseUrl + "/api/v1/tiles/custom/" + encodedTileUrl + "/" + tileProxySignatureService.sign(encodedTileUrl) + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + private String styleSourceTileJsonUrl(String baseUrl, String styleId, String sourceId) { + return baseUrl + "/api/v1/tiles/styles/" + styleId + "/sources/" + MapStylePathUtils.sourcePathId(sourceId) + "/tilejson.json"; } - private String normalizeTileTemplateForProxy(String tileUrl) { - return tileUrl.replace("{r}", ""); + private String styleSourceTileUrl(String baseUrl, String styleId, String sourceId, int tileIndex, String tileUrl) { + String normalizedTileUrl = tileUrl.replace("{r}", ""); + return baseUrl + "/api/v1/tiles/styles/" + styleId + "/sources/" + MapStylePathUtils.sourcePathId(sourceId) + + "/tiles/" + tileIndex + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); } - private String encodeTileTemplate(String tileUrl) { - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(tileUrl.getBytes(StandardCharsets.UTF_8)); + private ArrayNode singleTileArray(String tileUrl) { + ArrayNode tiles = objectMapper.createArrayNode(); + tiles.add(tileUrl); + return tiles; + } + + private String getFirstTileUrl(ObjectNode source) { + JsonNode tiles = source.get("tiles"); + if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { + return tileArray.get(0).asText(""); + } + return ""; } private ObjectNode ensureSourcesNode(ObjectNode mutableStyle) { @@ -478,23 +424,26 @@ private ObjectNode ensureSourcesNode(ObjectNode mutableStyle) { if (sources instanceof ObjectNode sourcesObject) { return sourcesObject; } - ObjectNode sourcesObject = objectMapper.createObjectNode(); mutableStyle.set("sources", sourcesObject); return sourcesObject; } + private ObjectNode getOrCreateMetadata(ObjectNode node) { + return node.has("metadata") && node.get("metadata") instanceof ObjectNode existing + ? existing + : objectMapper.createObjectNode(); + } + private Optional getStyleBaseUri(ObjectNode style) { JsonNode metadata = style.get("metadata"); if (!(metadata instanceof ObjectNode metadataObject)) { return Optional.empty(); } - String styleUrl = metadataObject.path("reitti:style-url").asText(""); if (!StringUtils.hasText(styleUrl)) { return Optional.empty(); } - try { return Optional.of(URI.create(styleUrl)); } catch (IllegalArgumentException e) { @@ -506,32 +455,19 @@ private boolean containsTilePlaceholders(String tileUrl) { return tileUrl.contains("{z}") && tileUrl.contains("{x}") && tileUrl.contains("{y}"); } - private void rewriteRasterSource(ObjectNode sources, String sourceName, String tileUrl) { - if (sources.has(sourceName) && sources.get(sourceName) instanceof ObjectNode source) { - if ("satellite-source".equals(sourceName) || RUNTIME_SATELLITE_SOURCE.equals(sourceName)) { - source.put("type", "raster"); - } - ArrayNode tiles = objectMapper.createArrayNode(); - tiles.add(tileUrl); - source.set("tiles", tiles); - } + private boolean shouldProxyTiles(UserMapStyle style) { + return style.dataSource() != null && style.dataSource().proxyTiles(); } private String getBaseUrl(HttpServletRequest request) { String scheme = request.getScheme(); - String serverName = request.getServerName(); int serverPort = request.getServerPort(); - String contextPath = request.getContextPath(); - StringBuilder url = new StringBuilder(); - url.append(scheme).append("://").append(serverName); - - if ((scheme.equals("http") && serverPort != 80) || - (scheme.equals("https") && serverPort != 443)) { + url.append(scheme).append("://").append(request.getServerName()); + if (("http".equals(scheme) && serverPort != 80) || ("https".equals(scheme) && serverPort != 443)) { url.append(":").append(serverPort); } - - url.append(contextPath); + url.append(request.getContextPath()); return url.toString(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java index 1de5eb583..29a354296 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java @@ -1,22 +1,28 @@ package com.dedicatedcode.reitti.controller.api; -import com.dedicatedcode.reitti.config.ConditionalOnPropertyNotEmpty; -import com.dedicatedcode.reitti.service.RemoteTileUrlValidator; -import com.dedicatedcode.reitti.service.SafeHttpClient; -import com.dedicatedcode.reitti.service.TileProxySignatureService; +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.UserMapStyle; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService; +import com.dedicatedcode.reitti.service.MapStylePathUtils; +import com.dedicatedcode.reitti.service.MapStyleUrlValidator; import com.dedicatedcode.reitti.service.TileUrlUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,34 +32,36 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URI; -import java.nio.charset.StandardCharsets; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.Base64; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.zip.GZIPInputStream; import java.util.zip.InflaterInputStream; @RestController @RequestMapping("/api/v1/tiles") -@ConditionalOnPropertyNotEmpty("reitti.ui.tiles.cache.url") public class TileProxyController { private static final Logger log = LoggerFactory.getLogger(TileProxyController.class); private static final String CUSTOM_UPSTREAM_HEADER = "X-Reitti-Upstream-Url"; - private static final Duration PRIVATE_TILE_DIRECT_TIMEOUT = Duration.ofSeconds(5); private final HttpClient httpClient; private final String tileCacheUrl; + private final boolean tileCacheEnabled; private final ObjectMapper objectMapper; - private final TileProxySignatureService tileProxySignatureService; - private final RemoteTileUrlValidator remoteTileUrlValidator; - private final SafeHttpClient safeHttpClient; + private final UserMapStyleJdbcService userMapStyleJdbcService; + private final MapStyleUrlValidator mapStyleUrlValidator; + private final Cache styleJsonCache; // Maps source names to internal paths and coordinate ordering private record SourceConfig(String path, boolean swapXY, String contentType) {} + private record TileSource(String tileJsonUrl, List tileUrlTemplates, boolean proxyTiles) {} private static final Map SOURCES = Map.of( "raster", new SourceConfig("/osm/", false, MediaType.IMAGE_PNG_VALUE), @@ -63,20 +71,31 @@ private record SourceConfig(String path, boolean swapXY, String contentType) {} "satellite", new SourceConfig("/satellite/", true, "image/jpeg") ); + private static final Map SOURCE_UPSTREAM_URLS = Map.of( + "raster", "https://tile.openstreetmap.org/", + "osm", "https://tile.openstreetmap.org/", + "vector", "https://tiles.dedicatedcode.com/planet/latest/", + "terrain", "https://tiles.mapterhorn.com/", + "satellite", "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/" + ); + public TileProxyController( - @Value("${reitti.ui.tiles.cache.url}") String tileCacheUrl, + @Value("${reitti.ui.tiles.cache.url:}") String tileCacheUrl, ObjectMapper objectMapper, - TileProxySignatureService tileProxySignatureService, - RemoteTileUrlValidator remoteTileUrlValidator, - SafeHttpClient safeHttpClient) { + UserMapStyleJdbcService userMapStyleJdbcService, + MapStyleUrlValidator mapStyleUrlValidator) { this.tileCacheUrl = tileCacheUrl; + this.tileCacheEnabled = StringUtils.hasText(tileCacheUrl); this.objectMapper = objectMapper; - this.tileProxySignatureService = tileProxySignatureService; - this.remoteTileUrlValidator = remoteTileUrlValidator; - this.safeHttpClient = safeHttpClient; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.mapStyleUrlValidator = mapStyleUrlValidator; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) - .followRedirects(HttpClient.Redirect.NEVER) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + this.styleJsonCache = Caffeine.newBuilder() + .maximumSize(20) + .expireAfterWrite(Duration.ofHours(1)) .build(); } @@ -89,21 +108,28 @@ public ResponseEntity getTileLegacy( return getTile("raster", z, x, y, "png", request); } - @GetMapping("/custom/tilejson/{encodedTileJsonUrl}/{signature}.json") - public ResponseEntity getCustomTileJson( - @PathVariable String encodedTileJsonUrl, - @PathVariable String signature, + @GetMapping("/styles/{styleId}/sources/{sourceId}/tilejson.json") + public ResponseEntity getStyleSourceTileJson( + @AuthenticationPrincipal User user, + @PathVariable String styleId, + @PathVariable String sourceId, HttpServletRequest request) { try { - if (!tileProxySignatureService.isValid(encodedTileJsonUrl, signature)) { - return ResponseEntity.status(403).build(); + Optional source = resolveTileSource(user, styleId, sourceId); + if (source.isEmpty()) { + return ResponseEntity.notFound().build(); } + String tileJsonUrl = source.get().tileJsonUrl(); + if (!StringUtils.hasText(tileJsonUrl)) { + return ResponseEntity.notFound().build(); + } + if (!source.get().proxyTiles()) { + return ResponseEntity.notFound().build(); + } + URI tileJsonUri = mapStyleUrlValidator.requireHttpUrl(tileJsonUrl, "Custom TileJSON URL"); - String tileJsonUrl = decodeTemplate(encodedTileJsonUrl); - URI tileJsonUri = remoteTileUrlValidator.requirePublicHttpUrl(tileJsonUrl, "Custom TileJSON URL"); - - HttpResponse response = fetchPublicUrl(tileJsonUrl, "Custom TileJSON URL"); + HttpResponse response = fetchUrl(tileJsonUrl); if (response.statusCode() != 200) { log.debug("Failed to fetch custom TileJSON [{}]: HTTP {}", tileJsonUri, response.statusCode()); return ResponseEntity.notFound().build(); @@ -112,17 +138,18 @@ public ResponseEntity getCustomTileJson( JsonNode tileJson = objectMapper.readTree(responseBody(response)); if (tileJson instanceof ObjectNode mutableTileJson && mutableTileJson.get("tiles") instanceof ArrayNode tiles) { ArrayNode rewrittenTiles = objectMapper.createArrayNode(); - tiles.forEach(tile -> { + for (int i = 0; i < tiles.size(); i++) { + JsonNode tile = tiles.get(i); String tileUrl = tile.asText(""); if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { - rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(tileUrl) ? customTileUrl(request, tileUrl) : tileUrl); + rewrittenTiles.add(styleSourceTileUrl(request, styleId, sourceId, i, tileUrl)); } else if (!tileUrl.isBlank()) { String resolvedTileUrl = tileJsonUri.resolve(tileUrl).toString(); - rewrittenTiles.add(remoteTileUrlValidator.isServerFetchAllowedTemplate(resolvedTileUrl) ? customTileUrl(request, resolvedTileUrl) : tileUrl); + rewrittenTiles.add(styleSourceTileUrl(request, styleId, sourceId, i, resolvedTileUrl)); } else { rewrittenTiles.add(tileUrl); } - }); + } mutableTileJson.set("tiles", rewrittenTiles); } @@ -130,45 +157,55 @@ public ResponseEntity getCustomTileJson( .cacheControl(CacheControl.noCache().cachePrivate()) .body(tileJson); } catch (Exception e) { - log.warn("Failed to fetch custom TileJSON [{}]: {}", encodedTileJsonUrl, e.getMessage()); + log.warn("Failed to fetch custom TileJSON [{}/{}]: {}", styleId, sourceId, e.getMessage()); return ResponseEntity.notFound().build(); } } - @GetMapping("/custom/{encodedTemplate}/{signature}/{z}/{x}/{y}.{ext}") - public ResponseEntity getCustomTile( - @PathVariable String encodedTemplate, - @PathVariable String signature, + @GetMapping("/styles/{styleId}/sources/{sourceId}/tiles/{tileIndex}/{z}/{x}/{y}.{ext}") + public ResponseEntity getStyleSourceTile( + @AuthenticationPrincipal User user, + @PathVariable String styleId, + @PathVariable String sourceId, + @PathVariable int tileIndex, @PathVariable int z, @PathVariable int x, @PathVariable int y, @PathVariable String ext) { try { - if (!tileProxySignatureService.isValid(encodedTemplate, signature)) { - return ResponseEntity.status(403).build(); + Optional source = resolveTileSource(user, styleId, sourceId); + if (source.isEmpty()) { + return ResponseEntity.notFound().build(); + } + String template = tileTemplate(source.get(), tileIndex); + if (!StringUtils.hasText(template)) { + return ResponseEntity.notFound().build(); + } + if (!source.get().proxyTiles()) { + return ResponseEntity.notFound().build(); } - - String template = decodeTemplate(encodedTemplate); String upstreamTileUrl = template .replace("{z}", String.valueOf(z)) .replace("{x}", String.valueOf(x)) .replace("{y}", String.valueOf(y)) .replace("{r}", ""); - URI upstreamTileUri = remoteTileUrlValidator.requirePublicHttpUrl(upstreamTileUrl, "Custom tile URL"); + URI upstreamTileUri = mapStyleUrlValidator.requireHttpUrl(upstreamTileUrl, "Custom tile URL"); + log.trace("Fetching custom tile [{}/{}]: {}", styleId, sourceId, upstreamTileUri); - if (remoteTileUrlValidator.isValidLocalUrl(upstreamTileUrl)) { - log.trace("Fetching private custom tile directly [{}]: {}", encodedTemplate, upstreamTileUri); - return fetchPrivateTile(upstreamTileUrl, contentTypeForExtension(ext)); + if (this.tileCacheEnabled) { + String tileUrl = tileCacheUrl + "/custom-vector/"; + return fetchTile(tileUrl, contentTypeForExtension(ext), "custom", Map.of(CUSTOM_UPSTREAM_HEADER, upstreamTileUrl)); } - String tileUrl = tileCacheUrl + "/custom/"; - log.trace("Fetching custom tile [{}]: {}", encodedTemplate, upstreamTileUri); - return fetchTile(tileUrl, contentTypeForExtension(ext), "custom", Map.of(CUSTOM_UPSTREAM_HEADER, upstreamTileUrl)); + return fetchTile(upstreamTileUrl, contentTypeForExtension(ext), "custom"); } catch (IllegalArgumentException e) { - log.warn("Failed to decode custom tile template [{}]: {}", encodedTemplate, e.getMessage()); + log.warn("Failed to resolve custom tile [{}/{}]: {}", styleId, sourceId, e.getMessage()); return ResponseEntity.badRequest().build(); + } catch (Exception e) { + log.warn("Failed to fetch custom tile [{}/{}]: {}", styleId, sourceId, e.getMessage()); + return ResponseEntity.notFound().build(); } } @@ -191,7 +228,16 @@ public ResponseEntity getTile( ? String.format("%d/%d/%d", z, y, x) : String.format("%d/%d/%d.%s", z, x, y, ext); - String tileUrl = tileCacheUrl + config.path() + coordPath; + String tileUrl; + if (this.tileCacheEnabled) { + tileUrl = tileCacheUrl + config.path() + coordPath; + } else { + String upstreamBaseUrl = SOURCE_UPSTREAM_URLS.get(source); + if (!StringUtils.hasText(upstreamBaseUrl)) { + return ResponseEntity.notFound().build(); + } + tileUrl = upstreamBaseUrl + coordPath; + } if (StringUtils.hasText(request.getQueryString())) { tileUrl += "?" + request.getQueryString(); } @@ -235,44 +281,10 @@ private ResponseEntity fetchTile(String tileUrl, String contentType, Str } } - private ResponseEntity fetchPrivateTile(String upstreamTileUrl, String contentType) { - try { - HttpResponse response = fetchRaw(upstreamTileUrl, Map.of(), PRIVATE_TILE_DIRECT_TIMEOUT); - - if (response.statusCode() == 200) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType(contentType)); - headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic()); - headers.add("Access-Control-Allow-Origin", "*"); - response.headers() - .firstValue(HttpHeaders.CONTENT_ENCODING) - .ifPresent(contentEncoding -> headers.add(HttpHeaders.CONTENT_ENCODING, contentEncoding)); - - return ResponseEntity.ok() - .headers(headers) - .body(response.body()); - } - - log.debug("Failed to fetch private custom tile directly: HTTP {}", response.statusCode()); - return ResponseEntity.notFound().build(); - } catch (Exception e) { - log.warn("Failed to fetch private custom tile directly: {}", e.getMessage()); - return ResponseEntity.notFound().build(); - } - } - - private HttpResponse fetchRaw(String url) throws Exception { - return fetchRaw(url, Map.of()); - } - private HttpResponse fetchRaw(String url, Map headers) throws Exception { - return fetchRaw(url, headers, Duration.ofSeconds(30)); - } - - private HttpResponse fetchRaw(String url, Map headers, Duration timeout) throws Exception { HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) - .timeout(timeout) + .timeout(Duration.ofSeconds(30)) .header(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate") .GET(); headers.forEach(requestBuilder::header); @@ -283,20 +295,14 @@ private HttpResponse fetchRaw(String url, Map headers, D ); } - private HttpResponse fetchPublicUrl(String url, String fieldName) throws Exception { + private HttpResponse fetchUrl(String url) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(30)) .header(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate") .GET() .build(); - - return safeHttpClient.sendFollowingPublicRedirects( - httpClient, - request, - HttpResponse.BodyHandlers.ofByteArray(), - fieldName - ); + return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); } private byte[] responseBody(HttpResponse response) throws IOException { @@ -314,24 +320,251 @@ private byte[] responseBody(HttpResponse response) throws IOException { return response.body(); } - private String customTileUrl(HttpServletRequest request, String tileUrl) { - String normalizedTileUrl = normalizeTileTemplateForProxy(tileUrl); - String encodedTemplate = encodeTemplate(normalizedTileUrl); - return getBaseUrl(request) + "/api/v1/tiles/custom/" + encodedTemplate + "/" + tileProxySignatureService.sign(encodedTemplate) + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + private Optional resolveTileSource(User user, String styleId, String sourceId) throws IOException, InterruptedException { + if (!StringUtils.hasText(styleId) || !StringUtils.hasText(sourceId)) { + return Optional.empty(); + } + + if ("reitti".equals(styleId)) { + return sourceFromStyle(readClasspathStyle("static/map/reitti.json"), sourceId, null, true); + } + + Optional customStyleId = UserMapStyleJdbcService.resolveCustomId(styleId); + if (customStyleId.isEmpty() || user == null) { + return Optional.empty(); + } + + Optional style = userMapStyleJdbcService.findById(user, customStyleId.get()); + if (style.isEmpty()) { + return Optional.empty(); + } + + Optional dataSource = sourceFromDataSource(style.get(), sourceId); + if (dataSource.isPresent()) { + return dataSource; + } + + return sourceFromUserStyle(style.get(), sourceId); } - private String normalizeTileTemplateForProxy(String tileUrl) { - return tileUrl.replace("{r}", ""); + private Optional sourceFromDataSource(UserMapStyle style, String sourceId) { + MapStyleDataSource dataSource = style.dataSource(); + if (dataSource == null || !MapStylePathUtils.matchesSourcePathId(sourceId, dataSource.sourceId())) { + return Optional.empty(); + } + + List tileTemplates = new ArrayList<>(); + if (StringUtils.hasText(dataSource.tileUrlTemplate())) { + tileTemplates.add(normalizeTileTemplateForProxy(dataSource.tileUrlTemplate())); + } + return Optional.of(new TileSource(dataSource.tileJsonUrl(), tileTemplates, dataSource.proxyTiles())); } - private String encodeTemplate(String template) { - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(template.getBytes(StandardCharsets.UTF_8)); + private JsonNode parseUserStyleJson(UserMapStyle style) throws IOException { + String cacheKey = "local:" + style.id() + ":" + style.version(); + try { + return styleJsonCache.get(cacheKey, k -> { + try { + return objectMapper.readTree(style.styleJson()); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + }); + } catch (java.io.UncheckedIOException e) { + throw e.getCause(); + } + } + + private Optional sourceFromUserStyle(UserMapStyle style, String sourceId) throws IOException, InterruptedException { + if (!"vector".equals(style.mapType())) { + return Optional.empty(); + } + + if (StringUtils.hasText(style.styleJson())) { + return sourceFromStyle(parseUserStyleJson(style), sourceId, null, shouldProxyTiles(style)); + } + + if (!StringUtils.hasText(style.styleUrl())) { + return Optional.empty(); + } + + return sourceFromStyle(fetchAndParseStyleJson(style), sourceId, URI.create(style.styleUrl()), shouldProxyTiles(style)); + } + + private Optional sourceFromStyle(JsonNode style, String sourceId, URI styleBaseUri, boolean proxyTiles) { + JsonNode source = findSource(style, sourceId); + if (source == null) { + return Optional.empty(); + } + + String tileJsonUrl = resolveSourceUrl(source.path("url").asText(""), styleBaseUri); + List tileTemplates = new ArrayList<>(); + JsonNode tiles = source.get("tiles"); + if (tiles instanceof ArrayNode tileArray) { + tileArray.forEach(tile -> { + String tileUrl = resolveTileTemplate(tile.asText(""), styleBaseUri); + if (StringUtils.hasText(tileUrl)) { + tileTemplates.add(normalizeTileTemplateForProxy(tileUrl)); + } + }); + } + return Optional.of(new TileSource(tileJsonUrl, tileTemplates, proxyTiles)); + } + + private JsonNode findSource(JsonNode style, String sourceId) { + JsonNode sources = style.path("sources"); + if (!(sources instanceof ObjectNode sourcesObject)) { + return null; + } + JsonNode exactSource = sourcesObject.get(sourceId); + if (exactSource instanceof ObjectNode) { + return exactSource; + } + var fields = sourcesObject.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + if (entry.getValue() instanceof ObjectNode && MapStylePathUtils.matchesSourcePathId(sourceId, entry.getKey())) { + return entry.getValue(); + } + } + return null; + } + + private String resolveSourceUrl(String sourceUrl, URI styleBaseUri) { + if (!StringUtils.hasText(sourceUrl)) { + return null; + } + if (sourceUrl.startsWith("http://") || sourceUrl.startsWith("https://")) { + return sourceUrl; + } + if (styleBaseUri != null) { + return styleBaseUri.resolve(sourceUrl).toString(); + } + return null; + } + + private String resolveTileTemplate(String tileUrl, URI styleBaseUri) { + if (!StringUtils.hasText(tileUrl)) { + return null; + } + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + return tileUrl; + } + if (styleBaseUri != null && containsTilePlaceholders(tileUrl)) { + return styleBaseUri.resolve(tileUrl).toString(); + } + return null; + } + + private String tileTemplate(TileSource source, int tileIndex) throws Exception { + if (tileIndex >= 0 && tileIndex < source.tileUrlTemplates().size()) { + return source.tileUrlTemplates().get(tileIndex); + } + + if (!StringUtils.hasText(source.tileJsonUrl())) { + return null; + } + + String tileJsonUrl = source.tileJsonUrl(); + URI tileJsonUri = mapStyleUrlValidator.requireHttpUrl(tileJsonUrl, "Custom TileJSON URL"); + + JsonNode tileJson; + try { + tileJson = styleJsonCache.get("tilejson:" + tileJsonUrl, k -> { + try { + HttpResponse response = fetchUrl(tileJsonUrl); + if (response.statusCode() != 200) { + throw new IOException("Failed to fetch TileJSON: " + response.statusCode()); + } + return objectMapper.readTree(responseBody(response)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } catch (RuntimeException e) { + throw new Exception("Failed to fetch custom TileJSON", e.getCause()); + } + + JsonNode tiles = tileJson.get("tiles"); + if (!(tiles instanceof ArrayNode tileArray) || tileIndex < 0 || tileIndex >= tileArray.size()) { + return null; + } + + String tileUrl = tileArray.get(tileIndex).asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + return normalizeTileTemplateForProxy(tileUrl); + } + if (StringUtils.hasText(tileUrl)) { + return normalizeTileTemplateForProxy(tileJsonUri.resolve(tileUrl).toString()); + } + return null; } - private String decodeTemplate(String encodedTemplate) { - return new String(Base64.getUrlDecoder().decode(encodedTemplate), StandardCharsets.UTF_8); + private JsonNode fetchAndParseStyleJson(UserMapStyle style) throws IOException, InterruptedException { + String styleUrl = style.styleUrl(); + String cacheKey = "url:" + style.id() + ":" + style.version() + ":" + styleUrl; + + try { + return styleJsonCache.get(cacheKey, k -> { + try { + HttpRequest request = HttpRequest.newBuilder(mapStyleUrlValidator.requireHttpUrl(styleUrl, "Vector style URL")) + .timeout(Duration.ofSeconds(20)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Unable to fetch map style: " + response.statusCode()); + } + + return objectMapper.readTree(response.body()); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + }); + } catch (RuntimeException e) { + if (e.getCause() instanceof IOException ioEx) { + throw ioEx; + } + if (e.getCause() instanceof InterruptedException intEx) { + throw intEx; + } + throw e; + } + } + + private JsonNode readClasspathStyle(String path) throws IOException { + String cacheKey = "classpath:" + path; + try { + return styleJsonCache.get(cacheKey, k -> { + try { + return objectMapper.readTree(new ClassPathResource(path).getInputStream()); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + }); + } catch (java.io.UncheckedIOException e) { + throw e.getCause(); + } + } + + private boolean containsTilePlaceholders(String tileUrl) { + return tileUrl.contains("{z}") && tileUrl.contains("{x}") && tileUrl.contains("{y}"); + } + + private boolean shouldProxyTiles(UserMapStyle style) { + return style.dataSource() != null && style.dataSource().proxyTiles(); + } + + private String styleSourceTileUrl(HttpServletRequest request, String styleId, String sourceId, int tileIndex, String tileUrl) { + String normalizedTileUrl = normalizeTileTemplateForProxy(tileUrl); + return getBaseUrl(request) + "/api/v1/tiles/styles/" + styleId + "/sources/" + MapStylePathUtils.sourcePathId(sourceId) + + "/tiles/" + tileIndex + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + } + + private String normalizeTileTemplateForProxy(String tileUrl) { + return tileUrl.replace("{r}", ""); } private String getBaseUrl(HttpServletRequest request) { diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java index b40b2a75d..96c8db8ad 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java @@ -20,14 +20,17 @@ public class MapStylesSettingsController { private final boolean dataManagementEnabled; private final UserMapStyleJdbcService userMapStyleJdbcService; + private final com.dedicatedcode.reitti.service.UserMapStyleValidator userMapStyleValidator; private final ContextPathHolder contextPathHolder; public MapStylesSettingsController( @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, UserMapStyleJdbcService userMapStyleJdbcService, + com.dedicatedcode.reitti.service.UserMapStyleValidator userMapStyleValidator, ContextPathHolder contextPathHolder) { this.dataManagementEnabled = dataManagementEnabled; this.userMapStyleJdbcService = userMapStyleJdbcService; + this.userMapStyleValidator = userMapStyleValidator; this.contextPathHolder = contextPathHolder; } @@ -48,7 +51,8 @@ public MapStyleSettingsDTO getSettings(@AuthenticationPrincipal User user) { @PostMapping("/api") @ResponseBody public MapStyleSettingsDTO saveStyle(@AuthenticationPrincipal User user, @RequestBody SaveMapStyleRequest request) { - UserMapStyle style = userMapStyleJdbcService.save(user, request); + UserMapStyle validatedStyle = userMapStyleValidator.validateAndNormalize(user, request); + UserMapStyle style = userMapStyleJdbcService.save(user, validatedStyle); userMapStyleJdbcService.setActiveStyleId(user, style.frontendId()); return userMapStyleJdbcService.getSettings(user, normalizedContextPath()); } diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java index 8292b53e7..52ceecf8e 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java +++ b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java @@ -9,6 +9,7 @@ public record MapStyleDataSource( Integer minzoom, Integer maxzoom, Integer tileSize, - String scheme + String scheme, + boolean proxyTiles ) { } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java index db50171e3..11f61154f 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java @@ -2,22 +2,14 @@ import com.dedicatedcode.reitti.dto.map.MapStyleConfigDTO; import com.dedicatedcode.reitti.dto.map.MapStyleSettingsDTO; -import com.dedicatedcode.reitti.dto.map.SaveMapStyleRequest; -import com.dedicatedcode.reitti.model.Role; import com.dedicatedcode.reitti.model.map.MapStyleDataSource; import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions; import com.dedicatedcode.reitti.model.map.UserMapStyle; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.I18nService; -import com.dedicatedcode.reitti.service.RemoteTileUrlValidator; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import java.util.List; import java.util.Optional; @@ -27,19 +19,9 @@ public class UserMapStyleJdbcService { private static final String DEFAULT_STYLE_ID = "reitti"; private final JdbcTemplate jdbcTemplate; - private final ObjectMapper objectMapper; - private final RemoteTileUrlValidator remoteTileUrlValidator; - private final I18nService i18nService; - public UserMapStyleJdbcService( - JdbcTemplate jdbcTemplate, - ObjectMapper objectMapper, - RemoteTileUrlValidator remoteTileUrlValidator, - I18nService i18nService) { + public UserMapStyleJdbcService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; - this.objectMapper = objectMapper; - this.remoteTileUrlValidator = remoteTileUrlValidator; - this.i18nService = i18nService; } private final RowMapper rowMapper = (rs, _) -> new UserMapStyle( @@ -60,7 +42,8 @@ public UserMapStyleJdbcService( (Integer) rs.getObject("minzoom"), (Integer) rs.getObject("maxzoom"), (Integer) rs.getObject("tile_size"), - rs.getString("scheme") + rs.getString("scheme"), + rs.getBoolean("proxy_tiles") ), new MapStyleVectorOptions( rs.getString("attribution_override"), @@ -118,14 +101,14 @@ public String getActiveStyleId(User user) { @Transactional public void setActiveStyleId(User user, String activeStyleId) { - String safeActiveStyleId = isValidStyleId(user, activeStyleId) + String storedActiveStyleId = isValidStyleId(user, activeStyleId) ? activeStyleId : DEFAULT_STYLE_ID; jdbcTemplate.update(""" INSERT INTO user_map_style_settings (user_id, active_style_id) VALUES (?, ?) ON CONFLICT (user_id) DO UPDATE SET active_style_id = EXCLUDED.active_style_id, updated_at = CURRENT_TIMESTAMP - """, user.getId(), safeActiveStyleId); + """, user.getId(), storedActiveStyleId); } private boolean isValidStyleId(User user, String styleId) { @@ -133,115 +116,72 @@ private boolean isValidStyleId(User user, String styleId) { } @Transactional - public UserMapStyle save(User user, SaveMapStyleRequest request) { - String label = clean(request.label()); - String mapType = normalizeChoice(request.mapType(), "vector", List.of("vector", "raster")); - String styleInputType = normalizeChoice(request.styleInputType(), "url", List.of("url", "json")); - String rasterSourceInputType = normalizeChoice(request.rasterSourceInputType(), "tile_template", List.of("tile_template", "tilejson")); - String styleInput = clean(request.styleInput()); - if (!StringUtils.hasText(label)) { - throw new IllegalArgumentException(message("error-name-required")); - } - - String styleJson = null; - String styleUrl = null; - if ("vector".equals(mapType)) { - if (!StringUtils.hasText(styleInput)) { - throw new IllegalArgumentException(message("json".equals(styleInputType) ? "error-style-json-required" : "error-style-url-required")); - } - styleJson = "json".equals(styleInputType) || styleInput.startsWith("{") ? styleInput : null; - styleUrl = styleJson == null ? styleInput : null; - if (styleJson != null) { - validateStyleJson(styleJson); - } else { - remoteTileUrlValidator.requireHttpUrl(styleUrl, label("style-json-url")); - } - } - MapStyleDataSource source = normalizeDataSource(request.dataSource()); - MapStyleVectorOptions vectorOptions = normalizeVectorOptions(request.vectorOptions()); - boolean shared = request.shared() && user.getRole() == Role.ADMIN; - if ("raster".equals(mapType)) { - validateRasterSource(rasterSourceInputType, source); - Integer tileSize = source.tileSize(); - String tileUrlTemplate = "tile_template".equals(rasterSourceInputType) ? normalizeRasterTileTemplate(source.tileUrlTemplate()) : null; - source = new MapStyleDataSource( - "custom-raster-source", - "raster", - "tilejson".equals(rasterSourceInputType) ? clean(source.tileJsonUrl()) : null, - tileUrlTemplate, - clean(source.attribution()), - source.minzoom(), - source.maxzoom(), - effectiveRasterTileSize(tileUrlTemplate, tileSize), - source.scheme() - ); - } - validateNoPrivateUrlsForStandardUser(user, styleUrl, styleJson, source, vectorOptions); - - Optional existingId = resolveCustomId(request.id()); - if (existingId.isPresent() && findOwnedById(user, existingId.get()).isPresent()) { + public UserMapStyle save(User user, UserMapStyle style) { + if (style.id() != null && findOwnedById(user, style.id()).isPresent()) { jdbcTemplate.update(""" UPDATE user_map_styles SET name = ?, map_type = ?, style_input_type = ?, raster_source_input_type = ?, style_json = ?, style_url = ?, source_id = ?, source_type = ?, tilejson_url = ?, tile_url_template = ?, attribution = ?, minzoom = ?, maxzoom = ?, tile_size = ?, scheme = ?, - attribution_override = ?, glyphs_url_override = ?, sprite_url_override = ?, shared = ?, + proxy_tiles = ?, attribution_override = ?, glyphs_url_override = ?, sprite_url_override = ?, shared = ?, updated_at = CURRENT_TIMESTAMP, version = version + 1 WHERE user_id = ? AND id = ? """, - label, - mapType, - styleInputType, - rasterSourceInputType, - styleJson, - styleUrl, - clean(source.sourceId()), - clean(source.type()), - clean(source.tileJsonUrl()), - clean(source.tileUrlTemplate()), - clean(source.attribution()), - source.minzoom(), - source.maxzoom(), - source.tileSize(), - source.scheme(), - clean(vectorOptions.attributionOverride()), - clean(vectorOptions.glyphsUrlOverride()), - clean(vectorOptions.spriteUrlOverride()), - shared, + style.name(), + style.mapType(), + style.styleInputType(), + style.rasterSourceInputType(), + style.styleJson(), + style.styleUrl(), + style.dataSource().sourceId(), + style.dataSource().type(), + style.dataSource().tileJsonUrl(), + style.dataSource().tileUrlTemplate(), + style.dataSource().attribution(), + style.dataSource().minzoom(), + style.dataSource().maxzoom(), + style.dataSource().tileSize(), + style.dataSource().scheme(), + style.dataSource().proxyTiles(), + style.vectorOptions().attributionOverride(), + style.vectorOptions().glyphsUrlOverride(), + style.vectorOptions().spriteUrlOverride(), + style.shared(), user.getId(), - existingId.get()); - return findOwnedById(user, existingId.get()).orElseThrow(); + style.id()); + return findOwnedById(user, style.id()).orElseThrow(); } Long id = jdbcTemplate.queryForObject(""" INSERT INTO user_map_styles (user_id, name, map_type, style_input_type, raster_source_input_type, style_json, style_url, source_id, source_type, tilejson_url, tile_url_template, attribution, minzoom, maxzoom, tile_size, scheme, - attribution_override, glyphs_url_override, sprite_url_override, shared) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + proxy_tiles, attribution_override, glyphs_url_override, sprite_url_override, shared) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id """, Long.class, user.getId(), - label, - mapType, - styleInputType, - rasterSourceInputType, - styleJson, - styleUrl, - clean(source.sourceId()), - clean(source.type()), - clean(source.tileJsonUrl()), - clean(source.tileUrlTemplate()), - clean(source.attribution()), - source.minzoom(), - source.maxzoom(), - source.tileSize(), - source.scheme(), - clean(vectorOptions.attributionOverride()), - clean(vectorOptions.glyphsUrlOverride()), - clean(vectorOptions.spriteUrlOverride()), - shared); + style.name(), + style.mapType(), + style.styleInputType(), + style.rasterSourceInputType(), + style.styleJson(), + style.styleUrl(), + style.dataSource().sourceId(), + style.dataSource().type(), + style.dataSource().tileJsonUrl(), + style.dataSource().tileUrlTemplate(), + style.dataSource().attribution(), + style.dataSource().minzoom(), + style.dataSource().maxzoom(), + style.dataSource().tileSize(), + style.dataSource().scheme(), + style.dataSource().proxyTiles(), + style.vectorOptions().attributionOverride(), + style.vectorOptions().glyphsUrlOverride(), + style.vectorOptions().spriteUrlOverride(), + style.shared()); return findOwnedById(user, id).orElseThrow(); } @@ -265,7 +205,7 @@ public MapStyleConfigDTO toDto(User user, UserMapStyle style, String contextPath style.frontendId(), style.name(), style.mapType(), - StringUtils.hasText(style.styleJson()) ? "json" : style.styleInputType(), + style.styleJson() != null ? "json" : style.styleInputType(), style.rasterSourceInputType(), styleUrlForClient(style, contextPath), style.styleInput(), @@ -278,7 +218,7 @@ public MapStyleConfigDTO toDto(User user, UserMapStyle style, String contextPath } public static Optional resolveCustomId(String frontendId) { - if (!StringUtils.hasText(frontendId) || !frontendId.startsWith("custom-")) { + if (frontendId == null || frontendId.isBlank() || !frontendId.startsWith("custom-")) { return Optional.empty(); } try { @@ -288,172 +228,11 @@ public static Optional resolveCustomId(String frontendId) { } } - private MapStyleDataSource normalizeDataSource(MapStyleDataSource dataSource) { - MapStyleDataSource source = dataSource != null ? dataSource : new MapStyleDataSource(null, "vector", null, null, null, null, null, null, null); - String type = normalizeChoice(source.type(), "vector", List.of("vector", "raster", "raster-dem")); - Integer tileSize = source.tileSize() == null || (source.tileSize() != 256 && source.tileSize() != 512) ? null : source.tileSize(); - String scheme = normalizeChoice(source.scheme(), null, List.of("xyz", "tms")); - return new MapStyleDataSource( - clean(source.sourceId()), - type, - clean(source.tileJsonUrl()), - clean(source.tileUrlTemplate()), - clean(source.attribution()), - source.minzoom(), - source.maxzoom(), - tileSize, - scheme - ); - } - - private MapStyleVectorOptions normalizeVectorOptions(MapStyleVectorOptions options) { - if (options == null) { - return new MapStyleVectorOptions(null, null, null); - } - return new MapStyleVectorOptions( - clean(options.attributionOverride()), - clean(options.glyphsUrlOverride()), - clean(options.spriteUrlOverride()) - ); - } - - private void validateRasterSource(String rasterSourceInputType, MapStyleDataSource source) { - if ("tile_template".equals(rasterSourceInputType)) { - String tileUrlTemplate = clean(source.tileUrlTemplate()); - if (!StringUtils.hasText(tileUrlTemplate) || !tileUrlTemplate.contains("{z}") || !tileUrlTemplate.contains("{x}") || !tileUrlTemplate.contains("{y}")) { - throw new IllegalArgumentException(message("error-tile-template-placeholders")); - } - remoteTileUrlValidator.requireHttpTemplate(tileUrlTemplate, label("tile-template")); - } else if (!StringUtils.hasText(source.tileJsonUrl())) { - throw new IllegalArgumentException(message("error-tilejson-required")); - } else { - remoteTileUrlValidator.requireHttpUrl(source.tileJsonUrl(), label("tilejson-url")); - } - validateZoom(source.minzoom(), label("minzoom")); - validateZoom(source.maxzoom(), label("maxzoom")); - if (source.minzoom() != null && source.maxzoom() != null && source.minzoom() >= source.maxzoom()) { - throw new IllegalArgumentException(message("error-zoom-order")); - } - } - - private void validateNoPrivateUrlsForStandardUser( - User user, - String styleUrl, - String styleJson, - MapStyleDataSource source, - MapStyleVectorOptions vectorOptions) { - if (user.getRole() == Role.ADMIN) { - return; - } - - rejectPrivateUrl(styleUrl); - rejectPrivateUrl(source.tileJsonUrl()); - rejectPrivateTemplate(source.tileUrlTemplate()); - rejectPrivateTemplate(vectorOptions.glyphsUrlOverride()); - rejectPrivateUrl(vectorOptions.spriteUrlOverride()); - - if (StringUtils.hasText(styleJson)) { - try { - rejectPrivateUrlsInJson(objectMapper.readTree(styleJson)); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException(message("error-json"), e); - } - } - } - - private void rejectPrivateUrlsInJson(JsonNode node) { - if (node == null || node.isNull()) { - return; - } - if (node.isTextual()) { - String value = node.asText(); - rejectPrivateUrl(value); - rejectPrivateTemplate(value); - return; - } - if (node.isContainerNode()) { - node.elements().forEachRemaining(this::rejectPrivateUrlsInJson); - } - } - - private void rejectPrivateUrl(String value) { - if (StringUtils.hasText(value) && startsWithHttp(value) && remoteTileUrlValidator.isValidLocalUrl(value)) { - throw new IllegalArgumentException(message("error-local-url")); - } - } - - private void rejectPrivateTemplate(String value) { - if (StringUtils.hasText(value) && startsWithHttp(value) && remoteTileUrlValidator.isValidLocalTemplate(value)) { - throw new IllegalArgumentException(message("error-local-url")); - } - } - - private boolean startsWithHttp(String value) { - String trimmed = value.trim().toLowerCase(); - return trimmed.startsWith("http://") || trimmed.startsWith("https://"); - } - - private void validateZoom(Integer zoom, String fieldName) { - if (zoom != null && (zoom < 0 || zoom > 24)) { - throw new IllegalArgumentException(message("error-zoom-range", fieldName)); - } - } - - private void validateStyleJson(String styleJson) { - try { - objectMapper.readTree(styleJson); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException(message("error-json"), e); - } - } - - private String normalizeRasterTileTemplate(String tileUrlTemplate) { - String template = clean(tileUrlTemplate); - if (!StringUtils.hasText(template)) { - return null; - } - return template.replace("{r}", "@2x"); - } - private String styleUrlForClient(UserMapStyle style, String contextPath) { - if ("vector".equals(style.mapType()) - && !StringUtils.hasText(style.styleJson()) - && StringUtils.hasText(style.styleUrl()) - && !remoteTileUrlValidator.isServerFetchAllowedUrl(style.styleUrl())) { - return style.styleUrl(); - } return contextPath + "/map/custom/" + style.id() + ".json?v=" + style.version(); } - private Integer effectiveRasterTileSize(String tileUrlTemplate, Integer configuredTileSize) { - if (StringUtils.hasText(tileUrlTemplate) && tileUrlTemplate.contains("@2x")) { - return 256; - } - return configuredTileSize; - } - - private String normalizeChoice(String value, String defaultValue, List allowedValues) { - String normalized = clean(value); - if (!StringUtils.hasText(normalized)) { - return defaultValue; - } - normalized = normalized.toLowerCase(); - return allowedValues.contains(normalized) ? normalized : defaultValue; - } - - private String clean(String value) { - return StringUtils.hasText(value) ? value.trim() : null; - } - - private String label(String key) { - return i18nService.translate("js.map.settings.dialog.map-styles." + key); - } - - private String message(String key, Object... args) { - return i18nService.translate("js.map.settings.dialog.map-styles." + key, args); - } - private static String defaultText(String value, String defaultValue) { - return StringUtils.hasText(value) ? value : defaultValue; + return value != null && !value.isBlank() ? value : defaultValue; } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/I18nService.java b/src/main/java/com/dedicatedcode/reitti/service/I18nService.java index a15355945..568d7163c 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/I18nService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/I18nService.java @@ -51,14 +51,6 @@ public String translate(String messageKey) { return messageSource.getMessage(messageKey, null, LocaleContextHolder.getLocale()); } - public String translateWithDefault(String messageKey, String defaultMessage) { - return messageSource.getMessage(messageKey, null, defaultMessage, LocaleContextHolder.getLocale()); - } - - public String translateWithDefault(String messageKey, String defaultMessage, Object... args) { - return messageSource.getMessage(messageKey, args, defaultMessage, LocaleContextHolder.getLocale()); - } - public String translate(String messageKey, Object... args) { return messageSource.getMessage(messageKey, args, LocaleContextHolder.getLocale()); } diff --git a/src/main/java/com/dedicatedcode/reitti/service/MapStylePathUtils.java b/src/main/java/com/dedicatedcode/reitti/service/MapStylePathUtils.java new file mode 100644 index 000000000..00aaecef7 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/MapStylePathUtils.java @@ -0,0 +1,25 @@ +package com.dedicatedcode.reitti.service; + +import org.springframework.util.StringUtils; + +import java.util.Locale; + +public final class MapStylePathUtils { + private MapStylePathUtils() { + } + + public static String sourcePathId(String sourceId) { + String sourceKey = StringUtils.hasText(sourceId) ? sourceId : "source"; + String slug = StringUtils.hasText(sourceId) + ? sourceId.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "-").replaceAll("^-+|-+$", "") + : "source"; + if (!StringUtils.hasText(slug)) { + slug = "source"; + } + return slug + "-" + Integer.toUnsignedString(sourceKey.hashCode(), 36); + } + + public static boolean matchesSourcePathId(String pathId, String sourceId) { + return sourceId != null && (sourceId.equals(pathId) || sourcePathId(sourceId).equals(pathId)); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/MapStyleUrlValidator.java b/src/main/java/com/dedicatedcode/reitti/service/MapStyleUrlValidator.java new file mode 100644 index 000000000..36a89a39b --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/MapStyleUrlValidator.java @@ -0,0 +1,64 @@ +package com.dedicatedcode.reitti.service; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.net.URI; +import java.util.regex.Pattern; + +@Service +public class MapStyleUrlValidator { + + private static final Pattern TEMPLATE_PLACEHOLDER_PATTERN = Pattern.compile("\\{[^}]+}"); + + private final I18nService i18nService; + + public MapStyleUrlValidator(I18nService i18nService) { + this.i18nService = i18nService; + } + + public URI requireHttpUrl(String value, String fieldName) { + if (!StringUtils.hasText(value)) { + throw validationError("map.settings.dialog.map-styles.error-url-required", fieldName); + } + + URI uri; + try { + uri = URI.create(value.trim()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(message("map.settings.dialog.map-styles.error-url-invalid", fieldName), e); + } + + String scheme = uri.getScheme(); + if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) { + throw validationError("map.settings.dialog.map-styles.error-url-scheme", fieldName); + } + if (!StringUtils.hasText(uri.getHost())) { + throw validationError("map.settings.dialog.map-styles.error-url-host", fieldName); + } + if (StringUtils.hasText(uri.getUserInfo())) { + throw validationError("map.settings.dialog.map-styles.error-url-credentials", fieldName); + } + + return uri; + } + + public URI requireHttpTemplate(String value, String fieldName) { + return requireHttpUrl(normalizeTemplateForParsing(value), fieldName); + } + + private IllegalArgumentException validationError(String key, Object... args) { + return new IllegalArgumentException(message(key, args)); + } + + private String message(String key, Object... args) { + return i18nService.translate(key, args); + } + + private static String normalizeTemplateForParsing(String value) { + if (!StringUtils.hasText(value)) { + return value; + } + return TEMPLATE_PLACEHOLDER_PATTERN.matcher(value.trim()).replaceAll("0"); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java b/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java index 1d3fca008..bdd238503 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java @@ -298,7 +298,7 @@ private String generateIntroductionText(List clusters, ProcessedVi SignificantPlace accommodationPlace = accommodation.getPlace(); String country; if (accommodationPlace.getCountryCode() != null) { - country = i18n.translateWithDefault("country." + accommodationPlace.getCountryCode() + ".label", accommodation.getPlace().getCountryCode().toLowerCase()); + country = i18n.translate("country." + accommodationPlace.getCountryCode() + ".label"); } else { country = i18n.translate("country.unknown.label"); } diff --git a/src/main/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidator.java b/src/main/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidator.java deleted file mode 100644 index 8e1f43c15..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/RemoteTileUrlValidator.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.dedicatedcode.reitti.service; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import java.net.InetAddress; -import java.net.URI; -import java.time.Duration; -import java.util.Locale; - -@Service -public class RemoteTileUrlValidator { - private static final long HOST_SAFETY_CACHE_MAX_SIZE = 50_000; - private static final Duration HOST_SAFETY_CACHE_TTL = Duration.ofMinutes(10); - - private final boolean proxyLocalTileUrls; - private final Cache hostSafetyCache; - private final I18nService i18nService; - - @Autowired - public RemoteTileUrlValidator( - @Value("${reitti.ui.tiles.proxy-local-urls:false}") boolean proxyLocalTileUrls, - I18nService i18nService) { - this.proxyLocalTileUrls = proxyLocalTileUrls; - this.i18nService = i18nService; - this.hostSafetyCache = Caffeine.newBuilder() - .maximumSize(HOST_SAFETY_CACHE_MAX_SIZE) - .expireAfterWrite(HOST_SAFETY_CACHE_TTL) - .build(); - } - - public RemoteTileUrlValidator(@Value("${reitti.ui.tiles.proxy-local-urls:false}") boolean proxyLocalTileUrls) { - this.proxyLocalTileUrls = proxyLocalTileUrls; - this.i18nService = null; - this.hostSafetyCache = Caffeine.newBuilder() - .maximumSize(HOST_SAFETY_CACHE_MAX_SIZE) - .expireAfterWrite(HOST_SAFETY_CACHE_TTL) - .build(); - } - - public URI requirePublicHttpUrl(String value, String fieldName) { - URI uri = parseHttpUrl(value, fieldName); - validatePublicHost(uri, fieldName); - return uri; - } - - public URI requireHttpUrl(String value, String fieldName) { - return parseHttpUrl(value, fieldName); - } - - public URI requirePublicHttpTemplate(String value, String fieldName) { - return requirePublicHttpUrl(normalizeTemplateForParsing(value), fieldName); - } - - public URI requireHttpTemplate(String value, String fieldName) { - return requireHttpUrl(normalizeTemplateForParsing(value), fieldName); - } - - public boolean isServerFetchAllowedUrl(String value) { - try { - requirePublicHttpUrl(value, "URL"); - return true; - } catch (IllegalArgumentException e) { - return false; - } - } - - public boolean isServerFetchAllowedTemplate(String value) { - try { - requirePublicHttpTemplate(value, "URL template"); - return true; - } catch (IllegalArgumentException e) { - return false; - } - } - - public boolean isValidLocalUrl(String value) { - try { - URI uri = parseHttpUrl(value, "URL"); - return isLocalHost(uri.getHost().toLowerCase(Locale.ROOT)); - } catch (IllegalArgumentException e) { - return false; - } - } - - public boolean isValidLocalTemplate(String value) { - return isValidLocalUrl(normalizeTemplateForParsing(value)); - } - - private URI parseHttpUrl(String value, String fieldName) { - if (!StringUtils.hasText(value)) { - throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-required", fieldName + " is required.", fieldName)); - } - - URI uri; - try { - uri = URI.create(value.trim()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-invalid", fieldName + " must be a valid URL.", fieldName), e); - } - - String scheme = uri.getScheme(); - if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) { - throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-scheme", fieldName + " must use HTTP or HTTPS.", fieldName)); - } - if (!StringUtils.hasText(uri.getHost())) { - throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-host", fieldName + " must include a host.", fieldName)); - } - if (StringUtils.hasText(uri.getUserInfo())) { - throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-credentials", fieldName + " must not contain embedded credentials.", fieldName)); - } - return uri; - } - - private void validatePublicHost(URI uri, String fieldName) { - if (proxyLocalTileUrls) { - return; - } - - String host = uri.getHost().toLowerCase(Locale.ROOT); - boolean isPublic = hostSafetyCache.get(host, this::isPublicHost); - if (!isPublic) { - throw new IllegalArgumentException(message("js.map.settings.dialog.map-styles.error-url-private", fieldName + " must not target local or private network addresses.", fieldName)); - } - } - - private boolean isPublicHost(String host) { - try { - InetAddress[] addresses = InetAddress.getAllByName(host); - if (addresses.length == 0) { - return false; - } - for (InetAddress address : addresses) { - if (!isPublicAddress(address)) { - return false; - } - } - return true; - } catch (Exception e) { - return false; - } - } - - private boolean isLocalHost(String host) { - if ("localhost".equals(host) || host.endsWith(".localhost") || host.endsWith(".local")) { - return true; - } - - try { - InetAddress[] addresses = InetAddress.getAllByName(host); - for (InetAddress address : addresses) { - if (!isPublicAddress(address)) { - return true; - } - } - return false; - } catch (Exception e) { - return false; - } - } - - private boolean isPublicAddress(InetAddress address) { - if (address.isAnyLocalAddress() - || address.isLoopbackAddress() - || address.isLinkLocalAddress() - || address.isSiteLocalAddress() - || address.isMulticastAddress()) { - return false; - } - - byte[] bytes = address.getAddress(); - if (bytes.length == 4) { - int first = Byte.toUnsignedInt(bytes[0]); - int second = Byte.toUnsignedInt(bytes[1]); - return !(first == 0 - || first == 10 - || first == 127 - || (first == 100 && second >= 64 && second <= 127) - || (first == 169 && second == 254) - || (first == 172 && second >= 16 && second <= 31) - || (first == 192 && second == 168) - || (first == 198 && (second == 18 || second == 19)) - || first >= 224); - } - if (bytes.length == 16) { - int first = Byte.toUnsignedInt(bytes[0]); - return (first & 0xfe) != 0xfc; - } - return false; - } - - private String normalizeTemplateForParsing(String value) { - if (!StringUtils.hasText(value)) { - return value; - } - return value.trim().replaceAll("\\{[^}]+}", "0"); - } - - private String message(String key, String defaultMessage, Object... args) { - return i18nService != null - ? i18nService.translateWithDefault(key, defaultMessage, args) - : defaultMessage; - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/SafeHttpClient.java b/src/main/java/com/dedicatedcode/reitti/service/SafeHttpClient.java deleted file mode 100644 index 94ade28a5..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/SafeHttpClient.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.dedicatedcode.reitti.service; - -import org.springframework.http.HttpHeaders; -import org.springframework.stereotype.Service; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Optional; - -@Service -public class SafeHttpClient { - private static final int MAX_REDIRECTS = 5; - - private final RemoteTileUrlValidator remoteTileUrlValidator; - - public SafeHttpClient(RemoteTileUrlValidator remoteTileUrlValidator) { - this.remoteTileUrlValidator = remoteTileUrlValidator; - } - - public HttpResponse sendFollowingPublicRedirects( - HttpClient httpClient, - HttpRequest request, - HttpResponse.BodyHandler bodyHandler, - String fieldName) throws IOException, InterruptedException { - HttpRequest currentRequest = request; - URI currentUri = request.uri(); - - for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) { - HttpResponse response = httpClient.send(currentRequest, bodyHandler); - if (!isRedirect(response.statusCode())) { - return response; - } - - if (redirectCount == MAX_REDIRECTS) { - throw new IOException("Too many redirects for " + fieldName + "."); - } - - URI redirectUri = resolveRedirectUri(currentUri, response); - remoteTileUrlValidator.requirePublicHttpUrl(redirectUri.toString(), fieldName + " redirect URL"); - currentUri = redirectUri; - currentRequest = redirectRequest(currentRequest, response.statusCode(), redirectUri); - } - - throw new IOException("Too many redirects for " + fieldName + "."); - } - - private boolean isRedirect(int statusCode) { - return statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308; - } - - private URI resolveRedirectUri(URI currentUri, HttpResponse response) throws IOException { - Optional location = response.headers().firstValue(HttpHeaders.LOCATION); - if (location.isEmpty() || location.get().isBlank()) { - throw new IOException("Redirect response is missing a Location header."); - } - return currentUri.resolve(location.get().trim()); - } - - private HttpRequest redirectRequest(HttpRequest previousRequest, int statusCode, URI redirectUri) { - HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(redirectUri) - .GET(); - - previousRequest.timeout().ifPresent(builder::timeout); - previousRequest.headers().map().forEach((name, values) -> { - if (!HttpHeaders.HOST.equalsIgnoreCase(name)) { - values.forEach(value -> builder.header(name, value)); - } - }); - - if (statusCode == 303) { - builder.GET(); - } - return builder.build(); - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/TileProxySignatureService.java b/src/main/java/com/dedicatedcode/reitti/service/TileProxySignatureService.java deleted file mode 100644 index f705ef8ae..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/TileProxySignatureService.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.dedicatedcode.reitti.service; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.util.Base64; - -@Service -public class TileProxySignatureService { - private static final String HMAC_ALGORITHM = "HmacSHA256"; - - private final byte[] secret; - - public TileProxySignatureService(@Value("${reitti.ui.tiles.proxy.signature-secret:}") String configuredSecret) { - if (StringUtils.hasText(configuredSecret)) { - this.secret = configuredSecret.getBytes(StandardCharsets.UTF_8); - } else { - this.secret = new byte[32]; - new SecureRandom().nextBytes(this.secret); - } - } - - public String sign(String value) { - try { - Mac mac = Mac.getInstance(HMAC_ALGORITHM); - mac.init(new SecretKeySpec(this.secret, HMAC_ALGORITHM)); - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); - } catch (Exception e) { - throw new IllegalStateException("Failed to sign tile proxy URL", e); - } - } - - public boolean isValid(String value, String signature) { - if (!StringUtils.hasText(signature)) { - return false; - } - return MessageDigest.isEqual(sign(value).getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8)); - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java new file mode 100644 index 000000000..e85522138 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java @@ -0,0 +1,207 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.dto.map.SaveMapStyleRequest; +import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions; +import com.dedicatedcode.reitti.model.map.UserMapStyle; +import com.dedicatedcode.reitti.model.security.User; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Service +public class UserMapStyleValidator { + + private final ObjectMapper objectMapper; + private final MapStyleUrlValidator mapStyleUrlValidator; + private final I18nService i18nService; + + public UserMapStyleValidator( + ObjectMapper objectMapper, + MapStyleUrlValidator mapStyleUrlValidator, + I18nService i18nService) { + this.objectMapper = objectMapper; + this.mapStyleUrlValidator = mapStyleUrlValidator; + this.i18nService = i18nService; + } + + public UserMapStyle validateAndNormalize(User user, SaveMapStyleRequest request) { + String label = clean(request.label()); + String mapType = normalizeChoice(request.mapType(), "vector", List.of("vector", "raster")); + String styleInputType = normalizeChoice(request.styleInputType(), "url", List.of("url", "json")); + String rasterSourceInputType = normalizeChoice(request.rasterSourceInputType(), "tile_template", List.of("tile_template", "tilejson")); + String styleInput = clean(request.styleInput()); + if (!StringUtils.hasText(label)) { + throw new IllegalArgumentException(message("error-name-required")); + } + + String styleJson = null; + String styleUrl = null; + if ("vector".equals(mapType)) { + if (!StringUtils.hasText(styleInput)) { + throw new IllegalArgumentException(message("json".equals(styleInputType) ? "error-style-json-required" : "error-style-url-required")); + } + styleJson = "json".equals(styleInputType) || styleInput.startsWith("{") ? styleInput : null; + styleUrl = styleJson == null ? styleInput : null; + if (styleJson != null) { + validateStyleJson(styleJson); + } else { + mapStyleUrlValidator.requireHttpUrl(styleUrl, label("style-json-url")); + } + } + MapStyleDataSource source = normalizeDataSource(request.dataSource()); + MapStyleVectorOptions vectorOptions = normalizeVectorOptions(request.vectorOptions()); + boolean shared = request.shared() && user.getRole() == Role.ADMIN; + if ("raster".equals(mapType)) { + validateRasterSource(rasterSourceInputType, source); + Integer tileSize = source.tileSize(); + String tileUrlTemplate = "tile_template".equals(rasterSourceInputType) ? normalizeRasterTileTemplate(source.tileUrlTemplate()) : null; + source = new MapStyleDataSource( + "custom-raster-source", + "raster", + "tilejson".equals(rasterSourceInputType) ? clean(source.tileJsonUrl()) : null, + tileUrlTemplate, + clean(source.attribution()), + source.minzoom(), + source.maxzoom(), + effectiveRasterTileSize(tileUrlTemplate, tileSize), + source.scheme(), + source.proxyTiles() + ); + } + if (source.proxyTiles() && user.getRole() != Role.ADMIN) { + source = new MapStyleDataSource( + source.sourceId(), + source.type(), + source.tileJsonUrl(), + source.tileUrlTemplate(), + source.attribution(), + source.minzoom(), + source.maxzoom(), + source.tileSize(), + source.scheme(), + false + ); + } + + Long parsedId = com.dedicatedcode.reitti.repository.UserMapStyleJdbcService.resolveCustomId(request.id()).orElse(null); + + return new UserMapStyle( + parsedId, + user.getId(), + label, + mapType, + styleInputType, + rasterSourceInputType, + styleJson, + styleUrl, + source, + vectorOptions, + shared, + null // version not handled here + ); + } + + private MapStyleDataSource normalizeDataSource(MapStyleDataSource dataSource) { + MapStyleDataSource source = dataSource != null ? dataSource : new MapStyleDataSource(null, "vector", null, null, null, null, null, null, null, false); + String type = normalizeChoice(source.type(), "vector", List.of("vector", "raster", "raster-dem")); + Integer tileSize = source.tileSize() == null || (source.tileSize() != 256 && source.tileSize() != 512) ? null : source.tileSize(); + String scheme = normalizeChoice(source.scheme(), null, List.of("xyz", "tms")); + return new MapStyleDataSource( + clean(source.sourceId()), + type, + clean(source.tileJsonUrl()), + clean(source.tileUrlTemplate()), + clean(source.attribution()), + source.minzoom(), + source.maxzoom(), + tileSize, + scheme, + source.proxyTiles() + ); + } + + private MapStyleVectorOptions normalizeVectorOptions(MapStyleVectorOptions options) { + if (options == null) { + return new MapStyleVectorOptions(null, null, null); + } + return new MapStyleVectorOptions( + clean(options.attributionOverride()), + clean(options.glyphsUrlOverride()), + clean(options.spriteUrlOverride()) + ); + } + + private void validateRasterSource(String rasterSourceInputType, MapStyleDataSource source) { + if ("tile_template".equals(rasterSourceInputType)) { + String tileUrlTemplate = clean(source.tileUrlTemplate()); + if (!StringUtils.hasText(tileUrlTemplate) || !tileUrlTemplate.contains("{z}") || !tileUrlTemplate.contains("{x}") || !tileUrlTemplate.contains("{y}")) { + throw new IllegalArgumentException(message("error-tile-template-placeholders")); + } + mapStyleUrlValidator.requireHttpTemplate(tileUrlTemplate, label("tile-template")); + } else if (!StringUtils.hasText(source.tileJsonUrl())) { + throw new IllegalArgumentException(message("error-tilejson-required")); + } else { + mapStyleUrlValidator.requireHttpUrl(source.tileJsonUrl(), label("tilejson-url")); + } + validateZoom(source.minzoom(), label("minzoom")); + validateZoom(source.maxzoom(), label("maxzoom")); + if (source.minzoom() != null && source.maxzoom() != null && source.minzoom() >= source.maxzoom()) { + throw new IllegalArgumentException(message("error-zoom-order")); + } + } + + private void validateZoom(Integer zoom, String fieldName) { + if (zoom != null && (zoom < 0 || zoom > 24)) { + throw new IllegalArgumentException(message("error-zoom-range", fieldName)); + } + } + + private void validateStyleJson(String styleJson) { + try { + objectMapper.readTree(styleJson); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(message("error-json"), e); + } + } + + private String normalizeRasterTileTemplate(String tileUrlTemplate) { + String template = clean(tileUrlTemplate); + if (!StringUtils.hasText(template)) { + return null; + } + return template.replace("{r}", "@2x"); + } + + private Integer effectiveRasterTileSize(String tileUrlTemplate, Integer configuredTileSize) { + if (StringUtils.hasText(tileUrlTemplate) && tileUrlTemplate.contains("@2x")) { + return 256; + } + return configuredTileSize; + } + + private String normalizeChoice(String value, String defaultValue, List allowedValues) { + String normalized = clean(value); + if (!StringUtils.hasText(normalized)) { + return defaultValue; + } + normalized = normalized.toLowerCase(); + return allowedValues.contains(normalized) ? normalized : defaultValue; + } + + private String clean(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private String label(String key) { + return i18nService.translate("js.map.settings.dialog.map-styles." + key); + } + + private String message(String key, Object... args) { + return i18nService.translate("js.map.settings.dialog.map-styles." + key, args); + } +} diff --git a/src/main/resources/application-docker.properties b/src/main/resources/application-docker.properties index c2624cc0f..16874bfee 100644 --- a/src/main/resources/application-docker.properties +++ b/src/main/resources/application-docker.properties @@ -38,8 +38,6 @@ reitti.process-data.schedule=${REITTI_PROCESS_DATA_CRON:0 */10 * * * *} reitti.ui.tiles.custom.service=${CUSTOM_TILES_SERVICE:} reitti.ui.tiles.custom.attribution=${CUSTOM_TILES_ATTRIBUTION:} reitti.ui.tiles.cache.url=${TILES_CACHE:http://tile-cache} -reitti.ui.tiles.proxy.signature-secret=${TILE_PROXY_SIGNATURE_SECRET:} -reitti.ui.tiles.proxy-local-urls=${PROXY_LOCAL_TILE_URLS:false} reitti.import.batch-size=${PROCESSING_BATCH_SIZE:10000} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f85a3dfee..86992d533 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -84,8 +84,6 @@ reitti.geocoding.photon.base-url= # Tiles Configuration reitti.ui.tiles.cache.url= -reitti.ui.tiles.proxy-local-urls=false -reitti.ui.tiles.proxy.signature-secret= reitti.ui.tiles.default.service=https://tile.openstreetmap.org/{z}/{x}/{y}.png reitti.ui.tiles.default.attribution=© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France diff --git a/src/main/resources/db/migration/V92__add_map_style_proxy_flag.sql b/src/main/resources/db/migration/V92__add_map_style_proxy_flag.sql new file mode 100644 index 000000000..603c31b54 --- /dev/null +++ b/src/main/resources/db/migration/V92__add_map_style_proxy_flag.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_map_styles + ADD COLUMN proxy_tiles BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 54e57cbfd..550310e17 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -1130,6 +1130,9 @@ js.map.settings.dialog.map-styles.remove=Remove js.map.settings.dialog.map-styles.shared=Share with all users js.map.settings.dialog.map-styles.shared-info=Other users can select this style, but only you can edit it. js.map.settings.dialog.map-styles.shared-badge=Shared +js.map.settings.dialog.map-styles.proxy-tiles=Proxy tiles through Reitti +js.map.settings.dialog.map-styles.proxy-tiles-info=Tile requests for this style are fetched through Reitti. +js.map.settings.dialog.map-styles.proxy-badge=Proxied js.map.settings.dialog.map-styles.remove-confirm=Remove this custom map style? js.map.settings.dialog.map-styles.empty=No custom styles yet js.map.settings.dialog.map-styles.error-name-required=Name is required. @@ -1148,14 +1151,12 @@ js.map.settings.dialog.map-styles.error-save=Could not save this custom style. js.map.settings.dialog.map-styles.error-save-status=Could not save this custom style (HTTP {0}). js.map.settings.dialog.map-styles.error-delete=Could not delete this custom style. js.map.settings.dialog.map-styles.error-active=Could not save the active map style. -js.map.settings.dialog.map-styles.error-local-url=Only admins can use local or private network map style URLs. js.map.settings.dialog.map-styles.error-zoom-range={0} must be between 0 and 24. js.map.settings.dialog.map-styles.error-url-required={0} is required. js.map.settings.dialog.map-styles.error-url-invalid={0} must be a valid URL. js.map.settings.dialog.map-styles.error-url-scheme={0} must use HTTP or HTTPS. js.map.settings.dialog.map-styles.error-url-host={0} must include a host. js.map.settings.dialog.map-styles.error-url-credentials={0} must not contain embedded credentials. -js.map.settings.dialog.map-styles.error-url-private="{0} must not target local or private network addresses. PROXY_LOCAL_TILE_URLS=true to enable." settings.map.styles=Map Styles settings.map.styles.description=Manage custom MapLibre styles and optional tile sources map.settings.dialog.map-styles.active=Active Style @@ -1195,6 +1196,14 @@ map.settings.dialog.map-styles.cancel=Cancel map.settings.dialog.map-styles.shared=Share with all users map.settings.dialog.map-styles.shared-info=Other users can select this style, but only you can edit it. map.settings.dialog.map-styles.shared-badge=Shared +map.settings.dialog.map-styles.proxy-tiles=Proxy tiles through Reitti +map.settings.dialog.map-styles.proxy-tiles-info=Tile requests for this style are fetched through Reitti. +map.settings.dialog.map-styles.proxy-badge=Proxied +map.settings.dialog.map-styles.error-url-required={0} is required. +map.settings.dialog.map-styles.error-url-invalid={0} must be a valid URL. +map.settings.dialog.map-styles.error-url-scheme={0} must use HTTP or HTTPS. +map.settings.dialog.map-styles.error-url-host={0} must include a host. +map.settings.dialog.map-styles.error-url-credentials={0} must not contain embedded credentials. map.settings.dialog.date-picker.title=Date Selection diff --git a/src/main/resources/static/js/map-style-settings.js b/src/main/resources/static/js/map-style-settings.js index 14ffb0b4f..35e14dff2 100644 --- a/src/main/resources/static/js/map-style-settings.js +++ b/src/main/resources/static/js/map-style-settings.js @@ -89,12 +89,13 @@ class MapStyleSettingsPage { ? t('map.settings.dialog.map-styles.style-input.json') : t('map.settings.dialog.map-styles.style-input.url')); const sharedLabel = style.shared ? ` · ${this.escapeHtml(t('map.settings.dialog.map-styles.shared-badge'))}` : ''; + const proxyLabel = style.dataSource?.proxyTiles ? ` · ${this.escapeHtml(t('map.settings.dialog.map-styles.proxy-badge'))}` : ''; return `
${this.escapeHtml(style.label)}
-
${this.escapeHtml(typeLabel)} · ${this.escapeHtml(inputLabel)}${sharedLabel}
+
${this.escapeHtml(typeLabel)} · ${this.escapeHtml(inputLabel)}${sharedLabel}${proxyLabel}
- From a273cc73ab1f5569222aa1adc55bb2f5def49787 Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Mon, 4 May 2026 23:22:12 +0000 Subject: [PATCH 06/13] global ui colors --- src/main/resources/static/css/main.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 48cc60670..6114371af 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -447,7 +447,7 @@ header { } .custom-map-style-form { - border-top: 1px solid rgba(245, 222, 179, 0.25); + border-top: 1px solid var(--color-highlight-transparent); margin-top: 12px; padding-top: 12px; display: flex; @@ -509,7 +509,7 @@ header { .custom-map-style-choice label { align-items: center; background: var(--color-background-dark); - border: 1px solid rgba(245, 222, 179, 0.35); + border: 1px solid var(--color-highlight-transparent); border-radius: 4px; color: var(--color-text-white); cursor: pointer; @@ -539,7 +539,7 @@ header { } .custom-map-style-advanced { - border: 1px solid rgba(245, 222, 179, 0.35); + border: 1px solid var(--color-highlight-transparent); border-radius: 6px; padding: 14px 16px; margin: 0; @@ -593,7 +593,7 @@ header { .custom-map-style-share-panel { align-items: center; - border-top: 1px solid rgba(245, 222, 179, 0.15); + border-top: 1px solid var(--color-highlight-transparent); display: flex; gap: 16px; justify-content: space-between; From 466afb85c8ff8ac405a91ca27e6ab29406ee7d4d Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Tue, 5 May 2026 00:09:16 +0000 Subject: [PATCH 07/13] + placeholders --- src/main/resources/messages.properties | 1 + .../templates/settings/map-styles.html | 30 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 550310e17..c81ffe36f 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -1204,6 +1204,7 @@ map.settings.dialog.map-styles.error-url-invalid={0} must be a valid URL. map.settings.dialog.map-styles.error-url-scheme={0} must use HTTP or HTTPS. map.settings.dialog.map-styles.error-url-host={0} must include a host. map.settings.dialog.map-styles.error-url-credentials={0} must not contain embedded credentials. +map.settings.dialog.map-styles.name.placeholder=My custom style map.settings.dialog.date-picker.title=Date Selection diff --git a/src/main/resources/templates/settings/map-styles.html b/src/main/resources/templates/settings/map-styles.html index 3472b1591..f65188fd8 100644 --- a/src/main/resources/templates/settings/map-styles.html +++ b/src/main/resources/templates/settings/map-styles.html @@ -58,7 +58,8 @@

Custom Styles

- +
@@ -92,12 +93,14 @@

Custom Styles

- +
Advanced Options @@ -106,19 +109,22 @@

Custom Styles

- +
- +
- +
@@ -141,23 +147,27 @@

Custom Styles

- +
- +
Tile Settings From 8258f039d658e42a1535d9d10da41b89e0942cc6 Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Tue, 5 May 2026 09:12:24 +0000 Subject: [PATCH 08/13] simplify proxy urls --- .../controller/api/MapStyleController.java | 157 +++++++++-------- .../controller/api/TileProxyController.java | 159 ++++++++---------- .../reitti/model/map/MapStyleDataSource.java | 4 + .../reitti/service/MapStylePathUtils.java | 42 ++++- .../reitti/service/UserMapStyleValidator.java | 13 +- .../api/MapStyleControllerTest.java | 2 +- .../api/TileProxyControllerTest.java | 3 - 7 files changed, 188 insertions(+), 192 deletions(-) diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java index 01740bde7..3b56737cd 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.node.TextNode; import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -27,7 +28,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.core.io.ClassPathResource; import java.io.IOException; import java.net.URI; @@ -36,6 +36,10 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; @RestController @@ -48,6 +52,8 @@ public class MapStyleController { private static final String RUNTIME_TERRAIN_SOURCE = "reitti-terrain-source"; private static final String RUNTIME_SATELLITE_SOURCE = "reitti-satellite-source"; private static final String RUNTIME_BUILDING_SOURCE = "reitti-building-source"; + private static final String TERRAIN_PROXY_PATH = "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"; + private static final String SATELLITE_PROXY_PATH = "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"; private final ObjectMapper objectMapper; private final ContextPathHolder contextPathHolder; @@ -124,9 +130,7 @@ private JsonNode fetchRemoteStyle(UserMapStyle style) throws IOException, Interr } JsonNode json = objectMapper.readTree(response.body()); if (json instanceof ObjectNode objectNode) { - ObjectNode metadata = getOrCreateMetadata(objectNode); - metadata.put("reitti:style-url", style.styleUrl()); - objectNode.set("metadata", metadata); + getOrCreateMetadata(objectNode).put("reitti:style-url", style.styleUrl()); } return json; } @@ -140,22 +144,16 @@ private JsonNode buildRasterStyle(UserMapStyle style) { source.put("tileSize", effectiveRasterTileSize(dataSource)); source.put("scheme", StringUtils.hasText(dataSource.scheme()) ? dataSource.scheme() : "xyz"); - ObjectNode sources = objectMapper.createObjectNode(); - sources.set("custom-raster-source", source); - ObjectNode rasterLayer = objectMapper.createObjectNode(); rasterLayer.put("id", "custom-raster-layer"); rasterLayer.put("type", "raster"); rasterLayer.put("source", "custom-raster-source"); - ArrayNode layers = objectMapper.createArrayNode(); - layers.add(rasterLayer); - ObjectNode styleJson = objectMapper.createObjectNode(); styleJson.put("version", 8); styleJson.put("name", style.name()); - styleJson.set("sources", sources); - styleJson.set("layers", layers); + styleJson.set("sources", objectMapper.createObjectNode().set("custom-raster-source", source)); + styleJson.set("layers", objectMapper.createArrayNode().add(rasterLayer)); return styleJson; } @@ -209,12 +207,9 @@ private JsonNode applyVectorOptions(JsonNode style, MapStyleVectorOptions option } private void applyAttributionOverride(ObjectNode mutableStyle, String attribution) { - ObjectNode metadata = getOrCreateMetadata(mutableStyle); - metadata.put("reitti:attribution-override", attribution); - mutableStyle.set("metadata", metadata); + getOrCreateMetadata(mutableStyle).put("reitti:attribution-override", attribution); - JsonNode sources = mutableStyle.get("sources"); - if (sources instanceof ObjectNode sourcesObject) { + if (mutableStyle.get("sources") instanceof ObjectNode sourcesObject) { sourcesObject.fields().forEachRemaining(entry -> { if (entry.getValue() instanceof ObjectNode source) { source.put("attribution", attribution); @@ -231,11 +226,10 @@ private JsonNode applyCustomDataSource(JsonNode style, MapStyleDataSource dataSo return style; } ObjectNode mutableStyle = style.deepCopy(); - ObjectNode sources = ensureSourcesNode(mutableStyle); ObjectNode source = objectMapper.createObjectNode(); source.put("type", StringUtils.hasText(dataSource.type()) ? dataSource.type() : "vector"); populateDataSourceFields(source, dataSource); - sources.set(dataSource.sourceId(), source); + ensureSourcesNode(mutableStyle).set(dataSource.sourceId(), source); return mutableStyle; } @@ -258,12 +252,10 @@ private JsonNode ensureRuntimeSources(JsonNode style, HttpServletRequest request String baseUrl = RequestHelper.getBaseUrl(request); if (!sources.has(RUNTIME_TERRAIN_SOURCE)) { - String tileUrl = tileCacheEnabled ? baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp" : TERRAIN_TILE_URL; - sources.set(RUNTIME_TERRAIN_SOURCE, buildTerrainSource(tileUrl)); + sources.set(RUNTIME_TERRAIN_SOURCE, buildTerrainSource(tileCacheEnabled ? baseUrl + TERRAIN_PROXY_PATH : TERRAIN_TILE_URL)); } if (!sources.has(RUNTIME_SATELLITE_SOURCE)) { - String tileUrl = tileCacheEnabled ? baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg" : SATELLITE_TILE_URL; - sources.set(RUNTIME_SATELLITE_SOURCE, buildSatelliteSource(tileUrl)); + sources.set(RUNTIME_SATELLITE_SOURCE, buildSatelliteSource(tileCacheEnabled ? baseUrl + SATELLITE_PROXY_PATH : SATELLITE_TILE_URL)); } if (!styleHasBuildingLayer(mutableStyle) && !sources.has(RUNTIME_BUILDING_SOURCE)) { sources.set(RUNTIME_BUILDING_SOURCE, buildBuildingSource()); @@ -304,8 +296,7 @@ private ObjectNode buildBuildingSource() { } private boolean styleHasBuildingLayer(ObjectNode style) { - JsonNode layers = style.get("layers"); - if (!(layers instanceof ArrayNode layerArray)) { + if (!(style.get("layers") instanceof ArrayNode layerArray)) { return false; } for (JsonNode layer : layerArray) { @@ -324,8 +315,7 @@ private boolean styleHasBuildingLayer(ObjectNode style) { private JsonNode rewriteResourceUrls(JsonNode style) { ObjectNode mutableStyle = style.deepCopy(); - JsonNode glyphs = mutableStyle.get("glyphs"); - if (glyphs instanceof TextNode glyphsText && glyphsText.asText().startsWith("/")) { + if (mutableStyle.get("glyphs") instanceof TextNode glyphsText && glyphsText.asText().startsWith("/")) { mutableStyle.set("glyphs", new TextNode(contextPathHolder.getContextPath() + glyphsText.asText())); } return mutableStyle; @@ -333,25 +323,42 @@ private JsonNode rewriteResourceUrls(JsonNode style) { private JsonNode rewriteUrlsForProxy(JsonNode style, HttpServletRequest request, String styleId) { ObjectNode mutableStyle = style.deepCopy(); - String baseUrl = RequestHelper.getBaseUrl(request); - JsonNode sources = mutableStyle.get("sources"); - if (!(sources instanceof ObjectNode mutableSources)) { + if (!(mutableStyle.get("sources") instanceof ObjectNode mutableSources)) { return mutableStyle; } + String baseUrl = RequestHelper.getBaseUrl(request); URI styleBaseUri = getStyleBaseUri(mutableStyle).orElse(null); + List sourceIds = new ArrayList<>(); + mutableSources.fieldNames().forEachRemaining(sourceIds::add); + + Map reservedRasterTiles = reservedRasterTileUrls(baseUrl); mutableSources.fields().forEachRemaining(entry -> { - if (entry.getValue() instanceof ObjectNode source) { - rewriteTileSource(source, baseUrl, styleId, entry.getKey(), styleBaseUri); + if (!(entry.getValue() instanceof ObjectNode source)) { + return; + } + String reservedTileUrl = reservedRasterTiles.get(entry.getKey()); + if (reservedTileUrl != null) { + if (RUNTIME_SATELLITE_SOURCE.equals(entry.getKey()) || "satellite-source".equals(entry.getKey())) { + source.put("type", "raster"); + } + source.set("tiles", singleTileArray(reservedTileUrl)); + return; } + rewriteTileSource(source, baseUrl, styleId, entry.getKey(), sourceIds, styleBaseUri); }); - rewriteRasterSource(mutableSources, "terrain-source", baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"); - rewriteRasterSource(mutableSources, "satellite-source", baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"); - rewriteRasterSource(mutableSources, RUNTIME_TERRAIN_SOURCE, baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"); - rewriteRasterSource(mutableSources, RUNTIME_SATELLITE_SOURCE, baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"); return mutableStyle; } - private void rewriteTileSource(ObjectNode source, String baseUrl, String styleId, String sourceId, URI styleBaseUri) { + private Map reservedRasterTileUrls(String baseUrl) { + Map reserved = new LinkedHashMap<>(); + reserved.put("terrain-source", baseUrl + TERRAIN_PROXY_PATH); + reserved.put("satellite-source", baseUrl + SATELLITE_PROXY_PATH); + reserved.put(RUNTIME_TERRAIN_SOURCE, baseUrl + TERRAIN_PROXY_PATH); + reserved.put(RUNTIME_SATELLITE_SOURCE, baseUrl + SATELLITE_PROXY_PATH); + return reserved; + } + + private void rewriteTileSource(ObjectNode source, String baseUrl, String styleId, String sourceId, List allSourceIds, URI styleBaseUri) { String sourceUrl = source.path("url").asText(""); String firstTileUrl = getFirstTileUrl(source); @@ -360,50 +367,37 @@ private void rewriteTileSource(ObjectNode source, String baseUrl, String styleId source.set("tiles", singleTileArray(baseUrl + "/api/v1/tiles/vector/{z}/{x}/{y}.pbf")); return; } - if (sourceUrl.startsWith("http://") || sourceUrl.startsWith("https://")) { - source.set("url", new TextNode(styleSourceTileJsonUrl(baseUrl, styleId, sourceId))); + if (isHttpUrl(sourceUrl)) { + source.set("url", new TextNode(styleSourceTileJsonUrl(baseUrl, styleId, sourceId, allSourceIds))); return; } - rewriteTileTemplates(source, baseUrl, styleId, sourceId, styleBaseUri); + rewriteTileTemplates(source, baseUrl, styleId, sourceId, allSourceIds, styleBaseUri); } - private void rewriteTileTemplates(ObjectNode source, String baseUrl, String styleId, String sourceId, URI styleBaseUri) { - JsonNode tiles = source.get("tiles"); - if (!(tiles instanceof ArrayNode tileArray) || tileArray.isEmpty()) { + private void rewriteTileTemplates(ObjectNode source, String baseUrl, String styleId, String sourceId, List allSourceIds, URI styleBaseUri) { + if (!(source.get("tiles") instanceof ArrayNode tileArray) || tileArray.isEmpty()) { return; } + String firstTileUrl = tileArray.get(0).asText(""); ArrayNode rewrittenTiles = objectMapper.createArrayNode(); - for (int i = 0; i < tileArray.size(); i++) { - String tileUrl = tileArray.get(i).asText(""); - if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { - rewrittenTiles.add(styleSourceTileUrl(baseUrl, styleId, sourceId, i, tileUrl)); - } else if (styleBaseUri != null && containsTilePlaceholders(tileUrl)) { - rewrittenTiles.add(styleSourceTileUrl(baseUrl, styleId, sourceId, i, styleBaseUri.resolve(tileUrl).toString())); - } else { - rewrittenTiles.add(tileUrl); - } + if (isHttpUrl(firstTileUrl)) { + rewrittenTiles.add(styleSourceTileUrl(baseUrl, styleId, sourceId, allSourceIds, firstTileUrl)); + } else if (styleBaseUri != null && containsTilePlaceholders(firstTileUrl)) { + rewrittenTiles.add(styleSourceTileUrl(baseUrl, styleId, sourceId, allSourceIds, styleBaseUri.resolve(firstTileUrl).toString())); + } else { + rewrittenTiles.add(firstTileUrl); } source.set("tiles", rewrittenTiles); } - private void rewriteRasterSource(ObjectNode sources, String sourceName, String tileUrl) { - if (!(sources.get(sourceName) instanceof ObjectNode source)) { - return; - } - if ("satellite-source".equals(sourceName) || RUNTIME_SATELLITE_SOURCE.equals(sourceName)) { - source.put("type", "raster"); - } - source.set("tiles", singleTileArray(tileUrl)); + private String styleSourceTileJsonUrl(String baseUrl, String styleId, String sourceId, List allSourceIds) { + return baseUrl + "/api/v1/tiles/styles/" + styleId + "/" + MapStylePathUtils.sourcePathId(sourceId, allSourceIds) + "/tilejson.json"; } - private String styleSourceTileJsonUrl(String baseUrl, String styleId, String sourceId) { - return baseUrl + "/api/v1/tiles/styles/" + styleId + "/sources/" + MapStylePathUtils.sourcePathId(sourceId) + "/tilejson.json"; - } - - private String styleSourceTileUrl(String baseUrl, String styleId, String sourceId, int tileIndex, String tileUrl) { + private String styleSourceTileUrl(String baseUrl, String styleId, String sourceId, List allSourceIds, String tileUrl) { String normalizedTileUrl = tileUrl.replace("{r}", ""); - return baseUrl + "/api/v1/tiles/styles/" + styleId + "/sources/" + MapStylePathUtils.sourcePathId(sourceId) - + "/tiles/" + tileIndex + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + return baseUrl + "/api/v1/tiles/styles/" + styleId + "/" + MapStylePathUtils.sourcePathId(sourceId, allSourceIds) + + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); } private ArrayNode singleTileArray(String tileUrl) { @@ -413,16 +407,14 @@ private ArrayNode singleTileArray(String tileUrl) { } private String getFirstTileUrl(ObjectNode source) { - JsonNode tiles = source.get("tiles"); - if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { + if (source.get("tiles") instanceof ArrayNode tileArray && !tileArray.isEmpty()) { return tileArray.get(0).asText(""); } return ""; } private ObjectNode ensureSourcesNode(ObjectNode mutableStyle) { - JsonNode sources = mutableStyle.get("sources"); - if (sources instanceof ObjectNode sourcesObject) { + if (mutableStyle.get("sources") instanceof ObjectNode sourcesObject) { return sourcesObject; } ObjectNode sourcesObject = objectMapper.createObjectNode(); @@ -431,14 +423,16 @@ private ObjectNode ensureSourcesNode(ObjectNode mutableStyle) { } private ObjectNode getOrCreateMetadata(ObjectNode node) { - return node.has("metadata") && node.get("metadata") instanceof ObjectNode existing - ? existing - : objectMapper.createObjectNode(); + if (node.get("metadata") instanceof ObjectNode existing) { + return existing; + } + ObjectNode metadata = objectMapper.createObjectNode(); + node.set("metadata", metadata); + return metadata; } private Optional getStyleBaseUri(ObjectNode style) { - JsonNode metadata = style.get("metadata"); - if (!(metadata instanceof ObjectNode metadataObject)) { + if (!(style.get("metadata") instanceof ObjectNode metadataObject)) { return Optional.empty(); } String styleUrl = metadataObject.path("reitti:style-url").asText(""); @@ -452,12 +446,15 @@ private Optional getStyleBaseUri(ObjectNode style) { } } - private boolean containsTilePlaceholders(String tileUrl) { + private static boolean isHttpUrl(String url) { + return url.startsWith("http://") || url.startsWith("https://"); + } + + private static boolean containsTilePlaceholders(String tileUrl) { return tileUrl.contains("{z}") && tileUrl.contains("{x}") && tileUrl.contains("{y}"); } - private boolean shouldProxyTiles(UserMapStyle style) { + private static boolean shouldProxyTiles(UserMapStyle style) { return style.dataSource() != null && style.dataSource().proxyTiles(); } - -} \ No newline at end of file +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java index bdc472cd2..c6138424c 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java @@ -32,6 +32,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.UncheckedIOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -109,7 +110,7 @@ public ResponseEntity getTileLegacy( return getTile("raster", z, x, y, "png", request); } - @GetMapping("/styles/{styleId}/sources/{sourceId}/tilejson.json") + @GetMapping("/styles/{styleId}/{sourceId}/tilejson.json") public ResponseEntity getStyleSourceTileJson( @AuthenticationPrincipal User user, @PathVariable String styleId, @@ -130,26 +131,23 @@ public ResponseEntity getStyleSourceTileJson( } URI tileJsonUri = mapStyleUrlValidator.requireHttpUrl(tileJsonUrl, "Custom TileJSON URL"); - HttpResponse response = fetchUrl(tileJsonUrl); + HttpResponse response = fetchRaw(tileJsonUrl, Map.of()); if (response.statusCode() != 200) { log.debug("Failed to fetch custom TileJSON [{}]: HTTP {}", tileJsonUri, response.statusCode()); return ResponseEntity.notFound().build(); } JsonNode tileJson = objectMapper.readTree(responseBody(response)); - if (tileJson instanceof ObjectNode mutableTileJson && mutableTileJson.get("tiles") instanceof ArrayNode tiles) { + if (tileJson instanceof ObjectNode mutableTileJson && mutableTileJson.get("tiles") instanceof ArrayNode tiles && !tiles.isEmpty()) { ArrayNode rewrittenTiles = objectMapper.createArrayNode(); - for (int i = 0; i < tiles.size(); i++) { - JsonNode tile = tiles.get(i); - String tileUrl = tile.asText(""); - if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { - rewrittenTiles.add(styleSourceTileUrl(request, styleId, sourceId, i, tileUrl)); - } else if (!tileUrl.isBlank()) { - String resolvedTileUrl = tileJsonUri.resolve(tileUrl).toString(); - rewrittenTiles.add(styleSourceTileUrl(request, styleId, sourceId, i, resolvedTileUrl)); - } else { - rewrittenTiles.add(tileUrl); - } + String firstTileUrl = tiles.get(0).asText(""); + if (firstTileUrl.startsWith("http://") || firstTileUrl.startsWith("https://")) { + rewrittenTiles.add(styleSourceTileUrl(request, styleId, sourceId, firstTileUrl)); + } else if (!firstTileUrl.isBlank()) { + String resolvedTileUrl = tileJsonUri.resolve(firstTileUrl).toString(); + rewrittenTiles.add(styleSourceTileUrl(request, styleId, sourceId, resolvedTileUrl)); + } else { + rewrittenTiles.add(firstTileUrl); } mutableTileJson.set("tiles", rewrittenTiles); } @@ -163,12 +161,11 @@ public ResponseEntity getStyleSourceTileJson( } } - @GetMapping("/styles/{styleId}/sources/{sourceId}/tiles/{tileIndex}/{z}/{x}/{y}.{ext}") + @GetMapping("/styles/{styleId}/{sourceId}/{z}/{x}/{y}.{ext}") public ResponseEntity getStyleSourceTile( @AuthenticationPrincipal User user, @PathVariable String styleId, @PathVariable String sourceId, - @PathVariable int tileIndex, @PathVariable int z, @PathVariable int x, @PathVariable int y, @@ -179,7 +176,7 @@ public ResponseEntity getStyleSourceTile( if (source.isEmpty()) { return ResponseEntity.notFound().build(); } - String template = tileTemplate(source.get(), tileIndex); + String template = tileTemplate(source.get()); if (!StringUtils.hasText(template)) { return ResponseEntity.notFound().build(); } @@ -292,10 +289,6 @@ private HttpResponse fetchRaw(String url, Map extraHeade return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); } - private HttpResponse fetchUrl(String url) throws Exception { - return fetchRaw(url, Map.of()); - } - private byte[] responseBody(HttpResponse response) throws IOException { String contentEncoding = response.headers().firstValue(HttpHeaders.CONTENT_ENCODING).orElse(""); if (contentEncoding.equalsIgnoreCase("gzip")) { @@ -352,18 +345,8 @@ private Optional sourceFromDataSource(UserMapStyle style, String sou } private JsonNode parseUserStyleJson(UserMapStyle style) throws IOException { - String cacheKey = "local:" + style.id() + ":" + style.version(); - try { - return styleJsonCache.get(cacheKey, k -> { - try { - return objectMapper.readTree(style.styleJson()); - } catch (IOException e) { - throw new java.io.UncheckedIOException(e); - } - }); - } catch (java.io.UncheckedIOException e) { - throw e.getCause(); - } + return cachedJson("local:" + style.id() + ":" + style.version(), + k -> objectMapper.readTree(style.styleJson())); } private Optional sourceFromUserStyle(UserMapStyle style, String sourceId) throws IOException, InterruptedException { @@ -411,10 +394,12 @@ private JsonNode findSource(JsonNode style, String sourceId) { if (exactSource instanceof ObjectNode) { return exactSource; } + List allSourceIds = new ArrayList<>(); + sourcesObject.fieldNames().forEachRemaining(allSourceIds::add); var fields = sourcesObject.fields(); while (fields.hasNext()) { Map.Entry entry = fields.next(); - if (entry.getValue() instanceof ObjectNode && MapStylePathUtils.matchesSourcePathId(sourceId, entry.getKey())) { + if (entry.getValue() instanceof ObjectNode && MapStylePathUtils.matchesSourcePathId(sourceId, entry.getKey(), allSourceIds)) { return entry.getValue(); } } @@ -447,9 +432,9 @@ private String resolveTileTemplate(String tileUrl, URI styleBaseUri) { return null; } - private String tileTemplate(TileSource source, int tileIndex) throws Exception { - if (tileIndex >= 0 && tileIndex < source.tileUrlTemplates().size()) { - return source.tileUrlTemplates().get(tileIndex); + private String tileTemplate(TileSource source) throws Exception { + if (!source.tileUrlTemplates().isEmpty()) { + return source.tileUrlTemplates().get(0); } if (!StringUtils.hasText(source.tileJsonUrl())) { @@ -459,29 +444,26 @@ private String tileTemplate(TileSource source, int tileIndex) throws Exception { String tileJsonUrl = source.tileJsonUrl(); URI tileJsonUri = mapStyleUrlValidator.requireHttpUrl(tileJsonUrl, "Custom TileJSON URL"); - JsonNode tileJson; - try { - tileJson = styleJsonCache.get("tilejson:" + tileJsonUrl, k -> { - try { - HttpResponse response = fetchUrl(tileJsonUrl); - if (response.statusCode() != 200) { - throw new IOException("Failed to fetch TileJSON: " + response.statusCode()); - } - return objectMapper.readTree(responseBody(response)); - } catch (Exception e) { - throw new RuntimeException(e); + JsonNode tileJson = cachedJson("tilejson:" + tileJsonUrl, k -> { + try { + HttpResponse response = fetchRaw(tileJsonUrl, Map.of()); + if (response.statusCode() != 200) { + throw new IOException("Failed to fetch TileJSON: " + response.statusCode()); } - }); - } catch (RuntimeException e) { - throw new Exception("Failed to fetch custom TileJSON", e.getCause()); - } + return objectMapper.readTree(responseBody(response)); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Failed to fetch custom TileJSON", e); + } + }); JsonNode tiles = tileJson.get("tiles"); - if (!(tiles instanceof ArrayNode tileArray) || tileIndex < 0 || tileIndex >= tileArray.size()) { + if (!(tiles instanceof ArrayNode tileArray) || tileArray.isEmpty()) { return null; } - String tileUrl = tileArray.get(tileIndex).asText(""); + String tileUrl = tileArray.get(0).asText(""); if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { return normalizeTileTemplateForProxy(tileUrl); } @@ -491,48 +473,49 @@ private String tileTemplate(TileSource source, int tileIndex) throws Exception { return null; } - private JsonNode fetchAndParseStyleJson(UserMapStyle style) throws IOException, InterruptedException { + private JsonNode fetchAndParseStyleJson(UserMapStyle style) throws IOException { String styleUrl = style.styleUrl(); - String cacheKey = "url:" + style.id() + ":" + style.version() + ":" + styleUrl; - - // Caffeine's cache loader cannot throw checked exceptions, so we wrap them - // in RuntimeException and unwrap on the way out. - try { - return styleJsonCache.get(cacheKey, k -> { - try { - HttpRequest request = HttpRequest.newBuilder(mapStyleUrlValidator.requireHttpUrl(styleUrl, "Vector style URL")) - .timeout(Duration.ofSeconds(20)) - .header("Accept", "application/json") - .GET() - .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() < 200 || response.statusCode() >= 300) { - throw new IOException("Unable to fetch map style: " + response.statusCode()); - } - return objectMapper.readTree(response.body()); - } catch (IOException | InterruptedException e) { - throw new RuntimeException(e); + return cachedJson("url:" + style.id() + ":" + style.version() + ":" + styleUrl, k -> { + try { + HttpRequest request = HttpRequest.newBuilder(mapStyleUrlValidator.requireHttpUrl(styleUrl, "Vector style URL")) + .timeout(Duration.ofSeconds(20)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Unable to fetch map style: " + response.statusCode()); } - }); - } catch (RuntimeException e) { - Throwable cause = e.getCause(); - if (cause instanceof IOException ioEx) throw ioEx; - if (cause instanceof InterruptedException intEx) throw intEx; - throw e; - } + return objectMapper.readTree(response.body()); + } catch (IOException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + }); } private JsonNode readClasspathStyle(String path) throws IOException { - String cacheKey = "classpath:" + path; + return cachedJson("classpath:" + path, + k -> objectMapper.readTree(new ClassPathResource(path).getInputStream())); + } + + @FunctionalInterface + private interface JsonLoader { + JsonNode load(String key) throws IOException; + } + + private JsonNode cachedJson(String cacheKey, JsonLoader loader) throws IOException { try { return styleJsonCache.get(cacheKey, k -> { try { - return objectMapper.readTree(new ClassPathResource(path).getInputStream()); + return loader.load(k); } catch (IOException e) { - throw new java.io.UncheckedIOException(e); + throw new UncheckedIOException(e); } }); - } catch (java.io.UncheckedIOException e) { + } catch (UncheckedIOException e) { throw e.getCause(); } } @@ -545,10 +528,10 @@ private boolean shouldProxyTiles(UserMapStyle style) { return style.dataSource() != null && style.dataSource().proxyTiles(); } - private String styleSourceTileUrl(HttpServletRequest request, String styleId, String sourceId, int tileIndex, String tileUrl) { + private String styleSourceTileUrl(HttpServletRequest request, String styleId, String sourceId, String tileUrl) { String normalizedTileUrl = normalizeTileTemplateForProxy(tileUrl); - return RequestHelper.getBaseUrl(request) + "/api/v1/tiles/styles/" + styleId + "/sources/" + MapStylePathUtils.sourcePathId(sourceId) - + "/tiles/" + tileIndex + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + return RequestHelper.getBaseUrl(request) + "/api/v1/tiles/styles/" + styleId + "/" + MapStylePathUtils.sourcePathId(sourceId) + + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); } private String normalizeTileTemplateForProxy(String tileUrl) { diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java index 52ceecf8e..1cf1dcccd 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java +++ b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java @@ -12,4 +12,8 @@ public record MapStyleDataSource( String scheme, boolean proxyTiles ) { + public MapStyleDataSource withProxyTiles(boolean proxyTiles) { + return new MapStyleDataSource(sourceId, type, tileJsonUrl, tileUrlTemplate, attribution, + minzoom, maxzoom, tileSize, scheme, proxyTiles); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/MapStylePathUtils.java b/src/main/java/com/dedicatedcode/reitti/service/MapStylePathUtils.java index 00aaecef7..a85eb1c05 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/MapStylePathUtils.java +++ b/src/main/java/com/dedicatedcode/reitti/service/MapStylePathUtils.java @@ -2,6 +2,8 @@ import org.springframework.util.StringUtils; +import java.util.Collection; +import java.util.List; import java.util.Locale; public final class MapStylePathUtils { @@ -9,17 +11,41 @@ private MapStylePathUtils() { } public static String sourcePathId(String sourceId) { - String sourceKey = StringUtils.hasText(sourceId) ? sourceId : "source"; - String slug = StringUtils.hasText(sourceId) - ? sourceId.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "-").replaceAll("^-+|-+$", "") - : "source"; - if (!StringUtils.hasText(slug)) { - slug = "source"; + return sourcePathId(sourceId, List.of(sourceId == null ? "" : sourceId)); + } + + public static String sourcePathId(String sourceId, Collection allSourceIds) { + String base = baseSlug(sourceId); + List colliding = allSourceIds.stream() + .filter(id -> baseSlug(id).equals(base)) + .distinct() + .sorted() + .toList(); + if (colliding.size() <= 1) { + return base; } - return slug + "-" + Integer.toUnsignedString(sourceKey.hashCode(), 36); + int index = colliding.indexOf(sourceId); + if (index < 0) { + return base; + } + return base + "-" + (index + 1); } public static boolean matchesSourcePathId(String pathId, String sourceId) { - return sourceId != null && (sourceId.equals(pathId) || sourcePathId(sourceId).equals(pathId)); + return matchesSourcePathId(pathId, sourceId, List.of(sourceId == null ? "" : sourceId)); + } + + public static boolean matchesSourcePathId(String pathId, String sourceId, Collection allSourceIds) { + return sourceId != null && (sourceId.equals(pathId) || sourcePathId(sourceId, allSourceIds).equals(pathId)); + } + + private static String baseSlug(String sourceId) { + if (!StringUtils.hasText(sourceId)) { + return "source"; + } + String slug = sourceId.trim().toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9_-]+", "-") + .replaceAll("^-+|-+$", ""); + return StringUtils.hasText(slug) ? slug : "source"; } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java index ec56c2c61..e3c0fac42 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java +++ b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java @@ -76,18 +76,7 @@ public UserMapStyle validateAndNormalize(User user, SaveMapStyleRequest request) ); } if (source.proxyTiles() && user.getRole() != Role.ADMIN) { - source = new MapStyleDataSource( - source.sourceId(), - source.type(), - source.tileJsonUrl(), - source.tileUrlTemplate(), - source.attribution(), - source.minzoom(), - source.maxzoom(), - source.tileSize(), - source.scheme(), - false - ); + source = source.withProxyTiles(false); } Long parsedId = resolveCustomId(request.id()).orElse(null); diff --git a/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java b/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java index cba8759d3..c9036cac6 100644 --- a/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java +++ b/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java @@ -70,7 +70,7 @@ void rewritesProxyTileUrlsWithEncodedStyleAndSourcePathSegments() throws Excepti JsonNode tiles = response.getBody().path("sources").path(JAWG_SOURCE_ID).path("tiles"); assertThat(tiles.get(0).asText()).isEqualTo( - "http://localhost/api/v1/tiles/styles/custom-42/sources/" + MapStylePathUtils.sourcePathId(JAWG_SOURCE_ID) + "/tiles/0/{z}/{x}/{y}.pbf" + "http://localhost/api/v1/tiles/styles/custom-42/" + MapStylePathUtils.sourcePathId(JAWG_SOURCE_ID) + "/{z}/{x}/{y}.pbf" ); } } diff --git a/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java b/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java index 8156ebd60..edd139ea6 100644 --- a/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java +++ b/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java @@ -79,7 +79,6 @@ void resolvesReadableSourcePathIdBackToOriginalTileTemplate() throws Exception { user, "custom-42", MapStylePathUtils.sourcePathId(JAWG_SOURCE_ID), - 0, 15, 17619, 10758, @@ -149,7 +148,6 @@ void doesNotProxyCustomStyleTileUrlsWhenDisabled() throws Exception { user, "custom-42", MapStylePathUtils.sourcePathId(JAWG_SOURCE_ID), - 0, 15, 17619, 10758, @@ -218,7 +216,6 @@ void proxiesCustomStyleTileUrlsDirectlyWhenCacheIsDisabled() throws Exception { user, "custom-42", MapStylePathUtils.sourcePathId(JAWG_SOURCE_ID), - 0, 15, 17619, 10758, From 9db3712101838ff8d71439e834beb2d9b8ad888e Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Tue, 5 May 2026 12:14:05 +0000 Subject: [PATCH 09/13] fix keys --- .../reitti/service/UserMapStyleValidator.java | 4 ++-- src/main/resources/messages.properties | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java index e3c0fac42..7c9fdd7ef 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java +++ b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java @@ -189,10 +189,10 @@ private String clean(String value) { } private String label(String key) { - return i18nService.translate("js.map.settings.dialog.map-styles." + key); + return i18nService.translate("map.settings.dialog.map-styles." + key); } private String message(String key, Object... args) { - return i18nService.translate("js.map.settings.dialog.map-styles." + key, args); + return i18nService.translate("map.settings.dialog.map-styles." + key, args); } } diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index c81ffe36f..8e0c3ff64 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -1151,12 +1151,6 @@ js.map.settings.dialog.map-styles.error-save=Could not save this custom style. js.map.settings.dialog.map-styles.error-save-status=Could not save this custom style (HTTP {0}). js.map.settings.dialog.map-styles.error-delete=Could not delete this custom style. js.map.settings.dialog.map-styles.error-active=Could not save the active map style. -js.map.settings.dialog.map-styles.error-zoom-range={0} must be between 0 and 24. -js.map.settings.dialog.map-styles.error-url-required={0} is required. -js.map.settings.dialog.map-styles.error-url-invalid={0} must be a valid URL. -js.map.settings.dialog.map-styles.error-url-scheme={0} must use HTTP or HTTPS. -js.map.settings.dialog.map-styles.error-url-host={0} must include a host. -js.map.settings.dialog.map-styles.error-url-credentials={0} must not contain embedded credentials. settings.map.styles=Map Styles settings.map.styles.description=Manage custom MapLibre styles and optional tile sources map.settings.dialog.map-styles.active=Active Style @@ -1204,6 +1198,14 @@ map.settings.dialog.map-styles.error-url-invalid={0} must be a valid URL. map.settings.dialog.map-styles.error-url-scheme={0} must use HTTP or HTTPS. map.settings.dialog.map-styles.error-url-host={0} must include a host. map.settings.dialog.map-styles.error-url-credentials={0} must not contain embedded credentials. +map.settings.dialog.map-styles.error-name-required=Name is required. +map.settings.dialog.map-styles.error-style-json-required=Style JSON is required. +map.settings.dialog.map-styles.error-style-url-required=Style JSON URL is required. +map.settings.dialog.map-styles.error-tile-template-placeholders=Tile URL Template must contain {z}, {x}, and {y}. +map.settings.dialog.map-styles.error-tilejson-required=TileJSON URL is required. +map.settings.dialog.map-styles.error-zoom-order=Min Zoom must be lower than Max Zoom. +map.settings.dialog.map-styles.error-zoom-range={0} must be between 0 and 24. +map.settings.dialog.map-styles.error-json=The pasted style JSON is invalid. map.settings.dialog.map-styles.name.placeholder=My custom style map.settings.dialog.date-picker.title=Date Selection From 8e854c72d591e29686163eabfd97f9db6f98c3c8 Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Tue, 5 May 2026 15:20:40 +0000 Subject: [PATCH 10/13] Unify custom api --- docker/tiles-cache/nginx.conf | 22 ------------------- .../controller/api/TileProxyController.java | 2 +- .../api/TileProxyControllerTest.java | 4 ++-- 3 files changed, 3 insertions(+), 25 deletions(-) diff --git a/docker/tiles-cache/nginx.conf b/docker/tiles-cache/nginx.conf index 34530b173..4370fb954 100644 --- a/docker/tiles-cache/nginx.conf +++ b/docker/tiles-cache/nginx.conf @@ -156,28 +156,6 @@ http { expires 1y; } - location /custom-vector/ { - set $custom_upstream $http_x_reitti_upstream_url; - if ($custom_upstream = "") { - return 400; - } - - proxy_pass $custom_upstream; - proxy_set_header Host $proxy_host; - proxy_set_header User-Agent "Reitti/1.0"; - proxy_ssl_server_name on; - - proxy_cache custom_tiles; - proxy_cache_key "$custom_upstream"; - proxy_cache_valid 200 302 1y; - proxy_cache_valid 404 1m; - proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504; - - add_header X-Cache-Status $upstream_cache_status; - add_header Cache-Control $tile_client_cache_control; - expires 1y; - } - location /terrain/ { proxy_pass https://tiles.mapterhorn.com/; proxy_set_header Host tiles.mapterhorn.com; diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java index c6138424c..44994de27 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java @@ -193,7 +193,7 @@ public ResponseEntity getStyleSourceTile( log.trace("Fetching custom tile [{}/{}]: {}", styleId, sourceId, upstreamTileUri); if (this.tileCacheEnabled) { - String tileUrl = tileCacheUrl + "/custom-vector/"; + String tileUrl = tileCacheUrl + "/custom/"; return fetchTile(tileUrl, contentTypeForExtension(ext), "custom", Map.of(CUSTOM_UPSTREAM_HEADER, upstreamTileUrl)); } diff --git a/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java b/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java index edd139ea6..76d7c8267 100644 --- a/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java +++ b/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java @@ -29,7 +29,7 @@ class TileProxyControllerTest { void resolvesReadableSourcePathIdBackToOriginalTileTemplate() throws Exception { HttpServer tileCache = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); AtomicReference upstreamHeader = new AtomicReference<>(); - tileCache.createContext("/custom-vector/", exchange -> { + tileCache.createContext("/custom/", exchange -> { upstreamHeader.set(exchange.getRequestHeaders().getFirst("X-Reitti-Upstream-Url")); byte[] body = "tile".getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, body.length); @@ -98,7 +98,7 @@ void resolvesReadableSourcePathIdBackToOriginalTileTemplate() throws Exception { void doesNotProxyCustomStyleTileUrlsWhenDisabled() throws Exception { HttpServer tileCache = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); AtomicReference upstreamHeader = new AtomicReference<>(); - tileCache.createContext("/custom-vector/", exchange -> { + tileCache.createContext("/custom/", exchange -> { upstreamHeader.set(exchange.getRequestHeaders().getFirst("X-Reitti-Upstream-Url")); byte[] body = "tile".getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, body.length); From a8d4e2bc9dc362fb1c6363ba2f34360741cbcc6b Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Tue, 5 May 2026 16:29:12 +0000 Subject: [PATCH 11/13] clean raster url --- .../controller/api/MapStyleController.java | 10 ++++- .../reitti/service/UserMapStyleValidator.java | 2 +- .../api/MapStyleControllerTest.java | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java index 3b56737cd..24c5b3285 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java @@ -144,15 +144,17 @@ private JsonNode buildRasterStyle(UserMapStyle style) { source.put("tileSize", effectiveRasterTileSize(dataSource)); source.put("scheme", StringUtils.hasText(dataSource.scheme()) ? dataSource.scheme() : "xyz"); + String rasterSourceId = StringUtils.hasText(dataSource.sourceId()) ? dataSource.sourceId() : "raster"; + ObjectNode rasterLayer = objectMapper.createObjectNode(); rasterLayer.put("id", "custom-raster-layer"); rasterLayer.put("type", "raster"); - rasterLayer.put("source", "custom-raster-source"); + rasterLayer.put("source", rasterSourceId); ObjectNode styleJson = objectMapper.createObjectNode(); styleJson.put("version", 8); styleJson.put("name", style.name()); - styleJson.set("sources", objectMapper.createObjectNode().set("custom-raster-source", source)); + styleJson.set("sources", objectMapper.createObjectNode().set(rasterSourceId, source)); styleJson.set("layers", objectMapper.createArrayNode().add(rasterLayer)); return styleJson; } @@ -229,6 +231,10 @@ private JsonNode applyCustomDataSource(JsonNode style, MapStyleDataSource dataSo ObjectNode source = objectMapper.createObjectNode(); source.put("type", StringUtils.hasText(dataSource.type()) ? dataSource.type() : "vector"); populateDataSourceFields(source, dataSource); + if ("raster".equals(dataSource.type())) { + source.put("tileSize", effectiveRasterTileSize(dataSource)); + source.put("scheme", StringUtils.hasText(dataSource.scheme()) ? dataSource.scheme() : "xyz"); + } ensureSourcesNode(mutableStyle).set(dataSource.sourceId(), source); return mutableStyle; } diff --git a/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java index 7c9fdd7ef..c7159ea97 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java +++ b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java @@ -63,7 +63,7 @@ public UserMapStyle validateAndNormalize(User user, SaveMapStyleRequest request) Integer tileSize = source.tileSize(); String tileUrlTemplate = "tile_template".equals(rasterSourceInputType) ? normalizeRasterTileTemplate(source.tileUrlTemplate()) : null; source = new MapStyleDataSource( - "custom-raster-source", + "raster", "raster", "tilejson".equals(rasterSourceInputType) ? clean(source.tileJsonUrl()) : null, tileUrlTemplate, diff --git a/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java b/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java index c9036cac6..6924d192b 100644 --- a/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java +++ b/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java @@ -73,4 +73,44 @@ void rewritesProxyTileUrlsWithEncodedStyleAndSourcePathSegments() throws Excepti "http://localhost/api/v1/tiles/styles/custom-42/" + MapStylePathUtils.sourcePathId(JAWG_SOURCE_ID) + "/{z}/{x}/{y}.pbf" ); } + + @Test + void buildsRasterStyleWithRasterSourceId() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + User user = new User(7L, "test", null, "Test", null, null, null, null); + UserMapStyle style = new UserMapStyle( + 42L, + user.getId(), + "Raster Style", + "raster", + "json", + "tile_template", + null, + null, + new MapStyleDataSource("raster", "raster", null, + "https://tiles.example.com/{z}/{x}/{y}.png", null, + 0, 14, 256, "xyz", false), + null, + false, + 1L + ); + UserMapStyleJdbcService userMapStyleJdbcService = mock(UserMapStyleJdbcService.class); + when(userMapStyleJdbcService.findById(user, 42L)).thenReturn(Optional.of(style)); + + MapStyleController controller = new MapStyleController( + objectMapper, + new ContextPathHolder(""), + mock(UserSettingsJdbcService.class), + userMapStyleJdbcService, + new MapStyleUrlValidator(mock(I18nService.class)), + "" + ); + + ResponseEntity response = controller.getUserCustomStyle(user, 42L, new MockHttpServletRequest()); + JsonNode body = response.getBody(); + + assertThat(body.path("sources").has("raster")).isTrue(); + assertThat(body.path("layers").get(0).path("source").asText()).isEqualTo("raster"); + assertThat(body.path("sources").path("raster").path("tiles").get(0).asText()).isEqualTo("https://tiles.example.com/{z}/{x}/{y}.png"); + } } From 898a5271f71f3dd7ff750ac532e512fbbc272a2f Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Wed, 6 May 2026 09:52:01 +0000 Subject: [PATCH 12/13] fix validator --- .../settings/MapStylesSettingsController.java | 10 ++++++++- .../repository/UserMapStyleJdbcService.java | 21 +++---------------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java index ef894ba01..deb227d2a 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java @@ -61,7 +61,15 @@ public MapStyleSettingsDTO saveStyle(@AuthenticationPrincipal User user, @Reques @PostMapping("/api/active") @ResponseBody public MapStyleSettingsDTO setActiveStyle(@AuthenticationPrincipal User user, @RequestBody ActiveMapStyleRequest request) { - userMapStyleJdbcService.setActiveStyleId(user, request.activeStyleId()); + String styleId = request.activeStyleId(); + boolean valid = UserMapStyleJdbcService.DEFAULT_STYLE_ID.equals(styleId) + || UserMapStyleJdbcService.resolveCustomId(styleId) + .flatMap(id -> userMapStyleJdbcService.findById(user, id)) + .isPresent(); + if (!valid) { + throw new IllegalArgumentException("Unknown style id: " + styleId); + } + userMapStyleJdbcService.setActiveStyleId(user, styleId); return userMapStyleJdbcService.getSettings(user, normalizedContextPath()); } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java index 11f61154f..aedaae893 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java @@ -16,7 +16,7 @@ @Service public class UserMapStyleJdbcService { - private static final String DEFAULT_STYLE_ID = "reitti"; + public static final String DEFAULT_STYLE_ID = "reitti"; private final JdbcTemplate jdbcTemplate; @@ -88,31 +88,16 @@ public String getActiveStyleId(User user) { String.class, user.getId() ); - if (results.isEmpty()) { - return DEFAULT_STYLE_ID; - } - String activeStyleId = results.getFirst(); - if (isValidStyleId(user, activeStyleId)) { - return activeStyleId; - } - setActiveStyleId(user, DEFAULT_STYLE_ID); - return DEFAULT_STYLE_ID; + return results.isEmpty() ? DEFAULT_STYLE_ID : results.getFirst(); } @Transactional public void setActiveStyleId(User user, String activeStyleId) { - String storedActiveStyleId = isValidStyleId(user, activeStyleId) - ? activeStyleId - : DEFAULT_STYLE_ID; jdbcTemplate.update(""" INSERT INTO user_map_style_settings (user_id, active_style_id) VALUES (?, ?) ON CONFLICT (user_id) DO UPDATE SET active_style_id = EXCLUDED.active_style_id, updated_at = CURRENT_TIMESTAMP - """, user.getId(), storedActiveStyleId); - } - - private boolean isValidStyleId(User user, String styleId) { - return DEFAULT_STYLE_ID.equals(styleId) || resolveCustomId(styleId).flatMap(id -> findById(user, id)).isPresent(); + """, user.getId(), activeStyleId); } @Transactional From 902ed3904491dacd67467d8bd804f80297d9c351 Mon Sep 17 00:00:00 2001 From: FreshImmuc <65733898+FreshImmuc@users.noreply.github.com> Date: Wed, 6 May 2026 10:02:10 +0000 Subject: [PATCH 13/13] merge db migrations --- src/main/resources/db/migration/V91__add_user_map_styles.sql | 1 + .../resources/db/migration/V92__add_map_style_proxy_flag.sql | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 src/main/resources/db/migration/V92__add_map_style_proxy_flag.sql diff --git a/src/main/resources/db/migration/V91__add_user_map_styles.sql b/src/main/resources/db/migration/V91__add_user_map_styles.sql index c09dcf807..95fcb17a3 100644 --- a/src/main/resources/db/migration/V91__add_user_map_styles.sql +++ b/src/main/resources/db/migration/V91__add_user_map_styles.sql @@ -21,6 +21,7 @@ CREATE TABLE user_map_styles glyphs_url_override TEXT, sprite_url_override TEXT, shared BOOLEAN NOT NULL DEFAULT FALSE, + proxy_tiles BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, version BIGINT NOT NULL DEFAULT 1, diff --git a/src/main/resources/db/migration/V92__add_map_style_proxy_flag.sql b/src/main/resources/db/migration/V92__add_map_style_proxy_flag.sql deleted file mode 100644 index 603c31b54..000000000 --- a/src/main/resources/db/migration/V92__add_map_style_proxy_flag.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE user_map_styles - ADD COLUMN proxy_tiles BOOLEAN NOT NULL DEFAULT FALSE;