diff --git a/README.md b/README.md index 945b5bc9..347e0a83 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ 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 | | | `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 937677a4..426f7519 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 0e3cd8a7..4370fb95 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,7 @@ http { access_log /var/log/nginx/access.log main; server_tokens off; + resolver 127.0.0.11 ipv6=off valid=300s; server { listen 80; @@ -128,6 +134,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 +194,4 @@ http { add_header Content-Type text/plain; } } -} \ No newline at end of file +} diff --git a/pom.xml b/pom.xml index df2b9f8a..4a1edfce 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 4faaef52..5ffc9085 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 9b044280..24c5b328 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.MapStylePathUtils; +import com.dedicatedcode.reitti.service.MapStyleUrlValidator; +import com.dedicatedcode.reitti.service.RequestHelper; +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; @@ -10,122 +18,449 @@ 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; 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.util.concurrent.TimeUnit; +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; @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 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; private final UserSettingsJdbcService userSettingsJdbcService; + private final UserMapStyleJdbcService userMapStyleJdbcService; + private final MapStyleUrlValidator mapStyleUrlValidator; + private final HttpClient httpClient; private final boolean tileCacheEnabled; - + public MapStyleController( ObjectMapper objectMapper, ContextPathHolder contextPathHolder, UserSettingsJdbcService userSettingsJdbcService, + UserMapStyleJdbcService userMapStyleJdbcService, + MapStyleUrlValidator mapStyleUrlValidator, @Value("${reitti.ui.tiles.cache.url:}") String cacheUrl) { this.objectMapper = objectMapper; this.contextPathHolder = contextPathHolder; this.userSettingsJdbcService = userSettingsJdbcService; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.mapStyleUrlValidator = mapStyleUrlValidator; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .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"); + 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); + } - JsonNode style; - if (this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).isPreferColoredMap()) { - style = objectMapper.readTree(coloredResource.getInputStream()); - } else { - style = objectMapper.readTree(resource.getInputStream()); + @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, style.get().frontendId(), shouldProxyTiles(style.get())); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } catch (IOException e) { + return ResponseEntity.notFound().build(); } + } - if (this.tileCacheEnabled) { - style = rewriteUrlsForProxy(style, request); + 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()); } + return fetchRemoteStyle(style); + } + + 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 = 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) { + getOrCreateMetadata(objectNode).put("reitti:style-url", style.styleUrl()); + } + return json; + } - if (!this.contextPathHolder.getContextPath().equals("/")) { - style = rewriteResourceUrls(style, request); + private JsonNode buildRasterStyle(UserMapStyle style) { + MapStyleDataSource dataSource = style.dataSource(); + + ObjectNode source = objectMapper.createObjectNode(); + source.put("type", "raster"); + populateDataSourceFields(source, dataSource); + 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", rasterSourceId); + + ObjectNode styleJson = objectMapper.createObjectNode(); + styleJson.put("version", 8); + styleJson.put("name", style.name()); + styleJson.set("sources", objectMapper.createObjectNode().set(rasterSourceId, source)); + styleJson.set("layers", objectMapper.createArrayNode().add(rasterLayer)); + return styleJson; + } + + private int effectiveRasterTileSize(MapStyleDataSource dataSource) { + 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()); + } + if (StringUtils.hasText(options.spriteUrlOverride())) { + mutableStyle.put("sprite", options.spriteUrlOverride()); + } + if (StringUtils.hasText(options.attributionOverride())) { + applyAttributionOverride(mutableStyle, options.attributionOverride()); + } + return mutableStyle; + } + + private void applyAttributionOverride(ObjectNode mutableStyle, String attribution) { + getOrCreateMetadata(mutableStyle).put("reitti:attribution-override", attribution); + + if (mutableStyle.get("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; + } + if (!StringUtils.hasText(dataSource.tileJsonUrl()) && !StringUtils.hasText(dataSource.tileUrlTemplate())) { + return style; + } + ObjectNode mutableStyle = style.deepCopy(); + 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; + } + + private ResponseEntity buildStyleResponse(JsonNode style, HttpServletRequest request, String styleId, boolean proxyCustomTiles) { + style = ensureRuntimeSources(style, request); + if (proxyCustomTiles) { + style = rewriteUrlsForProxy(style, request, styleId); + } + if (!contextPathHolder.getContextPath().equals("/")) { + style = rewriteResourceUrls(style); + } return ResponseEntity.ok() - .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)) + .cacheControl(CacheControl.noCache().cachePrivate()) .body(style); } - private JsonNode rewriteResourceUrls(JsonNode style, HttpServletRequest request) { + private JsonNode ensureRuntimeSources(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())); + ObjectNode sources = ensureSourcesNode(mutableStyle); + String baseUrl = RequestHelper.getBaseUrl(request); + + if (!sources.has(RUNTIME_TERRAIN_SOURCE)) { + sources.set(RUNTIME_TERRAIN_SOURCE, buildTerrainSource(tileCacheEnabled ? baseUrl + TERRAIN_PROXY_PATH : TERRAIN_TILE_URL)); + } + if (!sources.has(RUNTIME_SATELLITE_SOURCE)) { + 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()); + } + return mutableStyle; } - 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) { - ObjectNode mutableSources = (ObjectNode) sources; - - // 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); + 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) { + if (!(style.get("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; } - - // Handle raster sources - 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"); + 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) { + ObjectNode mutableStyle = style.deepCopy(); + if (mutableStyle.get("glyphs") instanceof TextNode glyphsText && glyphsText.asText().startsWith("/")) { + mutableStyle.set("glyphs", new TextNode(contextPathHolder.getContextPath() + glyphsText.asText())); } - return mutableStyle; } - private void rewriteRasterSource(ObjectNode sources, String sourceName, String tileUrl) { - if (sources.has(sourceName)) { - ObjectNode source = (ObjectNode) sources.get(sourceName); - ArrayNode tiles = objectMapper.createArrayNode(); - tiles.add(tileUrl); - source.set("tiles", tiles); + private JsonNode rewriteUrlsForProxy(JsonNode style, HttpServletRequest request, String styleId) { + ObjectNode mutableStyle = style.deepCopy(); + 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)) { + 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); + }); + return mutableStyle; + } + + 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); + + if (sourceUrl.contains("tiles.dedicatedcode.com") || firstTileUrl.contains("tiles.dedicatedcode.com")) { + source.remove("url"); + source.set("tiles", singleTileArray(baseUrl + "/api/v1/tiles/vector/{z}/{x}/{y}.pbf")); + return; + } + if (isHttpUrl(sourceUrl)) { + source.set("url", new TextNode(styleSourceTileJsonUrl(baseUrl, styleId, sourceId, allSourceIds))); + return; + } + rewriteTileTemplates(source, baseUrl, styleId, sourceId, allSourceIds, styleBaseUri); + } + + 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(); + 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 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 styleSourceTileUrl(String baseUrl, String styleId, String sourceId, List allSourceIds, String tileUrl) { + String normalizedTileUrl = tileUrl.replace("{r}", ""); + return baseUrl + "/api/v1/tiles/styles/" + styleId + "/" + MapStylePathUtils.sourcePathId(sourceId, allSourceIds) + + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + } + + private ArrayNode singleTileArray(String tileUrl) { + ArrayNode tiles = objectMapper.createArrayNode(); + tiles.add(tileUrl); + return tiles; + } + + private String getFirstTileUrl(ObjectNode source) { + if (source.get("tiles") instanceof ArrayNode tileArray && !tileArray.isEmpty()) { + return tileArray.get(0).asText(""); } + return ""; } - 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); + private ObjectNode ensureSourcesNode(ObjectNode mutableStyle) { + if (mutableStyle.get("sources") instanceof ObjectNode sourcesObject) { + return sourcesObject; } - - url.append(contextPath); - return url.toString(); + ObjectNode sourcesObject = objectMapper.createObjectNode(); + mutableStyle.set("sources", sourcesObject); + return sourcesObject; + } + + private ObjectNode getOrCreateMetadata(ObjectNode node) { + if (node.get("metadata") instanceof ObjectNode existing) { + return existing; + } + ObjectNode metadata = objectMapper.createObjectNode(); + node.set("metadata", metadata); + return metadata; + } + + private Optional getStyleBaseUri(ObjectNode style) { + if (!(style.get("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 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 static boolean shouldProxyTiles(UserMapStyle style) { + return style.dataSource() != null && style.dataSource().proxyTiles(); } } 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 838f2188..44994de2 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java @@ -1,38 +1,69 @@ package com.dedicatedcode.reitti.controller.api; -import com.dedicatedcode.reitti.config.ConditionalOnPropertyNotEmpty; +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.RequestHelper; +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; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +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; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; +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 final HttpClient httpClient; private final String tileCacheUrl; + private final boolean tileCacheEnabled; + private final ObjectMapper objectMapper; + 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), @@ -42,10 +73,31 @@ 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) { + 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, + ObjectMapper objectMapper, + UserMapStyleJdbcService userMapStyleJdbcService, + MapStyleUrlValidator mapStyleUrlValidator) { this.tileCacheUrl = tileCacheUrl; + this.tileCacheEnabled = StringUtils.hasText(tileCacheUrl); + this.objectMapper = objectMapper; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.mapStyleUrlValidator = mapStyleUrlValidator; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + this.styleJsonCache = Caffeine.newBuilder() + .maximumSize(20) + .expireAfterWrite(Duration.ofHours(1)) .build(); } @@ -58,6 +110,103 @@ public ResponseEntity getTileLegacy( return getTile("raster", z, x, y, "png", request); } + @GetMapping("/styles/{styleId}/{sourceId}/tilejson.json") + public ResponseEntity getStyleSourceTileJson( + @AuthenticationPrincipal User user, + @PathVariable String styleId, + @PathVariable String sourceId, + HttpServletRequest request) { + + try { + 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"); + + 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 && !tiles.isEmpty()) { + ArrayNode rewrittenTiles = objectMapper.createArrayNode(); + 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); + } + + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().cachePrivate()) + .body(tileJson); + } catch (Exception e) { + log.warn("Failed to fetch custom TileJSON [{}/{}]: {}", styleId, sourceId, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/styles/{styleId}/{sourceId}/{z}/{x}/{y}.{ext}") + public ResponseEntity getStyleSourceTile( + @AuthenticationPrincipal User user, + @PathVariable String styleId, + @PathVariable String sourceId, + @PathVariable int z, + @PathVariable int x, + @PathVariable int y, + @PathVariable String ext) { + + try { + Optional source = resolveTileSource(user, styleId, sourceId); + if (source.isEmpty()) { + return ResponseEntity.notFound().build(); + } + String template = tileTemplate(source.get()); + if (!StringUtils.hasText(template)) { + return ResponseEntity.notFound().build(); + } + if (!source.get().proxyTiles()) { + return ResponseEntity.notFound().build(); + } + String upstreamTileUrl = template + .replace("{z}", String.valueOf(z)) + .replace("{x}", String.valueOf(x)) + .replace("{y}", String.valueOf(y)) + .replace("{r}", ""); + + URI upstreamTileUri = mapStyleUrlValidator.requireHttpUrl(upstreamTileUrl, "Custom tile URL"); + log.trace("Fetching custom tile [{}/{}]: {}", styleId, sourceId, upstreamTileUri); + + if (this.tileCacheEnabled) { + String tileUrl = tileCacheUrl + "/custom/"; + 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 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(); + } + } + @GetMapping("/{source}/{z}/{x}/{y}.{ext}") public ResponseEntity getTile( @PathVariable String source, @@ -77,36 +226,325 @@ 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(); + } 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 {}: {}", x, y, z, source, e.getMessage()); + log.warn("Failed to fetch tile from {}: {}", source, e.getMessage()); return ResponseEntity.notFound().build(); } } + + private HttpResponse fetchRaw(String url, Map extraHeaders) throws Exception { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(30)) + .header(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate") + .GET(); + extraHeaders.forEach(requestBuilder::header); + return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + } + + 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 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 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 JsonNode parseUserStyleJson(UserMapStyle style) throws IOException { + return cachedJson("local:" + style.id() + ":" + style.version(), + k -> objectMapper.readTree(style.styleJson())); + } + + 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; + } + 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(), allSourceIds)) { + 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) throws Exception { + if (!source.tileUrlTemplates().isEmpty()) { + return source.tileUrlTemplates().get(0); + } + + if (!StringUtils.hasText(source.tileJsonUrl())) { + return null; + } + + String tileJsonUrl = source.tileJsonUrl(); + URI tileJsonUri = mapStyleUrlValidator.requireHttpUrl(tileJsonUrl, "Custom TileJSON URL"); + + 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()); + } + 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) || tileArray.isEmpty()) { + return null; + } + + String tileUrl = tileArray.get(0).asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + return normalizeTileTemplateForProxy(tileUrl); + } + if (StringUtils.hasText(tileUrl)) { + return normalizeTileTemplateForProxy(tileJsonUri.resolve(tileUrl).toString()); + } + return null; + } + + private JsonNode fetchAndParseStyleJson(UserMapStyle style) throws IOException { + String styleUrl = style.styleUrl(); + 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()); + } + 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 { + 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 loader.load(k); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (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, String tileUrl) { + String normalizedTileUrl = normalizeTileTemplateForProxy(tileUrl); + return RequestHelper.getBaseUrl(request) + "/api/v1/tiles/styles/" + styleId + "/" + MapStylePathUtils.sourcePathId(sourceId) + + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + } + + private String normalizeTileTemplateForProxy(String tileUrl) { + return tileUrl.replace("{r}", ""); + } + + 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 00000000..ef894ba0 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java @@ -0,0 +1,83 @@ +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 com.dedicatedcode.reitti.service.UserMapStyleValidator; +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 UserMapStyleValidator userMapStyleValidator; + private final ContextPathHolder contextPathHolder; + + public MapStylesSettingsController( + @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, + UserMapStyleJdbcService userMapStyleJdbcService, + UserMapStyleValidator userMapStyleValidator, + ContextPathHolder contextPathHolder) { + this.dataManagementEnabled = dataManagementEnabled; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.userMapStyleValidator = userMapStyleValidator; + 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 validatedStyle = userMapStyleValidator.validateAndNormalize(user, request); + UserMapStyle style = userMapStyleJdbcService.save(user, validatedStyle); + 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 00000000..c9a9790a --- /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 00000000..39838401 --- /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 00000000..15b53842 --- /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 00000000..97fe900d --- /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 00000000..1cf1dccc --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java @@ -0,0 +1,19 @@ +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, + 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/model/map/MapStyleVectorOptions.java b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java new file mode 100644 index 00000000..a1b89487 --- /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 00000000..d769b5a0 --- /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 00000000..11f61154 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java @@ -0,0 +1,238 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.dto.map.MapStyleConfigDTO; +import com.dedicatedcode.reitti.dto.map.MapStyleSettingsDTO; +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 org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +public class UserMapStyleJdbcService { + private static final String DEFAULT_STYLE_ID = "reitti"; + + private final JdbcTemplate jdbcTemplate; + + public UserMapStyleJdbcService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + 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"), + rs.getBoolean("proxy_tiles") + ), + 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 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(); + } + + @Transactional + 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 = ?, + proxy_tiles = ?, attribution_override = ?, glyphs_url_override = ?, sprite_url_override = ?, shared = ?, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE user_id = ? AND id = ? + """, + 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(), + 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, + proxy_tiles, attribution_override, glyphs_url_override, sprite_url_override, shared) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id + """, + Long.class, + user.getId(), + 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(); + } + + @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(), + style.styleJson() != null ? "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 (frontendId == null || frontendId.isBlank() || !frontendId.startsWith("custom-")) { + return Optional.empty(); + } + try { + return Optional.of(Long.parseLong(frontendId.substring("custom-".length()))); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + private String styleUrlForClient(UserMapStyle style, String contextPath) { + return contextPath + "/map/custom/" + style.id() + ".json?v=" + style.version(); + } + + private static String defaultText(String value, String 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 e4d2819c..568d7163 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/I18nService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/I18nService.java @@ -51,10 +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 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 00000000..a85eb1c0 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/MapStylePathUtils.java @@ -0,0 +1,51 @@ +package com.dedicatedcode.reitti.service; + +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +public final class MapStylePathUtils { + private MapStylePathUtils() { + } + + public static String sourcePathId(String sourceId) { + 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; + } + int index = colliding.indexOf(sourceId); + if (index < 0) { + return base; + } + return base + "-" + (index + 1); + } + + public static boolean matchesSourcePathId(String pathId, String sourceId) { + 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/MapStyleUrlValidator.java b/src/main/java/com/dedicatedcode/reitti/service/MapStyleUrlValidator.java new file mode 100644 index 00000000..36a89a39 --- /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 1d3fca00..7bffe7a9 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java @@ -90,7 +90,7 @@ public List generate(User user, Memory memory, ZoneId timeZone) log.info("Found {} visits after filtering (accommodation: {})", filteredVisits.size(), accommodation.map(a -> a.getPlace().getName()).orElse("none")); // Step 3: Scoring & Identifying "Interesting" Visits - List scoredVisits = scoreVisits(filteredVisits, accommodation.orElse(null)); + List scoredVisits = new ArrayList<>(scoreVisits(filteredVisits, accommodation.orElse(null))); // Sort by score descending scoredVisits.sort(Comparator.comparingDouble(ScoredVisit::score).reversed()); @@ -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"); } @@ -352,7 +352,7 @@ private List filterVisits(List visits, Processed return visit.getDurationSeconds() >= MIN_VISIT_DURATION_SECONDS; }) - .collect(Collectors.toList()); + .toList(); } /** @@ -372,11 +372,11 @@ private List scoreVisits(List visits, ProcessedVisi return visits.stream() .map(visit -> { double score = 0.0; - + // Duration score (normalized 0-1) double durationScore = (double) visit.getDurationSeconds() / maxDuration; score += WEIGHT_DURATION * durationScore; - + // Distance from accommodation score if (accommodation != null) { double distance = GeoUtils.distanceInMeters( @@ -389,19 +389,19 @@ private List scoreVisits(List visits, ProcessedVisi double distanceScore = Math.min(distance / 50000.0, 1.0); score += WEIGHT_DISTANCE * distanceScore; } - + // Category score double categoryScore = getCategoryWeight(visit.getPlace().getType()); score += WEIGHT_CATEGORY * categoryScore; - + // Novelty score (inverse of visit count, normalized) long visitCount = visitCounts.get(visit.getPlace().getId()); double noveltyScore = 1.0 / visitCount; score += WEIGHT_NOVELTY * noveltyScore; - + return new ScoredVisit(visit, score); }) - .collect(Collectors.toList()); + .toList(); } /** @@ -483,33 +483,33 @@ private record ScoredVisit(ProcessedVisit visit, double score) { */ private static class VisitCluster { private final List visits = new ArrayList<>(); - + public void addVisit(ScoredVisit visit) { visits.add(visit); } - + public List getVisits() { return visits; } - + public ScoredVisit getHighestScoredVisit() { return visits.stream() .max(Comparator.comparingDouble(ScoredVisit::score)) - .orElse(null); + .orElse(null); } - + public Instant getStartTime() { return visits.stream() .map(sv -> sv.visit().getStartTime()) - .min(Instant::compareTo) - .orElse(null); + .min(Instant::compareTo) + .orElse(null); } - + public Instant getEndTime() { return visits.stream() .map(sv -> sv.visit().getEndTime()) - .max(Instant::compareTo) - .orElse(null); + .max(Instant::compareTo) + .orElse(null); } } } 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 00000000..18fd360e --- /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/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java new file mode 100644 index 00000000..c7159ea9 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/UserMapStyleValidator.java @@ -0,0 +1,198 @@ +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; + +import static com.dedicatedcode.reitti.repository.UserMapStyleJdbcService.resolveCustomId; + +@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( + "raster", + "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 = source.withProxyTiles(false); + } + + Long parsedId = 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("map.settings.dialog.map-styles." + key); + } + + private String message(String key, Object... args) { + return i18nService.translate("map.settings.dialog.map-styles." + key, args); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9ca257e3..86992d53 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -107,4 +107,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 00000000..c09dcf80 --- /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/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 00000000..603c31b5 --- /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 8b3abc43..8e0c3ff6 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -1091,6 +1091,123 @@ 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.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. +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. +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.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.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 # Export Data @@ -1619,4 +1736,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 6f7d975c..6114371a 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -342,6 +342,387 @@ header { margin-bottom: 8px; } +.settings-secondary-btn, +.settings-primary-btn, +.settings-ghost-btn, +.settings-icon-btn { + color: var(--color-highlight); + border: 1px solid var(--color-highlight); +} + +.settings-secondary-btn, +.settings-primary-btn, +.settings-ghost-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 10px; +} + +.settings-primary-btn, +.btn.settings-primary-btn { + background: var(--color-highlight); + color: var(--color-background-dark); +} + +.settings-primary-btn i, +.settings-primary-btn span, +.btn.settings-primary-btn i, +.btn.settings-primary-btn span { + color: var(--color-background-dark); +} + +.settings-ghost-btn, +.btn.settings-ghost-btn { + background: transparent; + border-color: transparent; + color: var(--color-text-gray); +} + +.settings-ghost-btn:hover, +.btn.settings-ghost-btn:hover { + color: var(--color-text-white); +} + +.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-actions { + padding-top: 4px; +} + +.custom-map-style-form { + border-top: 1px solid var(--color-highlight-transparent); + margin-top: 12px; + padding-top: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.custom-map-style-form .form-group { + margin: 0; +} + +.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-text-gray); + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0.06em; + margin: 0; + text-transform: uppercase; +} + +.custom-map-style-mode-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.custom-map-style-mode-panel h4 { + display: none; +} + +.custom-map-style-choice { + border: none; + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; +} + +.custom-map-style-choice legend { + color: var(--color-text-gray); + font-size: 0.78rem; + letter-spacing: 0.05em; + padding: 0; + margin-bottom: 6px; + text-transform: uppercase; + width: 100%; +} + +.custom-map-style-choice label { + align-items: center; + background: var(--color-background-dark); + border: 1px solid var(--color-highlight-transparent); + border-radius: 4px; + color: var(--color-text-white); + cursor: pointer; + display: inline-flex; + gap: 6px; + padding: 5px 12px; + transition: border-color 0.15s, color 0.15s; +} + +.custom-map-style-choice label:has(input:checked) { + border-color: var(--color-highlight); + color: var(--color-highlight); +} + +.map-styles-settings-page .custom-map-style-choice input[type="radio"] { + appearance: none; + -webkit-appearance: none; + background: none; + border: none; + padding: 0; + margin: 0; + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.custom-map-style-advanced { + border: 1px solid var(--color-highlight-transparent); + border-radius: 6px; + padding: 14px 16px; + margin: 0; +} + +.custom-map-style-advanced summary { + background: transparent; + border: none; + color: var(--color-highlight); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + list-style: none; + margin: -14px -16px; + padding: 14px 16px; + user-select: none; +} + +.custom-map-style-advanced summary::-webkit-details-marker { + display: none; +} + +.custom-map-style-advanced summary::before { + content: "▶"; + display: inline-block; + font-size: 0.6rem; + margin-right: 5px; + transition: transform 0.15s ease; + vertical-align: middle; +} + +.custom-map-style-advanced[open] summary::before { + transform: rotate(90deg); +} + +.custom-map-style-advanced[open] summary { + margin-bottom: 10px; +} + +.custom-map-style-advanced > div, +.custom-map-style-advanced .form-group { + border: none; + margin-bottom: 8px; + padding: 0; +} + +.custom-map-style-advanced > div:last-child, +.custom-map-style-advanced .form-group:last-child { + margin-bottom: 0; +} + +.custom-map-style-share-panel { + align-items: center; + border-top: 1px solid var(--color-highlight-transparent); + display: flex; + gap: 16px; + justify-content: space-between; + padding: 10px 0 0; +} + +.custom-map-style-share-copy { + min-width: 0; +} + +.custom-map-style-share-copy label { + color: var(--color-text-white); + font-weight: 600; + font-size: 0.9rem; +} + +.custom-map-style-share-copy p { + color: var(--color-text-gray); + font-size: 0.82rem; + margin: 2px 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-grid .form-group { + margin: 0; +} + +.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 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 +2604,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 36826d98..58a40e90 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 414c74ac..2f50b17d 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 { + + + + + ${t('map.display-control.mode.3d.enabled.text')} @@ -49,11 +54,22 @@ class MapControls { this.toggleSatelliteModeBtn = document.getElementById('toggle-satellite-btn'); this.toggleGlobeProjectionModeBtn = document.getElementById('toggle-globeprojection-btn'); this.compassBtn = document.getElementById('compass-btn'); + this.mapStyleSelect = document.getElementById('map-style-select'); + this.refreshMapStyleOptions(); this._setup(); } _setup() { + this.mapStyleSelect.addEventListener('change', () => { + MapRenderer.setActiveMapStyleId(this.mapStyleSelect.value); + this.emit('selectionChanged', this.getState()); + }); + document.addEventListener('mapStylesChanged', (event) => { + this.refreshMapStyleOptions(event.detail?.activeStyleId); + this.emit('selectionChanged', this.getState()); + }); + this.toggle3dBtn.addEventListener('click', () => { const isEnabled = this.toggle3dBtn.classList.contains('active'); if (isEnabled) { @@ -145,6 +161,7 @@ class MapControls { getState() { return { + mapStyleId: this.mapStyleSelect.value, is3d: this.toggle3dBtn.classList.contains('active'), renderTerrain: this.toggleTerrainModeBtn.classList.contains('active'), renderBuildings: this.toggleBuildingsModeBtn.classList.contains('active'), @@ -152,6 +169,25 @@ class MapControls { renderGlobe: this.toggleGlobeProjectionModeBtn.classList.contains('active'), } } + + refreshMapStyleOptions(preferredStyleId = null) { + const mapStyles = MapRenderer.getMapStyles(); + const storedMapStyleId = preferredStyleId || MapRenderer.getActiveMapStyleId(); + const selectedMapStyleId = mapStyles.some(style => style.id === storedMapStyleId) + ? storedMapStyleId + : MapRenderer.getDefaultMapStyleId(); + + this.mapStyleSelect.innerHTML = mapStyles + .map(style => `${this._escapeHtml(style.label)}`) + .join(''); + window.reittiActiveMapStyleId = selectedMapStyleId; + } + + setMapStyleId(mapStyleId) { + this.refreshMapStyleOptions(mapStyleId); + window.reittiActiveMapStyleId = this.mapStyleSelect.value; + } + _enable3d() { const span = this.toggle3dBtn.querySelector('span'); localStorage.setItem('is3d', 'true') @@ -232,6 +268,16 @@ class MapControls { this.toggleGlobeProjectionModeBtn.title = t('map.display-control.globe_projection.disabled.title'); } + _escapeHtml(value) { + return String(value).replace(/[&<>"']/g, character => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[character])); + } + _deepMerge(target, source) { let result = {...target}; for (const key of Object.keys(source)) { diff --git a/src/main/resources/static/js/map-renderer.js b/src/main/resources/static/js/map-renderer.js index d5a468a2..6e3d198c 100644 --- a/src/main/resources/static/js/map-renderer.js +++ b/src/main/resources/static/js/map-renderer.js @@ -1,5 +1,111 @@ class MapRenderer { + static getBundledMapStyles() { + const contextPath = window.contextPath || ''; + return [ + { + id: 'reitti', + label: 'Reitti', + styleUrl: `${contextPath}/map/reitti.json?ts=${Date.now()}`, + capabilities: { + terrainSourceId: 'terrain-source', + hillshadeLayerId: 'hillshading', + satelliteLayerId: 'satellite-layer', + building3dLayerIds: ['building-3d'] + } + } + ]; + } + + static getCustomMapStyles() { + return Array.isArray(window.reittiCustomMapStyles) + ? window.reittiCustomMapStyles + : []; + } + + static getMapStyles() { + const bundledStyles = MapRenderer.getBundledMapStyles(); + const configuredStyles = Array.isArray(window.reittiMapStyles) + ? window.reittiMapStyles.filter(style => !bundledStyles.some(bundledStyle => bundledStyle.id === style.id)) + : []; + return [ + ...bundledStyles, + ...configuredStyles, + ...MapRenderer.getCustomMapStyles() + ]; + } + + static dispatchMapStylesChanged(activeStyleId = null) { + document.dispatchEvent(new CustomEvent('mapStylesChanged', { + detail: { + activeStyleId, + styles: MapRenderer.getMapStyles() + } + })); + } + + static getMapStyleValue(mapStyle) { + if (mapStyle?.styleJson) { + return MapRenderer._cloneStaticStyleDefinition(mapStyle.styleJson); + } + return mapStyle?.styleUrl; + } + + + + + + + static _cloneStaticStyleDefinition(definition) { + return JSON.parse(JSON.stringify(definition)); + } + + static getActiveMapStyleId() { + const activeStyleId = window.reittiActiveMapStyleId || window.localStorage?.getItem('mapStyleId'); + return MapRenderer.getMapStyles().some(style => style.id === activeStyleId) + ? activeStyleId + : MapRenderer.getDefaultMapStyleId(); + } + + static setActiveMapStyleId(mapStyleId) { + window.reittiActiveMapStyleId = mapStyleId; + window.localStorage?.setItem('mapStyleId', mapStyleId); + return fetch(`${window.contextPath || ''}/settings/map-styles/api/active`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({activeStyleId: mapStyleId}) + }).catch(error => { + console.warn('Unable to persist active map style:', error); + }); + } + + static getMapStyle(styleId) { + const styles = MapRenderer.getMapStyles(); + return styles.find(style => style.id === styleId) || styles[0]; + } + + static getDefaultMapStyleId() { + const styles = MapRenderer.getMapStyles(); + return styles[0]?.id || 'reitti'; + } + + static ensureRTLTextPlugin() { + if (MapRenderer.rtlTextPluginConfigured || !window.maplibregl?.setRTLTextPlugin) return; + + const pluginUrl = MapRenderer.getMapStyles().find(style => style.rtlTextPluginUrl)?.rtlTextPluginUrl; + if (!pluginUrl) return; + + try { + maplibregl.setRTLTextPlugin(pluginUrl, null, true); + } catch (error) { + console.warn('Unable to configure RTL text plugin:', error); + } finally { + MapRenderer.rtlTextPluginConfigured = true; + } + } + constructor(element, userSettings, initialViewState, viewConfig = {}) { + MapRenderer.ensureRTLTextPlugin(); + this.userSettings = userSettings; this.transitionQueue = Promise.resolve(); this.element = document.getElementById(element); @@ -34,12 +140,15 @@ class MapRenderer { this.gpsDataManagers = [] this.viewState = initialViewState; + this.viewState.mapStyleId = this.viewState.mapStyleId || MapRenderer.getActiveMapStyleId(); + this.currentMapStyle = MapRenderer.getMapStyle(this.viewState.mapStyleId); + this.satelliteHiddenPaintProperties = new Map(); this._pitchBearingAllowed = true; const mapOptions = { interleaved: true, container: element, - style: window.contextPath + '/map/reitti.json?ts=' + new Date().getTime(), + style: MapRenderer.getMapStyleValue(this.currentMapStyle), center: [userSettings.homeLongitude, userSettings.homeLatitude], pitch: this.viewState.is3d ? 45 : 0, maxPitch: 85, @@ -121,38 +230,75 @@ class MapRenderer { this.bounds = []; this.highlightLayer = null; - this._initialLoadPromise = new Promise(resolve => { - this.map.once('style.load', async () => { - console.log('Initial style loaded!'); - await this._switchMapBuildingLayer(this.viewState.renderBuildings && this.viewState.is3d); - await this._switchTerrainLayer(this.viewState.renderTerrain); - await this._switchSatelliteLayer(this.viewState.renderSatelliteView); - await this._switchProjection(this.viewState.renderGlobe); - this._syncPitchBearingState(false); - this.element.classList.remove('is-loading'); - this.element.classList.add('is-loaded'); - resolve(); - }); - }); + this._initialLoadPromise = this._initializeInitialStyle(); this._setup(); } + async _initializeInitialStyle() { + let loaded = await this._waitForStyleLoad(this.currentMapStyle); + if (!loaded) { + loaded = await this._fallbackToDefaultStyle(); + } + + if (!loaded) { + this.element.classList.remove('is-loading'); + console.warn('Map style failed to load and no fallback style was available.'); + return; + } + + this._ensureStyleCompatibilityLayers(); + await this._switchMapBuildingLayer(this.viewState.renderBuildings && this.viewState.is3d); + await this._switchTerrainLayer(this.viewState.renderTerrain); + await this._switchSatelliteLayer(this.viewState.renderSatelliteView); + await this._switchProjection(this.viewState.renderGlobe); + this._syncPitchBearingState(false); + this.element.classList.remove('is-loading'); + this.element.classList.add('is-loaded'); + } + async updateViewState(next) { - this.transitionQueue = this.transitionQueue.then(() => this._updateViewStateInternal(next)); + this.transitionQueue = this.transitionQueue + .catch(error => { + console.error('Previous map state transition failed:', error); + }) + .then(() => this._updateViewStateInternal(next)) + .catch(error => { + console.error('Map state transition failed:', error); + }); return this.transitionQueue; } async _updateViewStateInternal(next) { await this._initialLoadPromise; const prev = { ...this.viewState }; + next.mapStyleId = next.mapStyleId || prev.mapStyleId || MapRenderer.getDefaultMapStyleId(); this.viewState = next; + const styleChanged = prev.mapStyleId !== next.mapStyleId; const projectionChanged = prev.renderGlobe !== next.renderGlobe; const satelliteChanged = prev.renderSatelliteView !== next.renderSatelliteView; const terrainChanged = prev.renderTerrain !== next.renderTerrain; const buildingsChanged = (prev.renderBuildings !== next.renderBuildings) || (prev.is3d !== next.is3d); + if (styleChanged) { + const styleSwitched = await this._switchMapStyle(next.mapStyleId); + if (!styleSwitched) { + this.viewState = { + ...next, + mapStyleId: prev.mapStyleId + }; + } + this._ensureStyleCompatibilityLayers(); + await this._switchMapBuildingLayer(this.viewState.renderBuildings && this.viewState.is3d); + await this._switchTerrainLayer(this.viewState.renderTerrain); + await this._switchSatelliteLayer(this.viewState.renderSatelliteView); + await this._switchProjection(this.viewState.renderGlobe); + this._syncPitchBearingState(false); + this._rerenderOverlays(); + return; + } + // 1) If projection or satellite changes, temporarily turn off terrain to avoid render bugs. if (projectionChanged || satelliteChanged) { const terrainWasOn = !!this.map.getTerrain(); @@ -200,6 +346,333 @@ class MapRenderer { this._rerenderOverlays(); } + _getStyleCapabilities() { + const explicitCapabilities = this.currentMapStyle?.capabilities || {}; + if (Object.keys(explicitCapabilities).length) { + return explicitCapabilities; + } + + return this._buildGenericStyleCapabilities(); + } + + _buildGenericStyleCapabilities() { + const terrainSourceId = 'reitti-terrain-source'; + const satelliteSourceId = 'reitti-satellite-source'; + const buildingSourceId = 'reitti-building-source'; + const buildingCapabilities = this._detectBuildingCapabilities(buildingSourceId); + + return { + terrainSourceId, + terrainSourceDefinition: { + type: 'raster-dem', + tiles: ['https://tiles.mapterhorn.com/{z}/{x}/{y}.webp'], + tileSize: 256, + encoding: 'terrarium', + maxzoom: 14, + attribution: "© Mapterhorn" + }, + hillshadeLayerId: 'reitti-terrain-hillshade', + hillshadeLayerDefinition: { + id: 'reitti-terrain-hillshade', + type: 'hillshade', + source: terrainSourceId, + layout: { + visibility: 'none' + }, + paint: { + 'hillshade-exaggeration': 0.35 + } + }, + satelliteSourceId, + satelliteSourceDefinition: { + type: 'raster', + tiles: [ + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' + ], + tileSize: 256, + maxzoom: 18, + attribution: "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community" + }, + satelliteLayerId: 'reitti-satellite-layer', + satelliteLayerDefinition: { + id: 'reitti-satellite-layer', + type: 'raster', + source: satelliteSourceId, + paint: { + 'raster-opacity': 0 + } + }, + buildingSourceId, + buildingSourceDefinition: { + type: 'vector', + url: 'https://tiles.dedicatedcode.com/planet', + minzoom: 0, + maxzoom: 14, + attribution: "© OpenFreeMap © OSM" + }, + ...buildingCapabilities + }; + } + + _detectBuildingCapabilities(runtimeBuildingSourceId = '') { + const style = this.map?.getStyle?.(); + const layers = style?.layers || []; + const sources = style?.sources || {}; + + const existingExtrusionIds = layers + .filter(layer => layer.type === 'fill-extrusion' && this._looksLikeBuildingLayer(layer)) + .map(layer => layer.id); + if (existingExtrusionIds.length) { + return { + building3dLayerIds: existingExtrusionIds, + building3dLayerDefinitions: [] + }; + } + + const buildingFillLayer = layers.find(layer => layer.type === 'fill' && this._looksLikeBuildingLayer(layer)); + let sourceId = buildingFillLayer?.source; + let sourceLayer = buildingFillLayer?.['source-layer']; + if (!sourceId || !sourceLayer) { + sourceId = runtimeBuildingSourceId || this._getPreferredVectorSourceId(sources); + sourceLayer = this._getPreferredBuildingSourceLayer(sourceId, sources, runtimeBuildingSourceId); + } + if (!sourceId || !sourceLayer) { + return { + building3dLayerIds: [], + building3dLayerDefinitions: [] + }; + } + + return { + building3dLayerIds: ['reitti-building-3d'], + building3dLayerDefinitions: [ + { + id: 'reitti-building-3d', + type: 'fill-extrusion', + source: sourceId, + 'source-layer': sourceLayer, + minzoom: 15, + layout: { + visibility: 'none' + }, + paint: { + 'fill-extrusion-color': '#d2dde2', + 'fill-extrusion-height': [ + 'case', + ['has', 'height'], + ['to-number', ['get', 'height'], 8], + ['has', 'render_height'], + ['to-number', ['get', 'render_height'], 8], + ['has', 'levels'], + ['*', ['to-number', ['get', 'levels'], 3], 3], + 8 + ], + 'fill-extrusion-base': [ + 'case', + ['has', 'min_height'], + ['to-number', ['get', 'min_height'], 0], + ['has', 'render_min_height'], + ['to-number', ['get', 'render_min_height'], 0], + 0 + ], + 'fill-extrusion-opacity': 0.75, + 'fill-extrusion-vertical-gradient': true + } + } + ] + }; + } + + _looksLikeBuildingLayer(layer) { + const id = String(layer?.id || '').toLowerCase(); + const sourceLayer = String(layer?.['source-layer'] || '').toLowerCase(); + return id.includes('building') || sourceLayer.includes('building'); + } + + _getPreferredVectorSourceId(sources) { + const configuredSourceId = this.currentMapStyle?.dataSource?.sourceId; + if (configuredSourceId && sources[configuredSourceId]?.type === 'vector') { + return configuredSourceId; + } + + const entry = Object.entries(sources).find(([, source]) => source?.type === 'vector'); + return entry?.[0] || ''; + } + + _getPreferredBuildingSourceLayer(sourceId, sources, runtimeBuildingSourceId = '') { + if (!sourceId) return ''; + if (sourceId === runtimeBuildingSourceId) return 'building'; + if (sources[sourceId]?.type !== 'vector') return ''; + return 'building'; + } + + _cloneStyleDefinition(definition) { + return JSON.parse(JSON.stringify(definition)); + } + + _getFirstSymbolLayerId() { + const layers = this.map.getStyle()?.layers || []; + return layers.find(layer => layer.type === 'symbol')?.id; + } + + _ensureSource(sourceId, sourceDefinition) { + if (!sourceId || !sourceDefinition || this.map.getSource(sourceId)) return; + this.map.addSource(sourceId, this._cloneStyleDefinition(sourceDefinition)); + } + + _ensureLayer(layerDefinition, beforeLayerId = this._getFirstSymbolLayerId()) { + if (!layerDefinition || this.map.getLayer(layerDefinition.id)) return; + + const layer = this._cloneStyleDefinition(layerDefinition); + const sourceId = layer.source; + if (sourceId && !this.map.getSource(sourceId)) { + console.warn(`Cannot add map layer ${layer.id}; source ${sourceId} is not available.`); + return; + } + + if (beforeLayerId && this.map.getLayer(beforeLayerId)) { + this.map.addLayer(layer, beforeLayerId); + } else { + this.map.addLayer(layer); + } + } + + _ensureStyleCompatibilityLayers() { + const capabilities = this._getStyleCapabilities(); + const buildingLayerDefinitions = capabilities.building3dLayerDefinitions || []; + const needsBuildingSource = buildingLayerDefinitions.some(layerDefinition => layerDefinition.source === capabilities.buildingSourceId); + + this._ensureSource(capabilities.terrainSourceId, capabilities.terrainSourceDefinition); + this._ensureSource(capabilities.satelliteSourceId, capabilities.satelliteSourceDefinition); + if (needsBuildingSource) { + this._ensureSource(capabilities.buildingSourceId, capabilities.buildingSourceDefinition); + } + + this._ensureLayer(capabilities.satelliteLayerDefinition); + this._ensureLayer(capabilities.hillshadeLayerDefinition); + buildingLayerDefinitions.forEach(layerDefinition => this._ensureLayer(layerDefinition)); + } + + async _waitForStyleLoad(mapStyle, timeoutMs = 10000) { + if (this.map.isStyleLoaded()) { + return true; + } + + return new Promise(resolve => { + const timeout = window.setTimeout(() => { + cleanup(); + console.warn(`Timed out waiting for map style ${mapStyle?.id || 'unknown'} to load.`); + resolve(false); + }, timeoutMs); + const cleanup = () => { + window.clearTimeout(timeout); + this.map.off('style.load', handleLoad); + this.map.off('error', handleError); + }; + const handleLoad = () => { + cleanup(); + resolve(true); + }; + const handleError = (event) => { + console.warn(`Map style ${mapStyle?.id || 'unknown'} emitted an error while loading:`, event?.error || event); + }; + + this.map.once('style.load', handleLoad); + this.map.on('error', handleError); + }); + } + + async _setStyleAndWait(mapStyle, timeoutMs = 10000) { + return new Promise(resolve => { + const timeout = window.setTimeout(() => { + cleanup(); + console.warn(`Timed out waiting for map style ${mapStyle?.id || 'unknown'} to load.`); + resolve(false); + }, timeoutMs); + const cleanup = () => { + window.clearTimeout(timeout); + this.map.off('style.load', handleLoad); + this.map.off('error', handleError); + }; + const handleLoad = () => { + cleanup(); + resolve(true); + }; + const handleError = (event) => { + console.warn(`Map style ${mapStyle?.id || 'unknown'} emitted an error while loading:`, event?.error || event); + }; + + this.map.once('style.load', handleLoad); + this.map.on('error', handleError); + + try { + this.map.setStyle(MapRenderer.getMapStyleValue(mapStyle)); + } catch (error) { + cleanup(); + console.warn(`Unable to apply map style ${mapStyle?.id || 'unknown'}:`, error); + resolve(false); + } + }); + } + + async _fallbackToDefaultStyle() { + const fallbackStyle = MapRenderer.getMapStyle(MapRenderer.getDefaultMapStyleId()); + if (!fallbackStyle || fallbackStyle.id === this.currentMapStyle?.id) { + return false; + } + + console.warn(`Falling back to map style ${fallbackStyle.id}.`); + const loaded = await this._setStyleAndWait(fallbackStyle, 10000); + if (loaded) { + this.currentMapStyle = fallbackStyle; + this.viewState.mapStyleId = fallbackStyle.id; + this._persistMapStyleSelection(fallbackStyle.id); + } + return loaded; + } + + _persistMapStyleSelection(mapStyleId) { + window.reittiActiveMapStyleId = mapStyleId; + window.localStorage?.setItem('mapStyleId', mapStyleId); + const styleSelect = document.getElementById('map-style-select'); + if (styleSelect) { + styleSelect.value = mapStyleId; + } + const settingsStyleSelect = document.getElementById('settings-map-style-select'); + if (settingsStyleSelect) { + settingsStyleSelect.value = mapStyleId; + } + } + + async _switchMapStyle(mapStyleId) { + const nextStyle = MapRenderer.getMapStyle(mapStyleId); + if (!nextStyle || nextStyle.id === this.currentMapStyle?.id) return true; + + const previousStyle = this.currentMapStyle; + this.terrainLayer = null; + this.satelliteHiddenPaintProperties.clear(); + this.element.classList.add('is-loading'); + + let loaded = await this._setStyleAndWait(nextStyle); + if (loaded) { + this.currentMapStyle = nextStyle; + this.element.classList.remove('is-loading'); + return true; + } + + if (previousStyle) { + console.warn(`Restoring previous map style ${previousStyle.id}.`); + loaded = await this._setStyleAndWait(previousStyle, 10000); + if (loaded) { + this.currentMapStyle = previousStyle; + this._persistMapStyleSelection(previousStyle.id); + } + } + + this.element.classList.remove('is-loading'); + return false; + } + setGpsDataManagers(managers) { this.gpsDataManagers = [...managers].reverse(); this.bounds = []; @@ -820,6 +1293,7 @@ class MapRenderer { } async _switchSatelliteLayer(enable) { + this._ensureStyleCompatibilityLayers(); this._applySatellitePaintProperties(enable); await this._waitForIdle(); } @@ -827,18 +1301,23 @@ class MapRenderer { _applySatellitePaintProperties(enable) { if (!this.map || !this.map.getStyle) return; - if (this.map.getLayer && this.map.getLayer('satellite-layer')) { - this.map.setPaintProperty('satellite-layer', 'raster-opacity', enable ? 1 : 0); - } + const satelliteLayerId = this._getStyleCapabilities().satelliteLayerId; + if (!satelliteLayerId || !this.map.getLayer || !this.map.getLayer(satelliteLayerId)) return; + + this.map.setPaintProperty(satelliteLayerId, 'raster-opacity', enable ? 1 : 0); const style = this.map.getStyle(); if (!style || !Array.isArray(style.layers)) return; const targetTypes = ['fill', 'background', 'fill-extrusion', 'line']; - const protectedLayers = ['satellite-layer', 'sky', 'building-3d']; + const protectedLayers = new Set([ + satelliteLayerId, + 'sky', + ...(this._getStyleCapabilities().building3dLayerIds || []) + ]); style.layers.forEach(layer => { - if (targetTypes.includes(layer.type) && !protectedLayers.includes(layer.id)) { + if (targetTypes.includes(layer.type) && !protectedLayers.has(layer.id)) { let opacityProp = ''; if (layer.type === 'line') opacityProp = 'line-opacity'; if (layer.type === 'fill') opacityProp = 'fill-opacity'; @@ -846,7 +1325,16 @@ class MapRenderer { if (layer.type === 'background') opacityProp = 'background-opacity'; try { - this.map.setPaintProperty(layer.id, opacityProp, enable ? 0 : null); + const cacheKey = `${layer.id}:${opacityProp}`; + if (enable) { + if (!this.satelliteHiddenPaintProperties.has(cacheKey)) { + this.satelliteHiddenPaintProperties.set(cacheKey, this.map.getPaintProperty(layer.id, opacityProp)); + } + this.map.setPaintProperty(layer.id, opacityProp, 0); + } else if (this.satelliteHiddenPaintProperties.has(cacheKey)) { + this.map.setPaintProperty(layer.id, opacityProp, this.satelliteHiddenPaintProperties.get(cacheKey)); + this.satelliteHiddenPaintProperties.delete(cacheKey); + } } catch (_) { // Layer might not be present yet; ignore } @@ -854,18 +1342,32 @@ class MapRenderer { }); // Buildings: keep a faint extrusion over satellite if desired - if (this.map.getLayer && this.map.getLayer('building-3d')) { + (this._getStyleCapabilities().building3dLayerIds || []).forEach(layerId => { + if (!this.map.getLayer || !this.map.getLayer(layerId)) return; try { - this.map.setPaintProperty('building-3d', 'fill-extrusion-opacity', enable ? 0.6 : null); + const opacityProp = 'fill-extrusion-opacity'; + const cacheKey = `${layerId}:${opacityProp}`; + if (enable) { + if (!this.satelliteHiddenPaintProperties.has(cacheKey)) { + this.satelliteHiddenPaintProperties.set(cacheKey, this.map.getPaintProperty(layerId, opacityProp)); + } + this.map.setPaintProperty(layerId, opacityProp, 0.6); + } else if (this.satelliteHiddenPaintProperties.has(cacheKey)) { + this.map.setPaintProperty(layerId, opacityProp, this.satelliteHiddenPaintProperties.get(cacheKey)); + this.satelliteHiddenPaintProperties.delete(cacheKey); + } } catch (_) {} - } + }); } _extractTerrainUrl() { + const terrainSourceId = this._getStyleCapabilities().terrainSourceId; + if (!terrainSourceId) return null; + // 1. Try reading directly from the loaded Style JSON (Instant, no waiting) const style = this.map.getStyle(); - if (style && style.sources && style.sources['terrain-source']) { - const sourceDef = style.sources['terrain-source']; + if (style && style.sources && style.sources[terrainSourceId]) { + const sourceDef = style.sources[terrainSourceId]; if (sourceDef.tiles && sourceDef.tiles.length > 0) { return sourceDef.tiles[0]; } @@ -874,7 +1376,7 @@ class MapRenderer { } } - const source = this.map.getSource('terrain-source'); + const source = this.map.getSource(terrainSourceId); if (source && source.tiles && source.tiles.length > 0) { return source.tiles[0]; } @@ -883,7 +1385,17 @@ class MapRenderer { } async _switchTerrainLayer(enable) { - const hasHillshading = !!this.map.getLayer && this.map.getLayer('hillshading'); + this._ensureStyleCompatibilityLayers(); + const capabilities = this._getStyleCapabilities(); + const terrainSourceId = capabilities.terrainSourceId; + const hillshadeLayerId = capabilities.hillshadeLayerId; + const hasHillshading = !!hillshadeLayerId && !!this.map.getLayer && this.map.getLayer(hillshadeLayerId); + + if (!terrainSourceId || !this.map.getSource(terrainSourceId)) { + this.terrainLayer = null; + this.map.setTerrain(null); + return; + } if (enable) { // Get URL safely @@ -895,7 +1407,7 @@ class MapRenderer { } if (hasHillshading) { - this.map.setLayoutProperty('hillshading', 'visibility', 'visible'); + this.map.setLayoutProperty(hillshadeLayerId, 'visibility', 'visible'); } // Create DeckGL layer @@ -917,25 +1429,25 @@ class MapRenderer { // Set MapLibre terrain this.map.setTerrain({ - source: 'terrain-source', + source: terrainSourceId, exaggeration: 1 }); } else { this.terrainLayer = null; if (hasHillshading) { - this.map.setLayoutProperty('hillshading', 'visibility', 'none'); + this.map.setLayoutProperty(hillshadeLayerId, 'visibility', 'none'); } this.map.setTerrain(null); } } _switchMapBuildingLayer(is3d) { - if (is3d) { - this.map.setLayoutProperty('building-3d', 'visibility', 'visible'); - } else { - this.map.setLayoutProperty('building-3d', 'visibility', 'none'); - } + this._ensureStyleCompatibilityLayers(); + (this._getStyleCapabilities().building3dLayerIds || []).forEach(layerId => { + if (!this.map.getLayer || !this.map.getLayer(layerId)) return; + this.map.setLayoutProperty(layerId, 'visibility', is3d ? 'visible' : 'none'); + }); } setHighlight({ managerId, startTime, endTime }) { @@ -1277,3 +1789,5 @@ class MapRenderer { } + +MapRenderer.rtlTextPluginConfigured = false; diff --git a/src/main/resources/static/js/map-style-settings.js b/src/main/resources/static/js/map-style-settings.js new file mode 100644 index 00000000..35e14dff --- /dev/null +++ b/src/main/resources/static/js/map-style-settings.js @@ -0,0 +1,412 @@ +class MapStyleSettingsPage { + constructor(root) { + this.root = root; + this.editingMapStyleId = null; + + this.activeStyleSelect = this.root.querySelector('#settings-map-style-select'); + this.list = this.root.querySelector('#custom-map-style-list'); + this.form = this.root.querySelector('#custom-map-style-form'); + this.formTitle = this.root.querySelector('#custom-map-style-form-title'); + this.error = this.root.querySelector('#custom-map-style-error'); + + this.setupListeners(); + this.refresh(); + this.syncFormMode(); + } + + setupListeners() { + this.activeStyleSelect.addEventListener('change', (event) => { + this.setActiveStyle(event.target.value); + }); + + this.root.querySelector('#add-map-style-btn').addEventListener('click', () => { + this.openForm(); + }); + + this.root.querySelector('#cancel-map-style-btn').addEventListener('click', () => { + this.closeForm(); + }); + + this.root.querySelectorAll('input[name="custom-map-style-map-type"], input[name="custom-map-style-style-input"], input[name="custom-map-style-raster-input"]') + .forEach(input => input.addEventListener('change', () => this.syncFormMode())); + + this.form.addEventListener('submit', (event) => { + event.preventDefault(); + this.saveForm(); + }); + + this.list.addEventListener('click', (event) => { + const button = event.target.closest('[data-map-style-action]'); + if (!button) return; + + const action = button.dataset.mapStyleAction; + const styleId = button.dataset.mapStyleId; + if (action === 'edit') { + const style = MapRenderer.getCustomMapStyles().find(item => item.id === styleId); + if (style) { + this.openForm(style); + } + } else if (action === 'delete') { + this.deleteStyle(styleId); + } + }); + } + + refresh() { + this.refreshActiveStyleSelect(); + this.renderCustomStyleList(); + } + + refreshActiveStyleSelect() { + const styles = MapRenderer.getMapStyles(); + const storedStyleId = MapRenderer.getActiveMapStyleId(); + const selectedStyleId = styles.some(style => style.id === storedStyleId) + ? storedStyleId + : MapRenderer.getDefaultMapStyleId(); + + this.activeStyleSelect.innerHTML = styles + .map(style => `${this.escapeHtml(style.label)}`) + .join(''); + window.reittiActiveMapStyleId = selectedStyleId; + } + + renderCustomStyleList() { + const styles = MapRenderer.getCustomMapStyles().filter(style => style.editable !== false); + if (!styles.length) { + this.list.innerHTML = `${t('map.settings.dialog.map-styles.empty')}`; + return; + } + + this.list.innerHTML = styles.map(style => { + const typeLabel = style.mapType === 'raster' + ? t('map.settings.dialog.map-styles.map-type.raster') + : t('map.settings.dialog.map-styles.map-type.vector'); + const inputLabel = style.mapType === 'raster' + ? (style.rasterSourceInputType === 'tilejson' + ? t('map.settings.dialog.map-styles.source-input.tilejson') + : t('map.settings.dialog.map-styles.source-input.tile-template')) + : (style.styleInputType === 'json' + ? 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}${proxyLabel} + + + + + + + + + + + `; + }).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; + } + const proxyTilesInput = this.root.querySelector('#custom-map-style-proxy-tiles'); + if (proxyTilesInput) { + proxyTilesInput.checked = !!style?.dataSource?.proxyTiles; + } + 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') : '', + proxyTiles: !!window.reittiIsAdmin && !!this.root.querySelector('#custom-map-style-proxy-tiles')?.checked + }, + 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 1dd5183d..4f864c99 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. + + + + + Active + Style + + + + + + + Custom Styles + + + Add Style + + + + + + + + Add Custom Style + + + Name + + + + + Map Type + + + Vector Style + + + + Raster Tiles + + + + + + + + Style Input + + + Style JSON + URL + + + + Paste Style + JSON + + + + Style JSON URL + + + + Style JSON + + + + Advanced Options + + + Attribution + Override + + + + Glyphs URL + Override + + + + Sprite URL + Override + + + + + + + + Source Input + + + Tile + URL Template + + + + TileJSON + URL + + + + Tile URL Template + + + + Attribution + + + + TileJSON URL + + + + Attribution + Override + + + + Tile Settings + + + + Min Zoom + + + + Max Zoom + + + + Tile Size + + + Default (256) + 256 + 512 + + + + Scheme + + + Default (XYZ) + XYZ + TMS + + + + + + + + + Proxy tiles through Reitti + Fetches Tiles via Reitti, disable to use browser fetching. + + + + + + + + + Share with all users + Other users can select + this style, but only you can edit it. + + + + + + + + + + + Save + + + + Cancel + + + + + + + + + + + + diff --git a/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java b/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java new file mode 100644 index 00000000..6924d192 --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/controller/api/MapStyleControllerTest.java @@ -0,0 +1,116 @@ +package com.dedicatedcode.reitti.controller.api; + +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.repository.UserSettingsJdbcService; +import com.dedicatedcode.reitti.service.ContextPathHolder; +import com.dedicatedcode.reitti.service.I18nService; +import com.dedicatedcode.reitti.service.MapStylePathUtils; +import com.dedicatedcode.reitti.service.MapStyleUrlValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.http.ResponseEntity; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MapStyleControllerTest { + private static final String JAWG_SOURCE_ID = "streets-v2+landcover-v1.1+hillshade-v1"; + private static final String JAWG_TILE_URL = "https://tile.jawg.io/streets-v2+landcover-v1.1+hillshade-v1/{z}/{x}/{y}.pbf?access-token=test-token"; + + @Test + void rewritesProxyTileUrlsWithEncodedStyleAndSourcePathSegments() 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(), + "Jawg", + "vector", + "json", + "tile_template", + """ + { + "version": 8, + "sources": { + "%s": { + "type": "vector", + "tiles": ["%s"] + } + }, + "layers": [] + } + """.formatted(JAWG_SOURCE_ID, JAWG_TILE_URL), + null, + new MapStyleDataSource(null, "vector", null, null, null, null, null, null, null, true), + 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)), + "http://tile-cache" + ); + + ResponseEntity response = controller.getUserCustomStyle(user, 42L, new MockHttpServletRequest()); + + 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/" + 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"); + } +} diff --git a/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java b/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java new file mode 100644 index 00000000..76d7c826 --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/controller/api/TileProxyControllerTest.java @@ -0,0 +1,232 @@ +package com.dedicatedcode.reitti.controller.api; + +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.I18nService; +import com.dedicatedcode.reitti.service.MapStylePathUtils; +import com.dedicatedcode.reitti.service.MapStyleUrlValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TileProxyControllerTest { + private static final String JAWG_SOURCE_ID = "streets-v2+landcover-v1.1+hillshade-v1"; + private static final String JAWG_TILE_URL = "https://tile.jawg.io/streets-v2+landcover-v1.1+hillshade-v1/{z}/{x}/{y}.pbf?access-token=test-token"; + + @Test + void resolvesReadableSourcePathIdBackToOriginalTileTemplate() throws Exception { + HttpServer tileCache = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AtomicReference upstreamHeader = new AtomicReference<>(); + 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); + exchange.getResponseBody().write(body); + exchange.close(); + }); + tileCache.start(); + + try { + User user = new User(7L, "test", null, "Test", null, null, null, null); + UserMapStyle style = new UserMapStyle( + 42L, + user.getId(), + "Jawg", + "vector", + "json", + "tile_template", + """ + { + "version": 8, + "sources": { + "%s": { + "type": "vector", + "tiles": ["%s"] + } + }, + "layers": [] + } + """.formatted(JAWG_SOURCE_ID, JAWG_TILE_URL), + null, + new MapStyleDataSource(null, "vector", null, null, null, null, null, null, null, true), + null, + false, + 1L + ); + UserMapStyleJdbcService userMapStyleJdbcService = mock(UserMapStyleJdbcService.class); + when(userMapStyleJdbcService.findById(user, 42L)).thenReturn(Optional.of(style)); + + TileProxyController controller = new TileProxyController( + "http://127.0.0.1:" + tileCache.getAddress().getPort(), + new ObjectMapper(), + userMapStyleJdbcService, + new MapStyleUrlValidator(mock(I18nService.class)) + ); + + ResponseEntity response = controller.getStyleSourceTile( + user, + "custom-42", + MapStylePathUtils.sourcePathId(JAWG_SOURCE_ID), + 15, + 17619, + 10758, + "pbf" + ); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(upstreamHeader.get()).isEqualTo( + "https://tile.jawg.io/streets-v2+landcover-v1.1+hillshade-v1/15/17619/10758.pbf?access-token=test-token" + ); + } finally { + tileCache.stop(0); + } + } + + @Test + void doesNotProxyCustomStyleTileUrlsWhenDisabled() throws Exception { + HttpServer tileCache = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AtomicReference upstreamHeader = new AtomicReference<>(); + 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); + exchange.getResponseBody().write(body); + exchange.close(); + }); + tileCache.start(); + + try { + User user = new User(7L, "test", null, "Test", null, null, null, null); + UserMapStyle style = new UserMapStyle( + 42L, + user.getId(), + "Jawg", + "vector", + "json", + "tile_template", + """ + { + "version": 8, + "sources": { + "%s": { + "type": "vector", + "tiles": ["%s"] + } + }, + "layers": [] + } + """.formatted(JAWG_SOURCE_ID, JAWG_TILE_URL), + null, + new MapStyleDataSource(null, "vector", null, null, null, null, null, null, null, false), + null, + false, + 1L + ); + UserMapStyleJdbcService userMapStyleJdbcService = mock(UserMapStyleJdbcService.class); + when(userMapStyleJdbcService.findById(user, 42L)).thenReturn(Optional.of(style)); + + TileProxyController controller = new TileProxyController( + "http://127.0.0.1:" + tileCache.getAddress().getPort(), + new ObjectMapper(), + userMapStyleJdbcService, + new MapStyleUrlValidator(mock(I18nService.class)) + ); + + ResponseEntity response = controller.getStyleSourceTile( + user, + "custom-42", + MapStylePathUtils.sourcePathId(JAWG_SOURCE_ID), + 15, + 17619, + 10758, + "pbf" + ); + + assertThat(response.getStatusCode().is2xxSuccessful()).isFalse(); + assertThat(upstreamHeader.get()).isNull(); + } finally { + tileCache.stop(0); + } + } + + @Test + void proxiesCustomStyleTileUrlsDirectlyWhenCacheIsDisabled() throws Exception { + HttpServer upstream = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AtomicReference upstreamHeader = new AtomicReference<>(); + upstream.createContext("/streets/15/17619/10758.pbf", exchange -> { + upstreamHeader.set(exchange.getRequestHeaders().getFirst("X-Reitti-Upstream-Url")); + byte[] body = "tile".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + upstream.start(); + + try { + String upstreamUrl = "http://127.0.0.1:" + upstream.getAddress().getPort() + "/streets/{z}/{x}/{y}.pbf"; + User user = new User(7L, "test", null, "Test", null, null, null, null); + UserMapStyle style = new UserMapStyle( + 42L, + user.getId(), + "Jawg", + "vector", + "json", + "tile_template", + """ + { + "version": 8, + "sources": { + "%s": { + "type": "vector", + "tiles": ["%s"] + } + }, + "layers": [] + } + """.formatted(JAWG_SOURCE_ID, upstreamUrl), + null, + new MapStyleDataSource(null, "vector", null, null, null, null, null, null, null, true), + null, + false, + 1L + ); + UserMapStyleJdbcService userMapStyleJdbcService = mock(UserMapStyleJdbcService.class); + when(userMapStyleJdbcService.findById(user, 42L)).thenReturn(Optional.of(style)); + + TileProxyController controller = new TileProxyController( + "", + new ObjectMapper(), + userMapStyleJdbcService, + new MapStyleUrlValidator(mock(I18nService.class)) + ); + + ResponseEntity response = controller.getStyleSourceTile( + user, + "custom-42", + MapStylePathUtils.sourcePathId(JAWG_SOURCE_ID), + 15, + 17619, + 10758, + "pbf" + ); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(new String(response.getBody(), StandardCharsets.UTF_8)).isEqualTo("tile"); + assertThat(upstreamHeader.get()).isNull(); + } finally { + upstream.stop(0); + } + } +} diff --git a/src/test/java/com/dedicatedcode/reitti/service/MapStyleUrlValidatorTest.java b/src/test/java/com/dedicatedcode/reitti/service/MapStyleUrlValidatorTest.java new file mode 100644 index 00000000..8f2893c6 --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/service/MapStyleUrlValidatorTest.java @@ -0,0 +1,25 @@ +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.assertThrows; +import static org.mockito.Mockito.mock; + +class MapStyleUrlValidatorTest { + + private final MapStyleUrlValidator validator = new MapStyleUrlValidator(mock(I18nService.class)); + + @Test + void acceptsHttpUrlsAndTemplatesRegardlessOfNetworkLocation() { + assertDoesNotThrow(() -> validator.requireHttpUrl("https://tile.openstreetmap.org/0/0/0.png", "Tile URL")); + 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() { + assertThrows(IllegalArgumentException.class, () -> validator.requireHttpUrl("file:///etc/passwd", "Tile URL")); + assertThrows(IllegalArgumentException.class, () -> validator.requireHttpUrl("https://user:secret@example.com/tile.png", "Tile URL")); + } +} 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 00000000..7444a487 --- /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")); + } +}
+ Manage custom MapLibre styles and optional data sources. +
Fetches Tiles via Reitti, disable to use browser fetching.
Other users can select + this style, but only you can edit it.