Files
Argus/Program.cs
T

490 lines
15 KiB
C#

using System.Data;
using System.Globalization;
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<string> storedRelativePaths)
{
foreach (var storedRelativePath in storedRelativePaths)
{
storageService.TryDelete(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));
}
var releases = await LoadReleasePruneCandidatesAsync(dbContext, catalogAppId, cancellationToken);
var releasesToPrune = releases
.GroupBy(x => x.CatalogAppId, StringComparer.OrdinalIgnoreCase)
.SelectMany(group => group
.OrderByDescending(x => x.PublishedAt)
.ThenByDescending(x => x.Id)
.Skip(maxRetainedReleasesPerApp))
.ToArray();
if (releasesToPrune.Length == 0)
{
return;
}
await DeleteReleaseRowsAsync(dbContext, releasesToPrune.Select(x => x.RowId), cancellationToken);
DeleteStoredPackages(storageService, releasesToPrune.Select(x => x.StoredRelativePath));
}
static async Task<IReadOnlyList<ReleasePruneCandidate>> LoadReleasePruneCandidatesAsync(
ArgusDbContext dbContext,
Guid? catalogAppId,
CancellationToken cancellationToken)
{
var connection = dbContext.Database.GetDbConnection();
var mustClose = connection.State != ConnectionState.Open;
if (mustClose)
{
await connection.OpenAsync(cancellationToken);
}
try
{
await using var command = connection.CreateCommand();
command.CommandText =
"""
SELECT rowid, "CatalogAppId", "StoredRelativePath", "PublishedAt", "Id"
FROM "Releases"
WHERE $catalogAppId IS NULL OR lower("CatalogAppId") = lower($catalogAppId)
""";
var catalogAppIdParameter = command.CreateParameter();
catalogAppIdParameter.ParameterName = "$catalogAppId";
catalogAppIdParameter.Value = catalogAppId?.ToString("D") ?? (object)DBNull.Value;
command.Parameters.Add(catalogAppIdParameter);
var candidates = new List<ReleasePruneCandidate>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
candidates.Add(new ReleasePruneCandidate(
reader.GetInt64(0),
reader.GetString(1),
reader.GetString(2),
DateTimeOffset.Parse(reader.GetString(3), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
Guid.Parse(reader.GetString(4))));
}
return candidates;
}
finally
{
if (mustClose)
{
await connection.CloseAsync();
}
}
}
static async Task DeleteReleaseRowsAsync(
ArgusDbContext dbContext,
IEnumerable<long> rowIds,
CancellationToken cancellationToken)
{
var connection = dbContext.Database.GetDbConnection();
var mustClose = connection.State != ConnectionState.Open;
if (mustClose)
{
await connection.OpenAsync(cancellationToken);
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """DELETE FROM "Releases" WHERE rowid = $rowid""";
var rowIdParameter = command.CreateParameter();
rowIdParameter.ParameterName = "$rowid";
command.Parameters.Add(rowIdParameter);
foreach (var rowId in rowIds)
{
rowIdParameter.Value = rowId;
await command.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
finally
{
if (mustClose)
{
await connection.CloseAsync();
}
}
}
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);
sealed record ReleasePruneCandidate(
long RowId,
string CatalogAppId,
string StoredRelativePath,
DateTimeOffset PublishedAt,
Guid Id);