Initial Argus web catalog
This commit is contained in:
+390
@@ -0,0 +1,390 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user