Files
Курнат Андрей 5f80c4bf6a Initial Argus web catalog
2026-06-20 19:55:11 +03:00

391 lines
12 KiB
C#

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<ArgusDbContext>(options =>
options.UseSqlite(
builder.Configuration.GetConnectionString("ArgusDb") ??
"Data Source=Data/argus.db"));
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
options.KnownProxies.Add(IPAddress.Loopback);
options.KnownProxies.Add(IPAddress.IPv6Loopback);
});
builder.Services.AddSingleton<StoragePaths>();
builder.Services.AddSingleton<PackageStorageService>();
var app = builder.Build();
app.Services.GetRequiredService<StoragePaths>().EnsureCreated();
await using (var scope = app.Services.CreateAsyncScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ArgusDbContext>();
await SqliteSchemaMigrator.EnsureCurrentSchemaAsync(dbContext);
var storageService = scope.ServiceProvider.GetRequiredService<PackageStorageService>();
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<IResult> (
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<IResult> (
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<AppRelease> OrderReleases(IEnumerable<AppRelease> releases) =>
releases
.OrderByDescending(x => x.Version, Comparer<string>.Create(SemanticVersionUtility.Compare))
.ThenByDescending(x => x.PublishedAt)
.ThenByDescending(x => x.Id);
static void DeleteStoredPackages(PackageStorageService storageService, IEnumerable<AppRelease> 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<AppRelease> 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);