using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; using SatelliteProvider.DataAccess; using SatelliteProvider.DataAccess.Models; 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; var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog((context, configuration) => configuration.ReadFrom.Configuration(context.Configuration)); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); builder.Services.Configure(builder.Configuration.GetSection("MapConfig")); builder.Services.Configure(builder.Configuration.GetSection("StorageConfig")); builder.Services.Configure(builder.Configuration.GetSection("ProcessingConfig")); builder.Services.AddSingleton(sp => new TileRepository(connectionString, sp.GetRequiredService>())); builder.Services.AddSingleton(sp => new RegionRepository(connectionString, sp.GetRequiredService>())); 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 => { var logger = sp.GetRequiredService>(); return new RegionRequestQueue(processingConfig.QueueCapacity, logger); }); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; options.SerializerOptions.PropertyNameCaseInsensitive = true; }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "Satellite Provider API", Version = "v1" }); c.MapType(() => new OpenApiSchema { Type = "object", Properties = new Dictionary { ["timestamp"] = new() { Type = "string", Format = "date-time", Description = "Image capture timestamp" }, ["image"] = new() { Type = "string", Format = "binary", Description = "Image file to upload" }, ["lat"] = new() { Type = "number", Format = "double", Description = "Latitude coordinate where image was captured" }, ["lon"] = new() { Type = "number", Format = "double", Description = "Longitude coordinate where image was captured" }, ["height"] = new() { Type = "number", Format = "double", Description = "Height/altitude in meters where image was captured" }, ["focalLength"] = new() { Type = "number", Format = "double", Description = "Camera focal length in millimeters" }, ["sensorWidth"] = new() { Type = "number", Format = "double", Description = "Camera sensor width in millimeters" }, ["sensorHeight"] = new() { Type = "number", Format = "double", Description = "Camera sensor height in millimeters" } }, Required = new HashSet { "timestamp", "image", "lat", "lon", "height", "focalLength", "sensorWidth", "sensorHeight" } }); c.OperationFilter(); }); var app = builder.Build(); var logger = app.Services.GetRequiredService>(); var migrator = new DatabaseMigrator(connectionString, logger as ILogger); if (!migrator.RunMigrations()) { throw new Exception("Database migration failed. Application cannot start."); } var storageConfig = app.Configuration.GetSection("StorageConfig").Get() ?? new StorageConfig(); Directory.CreateDirectory(storageConfig.TilesDirectory); Directory.CreateDirectory(storageConfig.ReadyDirectory); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } 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" }); app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs) .WithOpenApi(op => new(op) { Summary = "Get satellite tiles by MGRS coordinates" }); app.MapPost("/api/satellite/upload", UploadImage) .Accepts("multipart/form-data") .WithOpenApi(op => new(op) { Summary = "Upload image with metadata and save to /maps folder" }) .DisableAntiforgery(); app.MapPost("/api/satellite/request", RequestRegion) .WithOpenApi(op => new(op) { Summary = "Request tiles for a region" }); app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus) .WithOpenApi(op => new(op) { Summary = "Get region status and file paths" }); app.MapPost("/api/satellite/route", CreateRoute) .WithOpenApi(op => new(op) { Summary = "Create a route with intermediate points" }); app.MapGet("/api/satellite/route/{id:guid}", GetRoute) .WithOpenApi(op => new(op) { Summary = "Get route information with calculated points" }); 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 { var downloadedTile = await downloader.DownloadSingleTileAsync( Latitude, Longitude, ZoomLevel); var now = DateTime.UtcNow; var currentVersion = now.Year; var tileEntity = new TileEntity { Id = Guid.NewGuid(), TileZoom = downloadedTile.ZoomLevel, 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 = currentVersion, FilePath = downloadedTile.FilePath, CreatedAt = now, UpdatedAt = now }; await tileRepository.InsertAsync(tileEntity); var response = new DownloadTileResponse { Id = tileEntity.Id, ZoomLevel = tileEntity.TileZoom, Latitude = tileEntity.Latitude, Longitude = tileEntity.Longitude, TileSizeMeters = tileEntity.TileSizeMeters, TileSizePixels = tileEntity.TileSizePixels, ImageType = tileEntity.ImageType, MapsVersion = tileEntity.MapsVersion, Version = currentVersion, FilePath = tileEntity.FilePath, CreatedAt = tileEntity.CreatedAt, UpdatedAt = tileEntity.UpdatedAt }; return Results.Ok(response); } catch (Exception ex) { logger.LogError(ex, "Failed to get tile"); return Results.Problem(detail: ex.Message, statusCode: 500); } } IResult GetSatelliteTilesByMgrs(string mgrs, double squareSideMeters) { return Results.Ok(new GetSatelliteTilesResponse()); } IResult UploadImage([FromForm] UploadImageRequest request) { return Results.Ok(new SaveResult { Success = false }); } async Task RequestRegion([FromBody] RequestRegionRequest request, IRegionService regionService, ILogger logger) { try { if (request.SizeMeters < 100 || request.SizeMeters > 10000) { return Results.BadRequest(new { error = "Size must be between 100 and 10000 meters" }); } var status = await regionService.RequestRegionAsync( request.Id, request.Latitude, request.Longitude, request.SizeMeters, request.ZoomLevel, request.StitchTiles); return Results.Ok(status); } catch (Exception ex) { logger.LogError(ex, "Failed to request region"); return Results.Problem(detail: ex.Message, statusCode: 500); } } async Task GetRegionStatus(Guid id, IRegionService regionService, ILogger logger) { try { var status = await regionService.GetRegionStatusAsync(id); if (status == null) { return Results.NotFound(new { error = $"Region {id} not found" }); } return Results.Ok(status); } catch (Exception ex) { logger.LogError(ex, "Failed to get region status"); return Results.Problem(detail: ex.Message, statusCode: 500); } } async Task CreateRoute([FromBody] CreateRouteRequest request, IRouteService routeService, ILogger logger) { try { var route = await routeService.CreateRouteAsync(request); return Results.Ok(route); } catch (ArgumentException ex) { logger.LogWarning(ex, "Invalid route request"); return Results.BadRequest(new { error = ex.Message }); } catch (Exception ex) { logger.LogError(ex, "Failed to create route"); return Results.Problem(detail: ex.Message, statusCode: 500); } } async Task GetRoute(Guid id, IRouteService routeService, ILogger logger) { try { var route = await routeService.GetRouteAsync(id); if (route == null) { return Results.NotFound(new { error = $"Route {id} not found" }); } return Results.Ok(route); } catch (Exception ex) { logger.LogError(ex, "Failed to get route"); return Results.Problem(detail: ex.Message, statusCode: 500); } } public record GetSatelliteTilesResponse { public List Tiles { get; set; } = new(); } public record SatelliteTile { public string TileId { get; set; } = string.Empty; public byte[] ImageData { get; set; } = Array.Empty(); public double Lat { get; set; } public double Lon { get; set; } public int ZoomLevel { get; set; } } public record UploadImageRequest { [Required] public DateTime Timestamp { get; set; } [Required] public IFormFile? Image { get; set; } [Required] public double Lat { get; set; } [Required] public double Lon { get; set; } [Required] public double Height { get; set; } [Required] public double FocalLength { get; set; } [Required] public double SensorWidth { get; set; } [Required] public double SensorHeight { get; set; } } public record SaveResult { public bool Success { get; set; } public string? Exception { get; set; } } public record DownloadTileResponse { public Guid Id { get; set; } public int ZoomLevel { get; set; } public double Latitude { get; set; } public double Longitude { get; set; } public double TileSizeMeters { get; set; } public int TileSizePixels { get; set; } public string ImageType { get; set; } = string.Empty; public string? MapsVersion { get; set; } public int Version { get; set; } public string FilePath { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } public record RequestRegionRequest { [Required] public Guid Id { get; set; } [Required] public double Latitude { get; set; } [Required] public double Longitude { get; set; } [Required] public double SizeMeters { get; set; } [Required] public int ZoomLevel { get; set; } = 18; public bool StitchTiles { get; set; } = false; } public class ParameterDescriptionFilter : IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { if (operation.Parameters == null) return; var parameterDescriptions = new Dictionary { ["lat"] = "Latitude coordinate where image was captured", ["lon"] = "Longitude coordinate where image was captured", ["mgrs"] = "MGRS coordinate string", ["squareSideMeters"] = "Square side size in meters", ["Latitude"] = "Latitude coordinate of the tile center", ["Longitude"] = "Longitude coordinate of the tile center", ["ZoomLevel"] = "Zoom level for the tile (higher values = more detail)" }; foreach (var parameter in operation.Parameters) { if (parameterDescriptions.TryGetValue(parameter.Name, out var description)) { parameter.Description = description; } } } }