391 lines
12 KiB
C#
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);
|