using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; using SatelliteProvider.Api; using SatelliteProvider.Api.Authentication; using SatelliteProvider.Api.DTOs; using SatelliteProvider.Api.Swagger; using SatelliteProvider.DataAccess; using SatelliteProvider.DataAccess.Repositories; using SatelliteProvider.DataAccess.TypeHandlers; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Services.RegionProcessing; using SatelliteProvider.Services.RouteManagement; using SatelliteProvider.Services.TileDownloader; 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."); DapperEnumTypeHandlers.RegisterAll(); builder.Services.Configure(builder.Configuration.GetSection("MapConfig")); builder.Services.Configure(builder.Configuration.GetSection("StorageConfig")); builder.Services.Configure(builder.Configuration.GetSection("ProcessingConfig")); builder.Services.Configure(builder.Configuration.GetSection("UavQuality")); var uavQuality = builder.Configuration.GetSection("UavQuality").Get() ?? new UavQualityConfig(); var uavBatchBodyLimit = checked((long)uavQuality.MaxBatchSize * uavQuality.MaxBytes); builder.Services.Configure(options => { options.Limits.MaxRequestBodySize = uavBatchBodyLimit; }); builder.Services.Configure(options => { options.MultipartBodyLengthLimit = uavBatchBodyLimit; options.ValueLengthLimit = Math.Max(options.ValueLengthLimit, uavQuality.MaxBatchSize * 512); }); builder.Services.AddSingleton(sp => new TileRepository(connectionString, sp.GetRequiredService>())); builder.Services.AddSingleton(sp => new RegionRepository(connectionString)); builder.Services.AddSingleton(sp => new RouteRepository(connectionString)); builder.Services.AddHttpClient(); builder.Services.AddTileDownloader(); builder.Services.AddRegionProcessing(); builder.Services.AddRouteManagement(); builder.Services.AddSatelliteJwt(builder.Configuration); builder.Services.AddSingleton(); builder.Services.AddAuthorization(options => { options.AddPolicy(SatellitePermissions.UavUploadPolicy, policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new PermissionsRequirement(SatellitePermissions.Gps)); }); }); var allowedOrigins = builder.Configuration.GetSection("CorsConfig:AllowedOrigins").Get() ?? Array.Empty(); var allowAnyOrigin = builder.Configuration.GetValue("CorsConfig:AllowAnyOrigin"); CorsConfigurationValidator.EnsureSafeForEnvironment(allowedOrigins, allowAnyOrigin, builder.Environment.EnvironmentName); builder.Services.AddCors(options => { options.AddPolicy("TilesCors", policy => { if (CorsConfigurationValidator.ShouldUsePermissivePolicy(allowedOrigins, allowAnyOrigin)) policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); else policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod(); }); }); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; options.SerializerOptions.PropertyNameCaseInsensitive = true; options.SerializerOptions.Converters.Add( new System.Text.Json.Serialization.JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase)); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "Satellite Provider API", Version = "v1" }); c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Name = "Authorization", Type = SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", In = ParameterLocation.Header, Description = "JWT Authorization header using the Bearer scheme. Example: 'Bearer {token}'" }); c.AddSecurityRequirement(_ => new OpenApiSecurityRequirement { { new OpenApiSecuritySchemeReference("Bearer"), new List() } }); c.MapType(() => new OpenApiSchema { Type = JsonSchemaType.Object, Properties = new Dictionary { ["metadata"] = new OpenApiSchema { Type = JsonSchemaType.String, Description = "JSON document `{ \"items\": [ { \"latitude\", \"longitude\", \"tileZoom\", \"tileSizeMeters\", \"capturedAt\" } ] }` where item ordinal index aligns with the matching file in `files`." }, ["files"] = new OpenApiSchema { Type = JsonSchemaType.Array, Description = "UAV tile JPEG files in the same order as `metadata.items`.", Items = new OpenApiSchema { Type = JsonSchemaType.String, Format = "binary" } } }, Required = new HashSet { "metadata", "files" } }); c.OperationFilter(); }); var app = builder.Build(); if (CorsConfigurationValidator.ShouldWarnAboutPermissiveDefault(allowedOrigins, allowAnyOrigin)) { app.Services .GetRequiredService>() .LogWarning(CorsConfigurationValidator.PermissiveDefaultWarning, app.Environment.EnvironmentName); } var migratorLogger = app.Services.GetRequiredService>(); var migrator = new DatabaseMigrator(connectionString, migratorLogger); 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.UseExceptionHandler(); app.UseHttpsRedirection(); app.UseCors("TilesCors"); app.UseAuthentication(); app.UseAuthorization(); app.MapGet("/tiles/{z:int}/{x:int}/{y:int}", ServeTile) .RequireAuthorization() .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) .RequireAuthorization() .WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" }); app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs) .RequireAuthorization() .ProducesProblem(StatusCodes.Status501NotImplemented) .WithOpenApi(op => new(op) { Summary = "Get satellite tiles by MGRS coordinates (NOT IMPLEMENTED)" }); app.MapPost("/api/satellite/upload", UploadUavTileBatch) .RequireAuthorization(SatellitePermissions.UavUploadPolicy) .Accepts("multipart/form-data") .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest) .WithOpenApi(op => new(op) { Summary = "Upload a batch of UAV-captured satellite tiles", Description = "AZ-488 / `uav-tile-upload.md` v1.0.0. Multipart form: a JSON `metadata` field and an aligned `files` collection. Each item is graded by the 5-rule quality gate and persisted with `source='uav'` when accepted. Returns 200 with per-item results (mixed accept/reject), 400 for envelope-level errors (malformed metadata, missing files, oversized batch), 401 without a valid JWT, 403 without the `GPS` permission claim." }) .DisableAntiforgery(); app.MapPost("/api/satellite/request", RequestRegion) .RequireAuthorization() .WithOpenApi(op => new(op) { Summary = "Request tiles for a region", Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing region resource with HTTP 200 and does not enqueue duplicate background processing.", }); app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus) .RequireAuthorization() .WithOpenApi(op => new(op) { Summary = "Get region status and file paths" }); app.MapPost("/api/satellite/route", CreateRoute) .RequireAuthorization() .WithOpenApi(op => new(op) { Summary = "Create a route with intermediate points", Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing route resource with HTTP 200 and does not regenerate intermediate points or re-queue geofence regions.", }); app.MapGet("/api/satellite/route/{id:guid}", GetRoute) .RequireAuthorization() .WithOpenApi(op => new(op) { Summary = "Get route information with calculated points" }); app.Run(); async Task ServeTile(int z, int x, int y, HttpContext httpContext, ITileService tileService) { var tile = await tileService.GetOrDownloadTileAsync(z, x, y, httpContext.RequestAborted); httpContext.Response.Headers.CacheControl = $"public, max-age={(long)tile.MaxAge.TotalSeconds}"; httpContext.Response.Headers.ETag = tile.ETag; return Results.Bytes(tile.Bytes, tile.ContentType); } async Task GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, HttpContext httpContext, ITileService tileService) { var tile = await tileService.DownloadAndStoreSingleTileAsync(Latitude, Longitude, ZoomLevel, httpContext.RequestAborted); var response = new DownloadTileResponse { Id = tile.Id, ZoomLevel = tile.TileZoom, Latitude = tile.Latitude, Longitude = tile.Longitude, TileSizeMeters = tile.TileSizeMeters, TileSizePixels = tile.TileSizePixels, ImageType = tile.ImageType, Version = tile.Version, FilePath = tile.FilePath, CreatedAt = tile.CreatedAt, UpdatedAt = tile.UpdatedAt }; return Results.Ok(response); } IResult GetSatelliteTilesByMgrs(string mgrs, double squareSideMeters) { return Results.Problem( statusCode: StatusCodes.Status501NotImplemented, title: "Not implemented", detail: "MGRS-based tile retrieval is not implemented."); } async Task UploadUavTileBatch( HttpContext httpContext, IUavTileUploadHandler handler, [FromForm] UavTileBatchUploadRequest request) { ArgumentNullException.ThrowIfNull(request); var files = request.Files ?? (IFormFileCollection)new FormFileCollection(); var uploadFiles = new List(files.Count); foreach (var file in files) { await using var stream = file.OpenReadStream(); using var buffer = new MemoryStream(checked((int)file.Length)); await stream.CopyToAsync(buffer, httpContext.RequestAborted); uploadFiles.Add(new UavUploadFile(file.FileName, file.ContentType, buffer.ToArray())); } var result = await handler.HandleAsync(request.Metadata, uploadFiles, httpContext.RequestAborted); if (result.EnvelopeRejected) { return Results.Problem( statusCode: StatusCodes.Status400BadRequest, title: "Invalid UAV tile batch", detail: result.EnvelopeError); } return Results.Ok(result.Response); } async Task RequestRegion([FromBody] RequestRegionRequest request, IRegionService regionService) { 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); } async Task GetRegionStatus(Guid id, IRegionService regionService) { var status = await regionService.GetRegionStatusAsync(id); if (status == null) { return Results.NotFound(new { error = $"Region {id} not found" }); } return Results.Ok(status); } 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 }); } } async Task GetRoute(Guid id, IRouteService routeService) { var route = await routeService.GetRouteAsync(id); if (route == null) { return Results.NotFound(new { error = $"Route {id} not found" }); } return Results.Ok(route); }