using System.Net; using Argus.Contracts; using Argus.Data; using Argus.Domain; using Argus.Infrastructure; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.EntityFrameworkCore; using EntityTagHeaderValue = Microsoft.Net.Http.Headers.EntityTagHeaderValue; var builder = WebApplication.CreateBuilder(args); const int MaxRetainedReleasesPerApp = 1; var configuredUrls = builder.Configuration["ASPNETCORE_URLS"] ?? builder.Configuration["URLS"] ?? "http://0.0.0.0:5105"; builder.WebHost.UseUrls(configuredUrls); builder.Services.AddOpenApi(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]); }); builder.Services.AddDbContextPool(options => options.UseSqlite( builder.Configuration.GetConnectionString("ArgusDb") ?? "Data Source=Data/argus.db")); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; options.KnownProxies.Add(IPAddress.Loopback); options.KnownProxies.Add(IPAddress.IPv6Loopback); }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var app = builder.Build(); app.Services.GetRequiredService().EnsureCreated(); await using (var scope = app.Services.CreateAsyncScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); await SqliteSchemaMigrator.EnsureCurrentSchemaAsync(dbContext); var storageService = scope.ServiceProvider.GetRequiredService(); await PruneOldReleasesAsync(dbContext, storageService, MaxRetainedReleasesPerApp); } if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } app.UseForwardedHeaders(); app.UseResponseCompression(); app.UseDefaultFiles(); app.UseStaticFiles(); app.MapGet("/health", async (ArgusDbContext dbContext, CancellationToken cancellationToken) => { var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); return canConnect ? Results.Ok(new { status = "ok" }) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable); }); var api = app.MapGroup("/api"); api.MapGet("/apps", async (ArgusDbContext dbContext, CancellationToken cancellationToken) => { var apps = await dbContext.Apps .AsNoTracking() .Include(x => x.Releases) .Where(x => x.IsListed) .OrderBy(x => x.Name) .ToListAsync(cancellationToken); return Results.Ok(apps.Select(MapListItem)); }); api.MapGet("/apps/{slug}", async (string slug, ArgusDbContext dbContext, CancellationToken cancellationToken) => { if (!SlugUtility.IsValid(slug)) { return Results.NotFound(); } var catalogApp = await dbContext.Apps .AsNoTracking() .Include(x => x.Releases) .SingleOrDefaultAsync(x => x.Slug == slug && x.IsListed, cancellationToken); return catalogApp is null ? Results.NotFound() : Results.Ok(MapDetailItem(catalogApp)); }); api.MapGet("/apps/{slug}/manifest", async ( string slug, string? platform, string? channel, ArgusDbContext dbContext, CancellationToken cancellationToken) => { if (!SlugUtility.IsValid(slug)) { return Results.NotFound(); } var normalizedPlatform = NormalizeFilter(platform); var normalizedChannel = NormalizeFilter(channel); var catalogApp = await dbContext.Apps .AsNoTracking() .Include(x => x.Releases.Where(release => (normalizedPlatform == null || release.Platform == normalizedPlatform) && (normalizedChannel == null || release.Channel == normalizedChannel))) .SingleOrDefaultAsync(x => x.Slug == slug && x.IsListed, cancellationToken); if (catalogApp is null) { return Results.NotFound(); } var latestRelease = SelectLatestRelease(catalogApp, normalizedPlatform, normalizedChannel); if (latestRelease is null) { return Results.NotFound(); } return Results.Ok(new AppManifestDto( catalogApp.Slug, catalogApp.Name, catalogApp.Summary, catalogApp.Description, catalogApp.RepositoryUrl, catalogApp.HomepageUrl, MapReleaseItem(catalogApp.Slug, latestRelease))); }); api.MapGet("/apps/{slug}/download/latest", async Task ( string slug, string? platform, string? channel, HttpContext httpContext, ArgusDbContext dbContext, PackageStorageService storageService, CancellationToken cancellationToken) => { if (!SlugUtility.IsValid(slug)) { return Results.NotFound(); } var normalizedPlatform = NormalizeFilter(platform); var normalizedChannel = NormalizeFilter(channel); var catalogApp = await dbContext.Apps .AsNoTracking() .Include(x => x.Releases.Where(release => (normalizedPlatform == null || release.Platform == normalizedPlatform) && (normalizedChannel == null || release.Channel == normalizedChannel))) .SingleOrDefaultAsync(x => x.Slug == slug && x.IsListed, cancellationToken); if (catalogApp is null) { return Results.NotFound(); } var latestRelease = SelectLatestRelease(catalogApp, normalizedPlatform, normalizedChannel); if (latestRelease is null) { return Results.NotFound(); } var physicalPath = storageService.ResolvePhysicalPath(latestRelease.StoredRelativePath); if (!File.Exists(physicalPath)) { return Results.NotFound(); } SetPackageCacheHeaders(httpContext, immutable: false); return Results.File( physicalPath, latestRelease.ContentType, latestRelease.OriginalFileName, lastModified: File.GetLastWriteTimeUtc(physicalPath), entityTag: CreatePackageEntityTag(latestRelease.Sha256), enableRangeProcessing: true); }); api.MapGet("/apps/{slug}/releases/{releaseId:guid}/download", async Task ( string slug, Guid releaseId, HttpContext httpContext, ArgusDbContext dbContext, PackageStorageService storageService, CancellationToken cancellationToken) => { if (!SlugUtility.IsValid(slug)) { return Results.NotFound(); } var release = await dbContext.Releases .AsNoTracking() .Where(x => x.Id == releaseId && x.CatalogApp.Slug == slug && x.CatalogApp.IsListed) .Select(x => new { x.StoredRelativePath, x.ContentType, x.OriginalFileName, x.Sha256 }) .SingleOrDefaultAsync( cancellationToken); if (release is null) { return Results.NotFound(); } var physicalPath = storageService.ResolvePhysicalPath(release.StoredRelativePath); if (!File.Exists(physicalPath)) { return Results.NotFound(); } SetPackageCacheHeaders(httpContext, immutable: true); return Results.File( physicalPath, release.ContentType, release.OriginalFileName, lastModified: File.GetLastWriteTimeUtc(physicalPath), entityTag: CreatePackageEntityTag(release.Sha256), enableRangeProcessing: true); }); api.MapGet("/releases/recent", async (ArgusDbContext dbContext, CancellationToken cancellationToken) => { var releases = (await dbContext.Releases .FromSqlRaw( """ SELECT "Releases".* FROM "Releases" INNER JOIN "Apps" ON "Apps"."Id" = "Releases"."CatalogAppId" WHERE "Apps"."IsListed" = 1 ORDER BY "Releases"."PublishedAt" DESC, "Releases"."Id" DESC LIMIT 12 """) .AsNoTracking() .Include(x => x.CatalogApp) .ToListAsync(cancellationToken)) .OrderByDescending(x => x.PublishedAt) .ThenByDescending(x => x.Id) .ToList(); var items = releases.Select(release => new ReleaseFeedItemDto( release.CatalogApp.Slug, release.CatalogApp.Name, MapReleaseItem(release.CatalogApp.Slug, release))); return Results.Ok(items); }); app.Run(); static AppListItemDto MapListItem(CatalogApp catalogApp) { var latestRelease = OrderReleases(catalogApp.Releases) .FirstOrDefault(); return new AppListItemDto( catalogApp.Slug, catalogApp.Name, catalogApp.Summary, catalogApp.RepositoryUrl, catalogApp.HomepageUrl, catalogApp.UpdatedAt, latestRelease is null ? null : MapReleaseItem(catalogApp.Slug, latestRelease), catalogApp.Releases.Count); } static AppDetailDto MapDetailItem(CatalogApp catalogApp) { var releases = OrderReleases(catalogApp.Releases) .Select(release => MapReleaseItem(catalogApp.Slug, release)) .ToArray(); return new AppDetailDto( catalogApp.Slug, catalogApp.Name, catalogApp.Summary, catalogApp.Description, catalogApp.RepositoryUrl, catalogApp.HomepageUrl, catalogApp.CreatedAt, catalogApp.UpdatedAt, catalogApp.IsListed, releases); } static AppReleaseDto MapReleaseItem(string slug, AppRelease release) => new( release.Id, release.Version, release.Channel, release.Platform, release.PackageKind, $"/api/apps/{slug}/releases/{release.Id}/download", release.OriginalFileName, release.ContentType, release.PackageSizeBytes, release.Sha256, release.PublishedAt, release.Notes); static AppRelease? SelectLatestRelease(CatalogApp catalogApp, string? platform, string? channel) { return OrderReleases(catalogApp.Releases .Where(x => platform is null || x.Platform == platform) .Where(x => channel is null || x.Channel == channel)) .FirstOrDefault(); } static IOrderedEnumerable OrderReleases(IEnumerable releases) => releases .OrderByDescending(x => x.Version, Comparer.Create(SemanticVersionUtility.Compare)) .ThenByDescending(x => x.PublishedAt) .ThenByDescending(x => x.Id); static void DeleteStoredPackages(PackageStorageService storageService, IEnumerable releases) { foreach (var release in releases) { storageService.TryDelete(release.StoredRelativePath); } } static async Task PruneOldReleasesAsync( ArgusDbContext dbContext, PackageStorageService storageService, int maxRetainedReleasesPerApp, CancellationToken cancellationToken = default, Guid? catalogAppId = null) { if (maxRetainedReleasesPerApp < 1) { throw new ArgumentOutOfRangeException(nameof(maxRetainedReleasesPerApp)); } IQueryable releasesQuery = dbContext.Releases; if (catalogAppId is { } appId) { releasesQuery = releasesQuery.Where(x => x.CatalogAppId == appId); } var releases = await releasesQuery.ToListAsync(cancellationToken); var releasesToPrune = releases .GroupBy(x => x.CatalogAppId) .SelectMany(group => group .OrderByDescending(x => x.PublishedAt) .ThenByDescending(x => x.Id) .Skip(maxRetainedReleasesPerApp)) .ToArray(); if (releasesToPrune.Length == 0) { return; } dbContext.Releases.RemoveRange(releasesToPrune); await dbContext.SaveChangesAsync(cancellationToken); DeleteStoredPackages(storageService, releasesToPrune); } static void SetPackageCacheHeaders(HttpContext httpContext, bool immutable) { httpContext.Response.Headers.CacheControl = immutable ? "public,max-age=31536000,immutable,no-transform" : "private,no-cache,no-transform"; } static EntityTagHeaderValue CreatePackageEntityTag(string sha256) => new($"\"{sha256}\""); static string? NormalizeFilter(string? value) => string.IsNullOrWhiteSpace(value) ? null : IdentifierUtility.Normalize(value, string.Empty);