From f08058ea9c0c8b7ad3e841ba5d0f029f9e89de0d Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Thu, 26 Mar 2026 00:34:42 +0200 Subject: [PATCH] Add CORS configuration and tile handling improvements --- SatelliteProvider.Api/Program.cs | 90 ++++++++++++++++++- SatelliteProvider.Api/appsettings.json | 3 + SatelliteProvider.Common/DTO/TileMetadata.cs | 5 +- .../Migrations/011_AddTileCoordinates.sql | 18 ++++ .../Models/TileEntity.cs | 5 +- .../Repositories/ITileRepository.cs | 2 +- .../Repositories/TileRepository.cs | 50 ++++++++--- .../GoogleMapsDownloaderV2.cs | 2 +- SatelliteProvider.Services/TileService.cs | 9 +- 9 files changed, 159 insertions(+), 25 deletions(-) create mode 100644 SatelliteProvider.DataAccess/Migrations/011_AddTileCoordinates.sql diff --git a/SatelliteProvider.Api/Program.cs b/SatelliteProvider.Api/Program.cs index 5f83bf7..3eaa2d2 100644 --- a/SatelliteProvider.Api/Program.cs +++ b/SatelliteProvider.Api/Program.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; using SatelliteProvider.DataAccess; @@ -8,6 +9,7 @@ using SatelliteProvider.DataAccess.Repositories; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Interfaces; +using SatelliteProvider.Common.Utils; using SatelliteProvider.Services; using Serilog; @@ -28,9 +30,22 @@ builder.Services.AddSingleton(sp => new RegionRepository(conn builder.Services.AddSingleton(sp => new RouteRepository(connectionString, sp.GetRequiredService>())); builder.Services.AddHttpClient(); +builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +var allowedOrigins = builder.Configuration.GetSection("CorsConfig:AllowedOrigins").Get() ?? Array.Empty(); +builder.Services.AddCors(options => +{ + options.AddPolicy("TilesCors", policy => + { + if (allowedOrigins.Length > 0) + policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod(); + else + policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); + }); +}); + var processingConfig = builder.Configuration.GetSection("ProcessingConfig").Get() ?? new ProcessingConfig(); builder.Services.AddSingleton(sp => { @@ -93,6 +108,10 @@ if (app.Environment.IsDevelopment()) } app.UseHttpsRedirection(); +app.UseCors("TilesCors"); + +app.MapGet("/tiles/{z:int}/{x:int}/{y:int}", ServeTile) + .WithOpenApi(op => new(op) { Summary = "Get satellite tile image by z/x/y coordinates (Slippy Map tile server)" }); app.MapGet("/api/satellite/tiles/latlon", GetTileByLatLon) .WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" }); @@ -119,6 +138,71 @@ app.MapGet("/api/satellite/route/{id:guid}", GetRoute) app.Run(); +async Task ServeTile(int z, int x, int y, HttpContext httpContext, ITileRepository tileRepository, GoogleMapsDownloaderV2 downloader, IMemoryCache cache, ILogger logger) +{ + var cacheKey = $"tile_{z}_{x}_{y}"; + try + { + if (cache.TryGetValue(cacheKey, out byte[]? cachedBytes) && cachedBytes != null) + { + httpContext.Response.Headers.CacheControl = "public, max-age=86400"; + httpContext.Response.Headers.ETag = $"\"{z}_{x}_{y}\""; + return Results.Bytes(cachedBytes, "image/jpeg"); + } + + string? filePath = null; + + var tile = await tileRepository.GetByTileCoordinatesAsync(z, x, y); + if (tile != null && File.Exists(tile.FilePath)) + { + filePath = tile.FilePath; + } + else + { + var tileCenter = GeoUtils.TileToWorldPos(x, y, z); + var downloadedTile = await downloader.DownloadSingleTileAsync(tileCenter.Lat, tileCenter.Lon, z); + + var now = DateTime.UtcNow; + var tileEntity = new TileEntity + { + Id = Guid.NewGuid(), + TileZoom = z, + TileX = downloadedTile.X, + TileY = downloadedTile.Y, + Latitude = downloadedTile.CenterLatitude, + Longitude = downloadedTile.CenterLongitude, + TileSizeMeters = downloadedTile.TileSizeMeters, + TileSizePixels = 256, + ImageType = "jpg", + MapsVersion = $"downloaded_{now:yyyy-MM-dd}", + Version = now.Year, + FilePath = downloadedTile.FilePath, + CreatedAt = now, + UpdatedAt = now + }; + + await tileRepository.InsertAsync(tileEntity); + filePath = tileEntity.FilePath; + } + + var bytes = await File.ReadAllBytesAsync(filePath); + cache.Set(cacheKey, bytes, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), + SlidingExpiration = TimeSpan.FromMinutes(30) + }); + + httpContext.Response.Headers.CacheControl = "public, max-age=86400"; + httpContext.Response.Headers.ETag = $"\"{z}_{x}_{y}\""; + return Results.Bytes(bytes, "image/jpeg"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to serve tile {Z}/{X}/{Y}", z, x, y); + return Results.Problem(detail: ex.Message, statusCode: 500); + } +} + async Task GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, GoogleMapsDownloaderV2 downloader, ITileRepository tileRepository, ILogger logger) { try @@ -133,7 +217,9 @@ async Task GetTileByLatLon([FromQuery] double Latitude, [FromQuery] dou var tileEntity = new TileEntity { Id = Guid.NewGuid(), - ZoomLevel = downloadedTile.ZoomLevel, + TileZoom = downloadedTile.ZoomLevel, + TileX = downloadedTile.X, + TileY = downloadedTile.Y, Latitude = downloadedTile.CenterLatitude, Longitude = downloadedTile.CenterLongitude, TileSizeMeters = downloadedTile.TileSizeMeters, @@ -151,7 +237,7 @@ async Task GetTileByLatLon([FromQuery] double Latitude, [FromQuery] dou var response = new DownloadTileResponse { Id = tileEntity.Id, - ZoomLevel = tileEntity.ZoomLevel, + ZoomLevel = tileEntity.TileZoom, Latitude = tileEntity.Latitude, Longitude = tileEntity.Longitude, TileSizeMeters = tileEntity.TileSizeMeters, diff --git a/SatelliteProvider.Api/appsettings.json b/SatelliteProvider.Api/appsettings.json index d2280c2..31eb32c 100644 --- a/SatelliteProvider.Api/appsettings.json +++ b/SatelliteProvider.Api/appsettings.json @@ -38,5 +38,8 @@ "QueueCapacity": 1000, "DelayBetweenRequestsMs": 50, "SessionTokenReuseCount": 100 + }, + "CorsConfig": { + "AllowedOrigins": [] } } diff --git a/SatelliteProvider.Common/DTO/TileMetadata.cs b/SatelliteProvider.Common/DTO/TileMetadata.cs index 5651fe5..652d0ce 100644 --- a/SatelliteProvider.Common/DTO/TileMetadata.cs +++ b/SatelliteProvider.Common/DTO/TileMetadata.cs @@ -3,7 +3,9 @@ namespace SatelliteProvider.Common.DTO; public class TileMetadata { public Guid Id { get; set; } - public int ZoomLevel { get; set; } + public int TileZoom { get; set; } + public int TileX { get; set; } + public int TileY { get; set; } public double Latitude { get; set; } public double Longitude { get; set; } public double TileSizeMeters { get; set; } @@ -15,4 +17,3 @@ public class TileMetadata public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } - diff --git a/SatelliteProvider.DataAccess/Migrations/011_AddTileCoordinates.sql b/SatelliteProvider.DataAccess/Migrations/011_AddTileCoordinates.sql new file mode 100644 index 0000000..7df3f69 --- /dev/null +++ b/SatelliteProvider.DataAccess/Migrations/011_AddTileCoordinates.sql @@ -0,0 +1,18 @@ +ALTER TABLE tiles RENAME COLUMN zoom_level TO tile_zoom; + +ALTER TABLE tiles ADD COLUMN tile_x INT; +ALTER TABLE tiles ADD COLUMN tile_y INT; + +UPDATE tiles SET + tile_x = FLOOR((longitude + 180.0) / 360.0 * POWER(2, tile_zoom))::INT, + tile_y = FLOOR((1.0 - LN(TAN(RADIANS(latitude)) + 1.0 / COS(RADIANS(latitude))) / PI()) / 2.0 * POWER(2, tile_zoom))::INT; + +ALTER TABLE tiles ALTER COLUMN tile_x SET NOT NULL; +ALTER TABLE tiles ALTER COLUMN tile_y SET NOT NULL; + +DROP INDEX IF EXISTS idx_tiles_zoom; +DROP INDEX IF EXISTS idx_tiles_unique_location; + +CREATE INDEX idx_tiles_zoom ON tiles(tile_zoom); +CREATE UNIQUE INDEX idx_tiles_unique_location ON tiles(latitude, longitude, tile_zoom, tile_size_meters, version); +CREATE INDEX idx_tiles_coordinates ON tiles(tile_zoom, tile_x, tile_y, version); diff --git a/SatelliteProvider.DataAccess/Models/TileEntity.cs b/SatelliteProvider.DataAccess/Models/TileEntity.cs index 0fba65b..54fcfce 100644 --- a/SatelliteProvider.DataAccess/Models/TileEntity.cs +++ b/SatelliteProvider.DataAccess/Models/TileEntity.cs @@ -3,7 +3,9 @@ namespace SatelliteProvider.DataAccess.Models; public class TileEntity { public Guid Id { get; set; } - public int ZoomLevel { get; set; } + public int TileZoom { get; set; } + public int TileX { get; set; } + public int TileY { get; set; } public double Latitude { get; set; } public double Longitude { get; set; } public double TileSizeMeters { get; set; } @@ -15,4 +17,3 @@ public class TileEntity public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } - diff --git a/SatelliteProvider.DataAccess/Repositories/ITileRepository.cs b/SatelliteProvider.DataAccess/Repositories/ITileRepository.cs index 903ce5d..e7ffc54 100644 --- a/SatelliteProvider.DataAccess/Repositories/ITileRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/ITileRepository.cs @@ -5,10 +5,10 @@ namespace SatelliteProvider.DataAccess.Repositories; public interface ITileRepository { Task GetByIdAsync(Guid id); + Task GetByTileCoordinatesAsync(int tileZoom, int tileX, int tileY); Task FindExistingTileAsync(double latitude, double longitude, double tileSizeMeters, int zoomLevel, int version); Task> GetTilesByRegionAsync(double latitude, double longitude, double sizeMeters, int zoomLevel); Task InsertAsync(TileEntity tile); Task UpdateAsync(TileEntity tile); Task DeleteAsync(Guid id); } - diff --git a/SatelliteProvider.DataAccess/Repositories/TileRepository.cs b/SatelliteProvider.DataAccess/Repositories/TileRepository.cs index 4645b95..7045ba3 100644 --- a/SatelliteProvider.DataAccess/Repositories/TileRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/TileRepository.cs @@ -20,22 +20,40 @@ public class TileRepository : ITileRepository { using var connection = new NpgsqlConnection(_connectionString); const string sql = @" - SELECT id, zoom_level as ZoomLevel, latitude, longitude, + SELECT id, tile_zoom as TileZoom, tile_x as TileX, tile_y as TileY, + latitude, longitude, tile_size_meters as TileSizeMeters, tile_size_pixels as TileSizePixels, image_type as ImageType, maps_version as MapsVersion, version, file_path as FilePath, created_at as CreatedAt, updated_at as UpdatedAt FROM tiles WHERE id = @Id"; - var tile = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); - return tile; + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task GetByTileCoordinatesAsync(int tileZoom, int tileX, int tileY) + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = @" + SELECT id, tile_zoom as TileZoom, tile_x as TileX, tile_y as TileY, + latitude, longitude, + tile_size_meters as TileSizeMeters, tile_size_pixels as TileSizePixels, + image_type as ImageType, maps_version as MapsVersion, version, + file_path as FilePath, created_at as CreatedAt, updated_at as UpdatedAt + FROM tiles + WHERE tile_zoom = @TileZoom AND tile_x = @TileX AND tile_y = @TileY + ORDER BY version DESC + LIMIT 1"; + + return await connection.QuerySingleOrDefaultAsync(sql, new { TileZoom = tileZoom, TileX = tileX, TileY = tileY }); } public async Task FindExistingTileAsync(double latitude, double longitude, double tileSizeMeters, int zoomLevel, int version) { using var connection = new NpgsqlConnection(_connectionString); const string sql = @" - SELECT id, zoom_level as ZoomLevel, latitude, longitude, + SELECT id, tile_zoom as TileZoom, tile_x as TileX, tile_y as TileY, + latitude, longitude, tile_size_meters as TileSizeMeters, tile_size_pixels as TileSizePixels, image_type as ImageType, maps_version as MapsVersion, version, file_path as FilePath, created_at as CreatedAt, updated_at as UpdatedAt @@ -43,7 +61,7 @@ public class TileRepository : ITileRepository WHERE ABS(latitude - @Latitude) < 0.0001 AND ABS(longitude - @Longitude) < 0.0001 AND ABS(tile_size_meters - @TileSizeMeters) < 1 - AND zoom_level = @ZoomLevel + AND tile_zoom = @TileZoom AND version = @Version LIMIT 1"; @@ -52,7 +70,7 @@ public class TileRepository : ITileRepository Latitude = latitude, Longitude = longitude, TileSizeMeters = tileSizeMeters, - ZoomLevel = zoomLevel, + TileZoom = zoomLevel, Version = version }); } @@ -73,14 +91,15 @@ public class TileRepository : ITileRepository var lonRange = expandedSizeMeters / (111000.0 * Math.Cos(latitude * Math.PI / 180.0)); const string sql = @" - SELECT id, zoom_level as ZoomLevel, latitude, longitude, + SELECT id, tile_zoom as TileZoom, tile_x as TileX, tile_y as TileY, + latitude, longitude, tile_size_meters as TileSizeMeters, tile_size_pixels as TileSizePixels, image_type as ImageType, maps_version as MapsVersion, version, file_path as FilePath, created_at as CreatedAt, updated_at as UpdatedAt FROM tiles WHERE latitude BETWEEN @MinLat AND @MaxLat AND longitude BETWEEN @MinLon AND @MaxLon - AND zoom_level = @ZoomLevel + AND tile_zoom = @TileZoom ORDER BY version DESC, latitude DESC, longitude ASC"; return await connection.QueryAsync(sql, new @@ -89,7 +108,7 @@ public class TileRepository : ITileRepository MaxLat = latitude + latRange / 2, MinLon = longitude - lonRange / 2, MaxLon = longitude + lonRange / 2, - ZoomLevel = zoomLevel + TileZoom = zoomLevel }); } @@ -97,15 +116,17 @@ public class TileRepository : ITileRepository { using var connection = new NpgsqlConnection(_connectionString); const string sql = @" - INSERT INTO tiles (id, zoom_level, latitude, longitude, tile_size_meters, + INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters, tile_size_pixels, image_type, maps_version, version, file_path, created_at, updated_at) - VALUES (@Id, @ZoomLevel, @Latitude, @Longitude, @TileSizeMeters, + VALUES (@Id, @TileZoom, @TileX, @TileY, @Latitude, @Longitude, @TileSizeMeters, @TileSizePixels, @ImageType, @MapsVersion, @Version, @FilePath, @CreatedAt, @UpdatedAt) - ON CONFLICT (latitude, longitude, zoom_level, tile_size_meters, version) + ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters, version) DO UPDATE SET file_path = EXCLUDED.file_path, + tile_x = EXCLUDED.tile_x, + tile_y = EXCLUDED.tile_y, updated_at = EXCLUDED.updated_at RETURNING id"; @@ -117,7 +138,9 @@ public class TileRepository : ITileRepository using var connection = new NpgsqlConnection(_connectionString); const string sql = @" UPDATE tiles - SET zoom_level = @ZoomLevel, + SET tile_zoom = @TileZoom, + tile_x = @TileX, + tile_y = @TileY, latitude = @Latitude, longitude = @Longitude, tile_size_meters = @TileSizeMeters, @@ -139,4 +162,3 @@ public class TileRepository : ITileRepository return await connection.ExecuteAsync(sql, new { Id = id }); } } - diff --git a/SatelliteProvider.Services/GoogleMapsDownloaderV2.cs b/SatelliteProvider.Services/GoogleMapsDownloaderV2.cs index 5b183c8..f41f46d 100644 --- a/SatelliteProvider.Services/GoogleMapsDownloaderV2.cs +++ b/SatelliteProvider.Services/GoogleMapsDownloaderV2.cs @@ -256,7 +256,7 @@ public class GoogleMapsDownloaderV2 var existingTile = existingTiles.FirstOrDefault(t => Math.Abs(t.Latitude - tileCenter.Lat) < 0.0001 && Math.Abs(t.Longitude - tileCenter.Lon) < 0.0001 && - t.ZoomLevel == zoomLevel); + t.TileZoom == zoomLevel); if (existingTile != null) { diff --git a/SatelliteProvider.Services/TileService.cs b/SatelliteProvider.Services/TileService.cs index 4830bda..e2c0ab5 100644 --- a/SatelliteProvider.Services/TileService.cs +++ b/SatelliteProvider.Services/TileService.cs @@ -59,7 +59,9 @@ public class TileService : ITileService var tileEntity = new TileEntity { Id = Guid.NewGuid(), - ZoomLevel = downloadedTile.ZoomLevel, + TileZoom = downloadedTile.ZoomLevel, + TileX = downloadedTile.X, + TileY = downloadedTile.Y, Latitude = downloadedTile.CenterLatitude, Longitude = downloadedTile.CenterLongitude, TileSizeMeters = downloadedTile.TileSizeMeters, @@ -100,7 +102,9 @@ public class TileService : ITileService return new TileMetadata { Id = entity.Id, - ZoomLevel = entity.ZoomLevel, + TileZoom = entity.TileZoom, + TileX = entity.TileX, + TileY = entity.TileY, Latitude = entity.Latitude, Longitude = entity.Longitude, TileSizeMeters = entity.TileSizeMeters, @@ -114,4 +118,3 @@ public class TileService : ITileService }; } } -