diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index ed420d6..e3c8ce4 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -24,8 +24,8 @@ jobs: fail-fast: false matrix: include: - - name: desktop-repository-server-tunit - project: DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj + - name: repository-server-tunit + project: RepositoryServer.Tests/RepositoryServer.Tests.csproj steps: - name: Checkout @@ -47,8 +47,8 @@ jobs: fail-fast: false matrix: include: - - dockerfile: docker/DesktopRepositoryServer.Dockerfile - image: desktop-repository-server + - dockerfile: docker/RepositoryServer.Dockerfile + image: repository-server steps: - name: Checkout @@ -82,7 +82,7 @@ jobs: fail-fast: false matrix: include: - - image: desktop-repository-server + - image: repository-server steps: - name: Checkout @@ -112,7 +112,7 @@ jobs: uses: OpenShock/actions/repository-dispatch@21ca2d511bca92cf24fb502fa302f82700822584 # v1.1.1 with: repo: openshock/kubernetes-cluster-gitops - event-type: update-desktop-repository-server-prod + event-type: update-repository-server-prod client-payload: | {"tag": "${{ inputs.tag-prefix || 'rn' }}-${{ github.run_number }}-a${{ github.run_attempt }}"} token: ${{ secrets.GITOPS_PAT }} \ No newline at end of file diff --git a/.github/workflows/ci-tag.yml b/.github/workflows/ci-tag.yml index b9f7de6..98e6463 100644 --- a/.github/workflows/ci-tag.yml +++ b/.github/workflows/ci-tag.yml @@ -8,7 +8,7 @@ name: ci-tag env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository_owner }}/api + IMAGE_NAME: ${{ github.repository_owner }}/repository-server jobs: @@ -40,7 +40,7 @@ jobs: id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: - images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/desktop-repository-server + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/repository-server flavor: | latest=false tags: | @@ -53,7 +53,7 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - file: Dockerfile + file: docker/RepositoryServer.Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 7b145da..f12a155 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .idea obj/ bin/ -*.user \ No newline at end of file +*.user +.claude/settings.local.json \ No newline at end of file diff --git a/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj b/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj deleted file mode 100644 index 6b40f31..0000000 --- a/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - net10.0 - enable - enable - false - true - 13 - OpenShock.Desktop.RepositoryServer.Tests - OpenShock.Desktop.RepositoryServer.Tests - OpenShock.Desktop.RepositoryServer.Tests - OpenShock.Desktop.RepositoryServer.Tests - - - - - \ No newline at end of file diff --git a/DesktopRepositoryServer.Tests/UnitTest1.cs b/DesktopRepositoryServer.Tests/UnitTest1.cs deleted file mode 100644 index 0361efd..0000000 --- a/DesktopRepositoryServer.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace OpenShock.Desktop.RepositoryServer.Tests; - -public class UnitTest1 -{ - [Test] - public async Task Test() - { - await Assert.That(true).IsTrue(); - } -} \ No newline at end of file diff --git a/DesktopRepositoryServer/AuthSchemas.cs b/DesktopRepositoryServer/AuthSchemas.cs deleted file mode 100644 index 52da677..0000000 --- a/DesktopRepositoryServer/AuthSchemas.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace OpenShock.Desktop.RepositoryServer; - -public static class AuthSchemas -{ - public const string AdminToken = "AdminToken"; -} \ No newline at end of file diff --git a/DesktopRepositoryServer/Config/ApiConfig.cs b/DesktopRepositoryServer/Config/ApiConfig.cs deleted file mode 100644 index b778351..0000000 --- a/DesktopRepositoryServer/Config/ApiConfig.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace OpenShock.Desktop.RepositoryServer.Config; - -public class ApiConfig -{ - [Required] public required DbConfig Db { get; init; } - [Required] public required string AdminToken { get; init; } - [Required] public required RepoConfig Repo { get; init; } - public MetricsConfig Metrics { get; init; } = new(); -} \ No newline at end of file diff --git a/DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs b/DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs deleted file mode 100644 index 000bd3e..0000000 --- a/DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs +++ /dev/null @@ -1,118 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using OpenShock.Desktop.RepositoryServer.RepoServerDb; - -#nullable disable - -namespace OpenShock.Desktop.RepositoryServer.Migrations -{ - [DbContext(typeof(MigrationOpenShockContext))] - partial class MigrationOpenShockContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", b => - { - b.Property("Id") - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("id"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text") - .HasColumnName("description"); - - b.Property("IconUrl") - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("icon_url"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("name"); - - b.Property("SourceUrl") - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("source_url"); - - b.HasKey("Id") - .HasName("modules_pkey"); - - b.ToTable("modules", (string)null); - }); - - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b => - { - b.Property("VersionName") - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("version"); - - b.Property("Module") - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("module"); - - b.Property("ChangelogUrl") - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("changelog_url"); - - b.Property("HashSha256") - .IsRequired() - .HasColumnType("bytea") - .HasColumnName("hash_sha256"); - - b.Property("ReleaseUrl") - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("release_url"); - - b.Property("ZipUrl") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("zip_url"); - - b.HasKey("VersionName", "Module") - .HasName("versions_pkey"); - - b.HasIndex("Module"); - - b.ToTable("versions", (string)null); - }); - - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b => - { - b.HasOne("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", "ModuleNavigation") - .WithMany("Versions") - .HasForeignKey("Module") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_versions_module"); - - b.Navigation("ModuleNavigation"); - }); - - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", b => - { - b.Navigation("Versions"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/DesktopRepositoryServer/Models/CreateModuleRequest.cs b/DesktopRepositoryServer/Models/CreateModuleRequest.cs deleted file mode 100644 index 3ad6327..0000000 --- a/DesktopRepositoryServer/Models/CreateModuleRequest.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OpenShock.Desktop.RepositoryServer.Models; - -public class CreateModuleRequest -{ - public required string Name { get; init; } - public required string Description { get; init; } - public Uri? SourceUrl { get; init; } = null; - public Uri? IconUrl { get; init; } = null; -} \ No newline at end of file diff --git a/DesktopRepositoryServer/Problems/ModuleError.cs b/DesktopRepositoryServer/Problems/ModuleError.cs deleted file mode 100644 index 9f9496d..0000000 --- a/DesktopRepositoryServer/Problems/ModuleError.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Net; - -namespace OpenShock.Desktop.RepositoryServer.Problems; - -public static class ModuleError -{ - public static OpenShockProblem ModuleNotFound => new OpenShockProblem("Module.NotFound", "The referenced module was not found", HttpStatusCode.NotFound); -} \ No newline at end of file diff --git a/DesktopRepositoryServer/RepoServerDb/Module.cs b/DesktopRepositoryServer/RepoServerDb/Module.cs deleted file mode 100644 index 78e0925..0000000 --- a/DesktopRepositoryServer/RepoServerDb/Module.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace OpenShock.Desktop.RepositoryServer.RepoServerDb; - -public partial class Module -{ - public string Id { get; set; } = null!; - - public string Name { get; set; } = null!; - - public string Description { get; set; } = null!; - - public Uri? SourceUrl { get; set; } = null!; - - public Uri? IconUrl { get; set; } - - public virtual ICollection Versions { get; set; } = new List(); -} diff --git a/DesktopRepositoryServer/RepoServerDb/RepoServerContext.cs b/DesktopRepositoryServer/RepoServerDb/RepoServerContext.cs deleted file mode 100644 index de70d09..0000000 --- a/DesktopRepositoryServer/RepoServerDb/RepoServerContext.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System; -using System.Collections.Generic; -using EntityFramework.Exceptions.PostgreSQL; -using Microsoft.EntityFrameworkCore; - -namespace OpenShock.Desktop.RepositoryServer.RepoServerDb; - -/// -/// This is meant for use in migrations only. -/// -public sealed class MigrationOpenShockContext : RepoServerContext -{ - private readonly string? _connectionString = null; - private readonly bool _debug; - private readonly bool _migrationTool; - private readonly ILoggerFactory? _loggerFactory = null; - - public MigrationOpenShockContext() - { - _migrationTool = true; - } - - public MigrationOpenShockContext(string connectionString, bool debug, ILoggerFactory loggerFactory) - { - _connectionString = connectionString; - _debug = debug; - _loggerFactory = loggerFactory; - } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - if (_migrationTool) - { - ConfigureOptionsBuilder(optionsBuilder, "Host=localhost;Database=desktop-repo-server;Username=openshock;Password=openshock", true); - return; - } - if(string.IsNullOrWhiteSpace(_connectionString)) - throw new InvalidOperationException("Connection string is not set."); - ConfigureOptionsBuilder(optionsBuilder, _connectionString, _debug); - - if (_loggerFactory != null) - optionsBuilder.UseLoggerFactory(_loggerFactory); - } -} - -public partial class RepoServerContext : DbContext -{ - public RepoServerContext() - { - } - - public RepoServerContext(DbContextOptions options) - : base(options) - { - } - - public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilder, string connectionString, - bool debug) - { - optionsBuilder.UseNpgsql(connectionString, npgsqlBuilder => - { - // Map Enums to their string values - }); - - optionsBuilder.UseExceptionProcessor(); - - if (debug) - { - optionsBuilder.EnableSensitiveDataLogging(); - optionsBuilder.EnableDetailedErrors(); - } - } - - public virtual DbSet Modules { get; set; } - - public virtual DbSet Versions { get; set; } - - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id).HasName("modules_pkey"); - - entity.ToTable("modules"); - - entity.Property(e => e.Id) - .HasMaxLength(128) - .HasColumnName("id"); - entity.Property(e => e.Description).HasColumnName("description"); - entity.Property(e => e.IconUrl) - .HasMaxLength(256) - .HasColumnName("icon_url"); - entity.Property(e => e.Name) - .HasMaxLength(128) - .HasColumnName("name"); - entity.Property(e => e.SourceUrl) - .HasMaxLength(256) - .HasColumnName("source_url"); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(e => new { Version1 = e.VersionName, e.Module }).HasName("versions_pkey"); - - entity.ToTable("versions"); - - entity.Property(e => e.VersionName) - .HasMaxLength(64) - .HasColumnName("version"); - entity.Property(e => e.Module) - .HasMaxLength(128) - .HasColumnName("module"); - entity.Property(e => e.ChangelogUrl) - .HasMaxLength(256) - .HasColumnName("changelog_url"); - entity.Property(e => e.HashSha256).HasColumnName("hash_sha256"); - entity.Property(e => e.ReleaseUrl) - .HasMaxLength(256) - .HasColumnName("release_url"); - entity.Property(e => e.ZipUrl) - .HasMaxLength(256) - .HasColumnName("zip_url"); - - entity.HasOne(d => d.ModuleNavigation).WithMany(p => p.Versions) - .HasForeignKey(d => d.Module) - .HasConstraintName("fk_versions_module"); - }); - } -} diff --git a/Directory.Packages.props b/Directory.Packages.props index d703c45..b0854b8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,6 +2,8 @@ true + + true net10.0 enable enable @@ -12,15 +14,21 @@ + + + + + + @@ -35,5 +43,7 @@ + + \ No newline at end of file diff --git a/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs b/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs new file mode 100644 index 0000000..e696f6e --- /dev/null +++ b/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs @@ -0,0 +1,39 @@ +using Testcontainers.PostgreSql; +using TUnit.Core.Interfaces; + +namespace OpenShock.RepositoryServer.Tests.Integration.Docker; + +/// +/// Per-test-session Postgres container. Mirrors the InMemoryDatabase pattern from +/// the OpenShock API integration tests. Lazy-starts on first access, disposed at end of +/// test session. +/// +public sealed class InMemoryDatabase : IAsyncInitializer, IAsyncDisposable +{ + private PostgreSqlContainer? _container; + + public PostgreSqlContainer Container + { + get + { + _container ??= new PostgreSqlBuilder("postgres:17-alpine") + .WithName($"repo-server-tests-pg-{Guid.NewGuid():N}") + .WithDatabase("repo_server_tests") + .WithUsername("repo_server") + .WithPassword("repo_server_test_password") + .Build(); + + return _container; + } + } + + public Task InitializeAsync() => Container.StartAsync(); + + public async ValueTask DisposeAsync() + { + if (_container is not null) + { + await _container.DisposeAsync(); + } + } +} diff --git a/RepositoryServer.Tests/Integration/TestAdminToken.cs b/RepositoryServer.Tests/Integration/TestAdminToken.cs new file mode 100644 index 0000000..8c2777c --- /dev/null +++ b/RepositoryServer.Tests/Integration/TestAdminToken.cs @@ -0,0 +1,10 @@ +namespace OpenShock.RepositoryServer.Tests.Integration; + +/// +/// Shared constants for the integration test harness. +/// +public static class TestAdminToken +{ + public const string Value = "test-admin-token"; + public const string HeaderName = "Authorization"; +} diff --git a/RepositoryServer.Tests/Integration/TestCiCdAuthHandler.cs b/RepositoryServer.Tests/Integration/TestCiCdAuthHandler.cs new file mode 100644 index 0000000..af36d7c --- /dev/null +++ b/RepositoryServer.Tests/Integration/TestCiCdAuthHandler.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace OpenShock.RepositoryServer.Tests.Integration; + +/// +/// Stands in for the GitHub OIDC handler on the scheme. +/// +/// +/// The real handler validates a GitHub-signed JWT against live JWKS, which a test cannot mint. It +/// then resolves the token's repository against the repositories allowlist and attaches +/// . This handler substitutes only the token-validation half: +/// tests declare which registered repository is calling, and everything downstream — the ownership +/// checks, the state machine — runs for real against the same claims the production handler emits. +/// +/// What this deliberately does not cover is the allowlist lookup itself. That path is +/// verified by construction instead: there is no code anywhere that inserts a repository row outside +/// the admin endpoint. +/// +public sealed class TestCiCdAuthHandler : AuthenticationHandler +{ + public const string RepositoryIdHeader = "X-Test-Repository-Id"; + public const string CommitHashHeader = "X-Test-Commit-Hash"; + public const string RefHeader = "X-Test-Ref"; + public const string RunIdHeader = "X-Test-Run-Id"; + + /// + /// Comma-separated scopes. Defaults to both, so tests opt in to restricting a grant. Use + /// to model a repository that is registered but granted nothing — an + /// empty header value cannot express that, because HttpClient drops empty headers. + /// + public const string ScopesHeader = "X-Test-Scopes"; + + /// Sentinel meaning "registered, but no scopes granted". + public const string NoScopes = "none"; + + public TestCiCdAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(RepositoryIdHeader, out var rawRepositoryId)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (!Guid.TryParse(rawRepositoryId.ToString(), out var repositoryId)) + { + return Task.FromResult(AuthenticateResult.Fail("Malformed test repository id.")); + } + + var commitHash = Request.Headers.TryGetValue(CommitHashHeader, out var rawCommit) + ? rawCommit.ToString() + : "abc1234567890abcdef1234567890abcdef12345"; + + var claims = new List + { + new(AuthSchemas.CiCdClaims.RepositoryId, repositoryId.ToString()), + new(AuthSchemas.CiCdClaims.CommitHash, commitHash) + }; + + var rawScopes = Request.Headers.TryGetValue(ScopesHeader, out var scopeHeader) + ? scopeHeader.ToString() + : "publish_firmware,publish_modules"; + if (!string.Equals(rawScopes, NoScopes, StringComparison.Ordinal)) + { + foreach (var scope in rawScopes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + claims.Add(new Claim(AuthSchemas.CiCdClaims.Scope, scope)); + } + } + + if (Request.Headers.TryGetValue(RefHeader, out var rawRef) && !string.IsNullOrWhiteSpace(rawRef)) + { + claims.Add(new Claim(AuthSchemas.CiCdClaims.Ref, rawRef.ToString())); + } + + if (Request.Headers.TryGetValue(RunIdHeader, out var rawRunId) && !string.IsNullOrWhiteSpace(rawRunId)) + { + claims.Add(new Claim(AuthSchemas.CiCdClaims.RunId, rawRunId.ToString())); + } + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name))); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs new file mode 100644 index 0000000..6ca56b5 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs @@ -0,0 +1,198 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class AdvisoriesAdminControllerTests +{ + private const string BasePath = "/2/firmware/admin/advisories"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task Post_ValidRequest_Returns201WithGeneratedId() + { + using var client = Factory.CreateAdminClient(); + + var response = await client.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest + { + Severity = "critical", + Title = "OTA bricking bug", + Content = "Versions before 1.4.0 have a bug.", + AffectedVersions = "<1.4.0", + Url = "https://example.invalid/issues/123" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body).IsNotNull(); + await Assert.That(body!.Id).IsNotEqualTo(Guid.Empty); + await Assert.That(body.Severity).IsEqualTo("critical"); + await Assert.That(body.Title).IsEqualTo("OTA bricking bug"); + } + + [Test] + public async Task Post_InvalidSeverity_Returns400() + { + using var client = Factory.CreateAdminClient(); + + var response = await client.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest + { + Severity = "catastrophic", + Title = "Nope", + Content = "Nope", + AffectedVersions = "<1.0.0" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Post_WithoutAuth_Returns401() + { + using var client = Factory.CreateClient(); + + var response = await client.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest + { + Severity = "info", + Title = "Whatever", + Content = "Whatever", + AffectedVersions = ">=1.0.0" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task Get_ReturnsAllAdvisoriesOrderedBySeverity() + { + await SeedAdvisoryAsync(AdvisorySeverity: "info", "Minor issue"); + await SeedAdvisoryAsync(AdvisorySeverity: "critical", "Bricking bug"); + await SeedAdvisoryAsync(AdvisorySeverity: "warning", "Gotcha"); + + using var client = Factory.CreateAdminClient(); + var response = await client.GetAsync(BasePath); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync>(); + await Assert.That(body).IsNotNull(); + await Assert.That(body!).Count().IsEqualTo(3); + + // Ordered by severity rank: Critical (0), Warning (1), Info (2). + await Assert.That(body[0].Severity).IsEqualTo("critical"); + await Assert.That(body[1].Severity).IsEqualTo("warning"); + await Assert.That(body[2].Severity).IsEqualTo("info"); + } + + [Test] + public async Task Put_ExistingAdvisory_UpdatesFields() + { + var id = await SeedAdvisoryAsync(AdvisorySeverity: "info", "Original title"); + + using var client = Factory.CreateAdminClient(); + var response = await client.PutAsJsonAsync($"{BasePath}/{id}", new UpsertFirmwareAdvisoryRequest + { + Severity = "warning", + Title = "Updated title", + Content = "Updated content", + AffectedVersions = ">=1.0.0 <2.0.0", + Url = "https://example.invalid/new" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var updated = await response.Content.ReadFromJsonAsync(); + await Assert.That(updated).IsNotNull(); + await Assert.That(updated!.Id).IsEqualTo(id); + await Assert.That(updated.Severity).IsEqualTo("warning"); + await Assert.That(updated.Title).IsEqualTo("Updated title"); + } + + [Test] + public async Task Put_UnknownId_Returns404() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PutAsJsonAsync($"{BasePath}/{Guid.NewGuid()}", + new UpsertFirmwareAdvisoryRequest + { + Severity = "info", + Title = "Nothing to update", + Content = "-", + AffectedVersions = ">=0.0.0" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Delete_ExistingAdvisory_Returns204() + { + var id = await SeedAdvisoryAsync(AdvisorySeverity: "warning", "Will be deleted"); + + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{id}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + // Confirm gone via GET + var list = await client.GetFromJsonAsync>(BasePath); + await Assert.That(list).IsNotNull(); + await Assert.That(list!.Any(a => a.Id == id)).IsFalse(); + } + + [Test] + public async Task Delete_UnknownId_Returns404() + { + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Post_Advisory_AppearsOnPublicManifest() + { + using var adminClient = Factory.CreateAdminClient(); + var createResponse = await adminClient.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest + { + Severity = "info", + Title = "FYI", + Content = "Something to know", + AffectedVersions = ">=1.0.0" + }); + await Assert.That(createResponse.StatusCode).IsEqualTo(HttpStatusCode.Created); + + using var publicClient = Factory.CreateClient(); + var manifest = await publicClient.GetFromJsonAsync("/2/firmware/manifest"); + var advisories = manifest.GetProperty("advisories").EnumerateArray().ToList(); + + await Assert.That(advisories).Count().IsEqualTo(1); + await Assert.That(advisories[0].GetProperty("title").GetString()).IsEqualTo("FYI"); + // Manifest DTO does not expose id — check the fields that are public. + await Assert.That(advisories[0].TryGetProperty("id", out _)).IsFalse(); + } + + private async Task SeedAdvisoryAsync(string AdvisorySeverity, string title) + { + using var client = Factory.CreateAdminClient(); + var response = await client.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest + { + Severity = AdvisorySeverity, + Title = title, + Content = "seed", + AffectedVersions = ">=0.0.0" + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body!.Id; + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/BoardsAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/BoardsAdminControllerTests.cs new file mode 100644 index 0000000..58114f5 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/BoardsAdminControllerTests.cs @@ -0,0 +1,270 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class BoardsAdminControllerTests +{ + private const string BasePath = "/2/firmware/admin/boards"; + private const string ChipsPath = "/2/firmware/admin/chips"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task Post_WithValidChip_Returns201() + { + var chipId = await SeedChipAsync("ESP32-S3"); + using var client = Factory.CreateAdminClient(); + + var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest + { + Name = "OpenShock-Core-V1", + ChipId = chipId, + RequiredArtifactTypes = ["merged"] + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("id").GetGuid()).IsNotEqualTo(Guid.Empty); + } + + [Test] + public async Task Post_UnknownChip_Returns404() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest + { + Name = "Phantom-Board", + ChipId = Guid.NewGuid(), + RequiredArtifactTypes = ["merged"] + }); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + [Arguments("OpenShock Core V1")] // spaces + [Arguments("core/../etc")] // path separator — would reshape the storage key + [Arguments("-leading-dash")] // must start alphanumeric + [Arguments("emoji-\U0001F600")] + public async Task Post_NonUrlSafeName_Returns400(string name) + { + var chipId = await SeedChipAsync("ESP32"); + using var client = Factory.CreateAdminClient(); + + var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest + { + Name = name, + ChipId = chipId, + RequiredArtifactTypes = ["merged"] + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Post_NameDifferingOnlyByCase_IsRejected() + { + var chipId = await SeedChipAsync("ESP32"); + using var client = Factory.CreateAdminClient(); + + var first = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest + { + Name = "ESP32-Core", + ChipId = chipId, + RequiredArtifactTypes = ["merged"] + }); + await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created); + + // Two boards differing only in case would both resolve to whichever the lookup picked, so the + // loser could never receive an upload and a hub compiled with its spelling would be served the + // other board's firmware. The unique index on lower(name) makes that unrepresentable. + var second = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest + { + Name = "esp32-core", + ChipId = chipId, + RequiredArtifactTypes = ["merged"] + }); + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Post_InvalidArtifactType_Returns400() + { + var chipId = await SeedChipAsync("ESP32"); + using var client = Factory.CreateAdminClient(); + var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest + { + Name = "bad-board", + ChipId = chipId, + RequiredArtifactTypes = ["unknown_type"] + }); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Patch_Discontinue_FlipsFlag() + { + var chipId = await SeedChipAsync("ESP32"); + var boardId = await SeedBoardAsync(chipId, "Pishock-Lite-2021"); + + using var client = Factory.CreateAdminClient(); + var response = await client.PatchAsync($"{BasePath}/{boardId}/discontinue", content: null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var board = await db.FirmwareBoards.FindAsync(boardId); + await Assert.That(board!.Discontinued).IsTrue(); + } + + [Test] + public async Task Delete_Unused_Returns204() + { + var chipId = await SeedChipAsync("ESP32-C3"); + var boardId = await SeedBoardAsync(chipId, "Generic-C3"); + + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{boardId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + [Test] + public async Task Delete_ReferencedByArtifact_Returns409InUse() + { + var chipId = await SeedChipAsync("ESP32"); + var boardId = await SeedBoardAsync(chipId, "Wemos-D1-Mini-ESP32"); + + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + // Set up: a repository, a firmware version, and an artifact referencing the board. + var repo = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = "openshock", + Repo = "firmware" + }; + var version = new FirmwareVersion + { + Version = "1.5.1", + Channel = ReleaseChannel.Stable, + ReleaseDate = DateTimeOffset.UtcNow, + RepositoryId = repo.Id, + CommitHash = "abc1234567890abcdef1234567890abcdef12345" + }; + db.Repositories.Add(repo); + db.FirmwareVersions.Add(version); + db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = version.Version, + BoardId = boardId, + ArtifactType = FirmwareArtifactType.Merged, + HashSha256 = new byte[32], + FileSize = 1024 + }); + await db.SaveChangesAsync(); + } + + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{boardId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Put_UpdateBoard_ChangesName() + { + var chipId = await SeedChipAsync("ESP32"); + var boardId = await SeedBoardAsync(chipId, "Original-Name"); + + using var client = Factory.CreateAdminClient(); + var response = await client.PutAsJsonAsync($"{BasePath}/{boardId}", new CreateFirmwareBoardRequest + { + Name = "New-Name", + ChipId = chipId, + RequiredArtifactTypes = ["merged", "app"] + }); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var board = await db.FirmwareBoards.FindAsync(boardId); + await Assert.That(board!.Name).IsEqualTo("New-Name"); + await Assert.That(board.RequiredArtifactTypes.Length).IsEqualTo(2); + } + + [Test] + public async Task AttachUsbDevice_Idempotent_WritesSingleRow() + { + var chipId = await SeedChipAsync("ESP32"); + var boardId = await SeedBoardAsync(chipId, "Sample-Board"); + + Guid deviceId; + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var device = new UsbDevice { Id = Guid.NewGuid(), Vid = 0x1A86, Pid = 0x7522, Name = "CH9102" }; + db.UsbDevices.Add(device); + await db.SaveChangesAsync(); + deviceId = device.Id; + } + + using var client = Factory.CreateAdminClient(); + var first = await client.PutAsync($"{BasePath}/{boardId}/usb-devices/{deviceId}", content: null); + var second = await client.PutAsync($"{BasePath}/{boardId}/usb-devices/{deviceId}", content: null); + + await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + await using var verify = Factory.Services.CreateAsyncScope(); + var db2 = verify.ServiceProvider.GetRequiredService(); + var count = db2.FirmwareBoardUsbDevices.Count(j => j.BoardId == boardId && j.UsbDeviceId == deviceId); + await Assert.That(count).IsEqualTo(1); + } + + [Test] + public async Task DetachUsbDevice_IgnoresMissingLink() + { + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync( + $"{BasePath}/{Guid.NewGuid()}/usb-devices/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + private async Task SeedChipAsync(string name) + { + using var client = Factory.CreateAdminClient(); + var response = await client.PostAsJsonAsync(ChipsPath, new CreateFirmwareChipRequest + { + Name = name + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("id").GetGuid(); + } + + private async Task SeedBoardAsync(Guid chipId, string name) + { + using var client = Factory.CreateAdminClient(); + var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest + { + Name = name, + ChipId = chipId, + RequiredArtifactTypes = [] + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("id").GetGuid(); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/ChipsAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/ChipsAdminControllerTests.cs new file mode 100644 index 0000000..4836480 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/ChipsAdminControllerTests.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class ChipsAdminControllerTests +{ + private const string BasePath = "/2/firmware/admin/chips"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task Post_ValidRequest_Returns201WithId() + { + using var client = Factory.CreateAdminClient(); + + var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareChipRequest + { + Name = "ESP32-S3", + Architecture = "xtensa" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(); + var id = body.GetProperty("id").GetGuid(); + await Assert.That(id).IsNotEqualTo(Guid.Empty); + } + + [Test] + public async Task Post_InvalidArchitecture_Returns400() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareChipRequest + { + Name = "ESP42", + Architecture = "quantum" + }); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Put_ExistingChip_UpdatesName() + { + var id = await SeedChipAsync(name: "ESP32-S3", arch: FirmwareChipArchitecture.Xtensa); + using var client = Factory.CreateAdminClient(); + + var response = await client.PutAsJsonAsync($"{BasePath}/{id}", new CreateFirmwareChipRequest + { + Name = "ESP32-S3 (renamed)", + Architecture = "xtensa" + }); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var chip = await db.FirmwareChips.FindAsync(id); + await Assert.That(chip!.Name).IsEqualTo("ESP32-S3 (renamed)"); + } + + [Test] + public async Task Put_UnknownId_Returns404() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PutAsJsonAsync($"{BasePath}/{Guid.NewGuid()}", + new CreateFirmwareChipRequest { Name = "ESP-any" }); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Delete_Unused_Returns204() + { + var id = await SeedChipAsync(name: "ESP32-C6", arch: FirmwareChipArchitecture.RiscV); + using var client = Factory.CreateAdminClient(); + + var response = await client.DeleteAsync($"{BasePath}/{id}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + [Test] + public async Task Delete_ReferencedByBoard_Returns409InUse() + { + var chipId = await SeedChipAsync(name: "ESP32", arch: FirmwareChipArchitecture.Xtensa); + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.FirmwareBoards.Add(new FirmwareBoard + { + Id = Guid.NewGuid(), + Name = "Wemos-D1-Mini-ESP32", + ChipId = chipId, + RequiredArtifactTypes = [] + }); + await db.SaveChangesAsync(); + } + + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{chipId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task AttachUsbDevice_Idempotent() + { + var chipId = await SeedChipAsync(name: "ESP32-S3", arch: FirmwareChipArchitecture.Xtensa); + Guid deviceId; + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var device = new UsbDevice { Id = Guid.NewGuid(), Vid = 0x303A, Pid = 0x1001, Name = "ESP32-S3 USB-JTAG" }; + db.UsbDevices.Add(device); + await db.SaveChangesAsync(); + deviceId = device.Id; + } + + using var client = Factory.CreateAdminClient(); + var first = await client.PutAsync($"{BasePath}/{chipId}/usb-devices/{deviceId}", content: null); + var second = await client.PutAsync($"{BasePath}/{chipId}/usb-devices/{deviceId}", content: null); + + await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + await using var verify = Factory.Services.CreateAsyncScope(); + var db2 = verify.ServiceProvider.GetRequiredService(); + var count = db2.FirmwareChipUsbDevices.Count(j => j.ChipId == chipId && j.UsbDeviceId == deviceId); + await Assert.That(count).IsEqualTo(1); + } + + [Test] + public async Task AttachUsbDevice_UnknownChip_Returns404() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PutAsync( + $"{BasePath}/{Guid.NewGuid()}/usb-devices/{Guid.NewGuid()}", content: null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + private async Task SeedChipAsync(string name, FirmwareChipArchitecture arch) + { + using var client = Factory.CreateAdminClient(); + var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareChipRequest + { + Name = name, + Architecture = arch.ToString().ToLowerInvariant() + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("id").GetGuid(); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/DesktopCiCdControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/DesktopCiCdControllerTests.cs new file mode 100644 index 0000000..82b5d99 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/DesktopCiCdControllerTests.cs @@ -0,0 +1,147 @@ +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +/// +/// Desktop module publishing shares the CI/CD principal with firmware ingestion, so it needs the same +/// authorization boundary: a module belongs to one repository, and nobody else may write to it. +/// +[NotInParallel("repo-server-integration")] +public class DesktopCiCdControllerTests +{ + private const string ModuleId = "test-module"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task Publish_ByOwningRepository_Succeeds() + { + var ownerId = await RegisterRepositoryAsync("openshock", "desktop"); + await SeedModuleAsync(ownerId); + + using var client = Factory.CreateCiCdClient(ownerId); + var response = await PublishAsync(client, "1.0.0"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + } + + [Test] + public async Task Publish_FromDifferentRepository_IsForbidden() + { + var ownerId = await RegisterRepositoryAsync("openshock", "desktop"); + var intruderId = await RegisterRepositoryAsync("attacker", "evil-repo"); + await SeedModuleAsync(ownerId); + + using var client = Factory.CreateCiCdClient(intruderId); + var response = await PublishAsync(client, "1.0.0"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Publish_ToModuleWithNoOwner_IsForbidden() + { + var repoId = await RegisterRepositoryAsync("openshock", "desktop"); + await SeedModuleAsync(owningRepositoryId: null); + + // Unassigned modules are closed to everyone rather than open to anyone. + using var client = Factory.CreateCiCdClient(repoId); + var response = await PublishAsync(client, "1.0.0"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Publish_ExistingVersion_IsRejectedAsImmutable() + { + var ownerId = await RegisterRepositoryAsync("openshock", "desktop"); + await SeedModuleAsync(ownerId); + + using var client = Factory.CreateCiCdClient(ownerId); + var first = await PublishAsync(client, "1.0.0"); + await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created); + + // The upsert is keyed on (module, version), so without a guard this would silently replace the + // published zip URL and hash in place. + var second = await PublishAsync(client, "1.0.0"); + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Publish_WithOnlyTheFirmwareScope_IsForbidden() + { + var ownerId = await RegisterRepositoryAsync("openshock", "desktop"); + await SeedModuleAsync(ownerId); + + // The mirror of the firmware check: a firmware-only grant must not reach module publishing. + using var client = Factory.CreateCiCdClient(ownerId, scopes: "publish_firmware"); + var response = await PublishAsync(client, "1.0.0"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + // ---- Helpers ---- + + private async Task RegisterRepositoryAsync(string owner, string repo) + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var row = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = owner, + Repo = repo + }; + db.Repositories.Add(row); + await db.SaveChangesAsync(); + return row.Id; + } + + private async Task SeedModuleAsync(Guid? owningRepositoryId) + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.Modules.Add(new Module + { + Id = ModuleId, + Name = "Test Module", + Description = "Fixture", + RepositoryId = owningRepositoryId + }); + await db.SaveChangesAsync(); + } + + private static async Task PublishAsync(HttpClient client, string version) + { + var content = new MultipartFormDataContent(); + content.Add(new ByteArrayContent(BuildModuleZip()), "zip", "module.zip"); + + return await client.PutAsync($"/1/cicd/modules/{ModuleId}/versions/{version}", content); + } + + /// Minimal zip passing the controller's root-entry validation (.dll/.pdb/.json only). + private static byte[] BuildModuleZip() + { + using var memory = new MemoryStream(); + using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry("module.dll"); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes("not-a-real-assembly")); + } + return memory.ToArray(); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/DiscordWebhooksControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/DiscordWebhooksControllerTests.cs new file mode 100644 index 0000000..7cd052f --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/DiscordWebhooksControllerTests.cs @@ -0,0 +1,149 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Discord; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class DiscordWebhooksControllerTests +{ + private const string BasePath = "/2/admin/discord-webhooks"; + private const string WebhookUrl = "https://discord.com/api/webhooks/123456789/s3cr3t-token-value"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task Post_CreatesWebhook_AndNeverEchoesTheUrl() + { + using var client = Factory.CreateAdminClient(); + + var response = await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest + { + Name = "releases", + Url = WebhookUrl, + Events = ["firmware_release_published"] + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + + // The URL is the credential — possession of it is authorization to post to the channel — so it + // must never come back out, in any field. + var raw = await response.Content.ReadAsStringAsync(); + await Assert.That(raw).DoesNotContain("s3cr3t-token-value"); + + var body = JsonSerializer.Deserialize(raw); + await Assert.That(body.GetProperty("urlMasked").GetString()).IsEqualTo( + "https://discord.com/api/webhooks/123456789/***"); + } + + [Test] + public async Task Get_ListsWebhooksWithoutLeakingUrls() + { + using var client = Factory.CreateAdminClient(); + await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest + { + Name = "releases", Url = WebhookUrl, Events = ["firmware_release_published"] + }); + + var response = await client.GetAsync(BasePath); + var raw = await response.Content.ReadAsStringAsync(); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(raw).DoesNotContain("s3cr3t-token-value"); + await Assert.That(raw).Contains("releases"); + } + + [Test] + public async Task Post_UnknownEvent_Returns400() + { + using var client = Factory.CreateAdminClient(); + + var response = await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest + { + Name = "releases", Url = WebhookUrl, Events = ["everything"] + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Put_UpdatesEventSubscription() + { + using var client = Factory.CreateAdminClient(); + var created = await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest + { + Name = "releases", Url = WebhookUrl, Events = ["firmware_release_published"] + }); + var id = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid(); + + var response = await client.PutAsJsonAsync($"{BasePath}/{id}", new UpsertDiscordWebhookRequest + { + Name = "maintainers", + Url = WebhookUrl, + Events = ["release_notes_need_editing", "staged_release_expired"] + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + var events = body.GetProperty("events").EnumerateArray().Select(e => e.GetString()).ToList(); + await Assert.That(events).Contains("release_notes_need_editing"); + await Assert.That(events).DoesNotContain("firmware_release_published"); + } + + [Test] + public async Task Delete_RemovesWebhook() + { + using var client = Factory.CreateAdminClient(); + var created = await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest + { + Name = "releases", Url = WebhookUrl, Events = [] + }); + var id = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid(); + + var response = await client.DeleteAsync($"{BasePath}/{id}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + var again = await client.DeleteAsync($"{BasePath}/{id}"); + await Assert.That(again.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Endpoints_RequireTheAdminToken() + { + using var client = Factory.CreateClient(); + + var response = await client.GetAsync(BasePath); + await Assert.That(response.IsSuccessStatusCode).IsFalse(); + } + + [Test] + public async Task StoredRows_RoundTripEventsAndEnabledFlag() + { + using var client = Factory.CreateAdminClient(); + await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest + { + Name = "maintainers", + Url = WebhookUrl, + Events = ["release_notes_need_editing"], + Enabled = false + }); + + // Verified against the database rather than the response, since the delivery path reads rows. + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = db.DiscordWebhooks.Single(); + + await Assert.That(row.Enabled).IsFalse(); + await Assert.That(row.Events).Contains(DiscordNotificationEvent.ReleaseNotesNeedEditing); + await Assert.That(row.Url).IsEqualTo(WebhookUrl); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs new file mode 100644 index 0000000..8177fb4 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs @@ -0,0 +1,315 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class LatestControllerTests +{ + /// Slug-form board name, matching what a hub compiles in as OPENSHOCK_FW_BOARD. + private const string BoardName = "Wemos-D1-Mini-ESP32"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task GetLatest_InvalidChannel_Returns400() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/latest/nightly"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task GetLatest_ChannelWithNoReleases_Returns404() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/latest/stable"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task GetLatest_HappyPath_ReturnsReleaseWithSourceAndBoards() + { + var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/latest/stable"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.1"); + await Assert.That(body.GetProperty("channel").GetString()).IsEqualTo("stable"); + + var source = body.GetProperty("source"); + await Assert.That(source.GetProperty("repository").GetProperty("provider").GetString()) + .IsEqualTo("github"); + await Assert.That(source.GetProperty("commitUrl").GetString()) + .IsEqualTo("https://github.com/openshock/firmware/commit/abc1234567890abcdef1234567890abcdef12345"); + + var boards = body.GetProperty("boards"); + var boardKeys = boards.EnumerateObject().Select(p => p.Name).ToList(); + await Assert.That(boardKeys).Contains(BoardName); + } + + [Test] + public async Task GetLatestForBoard_UnknownChannel_Returns400() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/2/firmware/latest/nightly/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task GetLatestForBoard_NoReleases_Returns404() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/2/firmware/latest/stable/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task GetLatestForBoard_MatchingVersion_Returns204() + { + var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/2/firmware/latest/stable/{boardId}?version=1.5.1"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + [Test] + public async Task GetLatestForBoard_DifferentVersion_Returns200WithArtifacts() + { + var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/2/firmware/latest/stable/{boardId}?version=1.4.0"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.1"); + await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName); + await Assert.That(body.GetProperty("artifacts").GetArrayLength()).IsEqualTo(1); + } + + [Test] + public async Task GetLatestForBoard_NoVersionParam_Returns200() + { + var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/2/firmware/latest/stable/{boardId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task GetLatestForBoard_ByName_Returns200WithNameEchoed() + { + await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/2/firmware/latest/stable/{BoardName}?version=1.4.0"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName); + } + + [Test] + public async Task GetLatestForBoard_ByNameDifferentCase_Returns200WithCanonicalName() + { + await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync( + $"/2/firmware/latest/stable/{BoardName.ToLowerInvariant()}?version=1.4.0"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // The canonical stored spelling comes back, not what the caller sent. + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName); + } + + [Test] + public async Task GetLatestForBoard_ByName_MatchingVersion_Returns204() + { + await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/2/firmware/latest/stable/{BoardName}?version=1.5.1"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + [Test] + public async Task GetLatestForBoard_ArtifactUrlUsesImmutableBoardId() + { + var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var body = await client.GetFromJsonAsync( + $"/2/firmware/latest/stable/{BoardName}"); + + // Storage paths are keyed by the board id so a rename cannot strand published artifacts, + // while the response still labels the board by name. + await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName); + + var url = body.GetProperty("artifacts")[0].GetProperty("url").GetString(); + await Assert.That(url).Contains($"/1.5.1/{boardId}/"); + } + + [Test] + public async Task GetLatestForBoard_UnknownName_Returns404NotNoContent() + { + await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + // An unknown board must not be masked as "already up to date" just because the + // version query happens to match the latest release. + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/latest/stable/No-Such-Board?version=1.5.1"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task GetLatestForBoard_UnknownBoard_Returns404() + { + await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/2/firmware/latest/stable/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task GetLatest_BetaChannel_IncludesNewerStableRelease() + { + // CI advances stable, beta and develop pointers together when it ships a stable build, so a + // beta subscriber must be offered it. Strict per-channel equality would pin them back to the + // last explicit release candidate and offer a downgrade. + var (_, chipId, repoId) = await SeedReleaseAsync("1.5.0-beta.1", ReleaseChannel.Beta); + await AddVersionAsync("1.5.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow, repoId); + + using var client = Factory.CreateClient(); + var body = await client.GetFromJsonAsync("/2/firmware/latest/beta"); + await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.0"); + } + + [Test] + public async Task GetLatest_StableChannel_ExcludesBetaReleases() + { + // The cascade only runs one way. + var (_, _, repoId) = await SeedReleaseAsync("1.5.0", ReleaseChannel.Stable); + await AddVersionAsync("1.6.0-beta.1", ReleaseChannel.Beta, DateTimeOffset.UtcNow.AddDays(1), repoId); + + using var client = Factory.CreateClient(); + var body = await client.GetFromJsonAsync("/2/firmware/latest/stable"); + await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.0"); + } + + [Test] + public async Task GetLatest_TiedReleaseDates_ResolvesDeterministicallyAndAgreesWithManifest() + { + // release_date is client-supplied and not unique — the spec's own example uses midnight. With + // no tiebreaker, ties were broken arbitrarily by the database and endpoints could disagree + // with each other and flip between requests, which a hub sees as firmware flapping. + var sameInstant = DateTimeOffset.Parse("2026-04-15T00:00:00Z"); + var (_, _, repoId) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable, sameInstant); + await AddVersionAsync("1.5.2", ReleaseChannel.Stable, sameInstant, repoId); + + using var client = Factory.CreateClient(); + + var first = await client.GetFromJsonAsync("/2/firmware/latest/stable"); + var picked = first.GetProperty("version").GetString(); + await Assert.That(picked).IsEqualTo("1.5.2"); + + // Stable across repeated calls... + for (var i = 0; i < 3; i++) + { + var again = await client.GetFromJsonAsync("/2/firmware/latest/stable"); + await Assert.That(again.GetProperty("version").GetString()).IsEqualTo(picked); + } + + // ...and consistent with the manifest, which computes latest independently. + var manifest = await client.GetFromJsonAsync("/2/firmware/manifest"); + await Assert.That(manifest.GetProperty("latest").GetProperty("stable").GetString()) + .IsEqualTo(picked); + } + + private async Task AddVersionAsync( + string version, ReleaseChannel channel, DateTimeOffset releaseDate, Guid repoId) + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.FirmwareVersions.Add(new FirmwareVersion + { + Version = version, + Channel = channel, + ReleaseDate = releaseDate, + RepositoryId = repoId, + CommitHash = "abc1234567890abcdef1234567890abcdef12345" + }); + await db.SaveChangesAsync(); + } + + private async Task<(Guid boardId, Guid chipId, Guid repoId)> SeedReleaseAsync( + string version, ReleaseChannel channel, DateTimeOffset? releaseDate = null) + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var chip = new FirmwareChip + { + Id = Guid.NewGuid(), + Name = "ESP32-S3", + Architecture = FirmwareChipArchitecture.Xtensa + }; + var board = new FirmwareBoard + { + Id = Guid.NewGuid(), + Name = BoardName, + ChipId = chip.Id, + RequiredArtifactTypes = [FirmwareArtifactType.Merged] + }; + var repo = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = "openshock", + Repo = "firmware" + }; + var fwVersion = new FirmwareVersion + { + Version = version, + Channel = channel, + ReleaseDate = releaseDate ?? DateTimeOffset.UtcNow, + RepositoryId = repo.Id, + CommitHash = "abc1234567890abcdef1234567890abcdef12345", + Ref = "refs/tags/v" + version + }; + var artifact = new FirmwareArtifact + { + Version = version, + BoardId = board.Id, + ArtifactType = FirmwareArtifactType.Merged, + HashSha256 = new byte[32], + FileSize = 1_572_864 + }; + + db.FirmwareChips.Add(chip); + db.FirmwareBoards.Add(board); + db.Repositories.Add(repo); + db.FirmwareVersions.Add(fwVersion); + db.FirmwareArtifacts.Add(artifact); + await db.SaveChangesAsync(); + + return (board.Id, chip.Id, repo.Id); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs new file mode 100644 index 0000000..fc1f0cf --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs @@ -0,0 +1,166 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class ManifestControllerTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task GetManifest_EmptyDatabase_ReturnsShellWithFixedChannels() + { + using var client = Factory.CreateClient(); + + var response = await client.GetAsync("/2/firmware/manifest"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var manifest = await response.Content.ReadFromJsonAsync(); + + var channels = manifest.GetProperty("channels").EnumerateArray() + .Select(e => e.GetString()).ToList(); + await Assert.That(channels).Contains("stable"); + await Assert.That(channels).Contains("beta"); + await Assert.That(channels).Contains("develop"); + + var latest = manifest.GetProperty("latest"); + await Assert.That(latest.EnumerateObject().Any()).IsFalse(); + + await Assert.That(manifest.GetProperty("boards").GetArrayLength()).IsEqualTo(0); + await Assert.That(manifest.GetProperty("chips").GetArrayLength()).IsEqualTo(0); + await Assert.That(manifest.GetProperty("usbSerialFilters").GetArrayLength()).IsEqualTo(0); + await Assert.That(manifest.GetProperty("usbDevices").GetArrayLength()).IsEqualTo(0); + await Assert.That(manifest.GetProperty("advisories").GetArrayLength()).IsEqualTo(0); + } + + [Test] + public async Task GetManifest_WithSeededData_ReturnsFullShape() + { + // Seed directly through EF so we don't rely on admin endpoints being under test. + Guid chipId; + Guid boardId; + Guid usbDeviceId; + + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + var chip = new FirmwareChip + { + Id = Guid.NewGuid(), + Name = "ESP32-S3", + Architecture = FirmwareChipArchitecture.Xtensa + }; + var board = new FirmwareBoard + { + Id = Guid.NewGuid(), + Name = "OpenShock-Core-V1", + ChipId = chip.Id, + Discontinued = false, + RequiredArtifactTypes = [FirmwareArtifactType.Merged] + }; + var usbDevice = new UsbDevice + { + Id = Guid.NewGuid(), + Vid = 0x1A86, + Pid = 0x7522, + Name = "CH9102" + }; + var filter = new UsbSerialFilter + { + Id = Guid.NewGuid(), + Vid = 0x1A86, + Pid = null, + Description = "WCH vendor-wide" + }; + var advisory = new FirmwareAdvisory + { + Id = Guid.NewGuid(), + Severity = AdvisorySeverity.Critical, + Title = "OTA bricking bug", + Content = "Versions before 1.4.0 have a bug that can brick the device during OTA.", + AffectedVersions = "<1.4.0", + Url = "https://example.invalid/issues/123" + }; + + db.FirmwareChips.Add(chip); + db.FirmwareBoards.Add(board); + db.UsbDevices.Add(usbDevice); + db.UsbSerialFilters.Add(filter); + db.FirmwareAdvisories.Add(advisory); + db.FirmwareBoardUsbDevices.Add(new FirmwareBoardUsbDevice + { + BoardId = board.Id, + UsbDeviceId = usbDevice.Id + }); + + await db.SaveChangesAsync(); + + chipId = chip.Id; + boardId = board.Id; + usbDeviceId = usbDevice.Id; + } + + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/manifest"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var manifest = await response.Content.ReadFromJsonAsync(); + + var boards = manifest.GetProperty("boards").EnumerateArray().ToList(); + await Assert.That(boards).Count().IsEqualTo(1); + // Boards and chips are identified by name on the public surface; UUIDs stay internal. + await Assert.That(boards[0].GetProperty("name").GetString()).IsEqualTo("OpenShock-Core-V1"); + await Assert.That(boards[0].GetProperty("chipName").GetString()).IsEqualTo("ESP32-S3"); + await Assert.That(boards[0].GetProperty("discontinued").GetBoolean()).IsFalse(); + + var boardUsb = boards[0].GetProperty("usbDevices").EnumerateArray().ToList(); + await Assert.That(boardUsb).Count().IsEqualTo(1); + await Assert.That(boardUsb[0].GetProperty("vid").GetInt32()).IsEqualTo(0x1A86); + await Assert.That(boardUsb[0].GetProperty("pid").GetInt32()).IsEqualTo(0x7522); + await Assert.That(boardUsb[0].GetProperty("name").GetString()).IsEqualTo("CH9102"); + + var chips = manifest.GetProperty("chips").EnumerateArray().ToList(); + await Assert.That(chips).Count().IsEqualTo(1); + await Assert.That(chips[0].GetProperty("name").GetString()).IsEqualTo("ESP32-S3"); + await Assert.That(chips[0].GetProperty("architecture").GetString()).IsEqualTo("xtensa"); + + var filters = manifest.GetProperty("usbSerialFilters").EnumerateArray().ToList(); + await Assert.That(filters).Count().IsEqualTo(1); + await Assert.That(filters[0].GetProperty("vid").GetInt32()).IsEqualTo(0x1A86); + var filterHasPid = filters[0].TryGetProperty("pid", out var pidProp) && + pidProp.ValueKind != JsonValueKind.Null; + await Assert.That(filterHasPid).IsFalse(); + + var devices = manifest.GetProperty("usbDevices").EnumerateArray().ToList(); + await Assert.That(devices).Count().IsEqualTo(1); + await Assert.That(Guid.Parse(devices[0].GetProperty("id").GetString()!)) + .IsEqualTo(usbDeviceId); + + var advisories = manifest.GetProperty("advisories").EnumerateArray().ToList(); + await Assert.That(advisories).Count().IsEqualTo(1); + await Assert.That(advisories[0].GetProperty("severity").GetString()).IsEqualTo("critical"); + await Assert.That(advisories[0].GetProperty("affectedVersions").GetString()).IsEqualTo("<1.4.0"); + } + + [Test] + public async Task GetManifest_SendsCacheControlHeader() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/manifest"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(response.Headers.CacheControl).IsNotNull(); + await Assert.That(response.Headers.CacheControl!.Public).IsTrue(); + await Assert.That(response.Headers.CacheControl.MaxAge).IsEqualTo(TimeSpan.FromSeconds(300)); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs new file mode 100644 index 0000000..c0d2dd4 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs @@ -0,0 +1,110 @@ +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class PublicBoardsAndChipsControllerTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task ListBoards_FilterByChipId_ReturnsOnlyMatching() + { + Guid chipAId; + Guid chipBId; + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var chipA = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32-S3" }; + var chipB = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32-C3" }; + db.FirmwareChips.Add(chipA); + db.FirmwareChips.Add(chipB); + db.FirmwareBoards.Add(new FirmwareBoard + { + Id = Guid.NewGuid(), Name = "Board-S3", ChipId = chipA.Id, RequiredArtifactTypes = [] + }); + db.FirmwareBoards.Add(new FirmwareBoard + { + Id = Guid.NewGuid(), Name = "Board-C3", ChipId = chipB.Id, RequiredArtifactTypes = [] + }); + await db.SaveChangesAsync(); + chipAId = chipA.Id; + chipBId = chipB.Id; + } + + using var client = Factory.CreateClient(); + var boardsForChipA = await client.GetFromJsonAsync>( + "/2/firmware/boards?chip=ESP32-S3"); + await Assert.That(boardsForChipA).IsNotNull(); + await Assert.That(boardsForChipA!).Count().IsEqualTo(1); + await Assert.That(boardsForChipA[0].ChipName).IsEqualTo("ESP32-S3"); + + // Chip names are matched case-insensitively, same as board names. + var lowerCased = await client.GetFromJsonAsync>( + "/2/firmware/boards?chip=esp32-s3"); + await Assert.That(lowerCased!).Count().IsEqualTo(1); + } + + [Test] + public async Task ListBoards_ExcludeDiscontinued_HidesEOLBoards() + { + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var chip = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32" }; + db.FirmwareChips.Add(chip); + db.FirmwareBoards.Add(new FirmwareBoard + { + Id = Guid.NewGuid(), Name = "Active", ChipId = chip.Id, + Discontinued = false, RequiredArtifactTypes = [] + }); + db.FirmwareBoards.Add(new FirmwareBoard + { + Id = Guid.NewGuid(), Name = "EOL", ChipId = chip.Id, + Discontinued = true, RequiredArtifactTypes = [] + }); + await db.SaveChangesAsync(); + } + + using var client = Factory.CreateClient(); + var withDiscontinued = await client.GetFromJsonAsync>( + "/2/firmware/boards?includeDiscontinued=true"); + var withoutDiscontinued = await client.GetFromJsonAsync>( + "/2/firmware/boards?includeDiscontinued=false"); + + await Assert.That(withDiscontinued!).Count().IsEqualTo(2); + await Assert.That(withoutDiscontinued!).Count().IsEqualTo(1); + await Assert.That(withoutDiscontinued![0].Name).IsEqualTo("Active"); + } + + [Test] + public async Task ListChips_ReturnsArchitectureLowercase() + { + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.FirmwareChips.Add(new FirmwareChip + { + Id = Guid.NewGuid(), + Name = "ESP32-C6", + Architecture = FirmwareChipArchitecture.RiscV + }); + await db.SaveChangesAsync(); + } + + using var client = Factory.CreateClient(); + var chips = await client.GetFromJsonAsync>("/2/firmware/chips"); + await Assert.That(chips).IsNotNull(); + await Assert.That(chips!).Count().IsEqualTo(1); + await Assert.That(chips[0].Name).IsEqualTo("ESP32-C6"); + await Assert.That(chips[0].Architecture).IsEqualTo("risc_v"); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/ReleasesControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/ReleasesControllerTests.cs new file mode 100644 index 0000000..857ea53 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/ReleasesControllerTests.cs @@ -0,0 +1,563 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +/// +/// Covers the CI/CD ingestion state machine: init, artifact upload, publish, abort — and in +/// particular the authorization boundary between two registered repositories. +/// +[NotInParallel("repo-server-integration")] +public class ReleasesControllerTests +{ + private const string BoardName = "Wemos-D1-Mini-ESP32"; + private const string Changelog = "### Info\n- Something changed"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + // ---- Happy path ---- + + [Test] + public async Task FullReleaseLifecycle_InitUploadPublish_MakesVersionPublic() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + var releaseId = await InitReleaseAsync(client, "1.5.1"); + + var upload = await UploadArtifactsAsync(client, releaseId, BoardName); + await Assert.That(upload.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null); + await Assert.That(publish.IsSuccessStatusCode).IsTrue(); + + // Once published it must be visible on the public read path. + using var anon = Factory.CreateClient(); + var latest = await anon.GetFromJsonAsync("/2/firmware/latest/stable"); + await Assert.That(latest.GetProperty("version").GetString()).IsEqualTo("1.5.1"); + } + + // ---- Ownership (B2) ---- + + [Test] + public async Task Upload_FromDifferentRepository_IsForbidden() + { + var seed = await SeedAsync(); + var intruderId = await RegisterRepositoryAsync("attacker", "evil-repo"); + + using var owner = Factory.CreateCiCdClient(seed.RepositoryId); + var releaseId = await InitReleaseAsync(owner, "1.5.1"); + + // A second registered repository is fully authenticated, but must not be able to inject + // binaries into someone else's in-flight release. + using var intruder = Factory.CreateCiCdClient(intruderId); + var response = await UploadArtifactsAsync(intruder, releaseId, BoardName); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Publish_FromDifferentRepository_IsForbidden() + { + var seed = await SeedAsync(); + var intruderId = await RegisterRepositoryAsync("attacker", "evil-repo"); + + using var owner = Factory.CreateCiCdClient(seed.RepositoryId); + var releaseId = await InitReleaseAsync(owner, "1.5.1"); + await UploadArtifactsAsync(owner, releaseId, BoardName); + + // Publishing someone else's release would attribute it to their repository and commit hash. + using var intruder = Factory.CreateCiCdClient(intruderId); + var response = await intruder.PostAsync($"/2/firmware/releases/{releaseId}/publish", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Abort_FromDifferentRepository_IsForbidden() + { + var seed = await SeedAsync(); + var intruderId = await RegisterRepositoryAsync("attacker", "evil-repo"); + + using var owner = Factory.CreateCiCdClient(seed.RepositoryId); + var releaseId = await InitReleaseAsync(owner, "1.5.1"); + + using var intruder = Factory.CreateCiCdClient(intruderId); + var response = await intruder.DeleteAsync($"/2/firmware/releases/{releaseId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Releases_WithoutCiCdPrincipal_AreRejected() + { + await SeedAsync(); + + using var anon = Factory.CreateClient(); + var response = await anon.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName], + Changelog = Changelog + }); + + await Assert.That(response.IsSuccessStatusCode).IsFalse(); + } + + [Test] + public async Task Releases_WithOnlyTheModulesScope_AreForbidden() + { + var seed = await SeedAsync(); + + // Firmware and desktop ingestion share the CI/CD scheme, so a repository onboarded purely to + // publish desktop modules must not be able to start a firmware release. + using var client = Factory.CreateCiCdClient(seed.RepositoryId, scopes: "publish_modules"); + var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName], + Changelog = Changelog + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Releases_WithNoScopes_AreForbidden() + { + var seed = await SeedAsync(); + + using var client = Factory.CreateCiCdClient(seed.RepositoryId, scopes: TestCiCdAuthHandler.NoScopes); + var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName], + Changelog = Changelog + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + // ---- Immutability (B3) ---- + + [Test] + public async Task Init_ForAlreadyPublishedVersion_IsRejected() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + var releaseId = await InitReleaseAsync(client, "1.5.1"); + await UploadArtifactsAsync(client, releaseId, BoardName); + await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null); + + // Re-initialising a published version would let a second release overwrite live artifacts at + // the same storage keys, and if abandoned, have them deleted by the TTL job. + var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName], + Changelog = Changelog + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Init_WhileAnotherReleaseIsStaging_IsRejected() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + await InitReleaseAsync(client, "1.5.1"); + + var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName], + Changelog = Changelog + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + // ---- Validation ---- + + [Test] + public async Task Init_UnknownBoard_NamesTheOffendingBoard() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName, "No-Such-Board"], + Changelog = Changelog + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + + // A CI operator reading this in a log needs the board name, not a UUID. + var body = await response.Content.ReadAsStringAsync(); + await Assert.That(body).Contains("No-Such-Board"); + } + + [Test] + public async Task Init_InvalidChangelogWithoutNofail_Fails() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName], + Changelog = "no headings here at all" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Init_InvalidChangelogWithNofail_LandsInEditing() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + var response = await client.PostAsJsonAsync("/2/firmware/releases?nofail", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName], + Changelog = "no headings here at all" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("status").GetString()).IsEqualTo("editing"); + } + + [Test] + public async Task Publish_WhileEditing_IsRejected() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + var init = await client.PostAsJsonAsync("/2/firmware/releases?nofail", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName], + Changelog = "no headings here at all" + }); + var releaseId = (await init.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid(); + + await UploadArtifactsAsync(client, releaseId, BoardName); + + var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null); + await Assert.That(publish.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Publish_WithoutAllDeclaredBoards_NamesTheMissingBoard() + { + var seed = await SeedAsync(extraBoardName: "Wemos-Lolin-S3"); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + var init = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName, "Wemos-Lolin-S3"], + Changelog = Changelog + }); + var releaseId = (await init.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid(); + + await UploadArtifactsAsync(client, releaseId, BoardName); + + var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null); + await Assert.That(publish.IsSuccessStatusCode).IsFalse(); + + var body = await publish.Content.ReadAsStringAsync(); + await Assert.That(body).Contains("Wemos-Lolin-S3"); + } + + [Test] + public async Task Upload_WithMismatchedSha256_IsRejected() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + var releaseId = await InitReleaseAsync(client, "1.5.1"); + + var content = new MultipartFormDataContent(); + var bytes = Encoding.UTF8.GetBytes("merged-artifact-bytes"); + content.Add(new ByteArrayContent(bytes), "merged", "firmware.bin"); + // A hash the payload does not have. + content.Add(new StringContent( + JsonSerializer.Serialize(new Dictionary { ["merged"] = new string('a', 64) })), + "sha256"); + + var response = await client.PutAsync( + $"/2/firmware/releases/{releaseId}/boards/{BoardName}", content); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Upload_ForUndeclaredBoard_IsRejected() + { + var seed = await SeedAsync(extraBoardName: "Wemos-Lolin-S3"); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + var releaseId = await InitReleaseAsync(client, "1.5.1"); + + var response = await UploadArtifactsAsync(client, releaseId, "Wemos-Lolin-S3"); + await Assert.That(response.IsSuccessStatusCode).IsFalse(); + } + + [Test] + public async Task Abort_ByOwner_MakesReleaseUnpublishable() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + var releaseId = await InitReleaseAsync(client, "1.5.1"); + + var abort = await client.DeleteAsync($"/2/firmware/releases/{releaseId}"); + await Assert.That(abort.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null); + await Assert.That(publish.IsSuccessStatusCode).IsFalse(); + } + + // ---- Atomicity and coercion ---- + + [Test] + public async Task Upload_PartialFailure_LeavesNoOrphanedBlobsOrLostState() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + var releaseId = await InitReleaseAsync(client, "1.5.1"); + + // A valid first upload establishes staged state we must not lose. + await UploadArtifactsAsync(client, releaseId, BoardName); + + // Now send two files where only one hashes correctly. + var goodBytes = Encoding.UTF8.GetBytes("good-app-bytes"); + var badBytes = Encoding.UTF8.GetBytes("bad-merged-bytes"); + var content = new MultipartFormDataContent(); + content.Add(new ByteArrayContent(goodBytes), "app", "app.bin"); + content.Add(new ByteArrayContent(badBytes), "merged", "firmware.bin"); + content.Add(new StringContent(JsonSerializer.Serialize(new Dictionary + { + ["app"] = Convert.ToHexString(SHA256.HashData(goodBytes)), + ["merged"] = new string('a', 64) + })), "sha256"); + + var response = await client.PutAsync( + $"/2/firmware/releases/{releaseId}/boards/{BoardName}", content); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + + // The prior staged artifact must survive: nothing is deleted or written until every file in + // the request has been verified. + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var staged = db.FirmwareStagedArtifacts.Where(a => a.ReleaseId == releaseId).ToList(); + await Assert.That(staged).Count().IsEqualTo(1); + await Assert.That(staged[0].ArtifactType).IsEqualTo(FirmwareArtifactType.Merged); + } + + [Test] + public async Task Init_NonUtcReleaseDate_IsAcceptedAndNormalized() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + // Npgsql rejects a non-zero offset for `timestamp with time zone`, which used to surface as a + // generic 500 for any CI runner outside UTC. + var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = "1.5.1", + Channel = "stable", + ReleaseDate = new DateTimeOffset(2026, 4, 15, 12, 0, 0, TimeSpan.FromHours(2)), + Boards = [BoardName], + Changelog = Changelog + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + } + + // ---- Staging isolation ---- + + [Test] + public async Task Upload_DoesNotWriteThePublishedKey() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + var releaseId = await InitReleaseAsync(client, "1.5.1"); + + await UploadArtifactsAsync(client, releaseId, BoardName); + + // Nothing is readable at its published path until publish runs. + await Assert.That(Factory.StoredFileExists($"1.5.1/{seed.BoardId}/firmware.bin")).IsFalse(); + await Assert.That(Factory.StoredFileExists($"_staging/{releaseId}/{seed.BoardId}/firmware.bin")).IsTrue(); + } + + [Test] + public async Task Publish_PromotesStagedArtifactsToPublishedKeys() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + var releaseId = await InitReleaseAsync(client, "1.5.1"); + await UploadArtifactsAsync(client, releaseId, BoardName); + + var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null); + await Assert.That(publish.IsSuccessStatusCode).IsTrue(); + + await Assert.That(Factory.StoredFileExists($"1.5.1/{seed.BoardId}/firmware.bin")).IsTrue(); + + // And the staging copies are cleared once they are dead weight. + await Assert.That(Factory.StoredFileExists($"_staging/{releaseId}/{seed.BoardId}/firmware.bin")).IsFalse(); + } + + [Test] + public async Task Abort_RemovesStagedArtifactsButNeverPublishedOnes() + { + var seed = await SeedAsync(); + using var client = Factory.CreateCiCdClient(seed.RepositoryId); + + // Publish 1.5.1 so there are live artifacts on disk to protect. + var publishedId = await InitReleaseAsync(client, "1.5.1"); + await UploadArtifactsAsync(client, publishedId, BoardName); + await client.PostAsync($"/2/firmware/releases/{publishedId}/publish", null); + + // A second, unrelated release is staged and then aborted. + var abortedId = await InitReleaseAsync(client, "1.6.0"); + await UploadArtifactsAsync(client, abortedId, BoardName); + var abort = await client.DeleteAsync($"/2/firmware/releases/{abortedId}"); + await Assert.That(abort.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + await Assert.That(Factory.StoredFileExists($"_staging/{abortedId}/{seed.BoardId}/firmware.bin")).IsFalse(); + await Assert.That(Factory.StoredFileExists($"1.5.1/{seed.BoardId}/firmware.bin")).IsTrue(); + } + + // ---- Helpers ---- + + private sealed record Seed(Guid RepositoryId, Guid BoardId, Guid ChipId); + + private async Task SeedAsync(string? extraBoardName = null) + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var repo = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = "openshock", + Repo = "firmware" + }; + var chip = new FirmwareChip + { + Id = Guid.NewGuid(), + Name = "ESP32", + Architecture = FirmwareChipArchitecture.Xtensa + }; + var board = new FirmwareBoard + { + Id = Guid.NewGuid(), + Name = BoardName, + ChipId = chip.Id, + RequiredArtifactTypes = [] + }; + + db.Repositories.Add(repo); + db.FirmwareChips.Add(chip); + db.FirmwareBoards.Add(board); + + if (extraBoardName is not null) + { + db.FirmwareBoards.Add(new FirmwareBoard + { + Id = Guid.NewGuid(), + Name = extraBoardName, + ChipId = chip.Id, + RequiredArtifactTypes = [] + }); + } + + await db.SaveChangesAsync(); + return new Seed(repo.Id, board.Id, chip.Id); + } + + private async Task RegisterRepositoryAsync(string owner, string repo) + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var row = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = owner, + Repo = repo + }; + db.Repositories.Add(row); + await db.SaveChangesAsync(); + return row.Id; + } + + private static async Task InitReleaseAsync(HttpClient client, string version) + { + var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest + { + Version = version, + Channel = "stable", + ReleaseDate = DateTimeOffset.UtcNow, + Boards = [BoardName], + Changelog = Changelog + }); + + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("id").GetGuid(); + } + + private static async Task UploadArtifactsAsync( + HttpClient client, Guid releaseId, string board) + { + var bytes = Encoding.UTF8.GetBytes($"merged-artifact-for-{board}"); + var hash = Convert.ToHexString(SHA256.HashData(bytes)); + + var content = new MultipartFormDataContent(); + content.Add(new ByteArrayContent(bytes), "merged", "firmware.bin"); + content.Add(new StringContent( + JsonSerializer.Serialize(new Dictionary { ["merged"] = hash })), + "sha256"); + + return await client.PutAsync($"/2/firmware/releases/{releaseId}/boards/{board}", content); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs new file mode 100644 index 0000000..79869e4 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs @@ -0,0 +1,237 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class RepositoriesAdminControllerTests +{ + private const string BasePath = "/2/firmware/admin/repositories"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task Get_EmptyDatabase_ReturnsEmptyArray() + { + using var client = Factory.CreateAdminClient(); + var repos = await client.GetFromJsonAsync>(BasePath); + await Assert.That(repos).IsNotNull(); + await Assert.That(repos!).Count().IsEqualTo(0); + } + + [Test] + public async Task Get_WithSeededData_ReturnsRepositories() + { + Guid repoId; + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var repo = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = "openshock", + Repo = "firmware" + }; + db.Repositories.Add(repo); + await db.SaveChangesAsync(); + repoId = repo.Id; + } + + using var client = Factory.CreateAdminClient(); + var repos = await client.GetFromJsonAsync>(BasePath); + await Assert.That(repos).IsNotNull(); + await Assert.That(repos!).Count().IsEqualTo(1); + await Assert.That(repos[0].Id).IsEqualTo(repoId); + await Assert.That(repos[0].Provider).IsEqualTo("github"); + await Assert.That(repos[0].Owner).IsEqualTo("openshock"); + await Assert.That(repos[0].Repo).IsEqualTo("firmware"); + } + + [Test] + public async Task Delete_Unreferenced_Returns204() + { + Guid repoId; + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var repo = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = "openshock", + Repo = "desktop" + }; + db.Repositories.Add(repo); + await db.SaveChangesAsync(); + repoId = repo.Id; + } + + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{repoId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + [Test] + public async Task Delete_UnknownId_Returns404() + { + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Delete_ReferencedByFirmwareVersion_Returns409() + { + Guid repoId; + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var repo = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = "openshock", + Repo = "firmware" + }; + var version = new FirmwareVersion + { + Version = "1.5.1", + Channel = ReleaseChannel.Stable, + ReleaseDate = DateTimeOffset.UtcNow, + RepositoryId = repo.Id, + CommitHash = "abc1234567890abcdef1234567890abcdef12345" + }; + db.Repositories.Add(repo); + db.FirmwareVersions.Add(version); + await db.SaveChangesAsync(); + repoId = repo.Id; + } + + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{repoId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Put_RegistersRepository_AsThePublishAllowlistEntry() + { + using var client = Factory.CreateAdminClient(); + + var response = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest + { + Provider = "github", + Owner = "openshock", + Repo = "firmware" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("owner").GetString()).IsEqualTo("openshock"); + await Assert.That(body.GetProperty("repo").GetString()).IsEqualTo("firmware"); + await Assert.That(Guid.Parse(body.GetProperty("id").GetString()!)).IsNotEqualTo(Guid.Empty); + } + + [Test] + public async Task Put_IsIdempotent_SoRerunningOnboardingIsHarmless() + { + using var client = Factory.CreateAdminClient(); + + var first = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest + { + Provider = "github", Owner = "openshock", Repo = "firmware" + }); + var firstId = (await first.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + var second = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest + { + Provider = "github", Owner = "openshock", Repo = "firmware" + }); + + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.OK); + var secondId = (await second.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + await Assert.That(secondId).IsEqualTo(firstId); + } + + [Test] + public async Task Put_DifferentCasing_UpdatesTheSameRegistration() + { + using var client = Factory.CreateAdminClient(); + + var first = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest + { + Provider = "github", Owner = "openshock", Repo = "firmware", + Scopes = ["publish_firmware"] + }); + var firstId = (await first.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + // GitHub treats owner/repo case-insensitively, so this must land on the same grant rather + // than creating a second row the OIDC lookup would never reach. + var second = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest + { + Provider = "GitHub", Owner = "OpenShock", Repo = "Firmware", + Scopes = ["publish_firmware", "publish_modules"] + }); + + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.OK); + var body = await second.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("id").GetString()).IsEqualTo(firstId); + + // And scopes are authoritative on re-registration, so a grant can be widened or narrowed. + var scopes = body.GetProperty("scopes").EnumerateArray().Select(e => e.GetString()).ToList(); + await Assert.That(scopes).Contains("publish_modules"); + + var all = await client.GetFromJsonAsync>(BasePath); + await Assert.That(all!).Count().IsEqualTo(1); + } + + [Test] + public async Task Put_UnknownScope_Returns400() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest + { + Provider = "github", Owner = "openshock", Repo = "firmware", + Scopes = ["publish_everything"] + }); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Put_UnknownProvider_Returns400() + { + using var client = Factory.CreateAdminClient(); + + var response = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest + { + Provider = "bitbucket", Owner = "openshock", Repo = "firmware" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Put_WithoutAdminToken_IsRejected() + { + // Onboarding is the authorization decision for publishing, so it must not be reachable + // without the admin token. + using var client = Factory.CreateClient(); + + var response = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest + { + Provider = "github", Owner = "attacker", Repo = "evil" + }); + + await Assert.That(response.IsSuccessStatusCode).IsFalse(); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/StagedReleaseCleanupServiceTests.cs b/RepositoryServer.Tests/Integration/Tests/StagedReleaseCleanupServiceTests.cs new file mode 100644 index 0000000..5bfd900 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/StagedReleaseCleanupServiceTests.cs @@ -0,0 +1,156 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Services; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +/// +/// Exercises the TTL cleanup job directly. The hosted service is suppressed in the test harness, so +/// this constructs it against the same real Postgres and drives it with a +/// rather than waiting on wall-clock time. +/// +[NotInParallel("repo-server-integration")] +public class StagedReleaseCleanupServiceTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task Tick_AbortsStagingReleasePastItsTtl() + { + var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z"); + var releaseId = await SeedReleaseAsync(ReleaseStatus.Staging, createdAt: now.AddHours(-2)); + + // StagedReleaseTtl is 1h in the test harness, so a 2h-old staging release is expired. + await RunTickAsync(now); + + await Assert.That(await GetStatusAsync(releaseId)).IsEqualTo(ReleaseStatus.Aborted); + } + + [Test] + public async Task Tick_LeavesStagingReleaseInsideItsTtl() + { + var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z"); + var releaseId = await SeedReleaseAsync(ReleaseStatus.Staging, createdAt: now.AddMinutes(-30)); + + await RunTickAsync(now); + + await Assert.That(await GetStatusAsync(releaseId)).IsEqualTo(ReleaseStatus.Staging); + } + + [Test] + public async Task Tick_UsesLongerTtlForEditingReleases() + { + var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z"); + + // Two hours old: past the 1h staging TTL, well inside the 7d editing TTL. An editing release + // is waiting on a human, so it must not be swept up on the CI timescale. + var editingId = await SeedReleaseAsync(ReleaseStatus.Editing, createdAt: now.AddHours(-2)); + var stagingId = await SeedReleaseAsync(ReleaseStatus.Staging, createdAt: now.AddHours(-2), version: "1.6.0"); + + await RunTickAsync(now); + + await Assert.That(await GetStatusAsync(editingId)).IsEqualTo(ReleaseStatus.Editing); + await Assert.That(await GetStatusAsync(stagingId)).IsEqualTo(ReleaseStatus.Aborted); + } + + [Test] + public async Task Tick_AbortsEditingReleasePastTheEditingTtl() + { + var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z"); + var releaseId = await SeedReleaseAsync(ReleaseStatus.Editing, createdAt: now.AddDays(-8)); + + await RunTickAsync(now); + + await Assert.That(await GetStatusAsync(releaseId)).IsEqualTo(ReleaseStatus.Aborted); + } + + [Test] + public async Task Tick_IgnoresPublishedReleases() + { + var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z"); + var releaseId = await SeedReleaseAsync(ReleaseStatus.Published, createdAt: now.AddDays(-365)); + + await RunTickAsync(now); + + await Assert.That(await GetStatusAsync(releaseId)).IsEqualTo(ReleaseStatus.Published); + } + + // ---- Helpers ---- + + /// + /// Runs exactly one cleanup pass with the clock pinned to . + /// + private async Task RunTickAsync(DateTimeOffset now) + { + var timeProvider = new FakeTimeProvider(now); + + await using var scope = Factory.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + + var service = new StagedReleaseCleanupService( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + timeProvider, + sp.GetRequiredService>()); + + await service.TickAsync(CancellationToken.None); + } + + private async Task SeedReleaseAsync( + ReleaseStatus status, DateTimeOffset createdAt, string version = "1.5.1") + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var repo = await db.Repositories.FirstOrDefaultAsync(); + if (repo is null) + { + repo = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = "openshock", + Repo = "firmware" + }; + db.Repositories.Add(repo); + } + + var release = new FirmwareRelease + { + Id = Guid.NewGuid(), + Version = version, + Channel = ReleaseChannel.Stable, + RepositoryId = repo.Id, + CommitHash = "abc1234567890abcdef1234567890abcdef12345", + ReleaseDate = createdAt, + Status = status, + DeclaredBoards = [], + CreatedAt = createdAt + }; + + db.FirmwareReleases.Add(release); + await db.SaveChangesAsync(); + return release.Id; + } + + private async Task GetStatusAsync(Guid releaseId) + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.FirmwareReleases + .Where(r => r.Id == releaseId) + .Select(r => r.Status) + .FirstAsync(); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs new file mode 100644 index 0000000..1c7064b --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class UsbDevicesAdminControllerTests +{ + private const string BasePath = "/2/firmware/admin/usb-devices"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task Put_NewDevice_Returns201() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest + { + Vid = 0x1A86, + Pid = 0x7522, + Name = "CH9102" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body).IsNotNull(); + await Assert.That(body!.Vid).IsEqualTo(0x1A86); + await Assert.That(body.Pid).IsEqualTo(0x7522); + await Assert.That(body.Name).IsEqualTo("CH9102"); + } + + [Test] + public async Task Put_DuplicateVidPid_UpdatesNameInPlace() + { + using var client = Factory.CreateAdminClient(); + + var first = await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest + { + Vid = 0x10C4, Pid = 0xEA60, Name = "CP2102" + }); + await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created); + var original = await first.Content.ReadFromJsonAsync(); + + // Same (vid, pid), new name — should update, reuse id, return 200 + var second = await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest + { + Vid = 0x10C4, Pid = 0xEA60, Name = "CP2102 (Silabs USB-UART)" + }); + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.OK); + var updated = await second.Content.ReadFromJsonAsync(); + + await Assert.That(updated).IsNotNull(); + await Assert.That(updated!.Id).IsEqualTo(original!.Id); + await Assert.That(updated.Name).IsEqualTo("CP2102 (Silabs USB-UART)"); + } + + [Test] + public async Task Delete_Unreferenced_Returns204() + { + using var client = Factory.CreateAdminClient(); + var created = await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest + { + Vid = 0x0403, Pid = 0x6001, Name = "FT232" + }); + var body = await created.Content.ReadFromJsonAsync(); + + var deleteResponse = await client.DeleteAsync($"{BasePath}/{body!.Id}"); + await Assert.That(deleteResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + [Test] + public async Task Delete_UnknownId_Returns404() + { + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Delete_AttachedToChip_Returns409InUse() + { + Guid deviceId; + await using (var scope = Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var chip = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32-S3" }; + var device = new UsbDevice { Id = Guid.NewGuid(), Vid = 0x303A, Pid = 0x1001, Name = "ESP32-S3 USB-JTAG" }; + db.FirmwareChips.Add(chip); + db.UsbDevices.Add(device); + db.FirmwareChipUsbDevices.Add(new FirmwareChipUsbDevice + { + ChipId = chip.Id, + UsbDeviceId = device.Id + }); + await db.SaveChangesAsync(); + deviceId = device.Id; + } + + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{deviceId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Get_ListsDevicesOrderedByVidPid() + { + using var client = Factory.CreateAdminClient(); + await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest { Vid = 0x1A86, Pid = 0x7522, Name = "CH9102" }); + await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest { Vid = 0x10C4, Pid = 0xEA60, Name = "CP2102" }); + await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest { Vid = 0x0403, Pid = 0x6001, Name = "FT232" }); + + var devices = await client.GetFromJsonAsync>(BasePath); + await Assert.That(devices).IsNotNull(); + await Assert.That(devices!).Count().IsEqualTo(3); + await Assert.That(devices[0].Vid).IsEqualTo(0x0403); + await Assert.That(devices[1].Vid).IsEqualTo(0x10C4); + await Assert.That(devices[2].Vid).IsEqualTo(0x1A86); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/UsbSerialFiltersAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/UsbSerialFiltersAdminControllerTests.cs new file mode 100644 index 0000000..f99343b --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/UsbSerialFiltersAdminControllerTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Net.Http.Json; +using OpenShock.RepositoryServer.Models.Firmware; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class UsbSerialFiltersAdminControllerTests +{ + private const string BasePath = "/2/firmware/admin/usb-serial-filters"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task Put_VendorWide_Returns201() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest + { + Vid = 0x1A86, + Description = "WCH vendor-wide" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body).IsNotNull(); + await Assert.That(body!.Vid).IsEqualTo(0x1A86); + await Assert.That(body.Pid).IsNull(); + await Assert.That(body.Description).IsEqualTo("WCH vendor-wide"); + } + + [Test] + public async Task Put_SpecificVidPid_Returns201WithBothFieldsSet() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest + { + Vid = 0x303A, + Pid = 0x1001, + Description = "ESP32-S3 native USB-JTAG" + }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body!.Vid).IsEqualTo(0x303A); + await Assert.That(body.Pid).IsEqualTo(0x1001); + } + + [Test] + public async Task Put_SameVendorWide_UpdatesDescriptionInPlace() + { + using var client = Factory.CreateAdminClient(); + var first = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest + { + Vid = 0x0403, + Description = "FTDI" + }); + var original = await first.Content.ReadFromJsonAsync(); + + var second = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest + { + Vid = 0x0403, + Description = "FTDI (all products)" + }); + + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.OK); + var updated = await second.Content.ReadFromJsonAsync(); + await Assert.That(updated).IsNotNull(); + await Assert.That(updated!.Id).IsEqualTo(original!.Id); + await Assert.That(updated.Description).IsEqualTo("FTDI (all products)"); + } + + [Test] + public async Task Get_ReturnsAllFilters() + { + using var client = Factory.CreateAdminClient(); + await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest { Vid = 0x1A86 }); + await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest { Vid = 0x10C4 }); + await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest { Vid = 0x303A, Pid = 0x1001 }); + + var body = await client.GetFromJsonAsync>(BasePath); + await Assert.That(body).IsNotNull(); + await Assert.That(body!).Count().IsEqualTo(3); + } + + [Test] + public async Task Delete_ExistingFilter_Returns204() + { + using var client = Factory.CreateAdminClient(); + var created = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest + { + Vid = 0x239A + }); + var body = await created.Content.ReadFromJsonAsync(); + + var response = await client.DeleteAsync($"{BasePath}/{body!.Id}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + [Test] + public async Task Delete_UnknownId_Returns404() + { + using var client = Factory.CreateAdminClient(); + var response = await client.DeleteAsync($"{BasePath}/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task ManifestShape_OmitsPidForVendorWide() + { + using var admin = Factory.CreateAdminClient(); + await admin.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest + { + Vid = 0x1A86 + }); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/manifest"); + var json = await response.Content.ReadAsStringAsync(); + + // Vendor-wide entries must have no `pid` key on the public shape. + await Assert.That(json.Contains("\"vid\":6790")).IsTrue(); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs new file mode 100644 index 0000000..45a61c5 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs @@ -0,0 +1,280 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Tests.Integration.Tests; + +[NotInParallel("repo-server-integration")] +public class VersionsControllerTests +{ + private const string BoardName = "Sample-Board"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory Factory { get; init; } + + [Before(Test)] + public Task Setup() => Factory.ResetDatabaseAsync(); + + [Test] + public async Task List_EmptyDatabase_ReturnsZeroTotal() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/versions"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("total").GetInt32()).IsEqualTo(0); + await Assert.That(body.GetProperty("versions").GetArrayLength()).IsEqualTo(0); + } + + [Test] + public async Task List_WithVersions_OrdersByReleaseDateDescending() + { + await SeedVersionsAsync(("1.5.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-5)), + ("1.5.1", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-1)), + ("1.5.2-beta.1", ReleaseChannel.Beta, DateTimeOffset.UtcNow)); + + using var client = Factory.CreateClient(); + var body = await client.GetFromJsonAsync("/2/firmware/versions"); + + await Assert.That(body.GetProperty("total").GetInt32()).IsEqualTo(3); + var versions = body.GetProperty("versions").EnumerateArray() + .Select(v => v.GetProperty("version").GetString()).ToList(); + await Assert.That(versions[0]).IsEqualTo("1.5.2-beta.1"); + await Assert.That(versions[1]).IsEqualTo("1.5.1"); + await Assert.That(versions[2]).IsEqualTo("1.5.0"); + } + + [Test] + public async Task List_FilterByChannel_ReturnsOnlyMatching() + { + await SeedVersionsAsync( + ("1.5.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-2)), + ("1.6.0-beta.1", ReleaseChannel.Beta, DateTimeOffset.UtcNow.AddDays(-1))); + + using var client = Factory.CreateClient(); + var body = await client.GetFromJsonAsync("/2/firmware/versions?channel=stable"); + + await Assert.That(body.GetProperty("total").GetInt32()).IsEqualTo(1); + var versions = body.GetProperty("versions").EnumerateArray() + .Select(v => v.GetProperty("version").GetString()).ToList(); + await Assert.That(versions).Count().IsEqualTo(1); + await Assert.That(versions[0]).IsEqualTo("1.5.0"); + } + + [Test] + public async Task List_InvalidChannel_Returns400() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/versions?channel=nightly"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task List_LimitAndOffset_Paginate() + { + await SeedVersionsAsync( + ("1.0.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-5)), + ("1.1.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-4)), + ("1.2.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-3)), + ("1.3.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-2)), + ("1.4.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-1))); + + using var client = Factory.CreateClient(); + var page1 = await client.GetFromJsonAsync("/2/firmware/versions?limit=2&offset=0"); + await Assert.That(page1.GetProperty("total").GetInt32()).IsEqualTo(5); + await Assert.That(page1.GetProperty("versions").GetArrayLength()).IsEqualTo(2); + + var page2 = await client.GetFromJsonAsync("/2/firmware/versions?limit=2&offset=2"); + await Assert.That(page2.GetProperty("versions").GetArrayLength()).IsEqualTo(2); + + var page3 = await client.GetFromJsonAsync("/2/firmware/versions?limit=2&offset=4"); + await Assert.That(page3.GetProperty("versions").GetArrayLength()).IsEqualTo(1); + + var firstPageVersions = page1.GetProperty("versions").EnumerateArray() + .Select(v => v.GetProperty("version").GetString()).ToList(); + var secondPageVersions = page2.GetProperty("versions").EnumerateArray() + .Select(v => v.GetProperty("version").GetString()).ToList(); + await Assert.That(firstPageVersions.Intersect(secondPageVersions).Any()).IsFalse(); + } + + [Test] + public async Task GetVersion_Unknown_Returns404() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/versions/9.9.9"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task GetVersion_WithReleaseNotes_ReturnsFullDto() + { + var boardId = await SeedVersionWithNotesAsync("1.5.1"); + + using var client = Factory.CreateClient(); + var body = await client.GetFromJsonAsync("/2/firmware/versions/1.5.1"); + await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.1"); + await Assert.That(body.GetProperty("releaseNotes").GetArrayLength()).IsEqualTo(2); + + var boards = body.GetProperty("boards").EnumerateObject() + .Select(p => p.Name).ToList(); + await Assert.That(boards).Contains(BoardName); + } + + [Test] + public async Task GetVersion_ImmutableCacheHeader() + { + await SeedVersionWithNotesAsync("1.5.1"); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/versions/1.5.1"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(response.Headers.CacheControl).IsNotNull(); + await Assert.That(response.Headers.CacheControl!.Public).IsTrue(); + await Assert.That(response.Headers.CacheControl.MaxAge).IsEqualTo(TimeSpan.FromSeconds(86400)); + } + + [Test] + public async Task GetVersionForBoard_UnknownVersion_Returns404() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/2/firmware/versions/9.9.9/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task GetVersionForBoard_HappyPath_ReturnsArtifacts() + { + var boardId = await SeedVersionWithNotesAsync("1.5.1"); + + using var client = Factory.CreateClient(); + var body = await client.GetFromJsonAsync( + $"/2/firmware/versions/1.5.1/{boardId}"); + + await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.1"); + await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName); + await Assert.That(body.GetProperty("artifacts").GetArrayLength()).IsGreaterThanOrEqualTo(1); + } + + [Test] + public async Task GetVersionForBoard_ByName_LabelsByNameAndAddressesById() + { + var boardId = await SeedVersionWithNotesAsync("1.5.1"); + + using var client = Factory.CreateClient(); + var body = await client.GetFromJsonAsync( + $"/2/firmware/versions/1.5.1/{BoardName}"); + + await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName); + + var url = body.GetProperty("artifacts")[0].GetProperty("url").GetString(); + await Assert.That(url).Contains($"/1.5.1/{boardId}/"); + } + + [Test] + public async Task GetVersionForBoard_UnknownName_Returns404() + { + await SeedVersionWithNotesAsync("1.5.1"); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/2/firmware/versions/1.5.1/No-Such-Board"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + private async Task SeedVersionsAsync(params (string Version, ReleaseChannel Channel, DateTimeOffset ReleaseDate)[] rows) + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var repo = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = "openshock", + Repo = "firmware" + }; + db.Repositories.Add(repo); + + foreach (var (version, channel, releaseDate) in rows) + { + db.FirmwareVersions.Add(new FirmwareVersion + { + Version = version, + Channel = channel, + ReleaseDate = releaseDate, + RepositoryId = repo.Id, + CommitHash = "abc1234567890abcdef1234567890abcdef12345" + }); + } + + await db.SaveChangesAsync(); + } + + private async Task SeedVersionWithNotesAsync(string version) + { + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var chip = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32" }; + var board = new FirmwareBoard + { + Id = Guid.NewGuid(), + Name = BoardName, + ChipId = chip.Id, + RequiredArtifactTypes = [FirmwareArtifactType.Merged] + }; + var repo = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = RepositoryProvider.Github, + Owner = "openshock", + Repo = "firmware" + }; + var fw = new FirmwareVersion + { + Version = version, + Channel = ReleaseChannel.Stable, + ReleaseDate = DateTimeOffset.UtcNow, + RepositoryId = repo.Id, + CommitHash = "abc1234567890abcdef1234567890abcdef12345" + }; + + db.FirmwareChips.Add(chip); + db.FirmwareBoards.Add(board); + db.Repositories.Add(repo); + db.FirmwareVersions.Add(fw); + + db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = version, + BoardId = board.Id, + ArtifactType = FirmwareArtifactType.Merged, + HashSha256 = new byte[32], + FileSize = 1024 + }); + + db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote + { + Version = version, + Index = 0, + SectionType = ReleaseNoteSectionType.Breaking, + Title = "Config format", + Content = "Changed config format to TOML" + }); + db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote + { + Version = version, + Index = 1, + SectionType = ReleaseNoteSectionType.Info, + Title = null, + Content = "Fixed WiFi reconnection" + }); + + await db.SaveChangesAsync(); + return board.Id; + } +} diff --git a/RepositoryServer.Tests/Integration/WebApplicationFactory.cs b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs new file mode 100644 index 0000000..bfd83be --- /dev/null +++ b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs @@ -0,0 +1,199 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Services; +using OpenShock.RepositoryServer.Tests.Integration.Docker; +using TUnit.Core.Interfaces; + +namespace OpenShock.RepositoryServer.Tests.Integration; + +/// +/// Custom +/// wired up to a Testcontainers Postgres instance. The harness: +/// * overlays in-memory configuration on top of whatever Program.cs loads from disk +/// (overrides db connection, admin token, local storage path, and forces skip-migration) +/// * removes the hosted service so it doesn't +/// race the schema creation inside +/// * applies the real EF migrations (via , the +/// context the migrations are attributed to) so the suite validates them +/// +public sealed class WebApplicationFactory + : Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory, IAsyncInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required InMemoryDatabase PostgreSql { get; init; } + + private readonly string _cdnStoragePath = Path.Combine( + Path.GetTempPath(), $"repo-server-tests-cdn-{Guid.NewGuid():N}"); + + public async Task InitializeAsync() + { + _ = Server; // force the host to build + + await using var scope = Services.CreateAsyncScope(); + var loggerFactory = scope.ServiceProvider.GetRequiredService(); + await using var migrationContext = new MigrationOpenShockContext( + PostgreSql.Container.GetConnectionString(), debug: false, loggerFactory); + await migrationContext.Database.MigrateAsync(); + } + + /// + /// Returns an with a pre-populated Authorization + /// header matching the admin token baked into the test configuration. + /// + public HttpClient CreateAdminClient() + { + var client = CreateClient(); + // AdminTokenAuthentication compares the raw Authorization header to the configured + // admin token — TryAddWithoutValidation bypasses HttpHeaders' scheme+token parser. + client.DefaultRequestHeaders.TryAddWithoutValidation( + TestAdminToken.HeaderName, TestAdminToken.Value); + return client; + } + + /// + /// Returns an authenticated as the CI/CD principal of + /// , which must be a registered repository. + /// + public HttpClient CreateCiCdClient(Guid repositoryId, string? commitHash = null, string? scopes = null) + { + var client = CreateClient(); + client.DefaultRequestHeaders.TryAddWithoutValidation( + TestCiCdAuthHandler.RepositoryIdHeader, repositoryId.ToString()); + if (scopes is not null) + { + client.DefaultRequestHeaders.TryAddWithoutValidation( + TestCiCdAuthHandler.ScopesHeader, scopes); + } + if (commitHash is not null) + { + client.DefaultRequestHeaders.TryAddWithoutValidation( + TestCiCdAuthHandler.CommitHashHeader, commitHash); + } + return client; + } + + /// + /// True when a path exists in the local CDN storage backing this factory. Lets tests assert on + /// what actually reached storage, rather than inferring it from response bodies. + /// + public bool StoredFileExists(string relativePath) => + File.Exists(Path.Combine(_cdnStoragePath, relativePath.Replace('/', Path.DirectorySeparatorChar))); + + /// + /// Wipes all mutable test data between tests without tearing down the container. + /// Firmware tables first (FK order), then repositories, then catalog. + /// + public async Task ResetDatabaseAsync(CancellationToken ct = default) + { + await using var scope = Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await db.Database.ExecuteSqlRawAsync( + """ + TRUNCATE TABLE + firmware_staged_release_notes, + firmware_staged_artifacts, + firmware_releases, + firmware_release_notes, + firmware_artifacts, + firmware_versions, + firmware_board_usb_devices, + firmware_chip_usb_devices, + firmware_boards, + firmware_chips, + firmware_advisories, + usb_serial_filters, + usb_devices, + discord_webhooks, + versions, + modules, + repositories + RESTART IDENTITY CASCADE; + """, ct); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + // Program.cs reads and validates ApiConfig BEFORE builder.Build(). For minimal-hosting + // apps, ConfigureAppConfiguration overlays are only applied at Build() time — too late. + // UseSetting goes through host configuration, which the deferred host builder flattens + // into command-line args for Program.Main, so Program's `.AddCommandLine(args)` picks + // these up before the config is validated. + var settings = new Dictionary + { + ["Db:Conn"] = PostgreSql.Container.GetConnectionString(), + ["Db:SkipMigration"] = "true", + ["Db:Debug"] = "false", + + ["AdminToken"] = TestAdminToken.Value, + + ["CiCd:Audience"] = "openshock-repository-server-test", + + ["Repo:CdnBaseUrl"] = "https://cdn-test.openshock.example/repo", + + ["Firmware:CdnBaseUrl"] = "https://cdn-test.openshock.example/firmware", + ["Firmware:Storage:Type"] = "Local", + ["Firmware:Storage:Local:BasePath"] = _cdnStoragePath, + ["Firmware:StagedReleaseTtl"] = "01:00:00", + ["Firmware:EditingReleaseTtl"] = "7.00:00:00", + }; + foreach (var (key, value) in settings) + { + builder.UseSetting(key, value); + } + + builder.ConfigureTestServices(services => + { + // Repoint the CI/CD scheme at the test handler — see TestCiCdAuthHandler for what this + // does and does not substitute. The scheme is already registered as JwtBearer by + // Program.cs, and registering the same name twice fails host startup, so swap the handler + // type on the existing registration rather than adding a second one. + services.PostConfigure(options => + { + if (options.SchemeMap.TryGetValue(AuthSchemas.CiCdToken, out var scheme)) + { + scheme.HandlerType = typeof(TestCiCdAuthHandler); + } + }); + + // The cleanup hosted service fires on startup and queries the DB. In tests + // we build schema AFTER the host starts, so suppress it to avoid noisy + // failure logs. Individual tests exercise its logic directly if needed. + var hostedDescriptor = services.FirstOrDefault( + d => d.ImplementationType == typeof(StagedReleaseCleanupService)); + if (hostedDescriptor is not null) + { + services.Remove(hostedDescriptor); + } + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + try + { + if (Directory.Exists(_cdnStoragePath)) + { + Directory.Delete(_cdnStoragePath, recursive: true); + } + } + catch + { + // best effort — tests shouldn't fail on cleanup + } + } + } +} diff --git a/RepositoryServer.Tests/RepositoryServer.Tests.csproj b/RepositoryServer.Tests/RepositoryServer.Tests.csproj new file mode 100644 index 0000000..17a8852 --- /dev/null +++ b/RepositoryServer.Tests/RepositoryServer.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + false + true + default + OpenShock.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests + + + + + + + + + + + diff --git a/RepositoryServer.Tests/Utils/ChangelogParserTests.cs b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs new file mode 100644 index 0000000..e91949b --- /dev/null +++ b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs @@ -0,0 +1,263 @@ +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Tests.Utils; + +public class ChangelogParserTests +{ + [Test] + public async Task Parse_Empty_ReturnsEmptyError() + { + var result = ChangelogParser.Parse(""); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.Empty); + } + + [Test] + public async Task Parse_Whitespace_ReturnsEmptyError() + { + var result = ChangelogParser.Parse(" \n\n\n "); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.Empty); + } + + [Test] + public async Task Parse_NoHeadings_ReturnsNoHeadingsError() + { + var result = ChangelogParser.Parse("Just some text\nwithout headings."); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.NoHeadings); + } + + [Test] + public async Task Parse_AllSectionsEmpty_ReturnsAllSectionsEmptyError() + { + var result = ChangelogParser.Parse("### Info\n\n### Warning\n\n### Breaking\n"); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.AllSectionsEmpty); + } + + [Test] + public async Task Parse_MapsBreakingWarningInfo_ToLowercaseEnumNames() + { + var input = "### Breaking\nLorem\n### Warning\nIpsum\n### Info\nDolor"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(3); + await Assert.That(notes[0].Type).IsEqualTo("breaking"); + await Assert.That(notes[1].Type).IsEqualTo("warning"); + await Assert.That(notes[2].Type).IsEqualTo("info"); + } + + [Test] + public async Task Parse_CustomHeading_BecomesSectionWithTitle() + { + var input = "### Features\n- New dashboard\n- Bluetooth pairing"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(2); + await Assert.That(notes[0].Type).IsEqualTo("section"); + await Assert.That(notes[0].Title).IsEqualTo("Features"); + await Assert.That(notes[0].Content).IsEqualTo("New dashboard"); + await Assert.That(notes[1].Title).IsEqualTo("Features"); + await Assert.That(notes[1].Content).IsEqualTo("Bluetooth pairing"); + } + + [Test] + public async Task Parse_ExtractsBoldTitlePrefix_WithEmDashSeparator() + { + var input = "### Breaking\n**Config format** — Changed to TOML"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(1); + await Assert.That(notes[0].Type).IsEqualTo("breaking"); + await Assert.That(notes[0].Title).IsEqualTo("Config format"); + await Assert.That(notes[0].Content).IsEqualTo("Changed to TOML"); + } + + [Test] + public async Task Parse_MultiLineItemWithoutBullets_IsCombined() + { + var input = "### Info\nLine one\nLine two"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(1); + await Assert.That(notes[0].Content).IsEqualTo("Line one\nLine two"); + } + + [Test] + public async Task Parse_PlainItem_NoTitleExtracted() + { + var input = "### Info\n- Fixed WiFi reconnection"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(1); + await Assert.That(notes[0].Type).IsEqualTo("info"); + await Assert.That(notes[0].Title).IsNull(); + await Assert.That(notes[0].Content).IsEqualTo("Fixed WiFi reconnection"); + } + + [Test] + [Arguments("### Info\n**Title** — content", "Title", "content")] + [Arguments("### Info\n**Title** – content", "Title", "content")] + [Arguments("### Info\n**Title** - content", "Title", "content")] + [Arguments("### Info\n**Title** -content", "Title", "content")] + public async Task Parse_SupportsMultipleTitleSeparators(string input, string expectedTitle, string expectedContent) + { + var result = ChangelogParser.Parse(input); + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(1); + await Assert.That(notes[0].Title).IsEqualTo(expectedTitle); + await Assert.That(notes[0].Content).IsEqualTo(expectedContent); + } + + [Test] + public async Task Parse_IgnoresLinesBeforeFirstHeading() + { + var input = "Intro text we should ignore\n\n### Info\nActual content"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(1); + await Assert.That(notes[0].Content).IsEqualTo("Actual content"); + } + + [Test] + public async Task Parse_HandlesCrlfLineEndings() + { + var input = "### Info\r\n- Fixed WiFi\r\n### Warning\r\nNeeds reset"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(2); + await Assert.That(notes[0].Type).IsEqualTo("info"); + await Assert.That(notes[0].Content).IsEqualTo("Fixed WiFi"); + await Assert.That(notes[1].Type).IsEqualTo("warning"); + await Assert.That(notes[1].Content).IsEqualTo("Needs reset"); + } + + [Test] + public async Task Parse_HeadingsAreCaseInsensitive() + { + var input = "### BREAKING\nFoo\n### warning\nBar\n### Info\nBaz"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(3); + await Assert.That(notes[0].Type).IsEqualTo("breaking"); + await Assert.That(notes[1].Type).IsEqualTo("warning"); + await Assert.That(notes[2].Type).IsEqualTo("info"); + } + + [Test] + public async Task Parse_SectionProseAndBulletsBothBecomeNotes() + { + // Prose alongside bullets is content, not noise — it used to be silently discarded. + var input = "### Info\nIntroductory prose\n- Actual item 1\n- Actual item 2"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(3); + await Assert.That(notes[0].Content).IsEqualTo("Introductory prose"); + await Assert.That(notes[1].Content).IsEqualTo("Actual item 1"); + await Assert.That(notes[2].Content).IsEqualTo("Actual item 2"); + } + + [Test] + public async Task Parse_NestedBulletsAreFlattenedNotDropped() + { + // The DTO has no nesting, so indented items become siblings. Previously they fell into the + // prose buffer and were discarded along with it. + var input = "### Info\n- Parent item\n - Nested item\n - Deeply nested item"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(3); + await Assert.That(notes[0].Content).IsEqualTo("Parent item"); + await Assert.That(notes[1].Content).IsEqualTo("Nested item"); + await Assert.That(notes[2].Content).IsEqualTo("Deeply nested item"); + } + + [Test] + public async Task Parse_EmptyBoldTitle_FallsBackToLiteralContent() + { + var input = "### Info\n**** — still content"; + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(1); + await Assert.That(notes[0].Title).IsNull(); + await Assert.That(notes[0].Content).IsEqualTo("**** — still content"); + } + + [Test] + public async Task Parse_SpecExample_MatchesGoldenStructure() + { + // Reproduced verbatim from the §5.3 worked example. Do not trim lines out of this fixture to + // make it pass — every line exercises a documented construct, and a shorter fixture is how the + // section-prose divergence went unnoticed. + var input = string.Join("\n", + "### Breaking", + "**Config format** — Changed config format to TOML", + "Multiple lines of content are concatenated into a single note.", + "", + "### Warning", + "Requires hub reset after update", + "", + "### Info", + "- Fixed WiFi reconnection", + "- Improved battery life", + "- **OTA** — Added automatic rollback on failed flash", + "", + "### Features", + "Custom sections become type \"section\" with the heading as title.", + "- New web dashboard", + "- Bluetooth pairing support"); + var result = ChangelogParser.Parse(input); + + await Assert.That(result.IsT0).IsTrue(); + var notes = result.AsT0; + await Assert.That(notes).Count().IsEqualTo(8); + + await Assert.That(notes[0].Type).IsEqualTo("breaking"); + await Assert.That(notes[0].Title).IsEqualTo("Config format"); + await Assert.That(notes[0].Content) + .IsEqualTo("Changed config format to TOML\nMultiple lines of content are concatenated into a single note."); + + await Assert.That(notes[1].Type).IsEqualTo("warning"); + await Assert.That(notes[1].Content).IsEqualTo("Requires hub reset after update"); + + await Assert.That(notes[2].Type).IsEqualTo("info"); + await Assert.That(notes[2].Content).IsEqualTo("Fixed WiFi reconnection"); + await Assert.That(notes[3].Content).IsEqualTo("Improved battery life"); + await Assert.That(notes[4].Title).IsEqualTo("OTA"); + await Assert.That(notes[4].Content).IsEqualTo("Added automatic rollback on failed flash"); + + // Section prose is a note in its own right, ahead of the section's bullets. + await Assert.That(notes[5].Type).IsEqualTo("section"); + await Assert.That(notes[5].Title).IsEqualTo("Features"); + await Assert.That(notes[5].Content) + .IsEqualTo("Custom sections become type \"section\" with the heading as title."); + + await Assert.That(notes[6].Type).IsEqualTo("section"); + await Assert.That(notes[6].Title).IsEqualTo("Features"); + await Assert.That(notes[6].Content).IsEqualTo("New web dashboard"); + await Assert.That(notes[7].Content).IsEqualTo("Bluetooth pairing support"); + } +} diff --git a/RepositoryServer.Tests/Utils/FirmwareArtifactFileNamesTests.cs b/RepositoryServer.Tests/Utils/FirmwareArtifactFileNamesTests.cs new file mode 100644 index 0000000..cb34c6d --- /dev/null +++ b/RepositoryServer.Tests/Utils/FirmwareArtifactFileNamesTests.cs @@ -0,0 +1,30 @@ +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Tests.Utils; + +public class FirmwareArtifactFileNamesTests +{ + [Test] + [Arguments(FirmwareArtifactType.Merged, "firmware.bin")] + [Arguments(FirmwareArtifactType.App, "app.bin")] + [Arguments(FirmwareArtifactType.Bootloader, "bootloader.bin")] + [Arguments(FirmwareArtifactType.Partitions, "partitions.bin")] + [Arguments(FirmwareArtifactType.StaticFs, "staticfs.bin")] + public async Task GetFileName_KnownType_ReturnsSpecFilename(FirmwareArtifactType type, string expected) + { + var actual = FirmwareArtifactFileNames.GetFileName(type); + await Assert.That(actual).IsEqualTo(expected); + } + + [Test] + public async Task GetFileName_Covers_AllEnumValues() + { + foreach (var value in Enum.GetValues()) + { + var result = FirmwareArtifactFileNames.GetFileName(value); + await Assert.That(result).IsNotNull(); + await Assert.That(result.EndsWith(".bin", StringComparison.Ordinal)).IsTrue(); + } + } +} diff --git a/RepositoryServer.Tests/Utils/SourceUrlBuilderTests.cs b/RepositoryServer.Tests/Utils/SourceUrlBuilderTests.cs new file mode 100644 index 0000000..5aba48e --- /dev/null +++ b/RepositoryServer.Tests/Utils/SourceUrlBuilderTests.cs @@ -0,0 +1,63 @@ +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Tests.Utils; + +public class SourceUrlBuilderTests +{ + [Test] + public async Task BuildCommitUrl_Github_ReturnsCanonicalCommitUrl() + { + var url = SourceUrlBuilder.BuildCommitUrl(RepositoryProvider.Github, "openshock", "firmware", "21e43623abcdef1234567890abcdef1234567890"); + await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/commit/21e43623abcdef1234567890abcdef1234567890"); + } + + [Test] + public async Task BuildCommitUrl_EmptyHash_ReturnsNull() + { + var url = SourceUrlBuilder.BuildCommitUrl(RepositoryProvider.Github, "openshock", "firmware", ""); + await Assert.That(url).IsNull(); + } + + [Test] + public async Task BuildRefUrl_TagRef_ReturnsReleasesTagUrl() + { + var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", "refs/tags/v1.5.1"); + await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/releases/tag/v1.5.1"); + } + + [Test] + public async Task BuildRefUrl_BranchRef_ReturnsTreeUrl() + { + var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", "refs/heads/main"); + await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/tree/main"); + } + + [Test] + public async Task BuildRefUrl_BareRef_TreatedAsBranch() + { + var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", "develop"); + await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/tree/develop"); + } + + [Test] + public async Task BuildRefUrl_Null_ReturnsNull() + { + var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", null); + await Assert.That(url).IsNull(); + } + + [Test] + public async Task BuildRunUrl_Github_ReturnsActionsRunUrl() + { + var url = SourceUrlBuilder.BuildRunUrl(RepositoryProvider.Github, "openshock", "firmware", "12345678901"); + await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/actions/runs/12345678901"); + } + + [Test] + public async Task BuildRunUrl_Empty_ReturnsNull() + { + var url = SourceUrlBuilder.BuildRunUrl(RepositoryProvider.Github, "openshock", "firmware", ""); + await Assert.That(url).IsNull(); + } +} diff --git a/RepositoryServer/AuthSchemas.cs b/RepositoryServer/AuthSchemas.cs new file mode 100644 index 0000000..b7e7d5b --- /dev/null +++ b/RepositoryServer/AuthSchemas.cs @@ -0,0 +1,31 @@ +namespace OpenShock.RepositoryServer; + +public static class AuthSchemas +{ + public const string AdminToken = "AdminToken"; + public const string CiCdToken = "CiCdToken"; + + /// Authorization policies layered on top of the CI/CD scheme. + public static class Policies + { + public const string PublishFirmware = "PublishFirmware"; + public const string PublishModules = "PublishModules"; + } + + /// + /// Claim keys attached to the CI/CD principal after successful GitHub OIDC validation. + /// + public static class CiCdClaims + { + public const string RepositoryId = "openshock:repository_id"; + public const string CommitHash = "openshock:commit_hash"; + public const string Ref = "openshock:ref"; + public const string RunId = "openshock:run_id"; + + /// + /// One claim per granted to the + /// calling repository. Authorization policies match on these. + /// + public const string Scope = "openshock:scope"; + } +} diff --git a/DesktopRepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs b/RepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs similarity index 90% rename from DesktopRepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs rename to RepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs index e51a4d1..9161b56 100644 --- a/DesktopRepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs +++ b/RepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs @@ -5,11 +5,11 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.Options; -using OpenShock.Desktop.RepositoryServer.Config; -using OpenShock.Desktop.RepositoryServer.Errors; -using OpenShock.Desktop.RepositoryServer.Problems; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Errors; +using OpenShock.RepositoryServer.Problems; -namespace OpenShock.Desktop.RepositoryServer.AuthenticationHandlers; +namespace OpenShock.RepositoryServer.AuthenticationHandlers; public sealed class AdminTokenAuthentication : AuthenticationHandler { diff --git a/RepositoryServer/AuthenticationHandlers/GitHubOidcAuthentication.cs b/RepositoryServer/AuthenticationHandlers/GitHubOidcAuthentication.cs new file mode 100644 index 0000000..a294401 --- /dev/null +++ b/RepositoryServer/AuthenticationHandlers/GitHubOidcAuthentication.cs @@ -0,0 +1,134 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.AuthenticationHandlers; + +/// +/// Configures the scheme as a GitHub OIDC token +/// validator. The ASP.NET Core does the JWT + JWKS +/// cryptography; this class adds an +/// hook that: +/// 1. Extracts owner/repo/commit/ref/run_id from the token claims. +/// 2. Looks up the repositories row for this owner/repo pair, failing if absent. +/// 3. Attaches to the principal so controllers +/// can pull the matched repository id, commit SHA, ref, and run id. +/// +/// +/// A valid GitHub OIDC token proves only that some GitHub Actions workflow requested it. The +/// issuer is shared by every repository on GitHub, and the audience is a plain string that any workflow +/// can ask for by name, so neither authenticates which repository is calling. Registration in +/// the repositories table is therefore the actual authorization decision: an unregistered +/// repository is rejected here rather than being registered on the spot. Onboarding is an explicit +/// admin action — see PUT /v2/firmware/admin/repositories. +/// +public static class GitHubOidcAuthentication +{ + public const string Issuer = "https://token.actions.githubusercontent.com"; + + public static void Configure(JwtBearerOptions options, string audience) + { + options.Authority = Issuer; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = Issuer, + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = OnTokenValidatedAsync + }; + } + + private static async Task OnTokenValidatedAsync(TokenValidatedContext context) + { + var principal = context.Principal; + if (principal is null) + { + context.Fail("Token principal missing."); + return; + } + + var owner = principal.FindFirstValue("repository_owner"); + var repoFull = principal.FindFirstValue("repository"); // format: "owner/repo" + var commitHash = principal.FindFirstValue("sha"); + var refValue = principal.FindFirstValue("ref"); + var runId = principal.FindFirstValue("run_id"); + + if (string.IsNullOrWhiteSpace(owner) || + string.IsNullOrWhiteSpace(repoFull) || + string.IsNullOrWhiteSpace(commitHash)) + { + context.Fail("Required GitHub OIDC claims missing."); + return; + } + + var repo = repoFull; + var slashIndex = repoFull.IndexOf('/'); + if (slashIndex >= 0 && slashIndex < repoFull.Length - 1) + { + repo = repoFull[(slashIndex + 1)..]; + } + + var dbFactory = context.HttpContext.RequestServices + .GetRequiredService>(); + await using var db = await dbFactory.CreateDbContextAsync(context.HttpContext.RequestAborted); + + var registration = await FindRepositoryAsync(db, owner, repo, context.HttpContext.RequestAborted); + if (registration is null) + { + // Deliberately does not disclose whether the repository is merely unregistered. + context.Fail("Repository is not authorized to publish."); + return; + } + + var identity = (ClaimsIdentity)principal.Identity!; + identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.RepositoryId, registration.Id.ToString())); + + // Scopes decide which ingestion endpoints this grant reaches. A repository with none is + // registered but cannot publish anything. + foreach (var scope in registration.Scopes) + { + identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.Scope, scope.ToScopeClaim())); + } + identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.CommitHash, commitHash)); + if (!string.IsNullOrWhiteSpace(refValue)) + identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.Ref, refValue)); + if (!string.IsNullOrWhiteSpace(runId)) + identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.RunId, runId)); + } + + private sealed record Registration(Guid Id, RepositoryScope[] Scopes); + + /// + /// Resolves a pre-registered repository. Returns null when the pair is not registered — + /// never creates the row, since that would make the allowlist self-populating and authorize + /// whoever showed up first. + /// + /// + /// Owner and repo are matched case-insensitively. GitHub treats them that way, and the casing in + /// the token's claims follows whatever the repository is currently named, so an exact match would + /// silently stop authorizing a repository after a cosmetic rename. + /// + private static async Task FindRepositoryAsync(RepoServerContext db, string owner, string repo, CancellationToken ct) + { + const RepositoryProvider provider = RepositoryProvider.Github; + var loweredOwner = owner.ToLowerInvariant(); + var loweredRepo = repo.ToLowerInvariant(); + + return await db.Repositories + .Where(r => r.Provider == provider + && r.Owner.ToLower() == loweredOwner + && r.Repo.ToLower() == loweredRepo) + .Select(r => new Registration(r.Id, r.Scopes)) + .FirstOrDefaultAsync(ct); + } +} diff --git a/RepositoryServer/Config/ApiConfig.cs b/RepositoryServer/Config/ApiConfig.cs new file mode 100644 index 0000000..3b9c1bf --- /dev/null +++ b/RepositoryServer/Config/ApiConfig.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Config; + +public class ApiConfig +{ + [Required] public required DbConfig Db { get; init; } + [Required] public required string AdminToken { get; init; } + [Required] public required RepoConfig Repo { get; init; } + [Required] public required FirmwareConfig Firmware { get; init; } + + /// + /// Shared CI/CD auth settings. Deliberately not nested under : the scheme it + /// configures also guards desktop module publishing. + /// + [Required] public required CiCdConfig CiCd { get; init; } + public MetricsConfig Metrics { get; init; } = new(); +} \ No newline at end of file diff --git a/RepositoryServer/Config/CiCdConfig.cs b/RepositoryServer/Config/CiCdConfig.cs new file mode 100644 index 0000000..bf21289 --- /dev/null +++ b/RepositoryServer/Config/CiCdConfig.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Config; + +/// +/// Settings for the shared CI/CD authentication scheme, used by both firmware release ingestion and +/// desktop module publishing. +/// +public sealed class CiCdConfig +{ + /// + /// GitHub OIDC audience this server accepts. A workflow chooses its own audience when it + /// requests a token, and tokens not addressed here are rejected. + /// + /// + /// This is what makes an OIDC token non-transferable between services. id-token: write is + /// granted per job, so any action in a publishing workflow can mint a token; without this check, + /// a token obtained for some unrelated vendor would still be a valid firmware-publishing + /// credential, because it carries the same repository claim. + /// + /// It is an identifier, not a secret — anyone can request it by name — so it authenticates nothing + /// about who is calling. That is the allowlist's job, and scopes decide what they may do. + /// + [Required(AllowEmptyStrings = false)] + public required string Audience { get; init; } +} diff --git a/DesktopRepositoryServer/Config/DbConfig.cs b/RepositoryServer/Config/DbConfig.cs similarity index 83% rename from DesktopRepositoryServer/Config/DbConfig.cs rename to RepositoryServer/Config/DbConfig.cs index e416a5e..91f9b55 100644 --- a/DesktopRepositoryServer/Config/DbConfig.cs +++ b/RepositoryServer/Config/DbConfig.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace OpenShock.Desktop.RepositoryServer.Config; +namespace OpenShock.RepositoryServer.Config; public class DbConfig { diff --git a/RepositoryServer/Config/FirmwareConfig.cs b/RepositoryServer/Config/FirmwareConfig.cs new file mode 100644 index 0000000..afb4542 --- /dev/null +++ b/RepositoryServer/Config/FirmwareConfig.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Config; + +public sealed class FirmwareConfig +{ + [Required(AllowEmptyStrings = false)] public required string CdnBaseUrl { get; init; } + [Required] public required StorageConfig Storage { get; init; } + + /// + /// How long a release may remain in staging status before the cleanup job aborts it. + /// + public TimeSpan StagedReleaseTtl { get; init; } = TimeSpan.FromHours(1); + + /// + /// How long a release may remain in editing status before the cleanup job aborts it. + /// + public TimeSpan EditingReleaseTtl { get; init; } = TimeSpan.FromDays(7); +} diff --git a/DesktopRepositoryServer/Config/MetricsConfig.cs b/RepositoryServer/Config/MetricsConfig.cs similarity index 59% rename from DesktopRepositoryServer/Config/MetricsConfig.cs rename to RepositoryServer/Config/MetricsConfig.cs index 600d72a..e596376 100644 --- a/DesktopRepositoryServer/Config/MetricsConfig.cs +++ b/RepositoryServer/Config/MetricsConfig.cs @@ -1,6 +1,6 @@ -using OpenShock.Desktop.RepositoryServer.Utils; +using OpenShock.RepositoryServer.Utils; -namespace OpenShock.Desktop.RepositoryServer.Config; +namespace OpenShock.RepositoryServer.Config; public sealed class MetricsConfig { diff --git a/DesktopRepositoryServer/Config/RepoConfig.cs b/RepositoryServer/Config/RepoConfig.cs similarity index 68% rename from DesktopRepositoryServer/Config/RepoConfig.cs rename to RepositoryServer/Config/RepoConfig.cs index a1f138e..e27ed25 100644 --- a/DesktopRepositoryServer/Config/RepoConfig.cs +++ b/RepositoryServer/Config/RepoConfig.cs @@ -1,4 +1,4 @@ -namespace OpenShock.Desktop.RepositoryServer.Config; +namespace OpenShock.RepositoryServer.Config; public sealed class RepoConfig { @@ -6,4 +6,5 @@ public sealed class RepoConfig public required string Name { get; init; } public required string Author { get; init; } public Uri? Homepage { get; init; } = null; + public required string CdnBaseUrl { get; init; } } \ No newline at end of file diff --git a/RepositoryServer/Config/StorageConfig.cs b/RepositoryServer/Config/StorageConfig.cs new file mode 100644 index 0000000..b74ac33 --- /dev/null +++ b/RepositoryServer/Config/StorageConfig.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Config; + +public sealed class StorageConfig +{ + [Required] public required StorageType Type { get; init; } + + public BunnyCdnStorageConfig? BunnyCdn { get; init; } + public LocalStorageConfig? Local { get; init; } + public S3StorageConfig? S3 { get; init; } +} + +public enum StorageType +{ + BunnyCdn, + Local, + S3, +} + +public sealed class BunnyCdnStorageConfig +{ + [Required(AllowEmptyStrings = false)] public required string StorageUrl { get; init; } + [Required(AllowEmptyStrings = false)] public required string ApiKey { get; init; } +} + +public sealed class LocalStorageConfig +{ + [Required(AllowEmptyStrings = false)] public required string BasePath { get; init; } +} + +public sealed class S3StorageConfig +{ + [Required(AllowEmptyStrings = false)] public required string BucketName { get; init; } + [Required(AllowEmptyStrings = false)] public required string AccessKey { get; init; } + [Required(AllowEmptyStrings = false)] public required string SecretKey { get; init; } + + /// + /// Custom endpoint URL for S3-compatible services (Cloudflare R2, MinIO, etc.). + /// Leave null for AWS S3. + /// + public string? ServiceUrl { get; init; } + + /// + /// Optional key prefix prepended to all paths, e.g. "firmware/". + /// + public string? KeyPrefix { get; init; } + + /// + /// AWS region. Required for AWS S3, optional for most S3-compatible services. + /// + public string? Region { get; init; } +} diff --git a/DesktopRepositoryServer/ConfigurationExtensions.cs b/RepositoryServer/ConfigurationExtensions.cs similarity index 50% rename from DesktopRepositoryServer/ConfigurationExtensions.cs rename to RepositoryServer/ConfigurationExtensions.cs index 4f00d84..6ffdc89 100644 --- a/DesktopRepositoryServer/ConfigurationExtensions.cs +++ b/RepositoryServer/ConfigurationExtensions.cs @@ -1,13 +1,21 @@ using System.Text; using System.Text.Json; -namespace OpenShock.Desktop.RepositoryServer; +namespace OpenShock.RepositoryServer; public static class ConfigurationExtensions { public static T GetAndRegisterOpenShockConfig(this WebApplicationBuilder builder) where T : class { #if DEBUG - Console.WriteLine(builder.Configuration.GetDebugView()); + // GetDebugView() prints every configuration value AND the process environment, which includes + // the admin token, the database password, storage credentials and Discord webhook URLs. Only + // the resolved keys are printed, which is what is actually useful for diagnosing binding. + Console.WriteLine("Configuration keys:"); + foreach (var (key, value) in builder.Configuration.AsEnumerable().OrderBy(kv => kv.Key)) + { + if (value is null) continue; + Console.WriteLine($" {key} = {RedactIfSensitive(key, value)}"); + } #endif var config = builder.Configuration @@ -37,12 +45,30 @@ public static T GetAndRegisterOpenShockConfig(this WebApplicationBuilder buil Environment.Exit(-10); } -#if DEBUG - Console.WriteLine(JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true })); -#endif builder.Services.AddSingleton(config); return config; } + +#if DEBUG + private static readonly string[] SensitiveKeyFragments = + ["token", "password", "secret", "key", "conn", "webhook"]; + + /// + /// Masks values whose key looks credential-bearing. Name-based rather than attribute-based so a + /// newly added secret is redacted by default instead of needing to be remembered. + /// + private static string RedactIfSensitive(string key, string value) + { + foreach (var fragment in SensitiveKeyFragments) + { + if (key.Contains(fragment, StringComparison.OrdinalIgnoreCase)) + { + return $"*** ({value.Length} chars)"; + } + } + return value; + } +#endif } diff --git a/DesktopRepositoryServer/Controllers/OpenShockControllerBase.cs b/RepositoryServer/Controllers/OpenShockControllerBase.cs similarity index 71% rename from DesktopRepositoryServer/Controllers/OpenShockControllerBase.cs rename to RepositoryServer/Controllers/OpenShockControllerBase.cs index 6eb3d4a..660b768 100644 --- a/DesktopRepositoryServer/Controllers/OpenShockControllerBase.cs +++ b/RepositoryServer/Controllers/OpenShockControllerBase.cs @@ -1,8 +1,8 @@ using System.Net.Mime; using Microsoft.AspNetCore.Mvc; -using OpenShock.Desktop.RepositoryServer.Problems; +using OpenShock.RepositoryServer.Problems; -namespace OpenShock.Desktop.RepositoryServer.Controllers; +namespace OpenShock.RepositoryServer.Controllers; [Consumes(MediaTypeNames.Application.Json)] public class OpenShockControllerBase : ControllerBase diff --git a/DesktopRepositoryServer/Controllers/AdminController.cs b/RepositoryServer/Controllers/V1/AdminController.cs similarity index 77% rename from DesktopRepositoryServer/Controllers/AdminController.cs rename to RepositoryServer/Controllers/V1/AdminController.cs index b504330..e2957a9 100644 --- a/DesktopRepositoryServer/Controllers/AdminController.cs +++ b/RepositoryServer/Controllers/V1/AdminController.cs @@ -1,15 +1,17 @@ -using Microsoft.AspNetCore.Authorization; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using OpenShock.Desktop.RepositoryServer.Models; -using OpenShock.Desktop.RepositoryServer.Problems; -using OpenShock.Desktop.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Models; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; using Semver; -using Module = OpenShock.Desktop.RepositoryServer.RepoServerDb.Module; -using Version = OpenShock.Desktop.RepositoryServer.RepoServerDb.Version; +using Module = OpenShock.RepositoryServer.RepoServerDb.Module; +using Version = OpenShock.RepositoryServer.RepoServerDb.Version; -namespace OpenShock.Desktop.RepositoryServer.Controllers; +namespace OpenShock.RepositoryServer.Controllers.V1; +[ApiVersion("1.0")] [ApiController] [Route("/{version:apiVersion}/admin")] [Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] @@ -25,18 +27,24 @@ public AdminController(RepoServerContext db) [HttpPut("modules/{moduleId}")] public async Task CreateModule([FromBody] CreateModuleRequest createModuleRequest, [FromRoute] string moduleId) { + if (createModuleRequest.RepositoryId is { } repositoryId && + !await _db.Repositories.AnyAsync(r => r.Id == repositoryId)) + { + return Problem(FirmwareError.FirmwareRepositoryNotFound); + } + var module = new Module { Id = moduleId.ToLowerInvariant(), Name = createModuleRequest.Name, Description = createModuleRequest.Description, SourceUrl = createModuleRequest.SourceUrl, - IconUrl = createModuleRequest.IconUrl + IconUrl = createModuleRequest.IconUrl, + RepositoryId = createModuleRequest.RepositoryId }; var executed = await _db.Modules.Upsert(module).On(x => x.Id).RunAsync(); - + if (executed <= 0) throw new Exception("Failed to upsert module"); - return Created(); } @@ -45,29 +53,29 @@ public async Task CreateModule([FromBody] CreateModuleRequest cre public async Task DeleteModule([FromRoute] string moduleId) { var executed = await _db.Modules.Where(x => x.Id == moduleId.ToLowerInvariant()).ExecuteDeleteAsync(); - + if(executed <= 0) return Problem(ModuleError.ModuleNotFound); return Ok(); } - + [Obsolete("Use the CI/CD endpoint at /v1/cicd/modules/{moduleId}/versions/{moduleVersion} instead.")] [HttpPut("modules/{moduleId}/versions/{moduleVersion}")] public async Task CreateVersion([FromBody] CreateModuleVersionRequest createModuleVersionRequest, [FromRoute] string moduleId, [FromRoute] string moduleVersion) { moduleId = moduleId.ToLowerInvariant(); moduleVersion = moduleVersion.ToLowerInvariant(); - + if (!SemVersion.TryParse(moduleVersion, SemVersionStyles.Strict, out _)) { return Problem(VersionError.VersionInvalidSemver); } - + if(!await _db.Modules.AnyAsync(x => x.Id == moduleId)) { return Problem(ModuleError.ModuleNotFound); } - + var version = new Version { Module = moduleId, @@ -81,10 +89,10 @@ public async Task CreateVersion([FromBody] CreateModuleVersionReq var executed = await _db.Versions.Upsert(version).On(x => new { x.Module, x.VersionName }).RunAsync(); if (executed <= 0) throw new Exception("Failed to upsert version"); - + return Created(); } - + [HttpDelete("modules/{moduleId}/versions/{moduleVersion}")] public async Task DeleteVersion([FromRoute] string moduleId, [FromRoute] string moduleVersion) { diff --git a/RepositoryServer/Controllers/V1/CiCdController.cs b/RepositoryServer/Controllers/V1/CiCdController.cs new file mode 100644 index 0000000..8b10f99 --- /dev/null +++ b/RepositoryServer/Controllers/V1/CiCdController.cs @@ -0,0 +1,158 @@ +using System.IO.Compression; +using System.Security.Claims; +using System.Security.Cryptography; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Services; +using Semver; +using Version = OpenShock.RepositoryServer.RepoServerDb.Version; + +namespace OpenShock.RepositoryServer.Controllers.V1; + +[ApiVersion("1.0")] +[ApiController] +[Route("/{version:apiVersion}/cicd")] +[Authorize(AuthenticationSchemes = AuthSchemas.CiCdToken, Policy = AuthSchemas.Policies.PublishModules)] +public class CiCdController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + private readonly IStorageService _storage; + private readonly ApiConfig _apiConfig; + + public CiCdController(RepoServerContext db, IStorageService storage, ApiConfig apiConfig) + { + _db = db; + _storage = storage; + _apiConfig = apiConfig; + } + + /// + /// Publishes a desktop module version by uploading a zip file. + /// The file is hashed server-side, uploaded to storage, and recorded in the database. + /// + [HttpPut("modules/{moduleId}/versions/{moduleVersion}")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(256 * 1024 * 1024)] // 256 MB + public async Task PublishVersion( + [FromRoute] string moduleId, + [FromRoute] string moduleVersion, + [FromQuery] string? changelogUrl, + [FromQuery] string? releaseUrl) + { + moduleId = moduleId.ToLowerInvariant(); + moduleVersion = moduleVersion.ToLowerInvariant(); + + if (!SemVersion.TryParse(moduleVersion, SemVersionStyles.Strict, out _)) + { + return Problem(VersionError.VersionInvalidSemver); + } + + var module = await _db.Modules.FirstOrDefaultAsync(x => x.Id == moduleId); + if (module is null) + { + return Problem(ModuleError.ModuleNotFound); + } + + // Every registered repository presents an equally valid CI/CD principal, so authentication + // alone does not say which module the caller may publish to. Unassigned modules are closed to + // all publishers rather than open to any. + var rawRepoId = User.FindFirstValue(AuthSchemas.CiCdClaims.RepositoryId); + if (module.RepositoryId is not { } owningRepositoryId + || !Guid.TryParse(rawRepoId, out var callerRepositoryId) + || owningRepositoryId != callerRepositoryId) + { + return Problem(ModuleError.ModuleNotOwned); + } + + // Published versions are immutable. The upsert below is keyed on (module, version), so without + // this an existing version's zip URL and hash would be silently replaced in place — and + // integrity checks would then pass against the replacement. + if (await _db.Versions.AnyAsync(v => v.Module == moduleId && v.VersionName == moduleVersion)) + { + return Problem(VersionError.VersionAlreadyExists); + } + + var file = Request.Form.Files.GetFile("zip"); + if (file == null) + { + return BadRequest(new { error = "No file uploaded. Expected a file field named 'zip'." }); + } + + // Read file into memory for validation + hashing + storage upload + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream); + var fileBytes = memoryStream.ToArray(); + + // Validate zip contents + memoryStream.Position = 0; + try + { + using var zip = new ZipArchive(memoryStream, ZipArchiveMode.Read, leaveOpen: true); + + if (zip.Entries.Count == 0) + return Problem(ModuleError.ZipEmpty); + + foreach (var entry in zip.Entries) + { + if (entry.FullName.Contains("..") || + Path.IsPathRooted(entry.FullName)) + return Problem(ModuleError.ZipPathTraversal); + + // Determine depth and root segment + var normalized = entry.FullName.Replace('\\', '/'); + var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length == 0) + continue; + + // Directory entries at root: only "wwwroot/" is allowed + if (segments.Length >= 2 && !segments[0].Equals("wwwroot", StringComparison.OrdinalIgnoreCase)) + return Problem(ModuleError.ZipDisallowedDirectory(segments[0])); + + // Files at root: only .dll, .pdb, .json + if (segments.Length == 1 && !entry.FullName.EndsWith('/')) + { + var ext = Path.GetExtension(entry.Name).ToLowerInvariant(); + if (ext is not (".dll" or ".pdb" or ".json")) + return Problem(ModuleError.ZipDisallowedRootFile(entry.Name)); + } + } + } + catch (InvalidDataException) + { + return Problem(ModuleError.ZipInvalid); + } + + // Compute SHA256 hash server-side + var hashBytes = SHA256.HashData(fileBytes); + + // Upload to storage + var storagePath = $"modules/{moduleId}/{moduleVersion}/module.zip"; + using var uploadStream = new MemoryStream(fileBytes); + await _storage.UploadFileAsync(storagePath, uploadStream); + + // Construct public download URL + var cdnBase = _apiConfig.Repo.CdnBaseUrl.TrimEnd('/'); + var zipUrl = new Uri($"{cdnBase}/{storagePath}"); + + var version = new Version + { + Module = moduleId, + VersionName = moduleVersion, + ZipUrl = zipUrl, + HashSha256 = hashBytes, + ChangelogUrl = changelogUrl != null ? new Uri(changelogUrl) : null, + ReleaseUrl = releaseUrl != null ? new Uri(releaseUrl) : null + }; + + var executed = await _db.Versions.Upsert(version).On(x => new { x.Module, x.VersionName }).RunAsync(); + if (executed <= 0) throw new Exception("Failed to upsert version"); + + return Created(); + } +} diff --git a/DesktopRepositoryServer/Controllers/RepoController.cs b/RepositoryServer/Controllers/V1/RepoController.cs similarity index 78% rename from DesktopRepositoryServer/Controllers/RepoController.cs rename to RepositoryServer/Controllers/V1/RepoController.cs index 8b70475..0a48de7 100644 --- a/DesktopRepositoryServer/Controllers/RepoController.cs +++ b/RepositoryServer/Controllers/V1/RepoController.cs @@ -1,16 +1,18 @@ using System.Collections.Immutable; +using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using OpenShock.Desktop.RepositoryServer.Config; -using OpenShock.Desktop.RepositoryServer.Models; -using OpenShock.Desktop.RepositoryServer.RepoServerDb; -using OpenShock.Desktop.RepositoryServer.Utils; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Models; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; using Semver; -using Module = OpenShock.Desktop.RepositoryServer.Models.Module; -using Version = OpenShock.Desktop.RepositoryServer.Models.Version; +using Module = OpenShock.RepositoryServer.Models.Module; +using Version = OpenShock.RepositoryServer.Models.Version; -namespace OpenShock.Desktop.RepositoryServer.Controllers; +namespace OpenShock.RepositoryServer.Controllers.V1; +[ApiVersion("1.0")] [ApiController] [Route("/{version:apiVersion}/")] public sealed class RepoController : OpenShockControllerBase @@ -23,12 +25,12 @@ public RepoController(RepoServerContext db, ApiConfig apiConfig) _db = db; _apiConfig = apiConfig; } - + [HttpGet] public async Task GetRepo() { var moduleRaw = await _db.Modules.Include(x => x.Versions).ToArrayAsync(); - + var modules = moduleRaw.ToImmutableDictionary(x => x.Id, x => new Module() { Name = x.Name, @@ -53,7 +55,7 @@ public async Task GetRepo() Homepage = _apiConfig.Repo.Homepage, Modules = modules }; - + return Ok(repository); } } \ No newline at end of file diff --git a/RepositoryServer/Controllers/V2/Admin/DiscordWebhooksController.cs b/RepositoryServer/Controllers/V2/Admin/DiscordWebhooksController.cs new file mode 100644 index 0000000..077ebbf --- /dev/null +++ b/RepositoryServer/Controllers/V2/Admin/DiscordWebhooksController.cs @@ -0,0 +1,126 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Discord; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Admin; + +/// +/// Manages Discord notification targets. +/// +/// +/// Not under /firmware/admin because these are not firmware-specific — desktop module +/// publishing notifies through the same webhooks. +/// +/// A webhook URL is a credential: anyone holding it can post to that channel. It is therefore +/// write-only. Responses carry a masked form that keeps the Discord webhook id (useful for matching a +/// row to a channel) and drops the token. +/// +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/admin/discord-webhooks")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class DiscordWebhooksController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public DiscordWebhooksController(RepoServerContext db) + { + _db = db; + } + + [HttpGet] + public async Task ListWebhooks(CancellationToken ct) + { + var rows = await _db.DiscordWebhooks.OrderBy(w => w.Name).ToListAsync(ct); + return Ok(rows.Select(DiscordWebhookDto.From)); + } + + [HttpPost] + public async Task CreateWebhook( + [FromBody] UpsertDiscordWebhookRequest request, + CancellationToken ct) + { + if (!TryParseEvents(request.Events, out var events, out var invalid)) + { + return Problem(DiscordError.DiscordInvalidNotificationEvent(invalid)); + } + + var webhook = new DiscordWebhook + { + Id = Guid.NewGuid(), + Name = request.Name, + Url = request.Url, + Events = events, + Enabled = request.Enabled + }; + + _db.DiscordWebhooks.Add(webhook); + await _db.SaveChangesAsync(ct); + + return Created((string?)null, DiscordWebhookDto.From(webhook)); + } + + [HttpPut("{webhookId:guid}")] + public async Task UpdateWebhook( + [FromRoute] Guid webhookId, + [FromBody] UpsertDiscordWebhookRequest request, + CancellationToken ct) + { + if (!TryParseEvents(request.Events, out var events, out var invalid)) + { + return Problem(DiscordError.DiscordInvalidNotificationEvent(invalid)); + } + + var webhook = await _db.DiscordWebhooks.FirstOrDefaultAsync(w => w.Id == webhookId, ct); + if (webhook is null) + { + return Problem(DiscordError.DiscordWebhookNotFound); + } + + webhook.Name = request.Name; + webhook.Url = request.Url; + webhook.Events = events; + webhook.Enabled = request.Enabled; + + await _db.SaveChangesAsync(ct); + return Ok(DiscordWebhookDto.From(webhook)); + } + + [HttpDelete("{webhookId:guid}")] + public async Task DeleteWebhook([FromRoute] Guid webhookId, CancellationToken ct) + { + var deleted = await _db.DiscordWebhooks.Where(w => w.Id == webhookId).ExecuteDeleteAsync(ct); + if (deleted <= 0) + { + return Problem(DiscordError.DiscordWebhookNotFound); + } + + return NoContent(); + } + + private static bool TryParseEvents( + List? raw, out DiscordNotificationEvent[] events, out string invalid) + { + var parsed = new List(); + foreach (var value in raw ?? []) + { + if (!DiscordNotificationEventExtensions.TryParseEvent(value, out var parsedEvent)) + { + events = []; + invalid = value; + return false; + } + if (!parsed.Contains(parsedEvent)) parsed.Add(parsedEvent); + } + + events = parsed.ToArray(); + invalid = string.Empty; + return true; + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs new file mode 100644 index 0000000..436a7a6 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs @@ -0,0 +1,112 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/admin/advisories")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class AdvisoriesController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public AdvisoriesController(RepoServerContext db) + { + _db = db; + } + + [HttpPost] + public async Task CreateAdvisory([FromBody] UpsertFirmwareAdvisoryRequest request, CancellationToken ct) + { + if (!TryParseSeverity(request.Severity, out var severity)) + { + return Problem(FirmwareError.FirmwareInvalidAdvisorySeverity); + } + + var advisory = new FirmwareAdvisory + { + Id = Guid.NewGuid(), + Severity = severity, + Title = request.Title, + Content = request.Content, + AffectedVersions = request.AffectedVersions, + Url = request.Url + }; + _db.FirmwareAdvisories.Add(advisory); + await _db.SaveChangesAsync(ct); + + return Created((string?)null, ToDto(advisory)); + } + + [HttpGet] + public async Task ListAdvisories(CancellationToken ct) + { + // Postgres sorts enum columns by label creation order (alphabetical with MapEnum), + // not severity rank — order on the CLR enum value in memory instead. + var rows = await _db.FirmwareAdvisories.ToListAsync(ct); + + return Ok(rows + .OrderBy(a => a.Severity) + .ThenBy(a => a.Title) + .Select(ToDto)); + } + + [HttpPut("{id:guid}")] + public async Task UpdateAdvisory( + [FromRoute] Guid id, + [FromBody] UpsertFirmwareAdvisoryRequest request, + CancellationToken ct) + { + if (!TryParseSeverity(request.Severity, out var severity)) + { + return Problem(FirmwareError.FirmwareInvalidAdvisorySeverity); + } + + var advisory = await _db.FirmwareAdvisories.FirstOrDefaultAsync(a => a.Id == id, ct); + if (advisory is null) + { + return Problem(FirmwareError.FirmwareAdvisoryNotFound); + } + + advisory.Severity = severity; + advisory.Title = request.Title; + advisory.Content = request.Content; + advisory.AffectedVersions = request.AffectedVersions; + advisory.Url = request.Url; + await _db.SaveChangesAsync(ct); + + return Ok(ToDto(advisory)); + } + + [HttpDelete("{id:guid}")] + public async Task DeleteAdvisory([FromRoute] Guid id, CancellationToken ct) + { + var deleted = await _db.FirmwareAdvisories.Where(a => a.Id == id).ExecuteDeleteAsync(ct); + if (deleted <= 0) + { + return Problem(FirmwareError.FirmwareAdvisoryNotFound); + } + + return NoContent(); + } + + private static bool TryParseSeverity(string value, out AdvisorySeverity severity) => + Enum.TryParse(value, true, out severity); + + private static FirmwareAdvisoryAdminDto ToDto(FirmwareAdvisory a) => new() + { + Id = a.Id, + Severity = a.Severity.ToString().ToLowerInvariant(), + Title = a.Title, + Content = a.Content, + AffectedVersions = a.AffectedVersions, + Url = a.Url + }; +} diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs new file mode 100644 index 0000000..aa78c6c --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs @@ -0,0 +1,202 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/admin/boards")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class BoardsAdminController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public BoardsAdminController(RepoServerContext db) + { + _db = db; + } + + /// + /// Create a new board. Name is the unique identifier visible to clients. + /// + [HttpPost] + public async Task CreateBoard( + [FromBody] CreateFirmwareBoardRequest request, + CancellationToken ct) + { + if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId, ct)) + { + return Problem(FirmwareError.FirmwareChipNotFound); + } + + if (!TryParseRequiredArtifactTypes(request.RequiredArtifactTypes, out var requiredArtifactTypes)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + + var board = new FirmwareBoard + { + Id = Guid.NewGuid(), + Name = request.Name, + ChipId = request.ChipId, + Discontinued = false, + RequiredArtifactTypes = requiredArtifactTypes + }; + + _db.FirmwareBoards.Add(board); + + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (UniqueViolation.IsOn(ex, UniqueViolation.BoardNameLower)) + { + return Problem(FirmwareError.FirmwareBoardNameConflict); + } + + return Created((string?)null, new { id = board.Id }); + } + + [HttpPut("{boardId:guid}")] + public async Task UpdateBoard( + [FromRoute] Guid boardId, + [FromBody] CreateFirmwareBoardRequest request, + CancellationToken ct) + { + if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId, ct)) + { + return Problem(FirmwareError.FirmwareChipNotFound); + } + + if (!TryParseRequiredArtifactTypes(request.RequiredArtifactTypes, out var requiredArtifactTypes)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + + var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId, ct); + if (board is null) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + board.Name = request.Name; + board.ChipId = request.ChipId; + board.RequiredArtifactTypes = requiredArtifactTypes; + + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (UniqueViolation.IsOn(ex, UniqueViolation.BoardNameLower)) + { + return Problem(FirmwareError.FirmwareBoardNameConflict); + } + + return Ok(); + } + + [HttpPatch("{boardId:guid}/discontinue")] + public async Task DiscontinueBoard([FromRoute] Guid boardId, CancellationToken ct) + { + var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId, ct); + if (board is null) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + board.Discontinued = true; + await _db.SaveChangesAsync(ct); + return Ok(); + } + + [HttpDelete("{boardId:guid}")] + public async Task DeleteBoard([FromRoute] Guid boardId, CancellationToken ct) + { + if (await _db.FirmwareArtifacts.AnyAsync(a => a.BoardId == boardId, ct)) + { + return Problem(FirmwareError.FirmwareBoardInUse); + } + + var deleted = await _db.FirmwareBoards.Where(b => b.Id == boardId).ExecuteDeleteAsync(ct); + if (deleted <= 0) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + return NoContent(); + } + + [HttpPut("{boardId:guid}/usb-devices/{usbDeviceId:guid}")] + public async Task AttachUsbDevice( + [FromRoute] Guid boardId, + [FromRoute] Guid usbDeviceId, + CancellationToken ct) + { + if (!await _db.FirmwareBoards.AnyAsync(b => b.Id == boardId, ct)) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + if (!await _db.UsbDevices.AnyAsync(d => d.Id == usbDeviceId, ct)) + { + return Problem(FirmwareError.FirmwareUsbDeviceNotFound); + } + + var exists = await _db.FirmwareBoardUsbDevices + .AnyAsync(j => j.BoardId == boardId && j.UsbDeviceId == usbDeviceId, ct); + if (!exists) + { + _db.FirmwareBoardUsbDevices.Add(new FirmwareBoardUsbDevice + { + BoardId = boardId, + UsbDeviceId = usbDeviceId + }); + await _db.SaveChangesAsync(ct); + } + + return NoContent(); + } + + [HttpDelete("{boardId:guid}/usb-devices/{usbDeviceId:guid}")] + public async Task DetachUsbDevice( + [FromRoute] Guid boardId, + [FromRoute] Guid usbDeviceId, + CancellationToken ct) + { + await _db.FirmwareBoardUsbDevices + .Where(j => j.BoardId == boardId && j.UsbDeviceId == usbDeviceId) + .ExecuteDeleteAsync(ct); + + return NoContent(); + } + + private static bool TryParseRequiredArtifactTypes(List? raw, out FirmwareArtifactType[] parsed) + { + if (raw is null || raw.Count == 0) + { + parsed = Array.Empty(); + return true; + } + + var list = new List(raw.Count); + foreach (var typeStr in raw) + { + if (!Enum.TryParse(typeStr, true, out var t)) + { + parsed = Array.Empty(); + return false; + } + list.Add(t); + } + + parsed = list.Distinct().ToArray(); + return true; + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs new file mode 100644 index 0000000..4f799df --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs @@ -0,0 +1,151 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/admin/chips")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class ChipsAdminController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public ChipsAdminController(RepoServerContext db) + { + _db = db; + } + + [HttpPost] + public async Task CreateChip( + [FromBody] CreateFirmwareChipRequest request, + CancellationToken ct) + { + FirmwareChipArchitecture? architecture = null; + if (request.Architecture is not null) + { + if (!Enum.TryParse(request.Architecture, true, out var parsed)) + { + return Problem(FirmwareError.FirmwareInvalidArchitecture); + } + architecture = parsed; + } + + var chip = new FirmwareChip + { + Id = Guid.NewGuid(), + Name = request.Name, + Architecture = architecture + }; + + _db.FirmwareChips.Add(chip); + + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (UniqueViolation.IsOn(ex, UniqueViolation.ChipNameLower)) + { + return Problem(FirmwareError.FirmwareChipNameConflict); + } + + return Created((string?)null, new { id = chip.Id }); + } + + [HttpPut("{chipId:guid}")] + public async Task UpdateChip( + [FromRoute] Guid chipId, + [FromBody] CreateFirmwareChipRequest request, + CancellationToken ct) + { + FirmwareChipArchitecture? architecture = null; + if (request.Architecture is not null) + { + if (!Enum.TryParse(request.Architecture, true, out var parsed)) + { + return Problem(FirmwareError.FirmwareInvalidArchitecture); + } + architecture = parsed; + } + + var chip = await _db.FirmwareChips.FirstOrDefaultAsync(c => c.Id == chipId, ct); + if (chip is null) + { + return Problem(FirmwareError.FirmwareChipNotFound); + } + + chip.Name = request.Name; + chip.Architecture = architecture; + await _db.SaveChangesAsync(ct); + + return Ok(); + } + + [HttpDelete("{chipId:guid}")] + public async Task DeleteChip([FromRoute] Guid chipId, CancellationToken ct) + { + if (await _db.FirmwareBoards.AnyAsync(b => b.ChipId == chipId, ct)) + { + return Problem(FirmwareError.FirmwareChipInUse); + } + + var deleted = await _db.FirmwareChips.Where(c => c.Id == chipId).ExecuteDeleteAsync(ct); + if (deleted <= 0) + { + return Problem(FirmwareError.FirmwareChipNotFound); + } + + return NoContent(); + } + + [HttpPut("{chipId:guid}/usb-devices/{usbDeviceId:guid}")] + public async Task AttachUsbDevice( + [FromRoute] Guid chipId, + [FromRoute] Guid usbDeviceId, + CancellationToken ct) + { + if (!await _db.FirmwareChips.AnyAsync(c => c.Id == chipId, ct)) + { + return Problem(FirmwareError.FirmwareChipNotFound); + } + + if (!await _db.UsbDevices.AnyAsync(d => d.Id == usbDeviceId, ct)) + { + return Problem(FirmwareError.FirmwareUsbDeviceNotFound); + } + + var exists = await _db.FirmwareChipUsbDevices + .AnyAsync(j => j.ChipId == chipId && j.UsbDeviceId == usbDeviceId, ct); + if (!exists) + { + _db.FirmwareChipUsbDevices.Add(new FirmwareChipUsbDevice + { + ChipId = chipId, + UsbDeviceId = usbDeviceId + }); + await _db.SaveChangesAsync(ct); + } + + return NoContent(); + } + + [HttpDelete("{chipId:guid}/usb-devices/{usbDeviceId:guid}")] + public async Task DetachUsbDevice( + [FromRoute] Guid chipId, + [FromRoute] Guid usbDeviceId, + CancellationToken ct) + { + await _db.FirmwareChipUsbDevices + .Where(j => j.ChipId == chipId && j.UsbDeviceId == usbDeviceId) + .ExecuteDeleteAsync(ct); + + return NoContent(); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/ReleasesAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/ReleasesAdminController.cs new file mode 100644 index 0000000..e98bb19 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/ReleasesAdminController.cs @@ -0,0 +1,77 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/admin/releases")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class ReleasesAdminController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public ReleasesAdminController(RepoServerContext db) + { + _db = db; + } + + [HttpPut("{releaseId:guid}/changelog")] + public async Task FixChangelog( + [FromRoute] Guid releaseId, + [FromBody] FixChangelogRequest request, + CancellationToken ct) + { + var release = await _db.FirmwareReleases.FirstOrDefaultAsync(r => r.Id == releaseId, ct); + if (release is null) + { + return Problem(FirmwareError.FirmwareReleaseNotFound); + } + + if (release.Status != ReleaseStatus.Staging && release.Status != ReleaseStatus.Editing) + { + return Problem(FirmwareError.FirmwareReleaseNotEditable); + } + + var parseResult = ChangelogParser.Parse(request.Changelog); + if (!parseResult.TryPickT0(out var notes, out var error)) + { + return Problem(FirmwareError.FirmwareInvalidChangelog(error switch + { + ChangelogParseError.Empty => "Changelog is empty or whitespace-only", + ChangelogParseError.NoHeadings => "Changelog contains no '### Heading' sections", + ChangelogParseError.AllSectionsEmpty => "All changelog sections are empty", + _ => error.ToString() + })); + } + + await _db.FirmwareStagedReleaseNotes + .Where(n => n.ReleaseId == releaseId) + .ExecuteDeleteAsync(ct); + + for (var i = 0; i < notes.Count; i++) + { + var n = notes[i]; + _db.FirmwareStagedReleaseNotes.Add(new FirmwareStagedReleaseNote + { + ReleaseId = releaseId, + Index = i, + SectionType = Enum.Parse(n.Type, true), + Title = n.Title, + Content = n.Content, + }); + } + + release.Status = ReleaseStatus.Staging; + await _db.SaveChangesAsync(ct); + + return Ok(); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/RepositoriesController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/RepositoriesController.cs new file mode 100644 index 0000000..00a00e5 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/RepositoriesController.cs @@ -0,0 +1,125 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin; + +/// +/// Admin for the shared repositories table, which doubles as the publish allowlist. +/// +/// +/// Registration here is what authorizes a repository to publish: OIDC token validation proves a +/// workflow ran somewhere on GitHub, not that it ran in a repository we trust. Onboarding is therefore +/// deliberately a manual admin action, and deletion is a real revocation. +/// +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/admin/repositories")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class RepositoriesController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public RepositoriesController(RepoServerContext db) + { + _db = db; + } + + [HttpGet] + public async Task ListRepositories(CancellationToken ct) + { + var rows = await _db.Repositories + .OrderBy(r => r.Provider) + .ThenBy(r => r.Owner) + .ThenBy(r => r.Repo) + .ToListAsync(ct); + + return Ok(rows.Select(RepositoryDto.From)); + } + + /// + /// Registers a repository as authorized to publish, or returns the existing row if already + /// registered. Idempotent, so re-running onboarding is harmless. + /// + [HttpPut] + public async Task UpsertRepository( + [FromBody] UpsertRepositoryRequest request, + CancellationToken ct) + { + if (!Enum.TryParse(request.Provider, true, out var provider)) + { + return Problem(FirmwareError.FirmwareInvalidRepositoryProvider); + } + + var scopes = new List(); + foreach (var raw in request.Scopes ?? []) + { + if (!RepositoryScopeExtensions.TryParseScope(raw, out var scope)) + { + return Problem(FirmwareError.FirmwareInvalidRepositoryScope(raw)); + } + if (!scopes.Contains(scope)) scopes.Add(scope); + } + + // Matched case-insensitively, consistent with how GitHub treats owner and repo names and with + // how the OIDC handler resolves them — otherwise onboarding "OpenShock/Firmware" would create + // a second row that the handler's lookup for "openshock/firmware" would never reach. + var loweredOwner = request.Owner.ToLowerInvariant(); + var loweredRepo = request.Repo.ToLowerInvariant(); + + var existing = await _db.Repositories.FirstOrDefaultAsync( + r => r.Provider == provider + && r.Owner.ToLower() == loweredOwner + && r.Repo.ToLower() == loweredRepo, ct); + + if (existing is not null) + { + // Idempotent on identity, but scopes are authoritative: re-running onboarding with a + // different set is how a grant is widened or narrowed. + existing.Scopes = scopes.ToArray(); + await _db.SaveChangesAsync(ct); + return Ok(RepositoryDto.From(existing)); + } + + var row = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = provider, + Owner = request.Owner, + Repo = request.Repo, + Scopes = scopes.ToArray() + }; + + _db.Repositories.Add(row); + await _db.SaveChangesAsync(ct); + + return Created((string?)null, RepositoryDto.From(row)); + } + + [HttpDelete("{repositoryId:guid}")] + public async Task DeleteRepository([FromRoute] Guid repositoryId, CancellationToken ct) + { + var inUse = + await _db.FirmwareVersions.AnyAsync(v => v.RepositoryId == repositoryId, ct) || + await _db.FirmwareReleases.AnyAsync(r => r.RepositoryId == repositoryId, ct); + + if (inUse) + { + return Problem(FirmwareError.FirmwareRepositoryInUse); + } + + var deleted = await _db.Repositories.Where(r => r.Id == repositoryId).ExecuteDeleteAsync(ct); + if (deleted <= 0) + { + return Problem(FirmwareError.FirmwareRepositoryNotFound); + } + + return NoContent(); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/UsbDevicesController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/UsbDevicesController.cs new file mode 100644 index 0000000..d235296 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/UsbDevicesController.cs @@ -0,0 +1,95 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/admin/usb-devices")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class UsbDevicesController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public UsbDevicesController(RepoServerContext db) + { + _db = db; + } + + [HttpPut] + public async Task UpsertUsbDevice([FromBody] UpsertUsbDeviceRequest request, CancellationToken ct) + { + var existing = await _db.UsbDevices + .FirstOrDefaultAsync(d => d.Vid == request.Vid && d.Pid == request.Pid, ct); + + if (existing is not null) + { + existing.Name = request.Name; + await _db.SaveChangesAsync(ct); + return Ok(ToDto(existing)); + } + + var device = new UsbDevice + { + Id = Guid.NewGuid(), + Vid = request.Vid, + Pid = request.Pid, + Name = request.Name + }; + _db.UsbDevices.Add(device); + await _db.SaveChangesAsync(ct); + + return Created((string?)null, ToDto(device)); + } + + [HttpGet] + public async Task ListUsbDevices(CancellationToken ct) + { + var rows = await _db.UsbDevices + .OrderBy(d => d.Vid).ThenBy(d => d.Pid) + .Select(d => new FirmwareUsbDeviceDto + { + Id = d.Id, + Vid = d.Vid, + Pid = d.Pid, + Name = d.Name + }) + .ToListAsync(ct); + + return Ok(rows); + } + + [HttpDelete("{id:guid}")] + public async Task DeleteUsbDevice([FromRoute] Guid id, CancellationToken ct) + { + var inUse = + await _db.FirmwareChipUsbDevices.AnyAsync(j => j.UsbDeviceId == id, ct) || + await _db.FirmwareBoardUsbDevices.AnyAsync(j => j.UsbDeviceId == id, ct); + + if (inUse) + { + return Problem(FirmwareError.FirmwareUsbDeviceInUse); + } + + var deleted = await _db.UsbDevices.Where(d => d.Id == id).ExecuteDeleteAsync(ct); + if (deleted <= 0) + { + return Problem(FirmwareError.FirmwareUsbDeviceNotFound); + } + + return NoContent(); + } + + private static FirmwareUsbDeviceDto ToDto(UsbDevice d) => new() + { + Id = d.Id, + Vid = d.Vid, + Pid = d.Pid, + Name = d.Name + }; +} diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/UsbSerialFiltersController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/UsbSerialFiltersController.cs new file mode 100644 index 0000000..4eef254 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/UsbSerialFiltersController.cs @@ -0,0 +1,87 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/admin/usb-serial-filters")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class UsbSerialFiltersController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public UsbSerialFiltersController(RepoServerContext db) + { + _db = db; + } + + [HttpPut] + public async Task UpsertFilter([FromBody] UpsertUsbSerialFilterRequest request, CancellationToken ct) + { + // Unique (vid, pid) with NULLS NOT DISTINCT — at most one vendor-wide row per VID. + var existing = await _db.UsbSerialFilters + .FirstOrDefaultAsync(f => f.Vid == request.Vid && f.Pid == request.Pid, ct); + + if (existing is not null) + { + existing.Description = request.Description; + await _db.SaveChangesAsync(ct); + return Ok(ToDto(existing)); + } + + var filter = new UsbSerialFilter + { + Id = Guid.NewGuid(), + Vid = request.Vid, + Pid = request.Pid, + Description = request.Description + }; + _db.UsbSerialFilters.Add(filter); + await _db.SaveChangesAsync(ct); + + return Created((string?)null, ToDto(filter)); + } + + [HttpGet] + public async Task ListFilters(CancellationToken ct) + { + var rows = await _db.UsbSerialFilters + .OrderBy(f => f.Vid).ThenBy(f => f.Pid) + .Select(f => new FirmwareUsbSerialFilterAdminDto + { + Id = f.Id, + Vid = f.Vid, + Pid = f.Pid, + Description = f.Description + }) + .ToListAsync(ct); + + return Ok(rows); + } + + [HttpDelete("{id:guid}")] + public async Task DeleteFilter([FromRoute] Guid id, CancellationToken ct) + { + var deleted = await _db.UsbSerialFilters.Where(f => f.Id == id).ExecuteDeleteAsync(ct); + if (deleted <= 0) + { + return Problem(FirmwareError.FirmwareUsbSerialFilterNotFound); + } + + return NoContent(); + } + + private static FirmwareUsbSerialFilterAdminDto ToDto(UsbSerialFilter f) => new() + { + Id = f.Id, + Vid = f.Vid, + Pid = f.Pid, + Description = f.Description + }; +} diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/VersionsAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/VersionsAdminController.cs new file mode 100644 index 0000000..e4b77a5 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/VersionsAdminController.cs @@ -0,0 +1,37 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/admin/versions")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class VersionsAdminController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public VersionsAdminController(RepoServerContext db) + { + _db = db; + } + + [HttpDelete("{firmwareVersion}")] + public async Task DeleteVersion([FromRoute] string firmwareVersion, CancellationToken ct) + { + var deleted = await _db.FirmwareVersions + .Where(v => v.Version == firmwareVersion) + .ExecuteDeleteAsync(ct); + + if (deleted <= 0) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + return NoContent(); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs new file mode 100644 index 0000000..8ce0848 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs @@ -0,0 +1,62 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/boards")] +public sealed class BoardsController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public BoardsController(RepoServerContext db) + { + _db = db; + } + + [HttpGet] + [CacheControl(300)] + public async Task ListBoards( + [FromQuery] string? chip, + [FromQuery] bool includeDiscontinued = true, + CancellationToken ct = default) + { + IQueryable query = _db.FirmwareBoards + .Include(b => b.ChipNavigation) + .Include(b => b.UsbDevices); + + // Filter by chip name — what a connected-device detection yields, and what the manifest's + // chips array is keyed by. Matched case-insensitively against the unique lower(name) index. + if (!string.IsNullOrWhiteSpace(chip)) + { + var loweredChip = chip.ToLowerInvariant(); + query = query.Where(b => b.ChipNavigation.Name.ToLower() == loweredChip); + } + + if (!includeDiscontinued) + { + query = query.Where(b => !b.Discontinued); + } + + var rows = await query.OrderBy(b => b.Name).ToListAsync(ct); + + var boards = rows + .Select(b => new FirmwareBoardDto + { + Name = b.Name, + ChipName = b.ChipNavigation.Name, + Discontinued = b.Discontinued, + UsbDevices = b.UsbDevices + .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name }) + .ToList() + }) + .ToList(); + + return Ok(boards); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs new file mode 100644 index 0000000..f4960b6 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs @@ -0,0 +1,44 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/chips")] +public sealed class ChipsController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public ChipsController(RepoServerContext db) + { + _db = db; + } + + [HttpGet] + [CacheControl(300)] + public async Task ListChips(CancellationToken ct) + { + var rows = await _db.FirmwareChips + .Include(c => c.UsbDevices) + .OrderBy(c => c.Name) + .ToListAsync(ct); + + var chips = rows + .Select(c => new FirmwareChipDto + { + Name = c.Name, + Architecture = EnumNaming.FormatArchitecture(c.Architecture), + UsbDevices = c.UsbDevices + .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name }) + .ToList() + }) + .ToList(); + + return Ok(chips); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs new file mode 100644 index 0000000..c424f88 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs @@ -0,0 +1,121 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/latest")] +public sealed class LatestController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + private readonly ApiConfig _apiConfig; + + public LatestController(RepoServerContext db, ApiConfig apiConfig) + { + _db = db; + _apiConfig = apiConfig; + } + + [HttpGet("{channel}")] + [CacheControl(300)] + public async Task GetLatest([FromRoute] string channel, CancellationToken ct) + { + if (!Enum.TryParse(channel, true, out var firmwareChannel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + + var visibleChannels = ReleaseChannels.VisibleTo(firmwareChannel); + var latest = await _db.FirmwareVersions + .Where(v => visibleChannels.Contains(v.Channel)) + .OrderByNewest() + .Include(v => v.RepositoryNavigation) + .Include(v => v.Artifacts) + .Include(v => v.ReleaseNotes) + .FirstOrDefaultAsync(ct); + + if (latest is null) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var boardIds = latest.Artifacts.Select(a => a.BoardId).Distinct().ToList(); + var boards = await _db.FirmwareBoards + .Include(b => b.ChipNavigation) + .Where(b => boardIds.Contains(b.Id)) + .ToListAsync(ct); + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + return Ok(FirmwareResponseMapper.ToReleaseDto(latest, boards, cdnBase)); + } + + /// Board name (e.g. "Wemos-D1-Mini-ESP32") or board id. + [HttpGet("{channel}/{board}")] + [CacheControl(300)] + public async Task GetLatestForBoard( + [FromRoute] string channel, + [FromRoute] string board, + [FromQuery] string? version, + CancellationToken ct) + { + if (!Enum.TryParse(channel, true, out var firmwareChannel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + + // Resolved before the up-to-date check so an unknown board always reports 404 rather than + // being masked as "no update needed". + var resolved = await _db.ResolveBoardAsync(board, ct); + if (resolved is not { } boardRef) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var visibleChannels = ReleaseChannels.VisibleTo(firmwareChannel); + var latestVersion = await _db.FirmwareVersions + .Where(v => visibleChannels.Contains(v.Channel)) + .OrderByNewest() + .Select(v => v.Version) + .FirstOrDefaultAsync(ct); + + if (latestVersion is null) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + // String equality (not semver) is intentional: rollbacks — if a version is pulled + // and an older version becomes "latest", the hub's string compare will still differ + // and trigger an update. See firmware-api-spec.md §4.2. + if (!string.IsNullOrWhiteSpace(version) && string.Equals(version, latestVersion, StringComparison.Ordinal)) + { + return NoContent(); + } + + var artifacts = await _db.FirmwareArtifacts + .Where(a => a.Version == latestVersion && a.BoardId == boardRef.Id) + .ToListAsync(ct); + + if (artifacts.Count == 0) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + return Ok(new FirmwareBoardReleaseResponseDto + { + Version = latestVersion, + BoardId = boardRef.Name, + Artifacts = artifacts + .Select(a => FirmwareResponseMapper.ToArtifactDto(a, latestVersion, cdnBase)) + .ToList() + }); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs new file mode 100644 index 0000000..751a0bf --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs @@ -0,0 +1,131 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/manifest")] +public sealed class ManifestController : OpenShockControllerBase +{ + private static readonly ReleaseChannel[] AllChannels = + [ + ReleaseChannel.Stable, + ReleaseChannel.Beta, + ReleaseChannel.Develop + ]; + + private readonly RepoServerContext _db; + + public ManifestController(RepoServerContext db) + { + _db = db; + } + + [HttpGet] + [CacheControl(300)] + public async Task GetManifest(CancellationToken ct) + { + // DbContext is not thread-safe; queries run sequentially. The manifest is cached + // at the HTTP layer (max-age=300) so serialization here is a non-issue. + var latest = new Dictionary(); + foreach (var ch in AllChannels) + { + var visibleChannels = ReleaseChannels.VisibleTo(ch); + var version = await _db.FirmwareVersions + .Where(v => visibleChannels.Contains(v.Channel)) + .OrderByNewest() + .Select(v => v.Version) + .FirstOrDefaultAsync(ct); + + if (version is not null) + { + latest[ch.ToString().ToLowerInvariant()] = version; + } + } + + var boardRows = await _db.FirmwareBoards + .Include(b => b.ChipNavigation) + .Include(b => b.UsbDevices) + .OrderBy(b => b.Name) + .ToListAsync(ct); + + var chipRows = await _db.FirmwareChips + .Include(c => c.UsbDevices) + .OrderBy(c => c.Name) + .ToListAsync(ct); + + var filterRows = await _db.UsbSerialFilters + .OrderBy(f => f.Vid).ThenBy(f => f.Pid) + .Select(f => new FirmwareUsbSerialFilterDto { Vid = f.Vid, Pid = f.Pid }) + .ToListAsync(ct); + + var deviceRows = await _db.UsbDevices + .OrderBy(d => d.Vid).ThenBy(d => d.Pid) + .Select(d => new FirmwareUsbDeviceDto + { + Id = d.Id, + Vid = d.Vid, + Pid = d.Pid, + Name = d.Name + }) + .ToListAsync(ct); + + var boards = boardRows + .Select(b => new FirmwareBoardDto + { + Name = b.Name, + ChipName = b.ChipNavigation.Name, + Discontinued = b.Discontinued, + UsbDevices = b.UsbDevices + .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name }) + .ToList() + }) + .ToList(); + + var chips = chipRows + .Select(c => new FirmwareChipDto + { + Name = c.Name, + Architecture = EnumNaming.FormatArchitecture(c.Architecture), + UsbDevices = c.UsbDevices + .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name }) + .ToList() + }) + .ToList(); + + // Postgres sorts enum columns by label creation order (alphabetical with MapEnum), + // not severity rank — order on the CLR enum value in memory instead. + var advisoryRows = await _db.FirmwareAdvisories.ToListAsync(ct); + var advisories = advisoryRows + .OrderBy(a => a.Severity) + .ThenBy(a => a.Title) + .Select(a => new FirmwareAdvisoryDto + { + Severity = a.Severity.ToString().ToLowerInvariant(), + Title = a.Title, + Content = a.Content, + AffectedVersions = a.AffectedVersions, + Url = a.Url + }) + .ToList(); + + var response = new FirmwareManifestResponse + { + Channels = AllChannels.Select(c => c.ToString().ToLowerInvariant()).ToList(), + Latest = latest, + Boards = boards, + Chips = chips, + UsbSerialFilters = filterRows, + UsbDevices = deviceRows, + Advisories = advisories + }; + + return Ok(response); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs new file mode 100644 index 0000000..034a3c9 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs @@ -0,0 +1,662 @@ +using System.Security.Cryptography; +using System.Security.Claims; +using System.Text.Json; +using Asp.Versioning; +using FlexLabs.EntityFrameworkCore.Upsert; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using OneOf; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Services; +using OpenShock.RepositoryServer.Utils; +using Semver; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/releases")] +[Authorize(AuthenticationSchemes = AuthSchemas.CiCdToken, Policy = AuthSchemas.Policies.PublishFirmware)] +public class ReleasesController : OpenShockControllerBase +{ + private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase) + { + ["app"] = FirmwareArtifactType.App, + ["staticfs"] = FirmwareArtifactType.StaticFs, + ["merged"] = FirmwareArtifactType.Merged, + ["bootloader"] = FirmwareArtifactType.Bootloader, + ["partitions"] = FirmwareArtifactType.Partitions, + }; + + private readonly RepoServerContext _db; + private readonly IStorageService _storage; + private readonly ApiConfig _apiConfig; + private readonly IDiscordNotificationService _discord; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public ReleasesController( + RepoServerContext db, + IStorageService storage, + ApiConfig apiConfig, + IDiscordNotificationService discord, + TimeProvider timeProvider, + ILogger logger) + { + _db = db; + _storage = storage; + _apiConfig = apiConfig; + _discord = discord; + _timeProvider = timeProvider; + _logger = logger; + } + + // ---- Init Release ---- + + [HttpPost] + public async Task InitRelease( + [FromBody] InitReleaseRequest request, + [FromQuery(Name = "nofail")] bool? nofailQuery, + CancellationToken ct) + { + var nofail = nofailQuery == true || Request.Query.ContainsKey("nofail"); + + if (!SemVersion.TryParse(request.Version, SemVersionStyles.Strict, out _)) + { + return Problem(FirmwareError.FirmwareInvalidSemver); + } + + if (!Enum.TryParse(request.Channel, true, out var channel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + + if (request.Boards.Count == 0) + { + return Problem(FirmwareError.FirmwareReleaseBoardsEmpty); + } + + // Published versions are immutable (spec §4.4). Without this, re-initialising an existing + // version would let a second release overwrite live artifacts at the same storage keys before + // any publish call, and an abandoned one would later have them deleted by the TTL job. + if (await _db.FirmwareVersions.AnyAsync(v => v.Version == request.Version, ct)) + { + return Problem(FirmwareError.FirmwareVersionAlreadyPublished); + } + + var existingStaging = await _db.FirmwareReleases + .AnyAsync(r => r.Version == request.Version && + (r.Status == ReleaseStatus.Staging || r.Status == ReleaseStatus.Editing), + ct); + if (existingStaging) + { + return Problem(FirmwareError.FirmwareReleaseAlreadyStaging); + } + + var (declaredBoards, unknownBoards) = await _db.ResolveBoardsAsync(request.Boards, ct); + if (unknownBoards.Count > 0) + { + return Problem(FirmwareError.FirmwareBoardsNotFound(unknownBoards)); + } + + // Source traceability — from OIDC claims (attached by GitHubOidcAuthentication). + if (!TryReadSourceClaims(out var sourceClaims, out var missing)) + { + return Problem(FirmwareError.FirmwareInvalidChangelog($"Missing OIDC source claims: {missing}")); + } + + // Parse changelog. + var parseResult = ChangelogParser.Parse(request.Changelog); + ReleaseStatus status; + IReadOnlyList notes; + + if (parseResult.TryPickT0(out var parsedNotes, out var error)) + { + status = ReleaseStatus.Staging; + notes = parsedNotes; + } + else if (nofail) + { + status = ReleaseStatus.Editing; + notes = Array.Empty(); + } + else + { + return Problem(FirmwareError.FirmwareInvalidChangelog(DescribeParseError(error))); + } + + var release = new FirmwareRelease + { + Id = Guid.NewGuid(), + Version = request.Version, + Channel = channel, + RepositoryId = sourceClaims.RepositoryId, + CommitHash = sourceClaims.CommitHash, + Ref = sourceClaims.Ref, + RunId = sourceClaims.RunId, + // Npgsql refuses a DateTimeOffset with a non-zero offset for `timestamp with time zone`, + // which would surface as a generic 500. CI runners in a non-UTC zone emit exactly that. + ReleaseDate = request.ReleaseDate.ToUniversalTime(), + Status = status, + DeclaredBoards = declaredBoards.Select(b => b.Id).ToArray(), + CreatedAt = _timeProvider.GetUtcNow(), + }; + + _db.FirmwareReleases.Add(release); + + for (var i = 0; i < notes.Count; i++) + { + var n = notes[i]; + _db.FirmwareStagedReleaseNotes.Add(new FirmwareStagedReleaseNote + { + ReleaseId = release.Id, + Index = i, + SectionType = Enum.Parse(n.Type, true), + Title = n.Title, + Content = n.Content, + }); + } + + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (IsOpenReleaseConflict(ex)) + { + // Lost the race against a concurrent job for the same tag. The check above catches this in + // the common case; ix_firmware_releases_open_version closes the read-then-insert window, + // and both paths surface as the same 409. + return Problem(FirmwareError.FirmwareReleaseAlreadyStaging); + } + + if (status == ReleaseStatus.Editing) + { + await _discord.NotifyReleaseNotesNeedEditingAsync(release.Id, release.Version, channel.ToString().ToLowerInvariant(), ct); + } + + return Created((string?)null, new InitReleaseResponse + { + Id = release.Id, + Status = status.ToString().ToLowerInvariant() + }); + } + + // ---- Upload Board Artifacts ---- + + /// Board name (e.g. "Wemos-D1-Mini-ESP32") or board UUID. + [HttpPut("{releaseId:guid}/boards/{board}")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(64 * 1024 * 1024)] + public async Task UploadBoardArtifacts( + [FromRoute] Guid releaseId, + [FromRoute] string board, + CancellationToken ct) + { + var release = await _db.FirmwareReleases.FirstOrDefaultAsync(r => r.Id == releaseId, ct); + if (release is null) + { + return Problem(FirmwareError.FirmwareReleaseNotFound); + } + + if (!IsOwnedByCaller(release)) + { + return Problem(FirmwareError.FirmwareReleaseNotOwned); + } + + if (release.Status != ReleaseStatus.Staging && release.Status != ReleaseStatus.Editing) + { + return Problem(FirmwareError.FirmwareReleaseNotEditable); + } + + var resolved = await _db.ResolveBoardAsync(board, ct); + if (resolved is not { } boardRef) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + if (!release.DeclaredBoards.Contains(boardRef.Id)) + { + return Problem(FirmwareError.FirmwareBoardNotDeclared); + } + + var boardId = boardRef.Id; + var boardEntity = await _db.FirmwareBoards.FirstAsync(b => b.Id == boardId, ct); + + var files = Request.Form.Files; + if (files.Count == 0) + { + return Problem(FirmwareError.FirmwareManifestKeysMismatch("No artifact files uploaded")); + } + + var sha256FormValue = Request.Form["sha256"].ToString(); + if (string.IsNullOrWhiteSpace(sha256FormValue)) + { + return Problem(FirmwareError.FirmwareManifestKeysMismatch("Missing required 'sha256' form field")); + } + + Dictionary? expectedHashes; + try + { + expectedHashes = JsonSerializer.Deserialize>( + sha256FormValue, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException) + { + return Problem(FirmwareError.FirmwareManifestKeysMismatch("'sha256' form field is not valid JSON")); + } + + if (expectedHashes is null) + { + return Problem(FirmwareError.FirmwareManifestKeysMismatch("'sha256' form field is null")); + } + + // Validate file field names and build an uploaded-type map. + var uploadedByType = new Dictionary(); + foreach (var file in files) + { + if (!ArtifactFieldNames.TryGetValue(file.Name, out var artifactType)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + uploadedByType[artifactType] = file; + } + + // Normalize expected manifest keys to enum values. + var normalizedExpected = new Dictionary(); + foreach (var kv in expectedHashes) + { + if (!ArtifactFieldNames.TryGetValue(kv.Key, out var artifactType)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + normalizedExpected[artifactType] = kv.Value; + } + + // Keys must match exactly (spec §5.2). + var onlyInFiles = uploadedByType.Keys.Except(normalizedExpected.Keys).ToList(); + var onlyInManifest = normalizedExpected.Keys.Except(uploadedByType.Keys).ToList(); + if (onlyInFiles.Count > 0 || onlyInManifest.Count > 0) + { + var detail = $"Uploaded-only: [{string.Join(", ", onlyInFiles)}]; manifest-only: [{string.Join(", ", onlyInManifest)}]"; + return Problem(FirmwareError.FirmwareManifestKeysMismatch(detail)); + } + + // Validate required artifact types per board config. + if (boardEntity.RequiredArtifactTypes.Length > 0) + { + var missingRequired = boardEntity.RequiredArtifactTypes + .Where(r => !uploadedByType.ContainsKey(r)) + .Select(r => r.ToString().ToLowerInvariant()) + .ToList(); + if (missingRequired.Count > 0) + { + return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardRef.Name, missingRequired)); + } + } + + // Read and verify every artifact BEFORE touching storage or the database. + // + // The previous order — delete prior staged rows, then hash-and-upload each file in turn, + // then report mismatches — meant one good file alongside one bad one left the good blob on the + // CDN with no row referencing it. Neither abort nor the TTL job could ever find it, because + // both enumerate staged rows. It also destroyed the board's previously valid staged artifacts + // on the way to failing. + var verified = new List<(FirmwareArtifactType Type, byte[] Bytes, string Hash)>(); + var mismatches = new List(); + + foreach (var (artifactType, file) in uploadedByType) + { + await using var memory = new MemoryStream(); + await file.CopyToAsync(memory, ct); + var bytes = memory.ToArray(); + var actual = Convert.ToHexString(SHA256.HashData(bytes)); + + var expected = normalizedExpected[artifactType]; + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + mismatches.Add($"{artifactType.ToString().ToLowerInvariant()} (expected={expected}, actual={actual})"); + continue; + } + + verified.Add((artifactType, bytes, actual)); + } + + if (mismatches.Count > 0) + { + return Problem(FirmwareError.FirmwareSha256Mismatch(string.Join("; ", mismatches))); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + var uploadedArtifacts = new List(); + + await using var uploadTransaction = await _db.Database.BeginTransactionAsync(ct); + + // Replacing this board's staged rows. Uploads overwrite at the same deterministic staging + // keys, so a re-upload of the same board never leaves a stale blob behind. + await _db.FirmwareStagedArtifacts + .Where(a => a.ReleaseId == releaseId && a.BoardId == boardId) + .ExecuteDeleteAsync(ct); + + foreach (var (artifactType, bytes, hash) in verified) + { + // Staged, not published: the public key is written only by PublishRelease. + var stagingPath = FirmwareArtifactFileNames.BuildStagingPath(releaseId, boardId, artifactType); + + await using var uploadStream = new MemoryStream(bytes); + await _storage.UploadFileAsync(stagingPath, uploadStream, ct); + + _db.FirmwareStagedArtifacts.Add(new FirmwareStagedArtifact + { + ReleaseId = releaseId, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = Convert.FromHexString(hash), + FileSize = bytes.Length, + }); + + uploadedArtifacts.Add(new FirmwareArtifactDto + { + Type = artifactType.ToString().ToLowerInvariant(), + Url = FirmwareArtifactFileNames.BuildUrl(cdnBase, release.Version, boardId, artifactType), + Sha256Hash = hash, + FileSize = bytes.Length, + }); + } + + await _db.SaveChangesAsync(ct); + await uploadTransaction.CommitAsync(ct); + return Ok(uploadedArtifacts); + } + + // ---- Publish Release ---- + + [HttpPost("{releaseId:guid}/publish")] + public async Task PublishRelease([FromRoute] Guid releaseId, CancellationToken ct) + { + var release = await _db.FirmwareReleases + .Include(r => r.StagedArtifacts) + .Include(r => r.StagedReleaseNotes.OrderBy(n => n.Index)) + .FirstOrDefaultAsync(r => r.Id == releaseId, ct); + + if (release is null) + { + return Problem(FirmwareError.FirmwareReleaseNotFound); + } + + if (!IsOwnedByCaller(release)) + { + return Problem(FirmwareError.FirmwareReleaseNotOwned); + } + + if (release.Status == ReleaseStatus.Editing) + { + return Problem(FirmwareError.FirmwareReleaseNotesNotFinalized); + } + + if (release.Status != ReleaseStatus.Staging) + { + return Problem(FirmwareError.FirmwareReleaseNotStaging); + } + + var boards = await _db.FirmwareBoards + .Where(b => release.DeclaredBoards.Contains(b.Id)) + .ToDictionaryAsync(b => b.Id, ct); + + var uploadedBoardIds = release.StagedArtifacts.Select(a => a.BoardId).Distinct().ToHashSet(); + var missingBoards = release.DeclaredBoards + .Where(b => !uploadedBoardIds.Contains(b)) + .Select(b => boards.TryGetValue(b, out var board) ? board.Name : b.ToString()) + .ToList(); + if (missingBoards.Count > 0) + { + return Problem(FirmwareError.FirmwareReleaseIncomplete(missingBoards)); + } + + foreach (var boardId in release.DeclaredBoards) + { + if (!boards.TryGetValue(boardId, out var board) || board.RequiredArtifactTypes.Length == 0) + continue; + + var stagedTypes = release.StagedArtifacts + .Where(a => a.BoardId == boardId) + .Select(a => a.ArtifactType) + .ToHashSet(); + var missingTypes = board.RequiredArtifactTypes + .Where(r => !stagedTypes.Contains(r)) + .Select(r => r.ToString().ToLowerInvariant()) + .ToList(); + if (missingTypes.Count > 0) + { + return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(board.Name, missingTypes)); + } + } + + // Promote staged objects to their published keys before touching the database. + // + // Storage is not transactional, so one of the two orderings has to carry the risk. Copying + // first means a later database failure leaves unreferenced objects at published keys, which + // are invisible (no version row points at them) and are overwritten by a subsequent publish + // of the same version. Committing first would instead publish a version whose artifacts are + // not all present yet — hubs would download 404s. Unreferenced bytes beat a broken release. + var promoted = new List(); + try + { + foreach (var staged in release.StagedArtifacts) + { + var stagingPath = FirmwareArtifactFileNames.BuildStagingPath( + release.Id, staged.BoardId, staged.ArtifactType); + var publishedPath = FirmwareArtifactFileNames.BuildStoragePath( + release.Version, staged.BoardId, staged.ArtifactType); + + await _storage.CopyFileAsync(stagingPath, publishedPath, ct); + promoted.Add(publishedPath); + } + } + catch (Exception ex) + { + // Roll back the partial promotion so a half-populated version directory is not left + // behind. Best effort: if this fails too, the objects stay unreferenced and harmless. + _logger.LogError(ex, "Failed to promote staged artifacts for release {ReleaseId}", release.Id); + await TryDeleteAllAsync(promoted, ct); + throw; + } + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + var versionEntity = new FirmwareVersion + { + Version = release.Version, + Channel = release.Channel, + ReleaseDate = release.ReleaseDate, + RepositoryId = release.RepositoryId, + CommitHash = release.CommitHash, + Ref = release.Ref, + RunId = release.RunId, + }; + await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(ct); + + await _db.FirmwareArtifacts.Where(a => a.Version == release.Version).ExecuteDeleteAsync(ct); + foreach (var staged in release.StagedArtifacts) + { + _db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = release.Version, + BoardId = staged.BoardId, + ArtifactType = staged.ArtifactType, + HashSha256 = staged.HashSha256, + FileSize = staged.FileSize, + }); + } + + await _db.FirmwareReleaseNotes.Where(n => n.Version == release.Version).ExecuteDeleteAsync(ct); + foreach (var staged in release.StagedReleaseNotes) + { + _db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote + { + Version = release.Version, + Index = staged.Index, + SectionType = staged.SectionType, + Title = staged.Title, + Content = staged.Content, + }); + } + + release.Status = ReleaseStatus.Published; + + try + { + await _db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + } + catch + { + await TryDeleteAllAsync(promoted, ct); + throw; + } + + // The release is live; its staging copies are now dead weight. Deleting them is best effort — + // failing here would abort a publish that has already succeeded, and the TTL job does not + // revisit published releases, so the worst case is some orphaned staging objects. + try + { + await _storage.DeleteDirectoryAsync(FirmwareArtifactFileNames.BuildStagingPrefix(release.Id), ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Published release {ReleaseId} but failed to clear its staging prefix", release.Id); + } + + await _discord.NotifyFirmwareReleasePublishedAsync( + release.Version, + release.Channel.ToString().ToLowerInvariant(), + release.CommitHash, + ct); + + return CreatedAtAction( + nameof(VersionsController.GetVersion), + "Versions", + new { firmwareVersion = release.Version }, + null); + } + + // ---- Abort Release ---- + + [HttpDelete("{releaseId:guid}")] + public async Task AbortRelease([FromRoute] Guid releaseId, CancellationToken ct) + { + var release = await _db.FirmwareReleases + .Include(r => r.StagedArtifacts) + .FirstOrDefaultAsync(r => r.Id == releaseId, ct); + + if (release is null) + { + return Problem(FirmwareError.FirmwareReleaseNotFound); + } + + if (!IsOwnedByCaller(release)) + { + return Problem(FirmwareError.FirmwareReleaseNotOwned); + } + + if (release.Status != ReleaseStatus.Staging && release.Status != ReleaseStatus.Editing) + { + return Problem(FirmwareError.FirmwareReleaseNotEditable); + } + + // Everything this release wrote lives under its own staging prefix, so it can be dropped + // wholesale with no risk of deleting an object a published version is serving. + await _storage.DeleteDirectoryAsync(FirmwareArtifactFileNames.BuildStagingPrefix(release.Id), ct); + + release.Status = ReleaseStatus.Aborted; + await _db.SaveChangesAsync(ct); + + return NoContent(); + } + + // ---- Helpers ---- + + private readonly record struct SourceClaims(Guid RepositoryId, string CommitHash, string? Ref, string? RunId); + + /// + /// Best-effort deletion used to unwind a partial promotion. Never throws: it runs on paths that + /// are already failing, and turning a cleanup error into the reported fault would hide the cause. + /// + private async Task TryDeleteAllAsync(IEnumerable paths, CancellationToken ct) + { + foreach (var path in paths) + { + try + { + await _storage.DeleteFileAsync(path, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to remove promoted artifact {Path} while unwinding", path); + } + } + } + + /// + /// True when the failure is the partial unique index guarding one open release per version. + /// + private static bool IsOpenReleaseConflict(DbUpdateException ex) => + ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } pg + && pg.ConstraintName == "ix_firmware_releases_open_version"; + + /// + /// Confirms the authenticated repository is the one that created this release. + /// + /// + /// Every registered repository presents an equally valid CI/CD principal, so authentication alone + /// says nothing about which release the caller may touch. Without this check any + /// authorized repository could inject binaries into another's in-flight release, publish it under + /// that repository's identity and commit hash, or abort it. + /// + private bool IsOwnedByCaller(FirmwareRelease release) + { + var rawRepoId = User.FindFirstValue(AuthSchemas.CiCdClaims.RepositoryId); + return Guid.TryParse(rawRepoId, out var callerRepositoryId) + && release.RepositoryId == callerRepositoryId; + } + + private bool TryReadSourceClaims(out SourceClaims claims, out string missing) + { + var rawRepoId = User.FindFirstValue(AuthSchemas.CiCdClaims.RepositoryId); + var commitHash = User.FindFirstValue(AuthSchemas.CiCdClaims.CommitHash); + var refValue = User.FindFirstValue(AuthSchemas.CiCdClaims.Ref); + var runId = User.FindFirstValue(AuthSchemas.CiCdClaims.RunId); + + if (string.IsNullOrWhiteSpace(rawRepoId) || !Guid.TryParse(rawRepoId, out var repoId)) + { + claims = default; + missing = AuthSchemas.CiCdClaims.RepositoryId; + return false; + } + if (string.IsNullOrWhiteSpace(commitHash)) + { + claims = default; + missing = AuthSchemas.CiCdClaims.CommitHash; + return false; + } + + claims = new SourceClaims(repoId, commitHash, refValue, runId); + missing = string.Empty; + return true; + } + + private static string DescribeParseError(ChangelogParseError error) => error switch + { + ChangelogParseError.Empty => "Changelog is empty or whitespace-only", + ChangelogParseError.NoHeadings => "Changelog contains no '### Heading' sections", + ChangelogParseError.AllSectionsEmpty => "All changelog sections are empty", + _ => error.ToString() + }; +} diff --git a/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs new file mode 100644 index 0000000..a2fd3c2 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs @@ -0,0 +1,150 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; + +[ApiVersion("2.0")] +[ApiController] +[Route("/{version:apiVersion}/firmware/versions")] +public sealed class VersionsController : OpenShockControllerBase +{ + private const int DefaultLimit = 20; + private const int MaxLimit = 100; + + private readonly RepoServerContext _db; + private readonly ApiConfig _apiConfig; + + public VersionsController(RepoServerContext db, ApiConfig apiConfig) + { + _db = db; + _apiConfig = apiConfig; + } + + [HttpGet] + [CacheControl(3600)] + public async Task ListVersions( + [FromQuery] string? channel, + [FromQuery] int? limit, + [FromQuery] int? offset, + CancellationToken ct) + { + IQueryable query = _db.FirmwareVersions + .Include(v => v.RepositoryNavigation) + .Include(v => v.ReleaseNotes); + + if (!string.IsNullOrWhiteSpace(channel)) + { + if (!Enum.TryParse(channel, true, out var firmwareChannel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + // Cascading, so a beta subscriber's history includes the stable releases they can install. + var visibleChannels = ReleaseChannels.VisibleTo(firmwareChannel); + query = query.Where(v => visibleChannels.Contains(v.Channel)); + } + + var total = await query.CountAsync(ct); + + var effectiveLimit = Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); + var effectiveOffset = Math.Max(offset ?? 0, 0); + + var rows = await query + .OrderByNewest() + .Skip(effectiveOffset) + .Take(effectiveLimit) + .ToListAsync(ct); + + var summaries = rows + .Select(v => new FirmwareVersionSummary + { + Version = v.Version, + Channel = v.Channel.ToString().ToLowerInvariant(), + ReleaseDate = v.ReleaseDate, + Source = FirmwareSourceDto.From(v), + ReleaseNotes = v.ReleaseNotes + .OrderBy(n => n.Index) + .Select(n => new FirmwareReleaseNoteDto + { + Type = n.SectionType.ToString().ToLowerInvariant(), + Title = n.Title, + Content = n.Content + }) + .ToList() + }) + .ToList(); + + return Ok(new VersionListResponse { Versions = summaries, Total = total }); + } + + [HttpGet("{firmwareVersion}")] + [CacheControl(86400, immutable: true)] + public async Task GetVersion([FromRoute] string firmwareVersion, CancellationToken ct) + { + var version = await _db.FirmwareVersions + .Include(v => v.RepositoryNavigation) + .Include(v => v.Artifacts) + .Include(v => v.ReleaseNotes) + .FirstOrDefaultAsync(v => v.Version == firmwareVersion, ct); + + if (version is null) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var boardIds = version.Artifacts.Select(a => a.BoardId).Distinct().ToList(); + var boards = await _db.FirmwareBoards + .Include(b => b.ChipNavigation) + .Where(b => boardIds.Contains(b.Id)) + .ToListAsync(ct); + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + return Ok(FirmwareResponseMapper.ToReleaseDto(version, boards, cdnBase)); + } + + /// Board name (e.g. "Wemos-D1-Mini-ESP32") or board id. + [HttpGet("{firmwareVersion}/{board}")] + [CacheControl(86400, immutable: true)] + public async Task GetVersionForBoard( + [FromRoute] string firmwareVersion, + [FromRoute] string board, + CancellationToken ct) + { + var exists = await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion, ct); + if (!exists) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var resolved = await _db.ResolveBoardAsync(board, ct); + if (resolved is not { } boardRef) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var artifacts = await _db.FirmwareArtifacts + .Where(a => a.Version == firmwareVersion && a.BoardId == boardRef.Id) + .ToListAsync(ct); + + if (artifacts.Count == 0) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + return Ok(new FirmwareBoardReleaseResponseDto + { + Version = firmwareVersion, + BoardId = boardRef.Name, + Artifacts = artifacts + .Select(a => FirmwareResponseMapper.ToArtifactDto(a, firmwareVersion, cdnBase)) + .ToList() + }); + } +} diff --git a/RepositoryServer/Enums/AdvisorySeverity.cs b/RepositoryServer/Enums/AdvisorySeverity.cs new file mode 100644 index 0000000..f941a99 --- /dev/null +++ b/RepositoryServer/Enums/AdvisorySeverity.cs @@ -0,0 +1,13 @@ +namespace OpenShock.RepositoryServer.Enums; + +/// +/// Severity level for a firmware advisory surfaced on the manifest endpoint. +/// Lowercase member names so the Postgres enum values match the public API contract +/// (critical, warning, info). +/// +public enum AdvisorySeverity +{ + Critical, + Warning, + Info +} diff --git a/RepositoryServer/Enums/DiscordNotificationEvent.cs b/RepositoryServer/Enums/DiscordNotificationEvent.cs new file mode 100644 index 0000000..5136cf9 --- /dev/null +++ b/RepositoryServer/Enums/DiscordNotificationEvent.cs @@ -0,0 +1,13 @@ +namespace OpenShock.RepositoryServer.Enums; + +/// +/// Notification kinds a Discord webhook can subscribe to. Subscribing per event lets maintainer +/// alerts go somewhere other than a public releases channel. +/// +public enum DiscordNotificationEvent +{ + FirmwareReleasePublished, + DesktopModuleVersionPublished, + ReleaseNotesNeedEditing, + StagedReleaseExpired +} diff --git a/RepositoryServer/Enums/FirmwareArtifactType.cs b/RepositoryServer/Enums/FirmwareArtifactType.cs new file mode 100644 index 0000000..8bbf9ec --- /dev/null +++ b/RepositoryServer/Enums/FirmwareArtifactType.cs @@ -0,0 +1,10 @@ +namespace OpenShock.RepositoryServer.Enums; + +public enum FirmwareArtifactType +{ + Merged, + App, + Bootloader, + Partitions, + StaticFs +} diff --git a/RepositoryServer/Enums/FirmwareChipArchitecture.cs b/RepositoryServer/Enums/FirmwareChipArchitecture.cs new file mode 100644 index 0000000..1ed0b7b --- /dev/null +++ b/RepositoryServer/Enums/FirmwareChipArchitecture.cs @@ -0,0 +1,7 @@ +namespace OpenShock.RepositoryServer.Enums; + +public enum FirmwareChipArchitecture +{ + Xtensa, + RiscV +} diff --git a/RepositoryServer/Enums/ReleaseChannel.cs b/RepositoryServer/Enums/ReleaseChannel.cs new file mode 100644 index 0000000..f39d321 --- /dev/null +++ b/RepositoryServer/Enums/ReleaseChannel.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Enums; + +public enum ReleaseChannel +{ + Stable, + Beta, + Develop +} diff --git a/RepositoryServer/Enums/ReleaseNoteSectionType.cs b/RepositoryServer/Enums/ReleaseNoteSectionType.cs new file mode 100644 index 0000000..88e6a71 --- /dev/null +++ b/RepositoryServer/Enums/ReleaseNoteSectionType.cs @@ -0,0 +1,9 @@ +namespace OpenShock.RepositoryServer.Enums; + +public enum ReleaseNoteSectionType +{ + Warning, + Info, + Breaking, + Section +} diff --git a/RepositoryServer/Enums/ReleaseStatus.cs b/RepositoryServer/Enums/ReleaseStatus.cs new file mode 100644 index 0000000..14d9e4f --- /dev/null +++ b/RepositoryServer/Enums/ReleaseStatus.cs @@ -0,0 +1,10 @@ +namespace OpenShock.RepositoryServer.Enums; + +public enum ReleaseStatus +{ + Staging, + Editing, + Published, + Archived, + Aborted +} diff --git a/RepositoryServer/Enums/RepositoryProvider.cs b/RepositoryServer/Enums/RepositoryProvider.cs new file mode 100644 index 0000000..4b019f4 --- /dev/null +++ b/RepositoryServer/Enums/RepositoryProvider.cs @@ -0,0 +1,11 @@ +namespace OpenShock.RepositoryServer.Enums; + +/// +/// Source-code provider for the shared repositories table. Only GitHub is +/// supported today; new providers require a matching OIDC handler and URL builder. +/// Member name uses single-word casing so the Postgres enum value is github. +/// +public enum RepositoryProvider +{ + Github +} diff --git a/RepositoryServer/Enums/RepositoryScope.cs b/RepositoryServer/Enums/RepositoryScope.cs new file mode 100644 index 0000000..5da49fd --- /dev/null +++ b/RepositoryServer/Enums/RepositoryScope.cs @@ -0,0 +1,15 @@ +namespace OpenShock.RepositoryServer.Enums; + +/// +/// What a registered repository is permitted to publish. +/// +/// +/// Firmware and desktop module ingestion share one authentication scheme, so without scopes any +/// repository registered to publish desktop modules could also initialise and publish firmware +/// releases. Scopes keep an onboarding grant limited to what it was meant for. +/// +public enum RepositoryScope +{ + PublishFirmware, + PublishModules +} diff --git a/DesktopRepositoryServer/Errors/AuthResultError.cs b/RepositoryServer/Errors/AuthResultError.cs similarity index 77% rename from DesktopRepositoryServer/Errors/AuthResultError.cs rename to RepositoryServer/Errors/AuthResultError.cs index 421c60d..0109425 100644 --- a/DesktopRepositoryServer/Errors/AuthResultError.cs +++ b/RepositoryServer/Errors/AuthResultError.cs @@ -1,7 +1,7 @@ using System.Net; -using OpenShock.Desktop.RepositoryServer.Problems; +using OpenShock.RepositoryServer.Problems; -namespace OpenShock.Desktop.RepositoryServer.Errors; +namespace OpenShock.RepositoryServer.Errors; public static class AuthResultError { diff --git a/DesktopRepositoryServer/Errors/ExceptionError.cs b/RepositoryServer/Errors/ExceptionError.cs similarity index 51% rename from DesktopRepositoryServer/Errors/ExceptionError.cs rename to RepositoryServer/Errors/ExceptionError.cs index 4425b49..a8c3762 100644 --- a/DesktopRepositoryServer/Errors/ExceptionError.cs +++ b/RepositoryServer/Errors/ExceptionError.cs @@ -1,6 +1,6 @@ -using OpenShock.Desktop.RepositoryServer.Problems; +using OpenShock.RepositoryServer.Problems; -namespace OpenShock.Desktop.RepositoryServer.Errors; +namespace OpenShock.RepositoryServer.Errors; public static class ExceptionError { diff --git a/DesktopRepositoryServer/ExceptionHandler/ExceptionHandler.cs b/RepositoryServer/ExceptionHandler/ExceptionHandler.cs similarity index 90% rename from DesktopRepositoryServer/ExceptionHandler/ExceptionHandler.cs rename to RepositoryServer/ExceptionHandler/ExceptionHandler.cs index b6f074e..51f1b8a 100644 --- a/DesktopRepositoryServer/ExceptionHandler/ExceptionHandler.cs +++ b/RepositoryServer/ExceptionHandler/ExceptionHandler.cs @@ -1,8 +1,8 @@ using System.Net; using Microsoft.AspNetCore.Diagnostics; -using OpenShock.Desktop.RepositoryServer.Errors; +using OpenShock.RepositoryServer.Errors; -namespace OpenShock.Desktop.RepositoryServer.ExceptionHandler; +namespace OpenShock.RepositoryServer.ExceptionHandler; public sealed class OpenShockExceptionHandler : IExceptionHandler { @@ -19,7 +19,7 @@ public async ValueTask TryHandleAsync(HttpContext context, Exception excep { _logger.LogError(exception, "An unhandled exception occurred while processing the request."); context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; - + var responseObject = ExceptionError.Exception; responseObject.AddContext(context); diff --git a/DesktopRepositoryServer/ExceptionHandler/RequestInfo.cs b/RepositoryServer/ExceptionHandler/RequestInfo.cs similarity index 87% rename from DesktopRepositoryServer/ExceptionHandler/RequestInfo.cs rename to RepositoryServer/ExceptionHandler/RequestInfo.cs index a59ae1b..c6e87b8 100644 --- a/DesktopRepositoryServer/ExceptionHandler/RequestInfo.cs +++ b/RepositoryServer/ExceptionHandler/RequestInfo.cs @@ -1,5 +1,5 @@ // ReSharper disable UnusedAutoPropertyAccessor.Global -namespace OpenShock.Desktop.RepositoryServer.ExceptionHandler; +namespace OpenShock.RepositoryServer.ExceptionHandler; public sealed class RequestInfo { diff --git a/DesktopRepositoryServer/Migrations/20250315184152_Initial.Designer.cs b/RepositoryServer/Migrations/20250315184152_Initial.Designer.cs similarity index 87% rename from DesktopRepositoryServer/Migrations/20250315184152_Initial.Designer.cs rename to RepositoryServer/Migrations/20250315184152_Initial.Designer.cs index b40cfa2..905a9c4 100644 --- a/DesktopRepositoryServer/Migrations/20250315184152_Initial.Designer.cs +++ b/RepositoryServer/Migrations/20250315184152_Initial.Designer.cs @@ -4,11 +4,11 @@ using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using OpenShock.Desktop.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.RepoServerDb; #nullable disable -namespace OpenShock.Desktop.RepositoryServer.Migrations +namespace OpenShock.RepositoryServer.Migrations { [DbContext(typeof(MigrationOpenShockContext))] [Migration("20250315184152_Initial")] @@ -24,7 +24,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", b => + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => { b.Property("Id") .HasMaxLength(128) @@ -58,7 +58,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("modules", (string)null); }); - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b => + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Version", b => { b.Property("VersionName") .HasMaxLength(64) @@ -99,9 +99,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("versions", (string)null); }); - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b => + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Version", b => { - b.HasOne("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", "ModuleNavigation") + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.Module", "ModuleNavigation") .WithMany("Versions") .HasForeignKey("Module") .OnDelete(DeleteBehavior.Cascade) @@ -111,7 +111,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("ModuleNavigation"); }); - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", b => + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => { b.Navigation("Versions"); }); diff --git a/DesktopRepositoryServer/Migrations/20250315184152_Initial.cs b/RepositoryServer/Migrations/20250315184152_Initial.cs similarity index 98% rename from DesktopRepositoryServer/Migrations/20250315184152_Initial.cs rename to RepositoryServer/Migrations/20250315184152_Initial.cs index 91b0dea..1c88190 100644 --- a/DesktopRepositoryServer/Migrations/20250315184152_Initial.cs +++ b/RepositoryServer/Migrations/20250315184152_Initial.cs @@ -2,7 +2,7 @@ #nullable disable -namespace OpenShock.Desktop.RepositoryServer.Migrations +namespace OpenShock.RepositoryServer.Migrations { /// public partial class Initial : Migration diff --git a/RepositoryServer/Migrations/20260813123629_UnifiedRepositoryServer.Designer.cs b/RepositoryServer/Migrations/20260813123629_UnifiedRepositoryServer.Designer.cs new file mode 100644 index 0000000..ffb9696 --- /dev/null +++ b/RepositoryServer/Migrations/20260813123629_UnifiedRepositoryServer.Designer.cs @@ -0,0 +1,827 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +#nullable disable + +namespace OpenShock.RepositoryServer.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20260813123629_UnifiedRepositoryServer")] + partial class UnifiedRepositoryServer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "advisory_severity", new[] { "critical", "info", "warning" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "discord_notification_event", new[] { "desktop_module_version_published", "firmware_release_published", "release_notes_need_editing", "staged_release_expired" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_artifact_type", new[] { "app", "bootloader", "merged", "partitions", "static_fs" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_chip_architecture", new[] { "risc_v", "xtensa" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_release_note_type", new[] { "breaking", "info", "section", "warning" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "release_channel", new[] { "beta", "develop", "stable" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "release_status", new[] { "aborted", "archived", "editing", "published", "staging" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "repository_provider", new[] { "github" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "repository_scope", new[] { "publish_firmware", "publish_modules" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.DiscordWebhook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("discord_notification_event[]") + .HasColumnName("events"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareAdvisory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedVersions") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("affected_versions"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("Severity") + .HasColumnType("advisory_severity") + .HasColumnName("severity"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("Url") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("firmware_advisories_pkey"); + + b.ToTable("firmware_advisories", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareArtifact", b => + { + b.Property("Version") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("BoardId") + .HasColumnType("uuid") + .HasColumnName("board_id"); + + b.Property("ArtifactType") + .HasColumnType("firmware_artifact_type") + .HasColumnName("artifact_type"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasColumnName("file_size"); + + b.Property("HashSha256") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash_sha256"); + + b.HasKey("Version", "BoardId", "ArtifactType") + .HasName("firmware_artifacts_pkey"); + + b.HasIndex("BoardId"); + + b.ToTable("firmware_artifacts", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChipId") + .HasColumnType("uuid") + .HasColumnName("chip_id"); + + b.Property("Discontinued") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("discontinued"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.PrimitiveCollection("RequiredArtifactTypes") + .IsRequired() + .HasColumnType("firmware_artifact_type[]") + .HasColumnName("required_artifact_types"); + + b.HasKey("Id") + .HasName("firmware_boards_pkey"); + + b.HasIndex("ChipId"); + + b.HasIndex("Name") + .HasDatabaseName("ix_firmware_boards_name"); + + b.ToTable("firmware_boards", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoardUsbDevice", b => + { + b.Property("BoardId") + .HasColumnType("uuid") + .HasColumnName("board_id"); + + b.Property("UsbDeviceId") + .HasColumnType("uuid") + .HasColumnName("usb_device_id"); + + b.HasKey("BoardId", "UsbDeviceId") + .HasName("firmware_board_usb_devices_pkey"); + + b.HasIndex("UsbDeviceId"); + + b.ToTable("firmware_board_usb_devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Architecture") + .HasColumnType("firmware_chip_architecture") + .HasColumnName("architecture"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("firmware_chips_pkey"); + + b.HasIndex("Name") + .HasDatabaseName("ix_firmware_chips_name"); + + b.ToTable("firmware_chips", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChipUsbDevice", b => + { + b.Property("ChipId") + .HasColumnType("uuid") + .HasColumnName("chip_id"); + + b.Property("UsbDeviceId") + .HasColumnType("uuid") + .HasColumnName("usb_device_id"); + + b.HasKey("ChipId", "UsbDeviceId") + .HasName("firmware_chip_usb_devices_pkey"); + + b.HasIndex("UsbDeviceId"); + + b.ToTable("firmware_chip_usb_devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("release_channel") + .HasColumnName("channel"); + + b.Property("CommitHash") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("commit_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.PrimitiveCollection("DeclaredBoards") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("declared_boards"); + + b.Property("Ref") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ref"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("RepositoryId") + .HasColumnType("uuid") + .HasColumnName("repository_id"); + + b.Property("RunId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("run_id"); + + b.Property("Status") + .HasColumnType("release_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("firmware_releases_pkey"); + + b.HasIndex("RepositoryId"); + + b.HasIndex("Version", "Status") + .HasDatabaseName("ix_firmware_releases_version_status"); + + b.ToTable("firmware_releases", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareReleaseNote", b => + { + b.Property("Version") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("SectionType") + .HasColumnType("firmware_release_note_type") + .HasColumnName("type"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.HasKey("Version", "Index") + .HasName("firmware_release_notes_pkey"); + + b.ToTable("firmware_release_notes", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareStagedArtifact", b => + { + b.Property("ReleaseId") + .HasColumnType("uuid") + .HasColumnName("release_id"); + + b.Property("BoardId") + .HasColumnType("uuid") + .HasColumnName("board_id"); + + b.Property("ArtifactType") + .HasColumnType("firmware_artifact_type") + .HasColumnName("artifact_type"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasColumnName("file_size"); + + b.Property("HashSha256") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash_sha256"); + + b.HasKey("ReleaseId", "BoardId", "ArtifactType") + .HasName("firmware_staged_artifacts_pkey"); + + b.ToTable("firmware_staged_artifacts", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareStagedReleaseNote", b => + { + b.Property("ReleaseId") + .HasColumnType("uuid") + .HasColumnName("release_id"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("SectionType") + .HasColumnType("firmware_release_note_type") + .HasColumnName("type"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.HasKey("ReleaseId", "Index") + .HasName("firmware_staged_release_notes_pkey"); + + b.ToTable("firmware_staged_release_notes", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", b => + { + b.Property("Version") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("Channel") + .HasColumnType("release_channel") + .HasColumnName("channel"); + + b.Property("CommitHash") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("commit_hash"); + + b.Property("Ref") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ref"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("RepositoryId") + .HasColumnType("uuid") + .HasColumnName("repository_id"); + + b.Property("RunId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("run_id"); + + b.HasKey("Version") + .HasName("firmware_versions_pkey"); + + b.HasIndex("Channel") + .HasDatabaseName("ix_firmware_versions_channel"); + + b.HasIndex("ReleaseDate") + .HasDatabaseName("ix_firmware_versions_release_date"); + + b.HasIndex("RepositoryId"); + + b.ToTable("firmware_versions", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("id"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("icon_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("RepositoryId") + .HasColumnType("uuid") + .HasColumnName("repository_id"); + + b.Property("SourceUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("source_url"); + + b.HasKey("Id") + .HasName("modules_pkey"); + + b.HasIndex("RepositoryId"); + + b.ToTable("modules", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("owner"); + + b.Property("Provider") + .HasColumnType("repository_provider") + .HasColumnName("provider"); + + b.Property("Repo") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("repo"); + + b.PrimitiveCollection("Scopes") + .IsRequired() + .HasColumnType("repository_scope[]") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("repositories_pkey"); + + b.HasIndex("Provider", "Owner", "Repo") + .HasDatabaseName("ix_repositories_provider_owner_repo"); + + b.ToTable("repositories", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.UsbDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Pid") + .HasColumnType("integer") + .HasColumnName("pid"); + + b.Property("Vid") + .HasColumnType("integer") + .HasColumnName("vid"); + + b.HasKey("Id") + .HasName("usb_devices_pkey"); + + b.HasIndex("Vid", "Pid") + .IsUnique() + .HasDatabaseName("ix_usb_devices_vid_pid"); + + b.ToTable("usb_devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.UsbSerialFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("description"); + + b.Property("Pid") + .HasColumnType("integer") + .HasColumnName("pid"); + + b.Property("Vid") + .HasColumnType("integer") + .HasColumnName("vid"); + + b.HasKey("Id") + .HasName("usb_serial_filters_pkey"); + + b.HasIndex("Vid", "Pid") + .IsUnique() + .HasDatabaseName("ix_usb_serial_filters_vid_pid"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("Vid", "Pid"), false); + + b.ToTable("usb_serial_filters", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Version", b => + { + b.Property("VersionName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("Module") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("module"); + + b.Property("ChangelogUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("changelog_url"); + + b.Property("HashSha256") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash_sha256"); + + b.Property("ReleaseUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("release_url"); + + b.Property("ZipUrl") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("zip_url"); + + b.HasKey("VersionName", "Module") + .HasName("versions_pkey"); + + b.HasIndex("Module"); + + b.ToTable("versions", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareArtifact", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", "BoardNavigation") + .WithMany("Artifacts") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_artifacts_board"); + + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", "VersionNavigation") + .WithMany("Artifacts") + .HasForeignKey("Version") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_artifacts_version"); + + b.Navigation("BoardNavigation"); + + b.Navigation("VersionNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", "ChipNavigation") + .WithMany("Boards") + .HasForeignKey("ChipId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_boards_chip"); + + b.Navigation("ChipNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoardUsbDevice", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", "Board") + .WithMany() + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_board_usb_devices_board"); + + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.UsbDevice", "UsbDevice") + .WithMany() + .HasForeignKey("UsbDeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_board_usb_devices_usb_device"); + + b.Navigation("Board"); + + b.Navigation("UsbDevice"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChipUsbDevice", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", "Chip") + .WithMany() + .HasForeignKey("ChipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_chip_usb_devices_chip"); + + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.UsbDevice", "UsbDevice") + .WithMany() + .HasForeignKey("UsbDeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_chip_usb_devices_usb_device"); + + b.Navigation("Chip"); + + b.Navigation("UsbDevice"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", "RepositoryNavigation") + .WithMany("FirmwareReleases") + .HasForeignKey("RepositoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_releases_repository"); + + b.Navigation("RepositoryNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareReleaseNote", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", "VersionNavigation") + .WithMany("ReleaseNotes") + .HasForeignKey("Version") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_release_notes_version"); + + b.Navigation("VersionNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareStagedArtifact", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", "ReleaseNavigation") + .WithMany("StagedArtifacts") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_staged_artifacts_release"); + + b.Navigation("ReleaseNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareStagedReleaseNote", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", "ReleaseNavigation") + .WithMany("StagedReleaseNotes") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_staged_release_notes_release"); + + b.Navigation("ReleaseNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", "RepositoryNavigation") + .WithMany("FirmwareVersions") + .HasForeignKey("RepositoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_versions_repository"); + + b.Navigation("RepositoryNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", "RepositoryNavigation") + .WithMany() + .HasForeignKey("RepositoryId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_modules_repository"); + + b.Navigation("RepositoryNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Version", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.Module", "ModuleNavigation") + .WithMany("Versions") + .HasForeignKey("Module") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_versions_module"); + + b.Navigation("ModuleNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", b => + { + b.Navigation("Artifacts"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", b => + { + b.Navigation("Boards"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", b => + { + b.Navigation("StagedArtifacts"); + + b.Navigation("StagedReleaseNotes"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", b => + { + b.Navigation("Artifacts"); + + b.Navigation("ReleaseNotes"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", b => + { + b.Navigation("FirmwareReleases"); + + b.Navigation("FirmwareVersions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RepositoryServer/Migrations/20260813123629_UnifiedRepositoryServer.cs b/RepositoryServer/Migrations/20260813123629_UnifiedRepositoryServer.cs new file mode 100644 index 0000000..6263489 --- /dev/null +++ b/RepositoryServer/Migrations/20260813123629_UnifiedRepositoryServer.cs @@ -0,0 +1,538 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using OpenShock.RepositoryServer.Enums; + +#nullable disable + +namespace OpenShock.RepositoryServer.Migrations +{ + /// + public partial class UnifiedRepositoryServer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:advisory_severity", "critical,info,warning") + .Annotation("Npgsql:Enum:discord_notification_event", "desktop_module_version_published,firmware_release_published,release_notes_need_editing,staged_release_expired") + .Annotation("Npgsql:Enum:firmware_artifact_type", "app,bootloader,merged,partitions,static_fs") + .Annotation("Npgsql:Enum:firmware_chip_architecture", "risc_v,xtensa") + .Annotation("Npgsql:Enum:firmware_release_note_type", "breaking,info,section,warning") + .Annotation("Npgsql:Enum:release_channel", "beta,develop,stable") + .Annotation("Npgsql:Enum:release_status", "aborted,archived,editing,published,staging") + .Annotation("Npgsql:Enum:repository_provider", "github") + .Annotation("Npgsql:Enum:repository_scope", "publish_firmware,publish_modules"); + + migrationBuilder.AddColumn( + name: "repository_id", + table: "modules", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "discord_webhooks", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + url = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + events = table.Column(type: "discord_notification_event[]", nullable: false), + enabled = table.Column(type: "boolean", nullable: false, defaultValue: true) + }, + constraints: table => + { + table.PrimaryKey("discord_webhooks_pkey", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "firmware_advisories", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + severity = table.Column(type: "advisory_severity", nullable: false), + title = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + content = table.Column(type: "text", nullable: false), + affected_versions = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + url = table.Column(type: "character varying(512)", maxLength: 512, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("firmware_advisories_pkey", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "firmware_chips", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + architecture = table.Column(type: "firmware_chip_architecture", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("firmware_chips_pkey", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "repositories", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + provider = table.Column(type: "repository_provider", nullable: false), + owner = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + repo = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + scopes = table.Column(type: "repository_scope[]", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("repositories_pkey", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "usb_devices", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + vid = table.Column(type: "integer", nullable: false), + pid = table.Column(type: "integer", nullable: false), + name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("usb_devices_pkey", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "usb_serial_filters", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + vid = table.Column(type: "integer", nullable: false), + pid = table.Column(type: "integer", nullable: true), + description = table.Column(type: "character varying(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("usb_serial_filters_pkey", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "firmware_boards", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + chip_id = table.Column(type: "uuid", nullable: false), + discontinued = table.Column(type: "boolean", nullable: false, defaultValue: false), + required_artifact_types = table.Column(type: "firmware_artifact_type[]", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("firmware_boards_pkey", x => x.id); + table.ForeignKey( + name: "fk_firmware_boards_chip", + column: x => x.chip_id, + principalTable: "firmware_chips", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "firmware_releases", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + version = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + channel = table.Column(type: "release_channel", nullable: false), + repository_id = table.Column(type: "uuid", nullable: false), + commit_hash = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + @ref = table.Column(name: "ref", type: "character varying(256)", maxLength: 256, nullable: true), + run_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + release_date = table.Column(type: "timestamp with time zone", nullable: false), + status = table.Column(type: "release_status", nullable: false), + declared_boards = table.Column(type: "uuid[]", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("firmware_releases_pkey", x => x.id); + table.ForeignKey( + name: "fk_firmware_releases_repository", + column: x => x.repository_id, + principalTable: "repositories", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "firmware_versions", + columns: table => new + { + version = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + channel = table.Column(type: "release_channel", nullable: false), + release_date = table.Column(type: "timestamp with time zone", nullable: false), + repository_id = table.Column(type: "uuid", nullable: false), + commit_hash = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + @ref = table.Column(name: "ref", type: "character varying(256)", maxLength: 256, nullable: true), + run_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("firmware_versions_pkey", x => x.version); + table.ForeignKey( + name: "fk_firmware_versions_repository", + column: x => x.repository_id, + principalTable: "repositories", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "firmware_chip_usb_devices", + columns: table => new + { + chip_id = table.Column(type: "uuid", nullable: false), + usb_device_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("firmware_chip_usb_devices_pkey", x => new { x.chip_id, x.usb_device_id }); + table.ForeignKey( + name: "fk_firmware_chip_usb_devices_chip", + column: x => x.chip_id, + principalTable: "firmware_chips", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_firmware_chip_usb_devices_usb_device", + column: x => x.usb_device_id, + principalTable: "usb_devices", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "firmware_board_usb_devices", + columns: table => new + { + board_id = table.Column(type: "uuid", nullable: false), + usb_device_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("firmware_board_usb_devices_pkey", x => new { x.board_id, x.usb_device_id }); + table.ForeignKey( + name: "fk_firmware_board_usb_devices_board", + column: x => x.board_id, + principalTable: "firmware_boards", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_firmware_board_usb_devices_usb_device", + column: x => x.usb_device_id, + principalTable: "usb_devices", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "firmware_staged_artifacts", + columns: table => new + { + release_id = table.Column(type: "uuid", nullable: false), + board_id = table.Column(type: "uuid", nullable: false), + artifact_type = table.Column(type: "firmware_artifact_type", nullable: false), + hash_sha256 = table.Column(type: "bytea", nullable: false), + file_size = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("firmware_staged_artifacts_pkey", x => new { x.release_id, x.board_id, x.artifact_type }); + table.ForeignKey( + name: "fk_firmware_staged_artifacts_release", + column: x => x.release_id, + principalTable: "firmware_releases", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "firmware_staged_release_notes", + columns: table => new + { + release_id = table.Column(type: "uuid", nullable: false), + index = table.Column(type: "integer", nullable: false), + type = table.Column(type: "firmware_release_note_type", nullable: false), + title = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + content = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("firmware_staged_release_notes_pkey", x => new { x.release_id, x.index }); + table.ForeignKey( + name: "fk_firmware_staged_release_notes_release", + column: x => x.release_id, + principalTable: "firmware_releases", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "firmware_artifacts", + columns: table => new + { + version = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + board_id = table.Column(type: "uuid", nullable: false), + artifact_type = table.Column(type: "firmware_artifact_type", nullable: false), + hash_sha256 = table.Column(type: "bytea", nullable: false), + file_size = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("firmware_artifacts_pkey", x => new { x.version, x.board_id, x.artifact_type }); + table.ForeignKey( + name: "fk_firmware_artifacts_board", + column: x => x.board_id, + principalTable: "firmware_boards", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_firmware_artifacts_version", + column: x => x.version, + principalTable: "firmware_versions", + principalColumn: "version", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "firmware_release_notes", + columns: table => new + { + version = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + index = table.Column(type: "integer", nullable: false), + type = table.Column(type: "firmware_release_note_type", nullable: false), + title = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + content = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("firmware_release_notes_pkey", x => new { x.version, x.index }); + table.ForeignKey( + name: "fk_firmware_release_notes_version", + column: x => x.version, + principalTable: "firmware_versions", + principalColumn: "version", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_modules_repository_id", + table: "modules", + column: "repository_id"); + + migrationBuilder.CreateIndex( + name: "IX_firmware_artifacts_board_id", + table: "firmware_artifacts", + column: "board_id"); + + migrationBuilder.CreateIndex( + name: "IX_firmware_board_usb_devices_usb_device_id", + table: "firmware_board_usb_devices", + column: "usb_device_id"); + + migrationBuilder.CreateIndex( + name: "IX_firmware_boards_chip_id", + table: "firmware_boards", + column: "chip_id"); + + migrationBuilder.CreateIndex( + name: "ix_firmware_boards_name", + table: "firmware_boards", + column: "name"); + + migrationBuilder.CreateIndex( + name: "IX_firmware_chip_usb_devices_usb_device_id", + table: "firmware_chip_usb_devices", + column: "usb_device_id"); + + migrationBuilder.CreateIndex( + name: "ix_firmware_chips_name", + table: "firmware_chips", + column: "name"); + + migrationBuilder.CreateIndex( + name: "IX_firmware_releases_repository_id", + table: "firmware_releases", + column: "repository_id"); + + migrationBuilder.CreateIndex( + name: "ix_firmware_releases_version_status", + table: "firmware_releases", + columns: new[] { "version", "status" }); + + migrationBuilder.CreateIndex( + name: "ix_firmware_versions_channel", + table: "firmware_versions", + column: "channel"); + + migrationBuilder.CreateIndex( + name: "ix_firmware_versions_release_date", + table: "firmware_versions", + column: "release_date"); + + migrationBuilder.CreateIndex( + name: "IX_firmware_versions_repository_id", + table: "firmware_versions", + column: "repository_id"); + + migrationBuilder.CreateIndex( + name: "ix_repositories_provider_owner_repo", + table: "repositories", + columns: new[] { "provider", "owner", "repo" }); + + migrationBuilder.CreateIndex( + name: "ix_usb_devices_vid_pid", + table: "usb_devices", + columns: new[] { "vid", "pid" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_usb_serial_filters_vid_pid", + table: "usb_serial_filters", + columns: new[] { "vid", "pid" }, + unique: true) + .Annotation("Npgsql:NullsDistinct", false); + + migrationBuilder.AddForeignKey( + name: "fk_modules_repository", + table: "modules", + column: "repository_id", + principalTable: "repositories", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + + // ---- Indexes the EF model cannot express ------------------------------------------- + // Expression and partial indexes have no model-builder equivalent, so they are created + // here in raw SQL. The corresponding model-level indexes above are deliberately declared + // non-unique: the real constraint is the one below. + // + // NOTE: `dotnet ef migrations remove` discards these, because they exist only here and not + // in the model. If this migration is ever regenerated, re-add them. + + // Board and chip names are resolved case-insensitively on the public endpoints. With only + // a case-sensitive unique index, "ESP32-Core" and "esp32-core" could coexist and + // resolution would silently pick one — the other could never receive an upload, and a hub + // compiled with its spelling would be served the other board's firmware, potentially for a + // different chip. This also lets the lower(name) lookups use an index rather than a scan. + migrationBuilder.Sql( + """ + CREATE UNIQUE INDEX ix_firmware_chips_name_lower + ON firmware_chips (lower(name)); + """); + + migrationBuilder.Sql( + """ + CREATE UNIQUE INDEX ix_firmware_boards_name_lower + ON firmware_boards (lower(name)); + """); + + // GitHub treats owner and repo names case-insensitively, and the casing in an OIDC token + // follows whatever the repository is currently called. Matching exactly would let a + // cosmetic rename silently de-authorize a repository, or let two rows represent one grant. + migrationBuilder.Sql( + """ + CREATE UNIQUE INDEX ix_repositories_provider_owner_repo_lower + ON repositories (provider, lower(owner), lower(repo)); + """); + + // At most one open release per version. InitRelease guards this with a read-then-insert, + // which two concurrent jobs for the same tag both pass; they would then stage into the + // same CDN keys and race each other's uploads, producing a permanent hash mismatch for + // every OTA client with no error surfaced anywhere. + migrationBuilder.Sql( + """ + CREATE UNIQUE INDEX ix_firmware_releases_open_version + ON firmware_releases (version) + WHERE status IN ('staging', 'editing'); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Raw-SQL indexes from Up — dropped first so the tables below come down cleanly. + migrationBuilder.Sql("DROP INDEX IF EXISTS ix_firmware_releases_open_version;"); + migrationBuilder.Sql("DROP INDEX IF EXISTS ix_repositories_provider_owner_repo_lower;"); + migrationBuilder.Sql("DROP INDEX IF EXISTS ix_firmware_boards_name_lower;"); + migrationBuilder.Sql("DROP INDEX IF EXISTS ix_firmware_chips_name_lower;"); + + migrationBuilder.DropForeignKey( + name: "fk_modules_repository", + table: "modules"); + + migrationBuilder.DropTable( + name: "discord_webhooks"); + + migrationBuilder.DropTable( + name: "firmware_advisories"); + + migrationBuilder.DropTable( + name: "firmware_artifacts"); + + migrationBuilder.DropTable( + name: "firmware_board_usb_devices"); + + migrationBuilder.DropTable( + name: "firmware_chip_usb_devices"); + + migrationBuilder.DropTable( + name: "firmware_release_notes"); + + migrationBuilder.DropTable( + name: "firmware_staged_artifacts"); + + migrationBuilder.DropTable( + name: "firmware_staged_release_notes"); + + migrationBuilder.DropTable( + name: "usb_serial_filters"); + + migrationBuilder.DropTable( + name: "firmware_boards"); + + migrationBuilder.DropTable( + name: "usb_devices"); + + migrationBuilder.DropTable( + name: "firmware_versions"); + + migrationBuilder.DropTable( + name: "firmware_releases"); + + migrationBuilder.DropTable( + name: "firmware_chips"); + + migrationBuilder.DropTable( + name: "repositories"); + + migrationBuilder.DropIndex( + name: "IX_modules_repository_id", + table: "modules"); + + migrationBuilder.DropColumn( + name: "repository_id", + table: "modules"); + + migrationBuilder.AlterDatabase() + .OldAnnotation("Npgsql:Enum:advisory_severity", "critical,info,warning") + .OldAnnotation("Npgsql:Enum:discord_notification_event", "desktop_module_version_published,firmware_release_published,release_notes_need_editing,staged_release_expired") + .OldAnnotation("Npgsql:Enum:firmware_artifact_type", "app,bootloader,merged,partitions,static_fs") + .OldAnnotation("Npgsql:Enum:firmware_chip_architecture", "risc_v,xtensa") + .OldAnnotation("Npgsql:Enum:firmware_release_note_type", "breaking,info,section,warning") + .OldAnnotation("Npgsql:Enum:release_channel", "beta,develop,stable") + .OldAnnotation("Npgsql:Enum:release_status", "aborted,archived,editing,published,staging") + .OldAnnotation("Npgsql:Enum:repository_provider", "github") + .OldAnnotation("Npgsql:Enum:repository_scope", "publish_firmware,publish_modules"); + } + } +} diff --git a/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs b/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs new file mode 100644 index 0000000..916b4fb --- /dev/null +++ b/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs @@ -0,0 +1,824 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +#nullable disable + +namespace OpenShock.Desktop.RepositoryServer.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + partial class MigrationOpenShockContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "advisory_severity", new[] { "critical", "info", "warning" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "discord_notification_event", new[] { "desktop_module_version_published", "firmware_release_published", "release_notes_need_editing", "staged_release_expired" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_artifact_type", new[] { "app", "bootloader", "merged", "partitions", "static_fs" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_chip_architecture", new[] { "risc_v", "xtensa" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_release_note_type", new[] { "breaking", "info", "section", "warning" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "release_channel", new[] { "beta", "develop", "stable" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "release_status", new[] { "aborted", "archived", "editing", "published", "staging" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "repository_provider", new[] { "github" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "repository_scope", new[] { "publish_firmware", "publish_modules" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.DiscordWebhook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("discord_notification_event[]") + .HasColumnName("events"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareAdvisory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedVersions") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("affected_versions"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("Severity") + .HasColumnType("advisory_severity") + .HasColumnName("severity"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("Url") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("firmware_advisories_pkey"); + + b.ToTable("firmware_advisories", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareArtifact", b => + { + b.Property("Version") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("BoardId") + .HasColumnType("uuid") + .HasColumnName("board_id"); + + b.Property("ArtifactType") + .HasColumnType("firmware_artifact_type") + .HasColumnName("artifact_type"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasColumnName("file_size"); + + b.Property("HashSha256") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash_sha256"); + + b.HasKey("Version", "BoardId", "ArtifactType") + .HasName("firmware_artifacts_pkey"); + + b.HasIndex("BoardId"); + + b.ToTable("firmware_artifacts", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChipId") + .HasColumnType("uuid") + .HasColumnName("chip_id"); + + b.Property("Discontinued") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("discontinued"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.PrimitiveCollection("RequiredArtifactTypes") + .IsRequired() + .HasColumnType("firmware_artifact_type[]") + .HasColumnName("required_artifact_types"); + + b.HasKey("Id") + .HasName("firmware_boards_pkey"); + + b.HasIndex("ChipId"); + + b.HasIndex("Name") + .HasDatabaseName("ix_firmware_boards_name"); + + b.ToTable("firmware_boards", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoardUsbDevice", b => + { + b.Property("BoardId") + .HasColumnType("uuid") + .HasColumnName("board_id"); + + b.Property("UsbDeviceId") + .HasColumnType("uuid") + .HasColumnName("usb_device_id"); + + b.HasKey("BoardId", "UsbDeviceId") + .HasName("firmware_board_usb_devices_pkey"); + + b.HasIndex("UsbDeviceId"); + + b.ToTable("firmware_board_usb_devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Architecture") + .HasColumnType("firmware_chip_architecture") + .HasColumnName("architecture"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("firmware_chips_pkey"); + + b.HasIndex("Name") + .HasDatabaseName("ix_firmware_chips_name"); + + b.ToTable("firmware_chips", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChipUsbDevice", b => + { + b.Property("ChipId") + .HasColumnType("uuid") + .HasColumnName("chip_id"); + + b.Property("UsbDeviceId") + .HasColumnType("uuid") + .HasColumnName("usb_device_id"); + + b.HasKey("ChipId", "UsbDeviceId") + .HasName("firmware_chip_usb_devices_pkey"); + + b.HasIndex("UsbDeviceId"); + + b.ToTable("firmware_chip_usb_devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Channel") + .HasColumnType("release_channel") + .HasColumnName("channel"); + + b.Property("CommitHash") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("commit_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.PrimitiveCollection("DeclaredBoards") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("declared_boards"); + + b.Property("Ref") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ref"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("RepositoryId") + .HasColumnType("uuid") + .HasColumnName("repository_id"); + + b.Property("RunId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("run_id"); + + b.Property("Status") + .HasColumnType("release_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("firmware_releases_pkey"); + + b.HasIndex("RepositoryId"); + + b.HasIndex("Version", "Status") + .HasDatabaseName("ix_firmware_releases_version_status"); + + b.ToTable("firmware_releases", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareReleaseNote", b => + { + b.Property("Version") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("SectionType") + .HasColumnType("firmware_release_note_type") + .HasColumnName("type"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.HasKey("Version", "Index") + .HasName("firmware_release_notes_pkey"); + + b.ToTable("firmware_release_notes", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareStagedArtifact", b => + { + b.Property("ReleaseId") + .HasColumnType("uuid") + .HasColumnName("release_id"); + + b.Property("BoardId") + .HasColumnType("uuid") + .HasColumnName("board_id"); + + b.Property("ArtifactType") + .HasColumnType("firmware_artifact_type") + .HasColumnName("artifact_type"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasColumnName("file_size"); + + b.Property("HashSha256") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash_sha256"); + + b.HasKey("ReleaseId", "BoardId", "ArtifactType") + .HasName("firmware_staged_artifacts_pkey"); + + b.ToTable("firmware_staged_artifacts", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareStagedReleaseNote", b => + { + b.Property("ReleaseId") + .HasColumnType("uuid") + .HasColumnName("release_id"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("SectionType") + .HasColumnType("firmware_release_note_type") + .HasColumnName("type"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.HasKey("ReleaseId", "Index") + .HasName("firmware_staged_release_notes_pkey"); + + b.ToTable("firmware_staged_release_notes", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", b => + { + b.Property("Version") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("Channel") + .HasColumnType("release_channel") + .HasColumnName("channel"); + + b.Property("CommitHash") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("commit_hash"); + + b.Property("Ref") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ref"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("RepositoryId") + .HasColumnType("uuid") + .HasColumnName("repository_id"); + + b.Property("RunId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("run_id"); + + b.HasKey("Version") + .HasName("firmware_versions_pkey"); + + b.HasIndex("Channel") + .HasDatabaseName("ix_firmware_versions_channel"); + + b.HasIndex("ReleaseDate") + .HasDatabaseName("ix_firmware_versions_release_date"); + + b.HasIndex("RepositoryId"); + + b.ToTable("firmware_versions", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("id"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("icon_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("RepositoryId") + .HasColumnType("uuid") + .HasColumnName("repository_id"); + + b.Property("SourceUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("source_url"); + + b.HasKey("Id") + .HasName("modules_pkey"); + + b.HasIndex("RepositoryId"); + + b.ToTable("modules", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("owner"); + + b.Property("Provider") + .HasColumnType("repository_provider") + .HasColumnName("provider"); + + b.Property("Repo") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("repo"); + + b.PrimitiveCollection("Scopes") + .IsRequired() + .HasColumnType("repository_scope[]") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("repositories_pkey"); + + b.HasIndex("Provider", "Owner", "Repo") + .HasDatabaseName("ix_repositories_provider_owner_repo"); + + b.ToTable("repositories", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.UsbDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Pid") + .HasColumnType("integer") + .HasColumnName("pid"); + + b.Property("Vid") + .HasColumnType("integer") + .HasColumnName("vid"); + + b.HasKey("Id") + .HasName("usb_devices_pkey"); + + b.HasIndex("Vid", "Pid") + .IsUnique() + .HasDatabaseName("ix_usb_devices_vid_pid"); + + b.ToTable("usb_devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.UsbSerialFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("description"); + + b.Property("Pid") + .HasColumnType("integer") + .HasColumnName("pid"); + + b.Property("Vid") + .HasColumnType("integer") + .HasColumnName("vid"); + + b.HasKey("Id") + .HasName("usb_serial_filters_pkey"); + + b.HasIndex("Vid", "Pid") + .IsUnique() + .HasDatabaseName("ix_usb_serial_filters_vid_pid"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("Vid", "Pid"), false); + + b.ToTable("usb_serial_filters", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Version", b => + { + b.Property("VersionName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("Module") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("module"); + + b.Property("ChangelogUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("changelog_url"); + + b.Property("HashSha256") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash_sha256"); + + b.Property("ReleaseUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("release_url"); + + b.Property("ZipUrl") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("zip_url"); + + b.HasKey("VersionName", "Module") + .HasName("versions_pkey"); + + b.HasIndex("Module"); + + b.ToTable("versions", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareArtifact", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", "BoardNavigation") + .WithMany("Artifacts") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_artifacts_board"); + + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", "VersionNavigation") + .WithMany("Artifacts") + .HasForeignKey("Version") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_artifacts_version"); + + b.Navigation("BoardNavigation"); + + b.Navigation("VersionNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", "ChipNavigation") + .WithMany("Boards") + .HasForeignKey("ChipId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_boards_chip"); + + b.Navigation("ChipNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoardUsbDevice", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", "Board") + .WithMany() + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_board_usb_devices_board"); + + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.UsbDevice", "UsbDevice") + .WithMany() + .HasForeignKey("UsbDeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_board_usb_devices_usb_device"); + + b.Navigation("Board"); + + b.Navigation("UsbDevice"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChipUsbDevice", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", "Chip") + .WithMany() + .HasForeignKey("ChipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_chip_usb_devices_chip"); + + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.UsbDevice", "UsbDevice") + .WithMany() + .HasForeignKey("UsbDeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_chip_usb_devices_usb_device"); + + b.Navigation("Chip"); + + b.Navigation("UsbDevice"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", "RepositoryNavigation") + .WithMany("FirmwareReleases") + .HasForeignKey("RepositoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_releases_repository"); + + b.Navigation("RepositoryNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareReleaseNote", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", "VersionNavigation") + .WithMany("ReleaseNotes") + .HasForeignKey("Version") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_release_notes_version"); + + b.Navigation("VersionNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareStagedArtifact", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", "ReleaseNavigation") + .WithMany("StagedArtifacts") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_staged_artifacts_release"); + + b.Navigation("ReleaseNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareStagedReleaseNote", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", "ReleaseNavigation") + .WithMany("StagedReleaseNotes") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_firmware_staged_release_notes_release"); + + b.Navigation("ReleaseNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", "RepositoryNavigation") + .WithMany("FirmwareVersions") + .HasForeignKey("RepositoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_firmware_versions_repository"); + + b.Navigation("RepositoryNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", "RepositoryNavigation") + .WithMany() + .HasForeignKey("RepositoryId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_modules_repository"); + + b.Navigation("RepositoryNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Version", b => + { + b.HasOne("OpenShock.RepositoryServer.RepoServerDb.Module", "ModuleNavigation") + .WithMany("Versions") + .HasForeignKey("Module") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_versions_module"); + + b.Navigation("ModuleNavigation"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareBoard", b => + { + b.Navigation("Artifacts"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", b => + { + b.Navigation("Boards"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareRelease", b => + { + b.Navigation("StagedArtifacts"); + + b.Navigation("StagedReleaseNotes"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareVersion", b => + { + b.Navigation("Artifacts"); + + b.Navigation("ReleaseNotes"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.SourceRepository", b => + { + b.Navigation("FirmwareReleases"); + + b.Navigation("FirmwareVersions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RepositoryServer/Models/CreateModuleRequest.cs b/RepositoryServer/Models/CreateModuleRequest.cs new file mode 100644 index 0000000..fb209b3 --- /dev/null +++ b/RepositoryServer/Models/CreateModuleRequest.cs @@ -0,0 +1,16 @@ +namespace OpenShock.RepositoryServer.Models; + +public class CreateModuleRequest +{ + public required string Name { get; init; } + public required string Description { get; init; } + public Uri? SourceUrl { get; init; } = null; + public Uri? IconUrl { get; init; } = null; + + /// + /// Repository authorized to publish versions of this module, from the repositories table. + /// Leave null to leave the module closed to all publishers — CI/CD uploads are rejected until an + /// owner is assigned. + /// + public Guid? RepositoryId { get; init; } = null; +} \ No newline at end of file diff --git a/DesktopRepositoryServer/Models/CreateModuleVersionRequest.cs b/RepositoryServer/Models/CreateModuleVersionRequest.cs similarity index 77% rename from DesktopRepositoryServer/Models/CreateModuleVersionRequest.cs rename to RepositoryServer/Models/CreateModuleVersionRequest.cs index fe1d938..decdc55 100644 --- a/DesktopRepositoryServer/Models/CreateModuleVersionRequest.cs +++ b/RepositoryServer/Models/CreateModuleVersionRequest.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; -using OpenShock.Desktop.RepositoryServer.Utils; +using OpenShock.RepositoryServer.Utils; -namespace OpenShock.Desktop.RepositoryServer.Models; +namespace OpenShock.RepositoryServer.Models; public sealed class CreateModuleVersionRequest { diff --git a/RepositoryServer/Models/Discord/DiscordWebhookDto.cs b/RepositoryServer/Models/Discord/DiscordWebhookDto.cs new file mode 100644 index 0000000..a262227 --- /dev/null +++ b/RepositoryServer/Models/Discord/DiscordWebhookDto.cs @@ -0,0 +1,28 @@ +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Models.Discord; + +/// +/// Admin view of a webhook. The URL is masked — it is the credential, so it is write-only. +/// +public sealed record DiscordWebhookDto +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + + /// Masked URL, retaining the Discord webhook id so a row can be matched to a channel. + public required string UrlMasked { get; init; } + + public required List Events { get; init; } + public required bool Enabled { get; init; } + + public static DiscordWebhookDto From(DiscordWebhook w) => new() + { + Id = w.Id, + Name = w.Name, + UrlMasked = DiscordWebhookMasking.Mask(w.Url), + Events = w.Events.Select(e => e.ToEventName()).ToList(), + Enabled = w.Enabled + }; +} diff --git a/RepositoryServer/Models/Discord/UpsertDiscordWebhookRequest.cs b/RepositoryServer/Models/Discord/UpsertDiscordWebhookRequest.cs new file mode 100644 index 0000000..06d79d2 --- /dev/null +++ b/RepositoryServer/Models/Discord/UpsertDiscordWebhookRequest.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Discord; + +public sealed class UpsertDiscordWebhookRequest +{ + /// Operator-facing label, e.g. "releases channel". + [Required(AllowEmptyStrings = false)] + [MaxLength(128)] + public required string Name { get; init; } + + /// + /// Full Discord webhook URL. Write-only: it is never returned by any endpoint. + /// + [Required(AllowEmptyStrings = false)] + [MaxLength(512)] + [Url] + public required string Url { get; init; } + + /// + /// Events this webhook should receive: firmware_release_published, + /// desktop_module_version_published, release_notes_need_editing, + /// staged_release_expired. An empty list means it receives nothing. + /// + public List? Events { get; init; } + + public bool Enabled { get; init; } = true; +} diff --git a/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs new file mode 100644 index 0000000..8244b1f --- /dev/null +++ b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class CreateFirmwareBoardRequest +{ + /// + /// Unique board identifier visible to clients, and the board's path segment in artifact URLs + /// and storage keys. Hubs send this as their board reference — it is their PlatformIO env name, + /// e.g. "Wemos-D1-Mini-ESP32". + /// + /// + /// Restricted to URL-safe characters because it is interpolated into CDN paths unescaped. + /// Notably this rejects /, which would otherwise let a board name reshape the storage key. + /// + [Required(AllowEmptyStrings = false)] + [MaxLength(128)] + [RegularExpression(@"^[A-Za-z0-9][A-Za-z0-9._-]*$", + ErrorMessage = "Board name must start alphanumeric and contain only letters, digits, '.', '_' or '-'.")] + public required string Name { get; init; } + + [Required] + public required Guid ChipId { get; init; } + + public List? RequiredArtifactTypes { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs new file mode 100644 index 0000000..c62059b --- /dev/null +++ b/RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class CreateFirmwareChipRequest +{ + /// Must match esptool-js chip identifiers exactly (e.g. "ESP32-S3"). + [Required(AllowEmptyStrings = false)] + [MaxLength(64)] + public required string Name { get; init; } + + public string? Architecture { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareAdvisoryAdminDto.cs b/RepositoryServer/Models/Firmware/FirmwareAdvisoryAdminDto.cs new file mode 100644 index 0000000..851838a --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareAdvisoryAdminDto.cs @@ -0,0 +1,15 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Admin-facing advisory shape — identical to the public shape plus the database id. +/// Returned by the admin CRUD endpoints only. +/// +public sealed record FirmwareAdvisoryAdminDto +{ + public required Guid Id { get; init; } + public required string Severity { get; init; } + public required string Title { get; init; } + public required string Content { get; init; } + public required string AffectedVersions { get; init; } + public string? Url { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareAdvisoryDto.cs b/RepositoryServer/Models/Firmware/FirmwareAdvisoryDto.cs new file mode 100644 index 0000000..c29ab8a --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareAdvisoryDto.cs @@ -0,0 +1,14 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Security / compatibility advisory exposed on the manifest endpoint. Served from the +/// firmware_advisories table; managed via /v2/firmware/admin/advisories. +/// +public sealed record FirmwareAdvisoryDto +{ + public required string Severity { get; init; } + public required string Title { get; init; } + public required string Content { get; init; } + public required string AffectedVersions { get; init; } + public string? Url { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareArtifactDto.cs b/RepositoryServer/Models/Firmware/FirmwareArtifactDto.cs new file mode 100644 index 0000000..f32e43b --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareArtifactDto.cs @@ -0,0 +1,9 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareArtifactDto +{ + public required string Type { get; init; } + public required string Url { get; init; } + public required string Sha256Hash { get; init; } + public required long FileSize { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareBoardDetailDto.cs b/RepositoryServer/Models/Firmware/FirmwareBoardDetailDto.cs new file mode 100644 index 0000000..e0e4cd6 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareBoardDetailDto.cs @@ -0,0 +1,12 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Per-board details within a release response: chip info, discontinuation flag, +/// and all artifacts available for the board in the given version. +/// +public sealed record FirmwareBoardDetailDto +{ + public required FirmwareChipRefDto Chip { get; init; } + public required bool Discontinued { get; init; } + public required List Artifacts { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs new file mode 100644 index 0000000..f25a207 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs @@ -0,0 +1,17 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Board summary used by the manifest and the public boards listing. Carries board-specific +/// USB identities inline (on-board USB-serial converters, board-unique VID/PIDs). Chip-level +/// native-USB entries are NOT duplicated here — consumers inherit them via chipName. +/// +public sealed record FirmwareBoardDto +{ + /// Canonical board name — the public identifier, e.g. "Wemos-D1-Mini-ESP32". + public required string Name { get; init; } + + /// Chip name, joining this board to an entry in the manifest's chips array. + public required string ChipName { get; init; } + public required bool Discontinued { get; init; } + public List UsbDevices { get; init; } = new(); +} diff --git a/RepositoryServer/Models/Firmware/FirmwareBoardReleaseResponseDto.cs b/RepositoryServer/Models/Firmware/FirmwareBoardReleaseResponseDto.cs new file mode 100644 index 0000000..08d37e8 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareBoardReleaseResponseDto.cs @@ -0,0 +1,14 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Minimal per-board response for a single version. No release notes, no chip info, +/// no other boards — just what a hub needs to download and flash. +/// +public sealed record FirmwareBoardReleaseResponseDto +{ + public required string Version { get; init; } + + /// Canonical board name, as stored — e.g. "Wemos-D1-Mini-ESP32". + public required string BoardId { get; init; } + public required List Artifacts { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareChipDto.cs b/RepositoryServer/Models/Firmware/FirmwareChipDto.cs new file mode 100644 index 0000000..40853ae --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareChipDto.cs @@ -0,0 +1,14 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Chip summary used by the manifest and the public chips listing. Carries chip-level +/// native-USB identities inline (e.g. ESP32-S3 USB-JTAG). Boards inherit these by matching +/// FirmwareBoardDto.ChipName against — they are not duplicated on the +/// board DTO. +/// +public sealed record FirmwareChipDto +{ + public required string Name { get; init; } + public string? Architecture { get; init; } + public List UsbDevices { get; init; } = new(); +} diff --git a/RepositoryServer/Models/Firmware/FirmwareChipRefDto.cs b/RepositoryServer/Models/Firmware/FirmwareChipRefDto.cs new file mode 100644 index 0000000..af33beb --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareChipRefDto.cs @@ -0,0 +1,15 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Slim chip reference embedded in per-board release responses. +/// +/// +/// is the chip's public identifier. It is unique, and pinned externally: it must +/// match esptool-js chip identifiers exactly (e.g. "ESP32-S3"), because the flashtool passes it +/// straight through. The chip's UUID stays internal — unlike a board, a chip never appears in a storage +/// path, so there is nothing an immutable surrogate key would buy here. +/// +public sealed record FirmwareChipRefDto +{ + public required string Name { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareManifestResponse.cs b/RepositoryServer/Models/Firmware/FirmwareManifestResponse.cs new file mode 100644 index 0000000..de02a5e --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareManifestResponse.cs @@ -0,0 +1,16 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Bootstrap payload returned by GET /v2/firmware/manifest. Aggregates channels, +/// latest version per channel, boards, chips, USB device filters, and security advisories. +/// +public sealed record FirmwareManifestResponse +{ + public required List Channels { get; init; } + public required Dictionary Latest { get; init; } + public required List Boards { get; init; } + public required List Chips { get; init; } + public required List UsbSerialFilters { get; init; } + public required List UsbDevices { get; init; } + public required List Advisories { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareReleaseDto.cs b/RepositoryServer/Models/Firmware/FirmwareReleaseDto.cs new file mode 100644 index 0000000..00338dc --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareReleaseDto.cs @@ -0,0 +1,16 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Full release response including release notes and all boards. Used by both the +/// latest endpoint and the specific-version endpoint — same type, same shape. +/// +public sealed record FirmwareReleaseDto +{ + public required string Version { get; init; } + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } + public required FirmwareSourceDto Source { get; init; } + public required List ReleaseNotes { get; init; } + /// Keyed by canonical board name — e.g. "Wemos-D1-Mini-ESP32". + public required Dictionary Boards { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareReleaseNoteDto.cs b/RepositoryServer/Models/Firmware/FirmwareReleaseNoteDto.cs new file mode 100644 index 0000000..84f857e --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareReleaseNoteDto.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareReleaseNoteDto +{ + public required string Type { get; init; } + public string? Title { get; init; } + public required string Content { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareSourceDto.cs b/RepositoryServer/Models/Firmware/FirmwareSourceDto.cs new file mode 100644 index 0000000..c656577 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareSourceDto.cs @@ -0,0 +1,35 @@ +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Source traceability block for a firmware release response. Repository reference plus +/// build-specific fields, with server-constructed URLs (never stored). +/// +public sealed record FirmwareSourceDto +{ + public required RepositoryDto Repository { get; init; } + public required string CommitHash { get; init; } + public string? Ref { get; init; } + public string? RunId { get; init; } + public required string CommitUrl { get; init; } + public string? RefUrl { get; init; } + public string? RunUrl { get; init; } + + public static FirmwareSourceDto From(SourceRepository repository, string commitHash, string? refValue, string? runId) => + new() + { + Repository = RepositoryDto.From(repository), + CommitHash = commitHash, + Ref = refValue, + RunId = runId, + CommitUrl = SourceUrlBuilder.BuildCommitUrl(repository.Provider, repository.Owner, repository.Repo, commitHash) + ?? string.Empty, + RefUrl = SourceUrlBuilder.BuildRefUrl(repository.Provider, repository.Owner, repository.Repo, refValue), + RunUrl = SourceUrlBuilder.BuildRunUrl(repository.Provider, repository.Owner, repository.Repo, runId) + }; + + public static FirmwareSourceDto From(FirmwareVersion v) => + From(v.RepositoryNavigation, v.CommitHash, v.Ref, v.RunId); +} diff --git a/RepositoryServer/Models/Firmware/FirmwareUsbDeviceDto.cs b/RepositoryServer/Models/Firmware/FirmwareUsbDeviceDto.cs new file mode 100644 index 0000000..7d3f0c4 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareUsbDeviceDto.cs @@ -0,0 +1,14 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Recognition catalog entry — specific VID+PID pair with human-readable name. +/// Consumed by the manifest usbDevices list and by each board's/chip's inline +/// usbDevices array. +/// +public sealed record FirmwareUsbDeviceDto +{ + public required Guid Id { get; init; } + public required int Vid { get; init; } + public required int Pid { get; init; } + public required string Name { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareUsbSerialFilterAdminDto.cs b/RepositoryServer/Models/Firmware/FirmwareUsbSerialFilterAdminDto.cs new file mode 100644 index 0000000..40f6e51 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareUsbSerialFilterAdminDto.cs @@ -0,0 +1,13 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Admin-only view of a USB serial filter row, including id + description. +/// Never surfaced on the public manifest. +/// +public sealed record FirmwareUsbSerialFilterAdminDto +{ + public required Guid Id { get; init; } + public required int Vid { get; init; } + public int? Pid { get; init; } + public string? Description { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareUsbSerialFilterDto.cs b/RepositoryServer/Models/Firmware/FirmwareUsbSerialFilterDto.cs new file mode 100644 index 0000000..0afcad1 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareUsbSerialFilterDto.cs @@ -0,0 +1,12 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Public WebSerial filter rule — either vendor-wide (omit ) or +/// specific (both and set). Used by the frontend +/// port picker, mapped to navigator.serial.requestPort({ filters }). +/// +public sealed record FirmwareUsbSerialFilterDto +{ + public required int Vid { get; init; } + public int? Pid { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs b/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs new file mode 100644 index 0000000..0355035 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs @@ -0,0 +1,14 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Version summary for paginated listings. Includes source traceability and release notes +/// so consumers don't need per-version follow-up requests. +/// +public sealed record FirmwareVersionSummary +{ + public required string Version { get; init; } + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } + public required FirmwareSourceDto Source { get; init; } + public List ReleaseNotes { get; init; } = new(); +} diff --git a/RepositoryServer/Models/Firmware/FixChangelogRequest.cs b/RepositoryServer/Models/Firmware/FixChangelogRequest.cs new file mode 100644 index 0000000..7960336 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FixChangelogRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FixChangelogRequest +{ + [Required(AllowEmptyStrings = true)] + public required string Changelog { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/InitReleaseRequest.cs b/RepositoryServer/Models/Firmware/InitReleaseRequest.cs new file mode 100644 index 0000000..58a743d --- /dev/null +++ b/RepositoryServer/Models/Firmware/InitReleaseRequest.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class InitReleaseRequest +{ + [Required(AllowEmptyStrings = false)] + public required string Version { get; init; } + + [Required(AllowEmptyStrings = false)] + public required string Channel { get; init; } + + [Required] + public required DateTimeOffset ReleaseDate { get; init; } + + /// + /// Boards this release will publish artifacts for, by name (e.g. "Wemos-D1-Mini-ESP32") — + /// the same identifier CI already knows as the PlatformIO env. Board UUIDs are also accepted. + /// + [Required] + [MinLength(1)] + public required List Boards { get; init; } + + /// + /// Markdown changelog per firmware-api-spec.md §5.3. The server parses this into + /// structured entries. Empty / whitespace-only + /// strings reach the parser and yield firmware/invalid-changelog. + /// + [Required(AllowEmptyStrings = true)] + public required string Changelog { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/InitReleaseResponse.cs b/RepositoryServer/Models/Firmware/InitReleaseResponse.cs new file mode 100644 index 0000000..6b235ba --- /dev/null +++ b/RepositoryServer/Models/Firmware/InitReleaseResponse.cs @@ -0,0 +1,7 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class InitReleaseResponse +{ + public required Guid Id { get; init; } + public required string Status { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/RepositoryDto.cs b/RepositoryServer/Models/Firmware/RepositoryDto.cs new file mode 100644 index 0000000..f0aaa00 --- /dev/null +++ b/RepositoryServer/Models/Firmware/RepositoryDto.cs @@ -0,0 +1,28 @@ +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Reference to a source code repository. Shared infrastructure for firmware version +/// traceability. Rows are auto-created from incoming CI/CD OIDC tokens — no manual upsert. +/// +public sealed record RepositoryDto +{ + public required Guid Id { get; init; } + public required string Provider { get; init; } + public required string Owner { get; init; } + public required string Repo { get; init; } + + /// Wire form of the granted scopes, e.g. ["publish_firmware"]. + public required List Scopes { get; init; } + + public static RepositoryDto From(RepoServerDb.SourceRepository r) => new() + { + Id = r.Id, + Provider = r.Provider.ToString().ToLowerInvariant(), + Owner = r.Owner, + Repo = r.Repo, + Scopes = r.Scopes.Select(s => s.ToScopeClaim()).ToList() + }; +} diff --git a/RepositoryServer/Models/Firmware/UpsertFirmwareAdvisoryRequest.cs b/RepositoryServer/Models/Firmware/UpsertFirmwareAdvisoryRequest.cs new file mode 100644 index 0000000..79361b9 --- /dev/null +++ b/RepositoryServer/Models/Firmware/UpsertFirmwareAdvisoryRequest.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class UpsertFirmwareAdvisoryRequest +{ + /// One of critical, warning, info. + [Required(AllowEmptyStrings = false)] + public required string Severity { get; init; } + + [Required(AllowEmptyStrings = false)] + [MaxLength(256)] + public required string Title { get; init; } + + [Required(AllowEmptyStrings = false)] + public required string Content { get; init; } + + /// Semver range string, e.g. "<1.4.0" or ">=1.3.0 <1.4.2". + [Required(AllowEmptyStrings = false)] + [MaxLength(128)] + public required string AffectedVersions { get; init; } + + [MaxLength(512)] + public string? Url { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/UpsertRepositoryRequest.cs b/RepositoryServer/Models/Firmware/UpsertRepositoryRequest.cs new file mode 100644 index 0000000..6d69cad --- /dev/null +++ b/RepositoryServer/Models/Firmware/UpsertRepositoryRequest.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Registers a source repository as authorized to publish. This is the allowlist entry that +/// checks — +/// a valid GitHub OIDC token alone proves nothing about which repository is calling. +/// +public sealed class UpsertRepositoryRequest +{ + /// Currently only github is supported. + [Required(AllowEmptyStrings = false)] + [MaxLength(32)] + public required string Provider { get; init; } + + /// Organization or user, matching the OIDC repository_owner claim exactly. + [Required(AllowEmptyStrings = false)] + [MaxLength(128)] + public required string Owner { get; init; } + + /// Repository name, matching the OIDC repository claim with the owner prefix stripped. + [Required(AllowEmptyStrings = false)] + [MaxLength(128)] + public required string Repo { get; init; } + + /// + /// What this repository may publish: publish_firmware, publish_modules, or both. + /// + /// + /// Firmware and desktop ingestion share one authentication scheme, so an unscoped grant would let + /// a repository onboarded for desktop modules publish firmware too. Omitting this registers the + /// repository without letting it publish anything. + /// + public List? Scopes { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/UpsertUsbDeviceRequest.cs b/RepositoryServer/Models/Firmware/UpsertUsbDeviceRequest.cs new file mode 100644 index 0000000..896d1f6 --- /dev/null +++ b/RepositoryServer/Models/Firmware/UpsertUsbDeviceRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class UpsertUsbDeviceRequest +{ + [Range(0, 0xFFFF)] public required int Vid { get; init; } + [Range(0, 0xFFFF)] public required int Pid { get; init; } + [Required(AllowEmptyStrings = false)][MaxLength(128)] public required string Name { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/UpsertUsbSerialFilterRequest.cs b/RepositoryServer/Models/Firmware/UpsertUsbSerialFilterRequest.cs new file mode 100644 index 0000000..0b18a05 --- /dev/null +++ b/RepositoryServer/Models/Firmware/UpsertUsbSerialFilterRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class UpsertUsbSerialFilterRequest +{ + [Range(0, 0xFFFF)] public required int Vid { get; init; } + [Range(0, 0xFFFF)] public int? Pid { get; init; } + [MaxLength(256)] public string? Description { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/VersionListResponse.cs b/RepositoryServer/Models/Firmware/VersionListResponse.cs new file mode 100644 index 0000000..4bf9e0b --- /dev/null +++ b/RepositoryServer/Models/Firmware/VersionListResponse.cs @@ -0,0 +1,11 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Paginated list response for GET /v2/firmware/versions. +/// is the unfiltered count before limit/offset. +/// +public sealed record VersionListResponse +{ + public required List Versions { get; init; } + public required int Total { get; init; } +} diff --git a/DesktopRepositoryServer/Models/Module.cs b/RepositoryServer/Models/Module.cs similarity index 87% rename from DesktopRepositoryServer/Models/Module.cs rename to RepositoryServer/Models/Module.cs index b2acf83..38fbecb 100644 --- a/DesktopRepositoryServer/Models/Module.cs +++ b/RepositoryServer/Models/Module.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using Semver; -namespace OpenShock.Desktop.RepositoryServer.Models; +namespace OpenShock.RepositoryServer.Models; public sealed class Module { diff --git a/RepositoryServer/Models/Release/ChangeEntry.cs b/RepositoryServer/Models/Release/ChangeEntry.cs new file mode 100644 index 0000000..cf4e648 --- /dev/null +++ b/RepositoryServer/Models/Release/ChangeEntry.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace OpenShock.RepositoryServer.Models.Release; + +/// +/// A single change in a release. Mirrors the ChangeEntry struct in the +/// release-tool (release-tool/internal/release/build.go). +/// +public sealed record ChangeEntry +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("kind")] + public required string Kind { get; init; } + + [JsonPropertyName("breaking")] + public required bool Breaking { get; init; } + + [JsonPropertyName("mandatory")] + public required bool Mandatory { get; init; } + + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("release_note")] + public ReleaseNoteEntry? ReleaseNote { get; init; } + + [JsonPropertyName("pr")] + public int? Pr { get; init; } + + [JsonPropertyName("notices")] + public required List Notices { get; init; } +} diff --git a/RepositoryServer/Models/Release/NoticeEntry.cs b/RepositoryServer/Models/Release/NoticeEntry.cs new file mode 100644 index 0000000..8fbfd17 --- /dev/null +++ b/RepositoryServer/Models/Release/NoticeEntry.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace OpenShock.RepositoryServer.Models.Release; + +/// +/// A leveled notice attached to a change. Mirrors the NoticeEntry struct in the +/// release-tool (release-tool/internal/release/build.go). +/// +public sealed record NoticeEntry +{ + [JsonPropertyName("level")] + public required string Level { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } +} diff --git a/RepositoryServer/Models/Release/ReleaseData.cs b/RepositoryServer/Models/Release/ReleaseData.cs new file mode 100644 index 0000000..7472735 --- /dev/null +++ b/RepositoryServer/Models/Release/ReleaseData.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace OpenShock.RepositoryServer.Models.Release; + +/// +/// C# representation of the release.json manifest emitted by the OpenShock +/// release-tool. Mirrors the ReleaseData struct in +/// release-tool/internal/release/build.go (schema_version 1). Keep in sync with +/// the source; the release-tool is the authority for this schema. +/// +public sealed record ReleaseData +{ + [JsonPropertyName("schema_version")] + public required int SchemaVersion { get; init; } + + [JsonPropertyName("repository")] + public Repository? Repository { get; init; } + + [JsonPropertyName("version")] + public required string Version { get; init; } + + [JsonPropertyName("tag")] + public required string Tag { get; init; } + + [JsonPropertyName("prerelease")] + public required bool Prerelease { get; init; } + + [JsonPropertyName("previous_version")] + public string? PreviousVersion { get; init; } + + [JsonPropertyName("previous_tag")] + public string? PreviousTag { get; init; } + + [JsonPropertyName("released_at")] + public required DateTimeOffset ReleasedAt { get; init; } + + [JsonPropertyName("commit")] + public required string Commit { get; init; } + + [JsonPropertyName("mandatory")] + public required bool Mandatory { get; init; } + + [JsonPropertyName("headline")] + public string? Headline { get; init; } + + [JsonPropertyName("changes")] + public required List Changes { get; init; } + + [JsonPropertyName("contributors")] + public required List Contributors { get; init; } +} diff --git a/RepositoryServer/Models/Release/ReleaseNoteEntry.cs b/RepositoryServer/Models/Release/ReleaseNoteEntry.cs new file mode 100644 index 0000000..fe4b60d --- /dev/null +++ b/RepositoryServer/Models/Release/ReleaseNoteEntry.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace OpenShock.RepositoryServer.Models.Release; + +/// +/// End-user-facing release note for a change. Mirrors the ReleaseNoteEntry struct +/// in the release-tool (release-tool/internal/release/build.go). +/// +public sealed record ReleaseNoteEntry +{ + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("description")] + public List? Description { get; init; } +} diff --git a/RepositoryServer/Models/Release/Repository.cs b/RepositoryServer/Models/Release/Repository.cs new file mode 100644 index 0000000..4b5dd21 --- /dev/null +++ b/RepositoryServer/Models/Release/Repository.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace OpenShock.RepositoryServer.Models.Release; + +/// +/// Source repository reference for a release. Mirrors the Repository struct in +/// the release-tool (release-tool/internal/release/build.go). +/// +public sealed record Repository +{ + [JsonPropertyName("platform")] + public required string Platform { get; init; } + + [JsonPropertyName("owner")] + public required string Owner { get; init; } + + [JsonPropertyName("repo")] + public required string Repo { get; init; } +} diff --git a/DesktopRepositoryServer/Models/Repository.cs b/RepositoryServer/Models/Repository.cs similarity index 86% rename from DesktopRepositoryServer/Models/Repository.cs rename to RepositoryServer/Models/Repository.cs index 4268f19..9b1d2d8 100644 --- a/DesktopRepositoryServer/Models/Repository.cs +++ b/RepositoryServer/Models/Repository.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace OpenShock.Desktop.RepositoryServer.Models; +namespace OpenShock.RepositoryServer.Models; public sealed class Repository { diff --git a/DesktopRepositoryServer/Models/Version.cs b/RepositoryServer/Models/Version.cs similarity index 76% rename from DesktopRepositoryServer/Models/Version.cs rename to RepositoryServer/Models/Version.cs index 4ea90de..95c0904 100644 --- a/DesktopRepositoryServer/Models/Version.cs +++ b/RepositoryServer/Models/Version.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; -using OpenShock.Desktop.RepositoryServer.Utils; +using OpenShock.RepositoryServer.Utils; -namespace OpenShock.Desktop.RepositoryServer.Models; +namespace OpenShock.RepositoryServer.Models; public sealed class Version { diff --git a/RepositoryServer/Problems/DiscordError.cs b/RepositoryServer/Problems/DiscordError.cs new file mode 100644 index 0000000..ad6d10e --- /dev/null +++ b/RepositoryServer/Problems/DiscordError.cs @@ -0,0 +1,14 @@ +using System.Net; + +namespace OpenShock.RepositoryServer.Problems; + +public static class DiscordError +{ + public static OpenShockProblem DiscordWebhookNotFound => + new("Discord.WebhookNotFound", "The referenced Discord webhook was not found", HttpStatusCode.NotFound); + + public static OpenShockProblem DiscordInvalidNotificationEvent(string value) => + new("Discord.InvalidNotificationEvent", + $"Unknown notification event '{value}'. Valid events: firmware_release_published, " + + "desktop_module_version_published, release_notes_need_editing, staged_release_expired"); +} diff --git a/DesktopRepositoryServer/Problems/ExceptionProblem.cs b/RepositoryServer/Problems/ExceptionProblem.cs similarity index 84% rename from DesktopRepositoryServer/Problems/ExceptionProblem.cs rename to RepositoryServer/Problems/ExceptionProblem.cs index 83d0ccf..57c6a7c 100644 --- a/DesktopRepositoryServer/Problems/ExceptionProblem.cs +++ b/RepositoryServer/Problems/ExceptionProblem.cs @@ -1,6 +1,6 @@ using System.Net; -namespace OpenShock.Desktop.RepositoryServer.Problems; +namespace OpenShock.RepositoryServer.Problems; public sealed class ExceptionProblem : OpenShockProblem { diff --git a/RepositoryServer/Problems/FirmwareError.cs b/RepositoryServer/Problems/FirmwareError.cs new file mode 100644 index 0000000..ba003c9 --- /dev/null +++ b/RepositoryServer/Problems/FirmwareError.cs @@ -0,0 +1,62 @@ +using System.Net; + +namespace OpenShock.RepositoryServer.Problems; + +public static class FirmwareError +{ + public static OpenShockProblem FirmwareVersionNotFound => new("Firmware.VersionNotFound", "The referenced firmware version was not found", HttpStatusCode.NotFound); + public static OpenShockProblem FirmwareBoardNotFound => new("Firmware.BoardNotFound", "The referenced firmware board was not found", HttpStatusCode.NotFound); + public static OpenShockProblem FirmwareChipNotFound => new("Firmware.ChipNotFound", "The referenced firmware chip was not found", HttpStatusCode.NotFound); + public static OpenShockProblem FirmwareInvalidChannel => new("Firmware.InvalidChannel", "The channel provided is not valid"); + public static OpenShockProblem FirmwareInvalidSemver => new("Firmware.InvalidSemver", "The version provided is not a valid Semantic Versioning string"); + public static OpenShockProblem FirmwareInvalidArtifactType => new("Firmware.InvalidArtifactType", "The artifact type provided is not valid"); + public static OpenShockProblem FirmwareInvalidArchitecture => new("Firmware.InvalidArchitecture", "The architecture provided is not valid"); + public static OpenShockProblem FirmwareBoardInUse => new("Firmware.BoardInUse", "Cannot delete board that has associated artifacts", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareChipInUse => new("Firmware.ChipInUse", "Cannot delete chip that has associated boards", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareMissingRequiredArtifacts(string boardName, IEnumerable missing) => + new("Firmware.MissingRequiredArtifacts", $"Board '{boardName}' is missing required artifact types: {string.Join(", ", missing)}"); + public static OpenShockProblem FirmwareBoardsNotFound(IEnumerable unknownBoards) => + new("Firmware.BoardNotFound", $"Unknown boards: {string.Join(", ", unknownBoards)}", HttpStatusCode.NotFound); + public static OpenShockProblem FirmwareArtifactNotFound => new("Firmware.ArtifactNotFound", "The referenced firmware artifact was not found", HttpStatusCode.NotFound); + + public static OpenShockProblem FirmwareReleaseNotFound => new("Firmware.ReleaseNotFound", "The referenced firmware release was not found", HttpStatusCode.NotFound); + public static OpenShockProblem FirmwareReleaseNotEditable => new("Firmware.ReleaseNotEditable", "The firmware release is not in a staging or editing status", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareReleaseNotStaging => new("Firmware.ReleaseNotStaging", "The firmware release is not in staging status", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareReleaseAlreadyStaging => new("Firmware.ReleaseAlreadyStaging", "A staging release for this version already exists", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareReleaseBoardsEmpty => new("Firmware.ReleaseBoardsEmpty", "At least one board must be declared for a release"); + public static OpenShockProblem FirmwareBoardNotDeclared => new("Firmware.BoardNotDeclared", "The board was not declared in the release init"); + public static OpenShockProblem FirmwareReleaseIncomplete(IEnumerable missingBoards) => + new("Firmware.ReleaseIncomplete", $"Not all declared boards have been uploaded. Missing: {string.Join(", ", missingBoards)}"); + + public static OpenShockProblem FirmwareInvalidChangelog(string reason) => + new("firmware/invalid-changelog", "The supplied changelog markdown is not valid", HttpStatusCode.BadRequest, reason); + public static OpenShockProblem FirmwareSha256Mismatch(string detail) => + new("firmware/sha256-mismatch", "Uploaded artifact hash does not match the provided SHA-256 manifest", HttpStatusCode.BadRequest, detail); + public static OpenShockProblem FirmwareManifestKeysMismatch(string detail) => + new("firmware/manifest-keys-mismatch", "The SHA-256 manifest keys do not match the uploaded files", HttpStatusCode.BadRequest, detail); + public static OpenShockProblem FirmwareReleaseNotesNotFinalized => + new("firmware/release-notes-not-finalized", "Release notes must be finalized before publish", HttpStatusCode.Conflict); + + public static OpenShockProblem FirmwareBoardNameConflict => + new("Firmware.BoardNameConflict", "A board with this name already exists (names are compared case-insensitively)", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareChipNameConflict => + new("Firmware.ChipNameConflict", "A chip with this name already exists (names are compared case-insensitively)", HttpStatusCode.Conflict); + + public static OpenShockProblem FirmwareInvalidRepositoryProvider => new("Firmware.InvalidRepositoryProvider", "The repository provider provided is not valid"); + public static OpenShockProblem FirmwareInvalidRepositoryScope(string scope) => + new("Firmware.InvalidRepositoryScope", $"Unknown repository scope '{scope}'. Valid scopes: publish_firmware, publish_modules"); + public static OpenShockProblem FirmwareReleaseNotOwned => + new("Firmware.ReleaseNotOwned", "The release belongs to a different repository", HttpStatusCode.Forbidden); + public static OpenShockProblem FirmwareVersionAlreadyPublished => + new("Firmware.VersionAlreadyPublished", "This version has already been published and is immutable", HttpStatusCode.Conflict); + + public static OpenShockProblem FirmwareRepositoryNotFound => new("Firmware.RepositoryNotFound", "The referenced source repository was not found", HttpStatusCode.NotFound); + public static OpenShockProblem FirmwareRepositoryInUse => new("Firmware.RepositoryInUse", "Cannot delete repository that is referenced by firmware or desktop versions", HttpStatusCode.Conflict); + + public static OpenShockProblem FirmwareUsbDeviceNotFound => new("Firmware.UsbDeviceNotFound", "The referenced USB device was not found", HttpStatusCode.NotFound); + public static OpenShockProblem FirmwareUsbDeviceInUse => new("Firmware.UsbDeviceInUse", "Cannot delete USB device that is still linked to a chip or board", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareUsbSerialFilterNotFound => new("Firmware.UsbSerialFilterNotFound", "The referenced USB serial filter was not found", HttpStatusCode.NotFound); + + public static OpenShockProblem FirmwareAdvisoryNotFound => new("Firmware.AdvisoryNotFound", "The referenced firmware advisory was not found", HttpStatusCode.NotFound); + public static OpenShockProblem FirmwareInvalidAdvisorySeverity => new("Firmware.InvalidAdvisorySeverity", "The advisory severity provided is not valid"); +} diff --git a/RepositoryServer/Problems/ModuleError.cs b/RepositoryServer/Problems/ModuleError.cs new file mode 100644 index 0000000..2d0dcfb --- /dev/null +++ b/RepositoryServer/Problems/ModuleError.cs @@ -0,0 +1,14 @@ +using System.Net; + +namespace OpenShock.RepositoryServer.Problems; + +public static class ModuleError +{ + public static OpenShockProblem ModuleNotFound => new OpenShockProblem("Module.NotFound", "The referenced module was not found", HttpStatusCode.NotFound); + public static OpenShockProblem ModuleNotOwned => new OpenShockProblem("Module.NotOwned", "This repository is not authorized to publish to this module", HttpStatusCode.Forbidden); + public static OpenShockProblem ZipInvalid => new OpenShockProblem("Module.ZipInvalid", "The uploaded file is not a valid zip archive"); + public static OpenShockProblem ZipEmpty => new OpenShockProblem("Module.ZipEmpty", "The uploaded zip archive contains no entries"); + public static OpenShockProblem ZipPathTraversal => new OpenShockProblem("Module.ZipPathTraversal", "The zip archive contains entries with path traversal sequences"); + public static OpenShockProblem ZipDisallowedDirectory(string dir) => new OpenShockProblem("Module.ZipDisallowedDirectory", $"The zip archive contains a disallowed root directory: '{dir}'. Only 'wwwroot' is permitted"); + public static OpenShockProblem ZipDisallowedRootFile(string file) => new OpenShockProblem("Module.ZipDisallowedRootFile", $"The zip archive contains a disallowed root file: '{file}'. Only .dll, .pdb, and .json files are allowed at the root"); +} \ No newline at end of file diff --git a/DesktopRepositoryServer/Problems/OpenShockProblem.cs b/RepositoryServer/Problems/OpenShockProblem.cs similarity index 95% rename from DesktopRepositoryServer/Problems/OpenShockProblem.cs rename to RepositoryServer/Problems/OpenShockProblem.cs index 86afea7..2bad7a6 100644 --- a/DesktopRepositoryServer/Problems/OpenShockProblem.cs +++ b/RepositoryServer/Problems/OpenShockProblem.cs @@ -1,7 +1,7 @@ using System.Net; using Microsoft.AspNetCore.Mvc; -namespace OpenShock.Desktop.RepositoryServer.Problems; +namespace OpenShock.RepositoryServer.Problems; /// /// Represents a problem diff --git a/DesktopRepositoryServer/Problems/ValidationProblem.cs b/RepositoryServer/Problems/ValidationProblem.cs similarity index 96% rename from DesktopRepositoryServer/Problems/ValidationProblem.cs rename to RepositoryServer/Problems/ValidationProblem.cs index 38328fe..c035a22 100644 --- a/DesktopRepositoryServer/Problems/ValidationProblem.cs +++ b/RepositoryServer/Problems/ValidationProblem.cs @@ -1,7 +1,7 @@ using System.Net; using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace OpenShock.Desktop.RepositoryServer.Problems; +namespace OpenShock.RepositoryServer.Problems; public sealed class ValidationProblem : OpenShockProblem { diff --git a/DesktopRepositoryServer/Problems/VersionError.cs b/RepositoryServer/Problems/VersionError.cs similarity index 57% rename from DesktopRepositoryServer/Problems/VersionError.cs rename to RepositoryServer/Problems/VersionError.cs index acd3e73..d3b8cdb 100644 --- a/DesktopRepositoryServer/Problems/VersionError.cs +++ b/RepositoryServer/Problems/VersionError.cs @@ -1,7 +1,10 @@ -namespace OpenShock.Desktop.RepositoryServer.Problems; +using System.Net; + +namespace OpenShock.RepositoryServer.Problems; public static class VersionError { public static OpenShockProblem VersionNotFound => new OpenShockProblem("Version.NotFound", "The version provided was not found"); + public static OpenShockProblem VersionAlreadyExists => new OpenShockProblem("Version.AlreadyExists", "This version has already been published and is immutable", HttpStatusCode.Conflict); public static OpenShockProblem VersionInvalidSemver => new OpenShockProblem("Version.InvalidSemVersion", "The version provided is not a valid Semantic Versioning string"); } \ No newline at end of file diff --git a/DesktopRepositoryServer/Program.cs b/RepositoryServer/Program.cs similarity index 60% rename from DesktopRepositoryServer/Program.cs rename to RepositoryServer/Program.cs index 8c6222a..f31d46f 100644 --- a/DesktopRepositoryServer/Program.cs +++ b/RepositoryServer/Program.cs @@ -1,25 +1,27 @@ -using System.Text.Json; +using System.Net; +using System.Text.Json; using Asp.Versioning; using EntityFramework.Exceptions.PostgreSQL; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection.Extensions; -using OpenShock.Desktop.RepositoryServer; -using OpenShock.Desktop.RepositoryServer.AuthenticationHandlers; -using OpenShock.Desktop.RepositoryServer.Config; -using OpenShock.Desktop.RepositoryServer.ExceptionHandler; -using OpenShock.Desktop.RepositoryServer.RepoServerDb; -using OpenShock.Desktop.RepositoryServer.Utils; +using OpenShock.RepositoryServer; +using OpenShock.RepositoryServer.AuthenticationHandlers; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.ExceptionHandler; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Services; +using OpenShock.RepositoryServer.Utils; using OpenTelemetry.Metrics; using Scalar.AspNetCore; using Serilog; -using ValidationProblem = OpenShock.Desktop.RepositoryServer.Problems.ValidationProblem; +using ValidationProblem = OpenShock.RepositoryServer.Problems.ValidationProblem; var builder = WebApplication.CreateSlimBuilder(args); - + builder.Configuration.Sources.Clear(); builder.Configuration .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) @@ -39,7 +41,7 @@ // Since we use slim builders, this allows for HTTPS builder.WebHost.UseKestrelHttpsConfiguration(); - + builder.WebHost.ConfigureKestrel(serverOptions => { serverOptions.Limits.RequestHeadersTimeout = TimeSpan.FromMilliseconds(3000); @@ -52,12 +54,25 @@ // <---- ASP.NET ----> builder.Services.AddExceptionHandler(); -builder.Services.AddAuthenticationCore(); -new AuthenticationBuilder(builder.Services) +builder.Services.AddAuthentication() .AddScheme( - AuthSchemas.AdminToken, _ => { }); + AuthSchemas.AdminToken, _ => { }) + .AddJwtBearer(AuthSchemas.CiCdToken, options => + { + GitHubOidcAuthentication.Configure(options, config.CiCd.Audience); + }); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorizationBuilder() + // Firmware and desktop ingestion share the CI/CD scheme, so being authenticated is not enough: + // each endpoint requires the scope its grant was issued for. + .AddPolicy(AuthSchemas.Policies.PublishFirmware, policy => policy + .AddAuthenticationSchemes(AuthSchemas.CiCdToken) + .RequireAuthenticatedUser() + .RequireClaim(AuthSchemas.CiCdClaims.Scope, RepositoryScope.PublishFirmware.ToScopeClaim())) + .AddPolicy(AuthSchemas.Policies.PublishModules, policy => policy + .AddAuthenticationSchemes(AuthSchemas.CiCdToken) + .RequireAuthenticatedUser() + .RequireClaim(AuthSchemas.CiCdClaims.Scope, RepositoryScope.PublishModules.ToScopeClaim())); builder.Services.ConfigureHttpJsonOptions(options => @@ -128,23 +143,56 @@ .AddHttpClientInstrumentation() .AddPrometheusExporter()); -// <---- Postgres EF Core ----> - -builder.Services.AddDbContextPool(dbBuilder => +// <---- Storage Service ----> +switch (config.Firmware.Storage.Type) { - RepoServerContext.ConfigureOptionsBuilder(dbBuilder, config.Db.Conn, config.Db.Debug); -}); + case StorageType.BunnyCdn: + var bunnyCdnConfig = config.Firmware.Storage.BunnyCdn + ?? throw new InvalidOperationException("BunnyCdn storage config is required when Type is BunnyCdn."); + builder.Services.AddSingleton(bunnyCdnConfig); + builder.Services.AddHttpClient(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + break; + + case StorageType.Local: + var localConfig = config.Firmware.Storage.Local + ?? throw new InvalidOperationException("Local storage config is required when Type is Local."); + builder.Services.AddSingleton(localConfig); + builder.Services.AddSingleton(); + break; + + case StorageType.S3: + var s3Config = config.Firmware.Storage.S3 + ?? throw new InvalidOperationException("S3 storage config is required when Type is S3."); + builder.Services.AddSingleton(s3Config); + builder.Services.AddSingleton(); + break; + + default: + throw new InvalidOperationException($"Unknown storage type: {config.Firmware.Storage.Type}"); +} + +// <---- Discord notifications ----> +// Notifications outlive the request that triggers them, so the service is a singleton that resolves +// its own HttpClient and DbContext rather than capturing request-scoped ones. +builder.Services.AddHttpClient(nameof(DiscordNotificationService)); +builder.Services.AddSingleton(); + +// <---- Background cleanup ----> +builder.Services.AddHostedService(); +// <---- Postgres EF Core ----> + +// Register the pooled factory only and derive scoped contexts from it. Adding +// AddDbContextPool alongside would apply a second options configuration to the same +// options builder (EF applies all registered configurations cumulatively), duplicating +// the Npgsql enum definitions and breaking type mapping. builder.Services.AddPooledDbContextFactory(dbBuilder => { - dbBuilder.UseNpgsql(config.Db.Conn); - dbBuilder.UseExceptionProcessor(); - if (config.Db.Debug) - { - dbBuilder.EnableSensitiveDataLogging(); - dbBuilder.EnableDetailedErrors(); - } + RepoServerContext.ConfigureOptionsBuilder(dbBuilder, config.Db.Conn, config.Db.Debug); }); +builder.Services.AddScoped(sp => + sp.GetRequiredService>().CreateDbContext()); @@ -154,10 +202,10 @@ { Log.Information("Running database migrations..."); using var scope = app.Services.CreateScope(); - + await using var migrationContext = new MigrationOpenShockContext( config.Db.Conn, - config.Db.Debug, + config.Db.Debug, scope.ServiceProvider.GetRequiredService()); var pendingMigrations = migrationContext.Database.GetPendingMigrations().ToArray(); @@ -191,7 +239,7 @@ // global cors policy app.UseCors(); - + app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromMinutes(1) @@ -200,12 +248,12 @@ app.UseAuthentication(); app.UseAuthorization(); -var metricsAllowedIpNetworks = config.Metrics.AllowedNetworks.Select(x => IPNetwork.Parse(x)); - +var metricsAllowedIpNetworks = config.Metrics.AllowedNetworks.Select(IPNetwork.Parse); + app.UseOpenTelemetryPrometheusScrapingEndpoint(context => { if(context.Request.Path != "/metrics") return false; - + var remoteIp = context.Connection.RemoteIpAddress; return remoteIp != null && metricsAllowedIpNetworks.Any(x => x.Contains(remoteIp)); }); @@ -216,3 +264,7 @@ app.MapScalarApiReference(options => options.AddDocument("1")); app.Run(); + +// Expose the auto-generated Program type so WebApplicationFactory in the +// integration test project can bootstrap the host. +public partial class Program { } diff --git a/DesktopRepositoryServer/Properties/launchSettings.json b/RepositoryServer/Properties/launchSettings.json similarity index 100% rename from DesktopRepositoryServer/Properties/launchSettings.json rename to RepositoryServer/Properties/launchSettings.json diff --git a/RepositoryServer/RepoServerDb/DesignTimeDbContextFactory.cs b/RepositoryServer/RepoServerDb/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..eb5cf30 --- /dev/null +++ b/RepositoryServer/RepoServerDb/DesignTimeDbContextFactory.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore.Design; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public MigrationOpenShockContext CreateDbContext(string[] args) + { + return new MigrationOpenShockContext(); + } +} diff --git a/RepositoryServer/RepoServerDb/DiscordWebhook.cs b/RepositoryServer/RepoServerDb/DiscordWebhook.cs new file mode 100644 index 0000000..b91b449 --- /dev/null +++ b/RepositoryServer/RepoServerDb/DiscordWebhook.cs @@ -0,0 +1,26 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +/// +/// A Discord webhook to notify, and which events it wants. +/// +/// +/// is a credential: possession of it is authorization to post to that channel. It is +/// therefore never returned by the API — the admin endpoints echo a masked form instead — and it is +/// the reason the notification service reads these rows rather than exposing them anywhere else. +/// +public class DiscordWebhook +{ + public Guid Id { get; set; } + + /// Operator-facing label, e.g. "releases channel". Not used for delivery. + public string Name { get; set; } = null!; + + public string Url { get; set; } = null!; + + /// Events this webhook receives. Empty means it receives nothing. + public DiscordNotificationEvent[] Events { get; set; } = []; + + public bool Enabled { get; set; } = true; +} diff --git a/RepositoryServer/RepoServerDb/FirmwareAdvisory.cs b/RepositoryServer/RepoServerDb/FirmwareAdvisory.cs new file mode 100644 index 0000000..89f4e73 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareAdvisory.cs @@ -0,0 +1,20 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +/// +/// Security / compatibility advisory shown on the firmware manifest endpoint. Managed +/// via /v2/firmware/admin/advisories. Table: firmware_advisories. +/// +public class FirmwareAdvisory +{ + public Guid Id { get; set; } + public AdvisorySeverity Severity { get; set; } + public string Title { get; set; } = null!; + public string Content { get; set; } = null!; + + /// Semver range string, e.g. "<1.4.0" or ">=1.3.0 <1.4.2". + public string AffectedVersions { get; set; } = null!; + + public string? Url { get; set; } +} diff --git a/RepositoryServer/RepoServerDb/FirmwareArtifact.cs b/RepositoryServer/RepoServerDb/FirmwareArtifact.cs new file mode 100644 index 0000000..c918df5 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareArtifact.cs @@ -0,0 +1,15 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareArtifact +{ + public string Version { get; set; } = null!; + public Guid BoardId { get; set; } + public FirmwareArtifactType ArtifactType { get; set; } + public byte[] HashSha256 { get; set; } = null!; + public long FileSize { get; set; } + + public virtual FirmwareVersion VersionNavigation { get; set; } = null!; + public virtual FirmwareBoard BoardNavigation { get; set; } = null!; +} diff --git a/RepositoryServer/RepoServerDb/FirmwareBoard.cs b/RepositoryServer/RepoServerDb/FirmwareBoard.cs new file mode 100644 index 0000000..f0aa3ed --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareBoard.cs @@ -0,0 +1,21 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareBoard +{ + public Guid Id { get; set; } + + /// + /// Human-readable board name (e.g. "OpenShock Core V1"). Unique. + /// + public string Name { get; set; } = null!; + + public Guid ChipId { get; set; } + public bool Discontinued { get; set; } + public FirmwareArtifactType[] RequiredArtifactTypes { get; set; } = []; + + public virtual FirmwareChip ChipNavigation { get; set; } = null!; + public virtual ICollection Artifacts { get; set; } = new List(); + public virtual ICollection UsbDevices { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/FirmwareBoardUsbDevice.cs b/RepositoryServer/RepoServerDb/FirmwareBoardUsbDevice.cs new file mode 100644 index 0000000..19c5ed6 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareBoardUsbDevice.cs @@ -0,0 +1,14 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +/// +/// Junction linking a firmware board to a USB device entry. Represents on-board USB-serial +/// converters or board-unique VID/PIDs (e.g. a Wemos variant shipped with a CP2104). +/// +public class FirmwareBoardUsbDevice +{ + public Guid BoardId { get; set; } + public Guid UsbDeviceId { get; set; } + + public virtual FirmwareBoard Board { get; set; } = null!; + public virtual UsbDevice UsbDevice { get; set; } = null!; +} diff --git a/RepositoryServer/RepoServerDb/FirmwareChip.cs b/RepositoryServer/RepoServerDb/FirmwareChip.cs new file mode 100644 index 0000000..43bfef2 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareChip.cs @@ -0,0 +1,19 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareChip +{ + public Guid Id { get; set; } + + /// + /// Display name — must match esptool-js chip identifiers exactly + /// (e.g. "ESP32", "ESP32-S3", "ESP32-C3"). Unique. + /// + public string Name { get; set; } = null!; + + public FirmwareChipArchitecture? Architecture { get; set; } + + public virtual ICollection Boards { get; set; } = new List(); + public virtual ICollection UsbDevices { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/FirmwareChipUsbDevice.cs b/RepositoryServer/RepoServerDb/FirmwareChipUsbDevice.cs new file mode 100644 index 0000000..60e5657 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareChipUsbDevice.cs @@ -0,0 +1,14 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +/// +/// Junction linking a firmware chip to a USB device entry. Represents native-USB modes +/// inherent to the silicon (e.g. ESP32-S3 USB-JTAG). +/// +public class FirmwareChipUsbDevice +{ + public Guid ChipId { get; set; } + public Guid UsbDeviceId { get; set; } + + public virtual FirmwareChip Chip { get; set; } = null!; + public virtual UsbDevice UsbDevice { get; set; } = null!; +} diff --git a/RepositoryServer/RepoServerDb/FirmwareRelease.cs b/RepositoryServer/RepoServerDb/FirmwareRelease.cs new file mode 100644 index 0000000..cbd4700 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareRelease.cs @@ -0,0 +1,22 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareRelease +{ + public Guid Id { get; set; } + public string Version { get; set; } = null!; + public ReleaseChannel Channel { get; set; } + public Guid RepositoryId { get; set; } + public string CommitHash { get; set; } = null!; + public string? Ref { get; set; } + public string? RunId { get; set; } + public DateTimeOffset ReleaseDate { get; set; } + public ReleaseStatus Status { get; set; } + public Guid[] DeclaredBoards { get; set; } = []; + public DateTimeOffset CreatedAt { get; set; } + + public virtual SourceRepository RepositoryNavigation { get; set; } = null!; + public virtual ICollection StagedArtifacts { get; set; } = new List(); + public virtual ICollection StagedReleaseNotes { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs b/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs new file mode 100644 index 0000000..9c216eb --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs @@ -0,0 +1,14 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareReleaseNote +{ + public string Version { get; set; } = null!; + public int Index { get; set; } + public ReleaseNoteSectionType SectionType { get; set; } + public string? Title { get; set; } + public string Content { get; set; } = null!; + + public virtual FirmwareVersion VersionNavigation { get; set; } = null!; +} diff --git a/RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs b/RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs new file mode 100644 index 0000000..6dffa70 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs @@ -0,0 +1,14 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareStagedArtifact +{ + public Guid ReleaseId { get; set; } + public Guid BoardId { get; set; } + public FirmwareArtifactType ArtifactType { get; set; } + public byte[] HashSha256 { get; set; } = null!; + public long FileSize { get; set; } + + public virtual FirmwareRelease ReleaseNavigation { get; set; } = null!; +} diff --git a/RepositoryServer/RepoServerDb/FirmwareStagedReleaseNote.cs b/RepositoryServer/RepoServerDb/FirmwareStagedReleaseNote.cs new file mode 100644 index 0000000..b9497c8 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareStagedReleaseNote.cs @@ -0,0 +1,14 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareStagedReleaseNote +{ + public Guid ReleaseId { get; set; } + public int Index { get; set; } + public ReleaseNoteSectionType SectionType { get; set; } + public string? Title { get; set; } + public string Content { get; set; } = null!; + + public virtual FirmwareRelease ReleaseNavigation { get; set; } = null!; +} diff --git a/RepositoryServer/RepoServerDb/FirmwareVersion.cs b/RepositoryServer/RepoServerDb/FirmwareVersion.cs new file mode 100644 index 0000000..71fd11a --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareVersion.cs @@ -0,0 +1,18 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareVersion +{ + public string Version { get; set; } = null!; + public ReleaseChannel Channel { get; set; } + public DateTimeOffset ReleaseDate { get; set; } + public Guid RepositoryId { get; set; } + public string CommitHash { get; set; } = null!; + public string? Ref { get; set; } + public string? RunId { get; set; } + + public virtual SourceRepository RepositoryNavigation { get; set; } = null!; + public virtual ICollection Artifacts { get; set; } = new List(); + public virtual ICollection ReleaseNotes { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/Module.cs b/RepositoryServer/RepoServerDb/Module.cs new file mode 100644 index 0000000..9d6f556 --- /dev/null +++ b/RepositoryServer/RepoServerDb/Module.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public partial class Module +{ + public string Id { get; set; } = null!; + + public string Name { get; set; } = null!; + + public string Description { get; set; } = null!; + + public Uri? SourceUrl { get; set; } = null!; + + public Uri? IconUrl { get; set; } + + /// + /// Repository authorized to publish versions of this module. Null means no repository may publish + /// to it — modules are opt-in, so an unassigned module cannot be written to by any CI/CD principal. + /// + public Guid? RepositoryId { get; set; } + + public virtual SourceRepository? RepositoryNavigation { get; set; } + + public virtual ICollection Versions { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/RepoServerContext.cs b/RepositoryServer/RepoServerDb/RepoServerContext.cs new file mode 100644 index 0000000..13ee554 --- /dev/null +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -0,0 +1,534 @@ +using EntityFramework.Exceptions.PostgreSQL; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +/// +/// This is meant for use in migrations only. +/// +public sealed class MigrationOpenShockContext : RepoServerContext +{ + private readonly string? _connectionString = null; + private readonly bool _debug; + private readonly bool _migrationTool; + private readonly ILoggerFactory? _loggerFactory = null; + + public MigrationOpenShockContext() + { + _migrationTool = true; + } + + public MigrationOpenShockContext(string connectionString, bool debug, ILoggerFactory loggerFactory) + { + _connectionString = connectionString; + _debug = debug; + _loggerFactory = loggerFactory; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (_migrationTool) + { + optionsBuilder.UseNpgsql("Host=localhost;Database=repo-server;Username=openshock;Password=openshock", MapEnums); + optionsBuilder.UseExceptionProcessor(); + optionsBuilder.EnableSensitiveDataLogging(); + optionsBuilder.EnableDetailedErrors(); + return; + } + if(string.IsNullOrWhiteSpace(_connectionString)) + throw new InvalidOperationException("Connection string is not set."); + ConfigureOptionsBuilder(optionsBuilder, _connectionString, _debug); + + if (_loggerFactory != null) + optionsBuilder.UseLoggerFactory(_loggerFactory); + } +} + +public partial class RepoServerContext : DbContext +{ + public RepoServerContext() + { + } + + public RepoServerContext(DbContextOptions options) + : base(options) + { + } + + /// + /// Single source of truth for PostgreSQL enum mappings. Since Npgsql 9, MapEnum also + /// injects the enum definitions into the EF model, so no HasPostgresEnum calls may + /// coexist with these — that duplicates the definitions and breaks type mapping. + /// Names are pinned explicitly because they don't all match the default translation + /// (firmware_release_note_type vs. ReleaseNoteSectionType). + /// + protected static void MapEnums(Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.NpgsqlDbContextOptionsBuilder npgsqlBuilder) + { + npgsqlBuilder.MapEnum("release_channel"); + npgsqlBuilder.MapEnum("firmware_artifact_type"); + npgsqlBuilder.MapEnum("firmware_release_note_type"); + npgsqlBuilder.MapEnum("firmware_chip_architecture"); + npgsqlBuilder.MapEnum("release_status"); + npgsqlBuilder.MapEnum("repository_provider"); + npgsqlBuilder.MapEnum("repository_scope"); + npgsqlBuilder.MapEnum("advisory_severity"); + npgsqlBuilder.MapEnum("discord_notification_event"); + } + + public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilder, string connectionString, + bool debug) + { + optionsBuilder.UseNpgsql(connectionString, MapEnums); + + optionsBuilder.UseExceptionProcessor(); + + // Suppress false positive: EF Core 10 sees enum schema differences from EF Core 9 snapshots + optionsBuilder.ConfigureWarnings(w => + w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); + + if (debug) + { + optionsBuilder.EnableSensitiveDataLogging(); + optionsBuilder.EnableDetailedErrors(); + } + } + + // Shared source-code repository registry (used by firmware + desktop) + public virtual DbSet Repositories { get; set; } + + // Desktop module tables + public virtual DbSet Modules { get; set; } + public virtual DbSet Versions { get; set; } + + // Firmware tables + public virtual DbSet FirmwareChips { get; set; } + public virtual DbSet FirmwareBoards { get; set; } + public virtual DbSet FirmwareVersions { get; set; } + public virtual DbSet FirmwareReleases { get; set; } + public virtual DbSet FirmwareArtifacts { get; set; } + public virtual DbSet FirmwareReleaseNotes { get; set; } + public virtual DbSet FirmwareStagedArtifacts { get; set; } + public virtual DbSet FirmwareStagedReleaseNotes { get; set; } + + // USB catalog (flashtool support) + public virtual DbSet UsbDevices { get; set; } + public virtual DbSet UsbSerialFilters { get; set; } + public virtual DbSet FirmwareChipUsbDevices { get; set; } + public virtual DbSet FirmwareBoardUsbDevices { get; set; } + + // Firmware advisories shown on the manifest endpoint + public virtual DbSet FirmwareAdvisories { get; set; } + + // Discord notification targets + public virtual DbSet DiscordWebhooks { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Shared source-code repository registry + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("repositories_pkey"); + entity.ToTable("repositories"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Provider).HasColumnName("provider"); + entity.Property(e => e.Owner).HasMaxLength(128).HasColumnName("owner"); + entity.Property(e => e.Repo).HasMaxLength(128).HasColumnName("repo"); + entity.Property(e => e.Scopes).HasColumnName("scopes"); + + // Uniqueness is enforced case-insensitively by a unique index on + // (provider, lower(owner), lower(repo)), created in the migration — expression indexes + // cannot be declared in the EF model. GitHub treats owner and repo case-insensitively, so + // a case-sensitive constraint would let "OpenShock/Firmware" and "openshock/firmware" + // exist as separate grants while the OIDC lookup only ever finds one of them. + entity.HasIndex(e => new { e.Provider, e.Owner, e.Repo }) + .HasDatabaseName("ix_repositories_provider_owner_repo"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("discord_webhooks_pkey"); + entity.ToTable("discord_webhooks"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Name).HasMaxLength(128).HasColumnName("name"); + entity.Property(e => e.Url).HasMaxLength(512).HasColumnName("url"); + entity.Property(e => e.Events).HasColumnName("events"); + entity.Property(e => e.Enabled).HasDefaultValue(true).HasColumnName("enabled"); + }); + + // Desktop module entities (unchanged — source traceability is not yet wired here) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("modules_pkey"); + + entity.ToTable("modules"); + + entity.Property(e => e.Id) + .HasMaxLength(128) + .HasColumnName("id"); + entity.Property(e => e.Description).HasColumnName("description"); + entity.Property(e => e.IconUrl) + .HasMaxLength(256) + .HasColumnName("icon_url"); + entity.Property(e => e.Name) + .HasMaxLength(128) + .HasColumnName("name"); + entity.Property(e => e.SourceUrl) + .HasMaxLength(256) + .HasColumnName("source_url"); + entity.Property(e => e.RepositoryId).HasColumnName("repository_id"); + + entity.HasOne(d => d.RepositoryNavigation).WithMany() + .HasForeignKey(d => d.RepositoryId) + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_modules_repository"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { Version1 = e.VersionName, e.Module }).HasName("versions_pkey"); + + entity.ToTable("versions"); + + entity.Property(e => e.VersionName) + .HasMaxLength(64) + .HasColumnName("version"); + entity.Property(e => e.Module) + .HasMaxLength(128) + .HasColumnName("module"); + entity.Property(e => e.ChangelogUrl) + .HasMaxLength(256) + .HasColumnName("changelog_url"); + entity.Property(e => e.HashSha256).HasColumnName("hash_sha256"); + entity.Property(e => e.ReleaseUrl) + .HasMaxLength(256) + .HasColumnName("release_url"); + entity.Property(e => e.ZipUrl) + .HasMaxLength(256) + .HasColumnName("zip_url"); + + entity.HasOne(d => d.ModuleNavigation).WithMany(p => p.Versions) + .HasForeignKey(d => d.Module) + .HasConstraintName("fk_versions_module"); + }); + + // Firmware entities + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("firmware_chips_pkey"); + entity.ToTable("firmware_chips"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Name) + .HasMaxLength(64) + .HasColumnName("name"); + entity.Property(e => e.Architecture) + .HasColumnName("architecture"); + + // Uniqueness is enforced case-insensitively by a unique index on lower(name), created in + // the AddCaseInsensitiveNameIndexes migration. Expression indexes cannot be declared in the + // EF model, so this is intentionally absent here rather than duplicated as a weaker + // case-sensitive index. Chip names must match esptool-js identifiers exactly. + entity.HasIndex(e => e.Name) + .HasDatabaseName("ix_firmware_chips_name"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("firmware_boards_pkey"); + entity.ToTable("firmware_boards"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.ChipId).HasColumnName("chip_id"); + entity.Property(e => e.Name) + .HasMaxLength(128) + .HasColumnName("name"); + entity.Property(e => e.Discontinued) + .HasDefaultValue(false) + .HasColumnName("discontinued"); + entity.Property(e => e.RequiredArtifactTypes) + .HasColumnName("required_artifact_types"); + + entity.HasOne(d => d.ChipNavigation).WithMany(p => p.Boards) + .HasForeignKey(d => d.ChipId) + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_firmware_boards_chip"); + + // Uniqueness is enforced case-insensitively by a unique index on lower(name), created in + // the AddCaseInsensitiveNameIndexes migration — see the chips comment above. A plain unique + // index here would permit "ESP32-Core" and "esp32-core" to coexist, and case-insensitive + // resolution would then silently pick one of them. + entity.HasIndex(e => e.Name) + .HasDatabaseName("ix_firmware_boards_name"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Version).HasName("firmware_versions_pkey"); + entity.ToTable("firmware_versions"); + + entity.Property(e => e.Version) + .HasMaxLength(64) + .HasColumnName("version"); + entity.Property(e => e.Channel) + .HasColumnName("channel"); + entity.Property(e => e.ReleaseDate) + .HasColumnName("release_date"); + entity.Property(e => e.RepositoryId) + .HasColumnName("repository_id"); + entity.Property(e => e.CommitHash) + .HasMaxLength(40) + .HasColumnName("commit_hash"); + entity.Property(e => e.Ref) + .HasMaxLength(256) + .HasColumnName("ref"); + entity.Property(e => e.RunId) + .HasMaxLength(64) + .HasColumnName("run_id"); + + entity.HasOne(d => d.RepositoryNavigation).WithMany(p => p.FirmwareVersions) + .HasForeignKey(d => d.RepositoryId) + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_firmware_versions_repository"); + + entity.HasIndex(e => e.Channel).HasDatabaseName("ix_firmware_versions_channel"); + entity.HasIndex(e => e.ReleaseDate).HasDatabaseName("ix_firmware_versions_release_date"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.Version, e.BoardId, e.ArtifactType }).HasName("firmware_artifacts_pkey"); + entity.ToTable("firmware_artifacts"); + + entity.Property(e => e.Version) + .HasMaxLength(64) + .HasColumnName("version"); + entity.Property(e => e.BoardId).HasColumnName("board_id"); + entity.Property(e => e.ArtifactType) + .HasColumnName("artifact_type"); + entity.Property(e => e.HashSha256) + .HasColumnName("hash_sha256"); + entity.Property(e => e.FileSize) + .HasColumnName("file_size"); + + entity.HasOne(d => d.VersionNavigation).WithMany(p => p.Artifacts) + .HasForeignKey(d => d.Version) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_firmware_artifacts_version"); + + entity.HasOne(d => d.BoardNavigation).WithMany(p => p.Artifacts) + .HasForeignKey(d => d.BoardId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_firmware_artifacts_board"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.Version, e.Index }).HasName("firmware_release_notes_pkey"); + entity.ToTable("firmware_release_notes"); + + entity.Property(e => e.Version) + .HasMaxLength(64) + .HasColumnName("version"); + entity.Property(e => e.Index) + .HasColumnName("index"); + entity.Property(e => e.SectionType) + .HasColumnName("type"); + entity.Property(e => e.Title) + .HasMaxLength(256) + .HasColumnName("title"); + entity.Property(e => e.Content) + .HasColumnName("content"); + + entity.HasOne(d => d.VersionNavigation).WithMany(p => p.ReleaseNotes) + .HasForeignKey(d => d.Version) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_firmware_release_notes_version"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("firmware_releases_pkey"); + entity.ToTable("firmware_releases"); + + entity.Property(e => e.Id) + .HasColumnName("id"); + entity.Property(e => e.Version) + .HasMaxLength(64) + .HasColumnName("version"); + entity.Property(e => e.Channel) + .HasColumnName("channel"); + entity.Property(e => e.RepositoryId) + .HasColumnName("repository_id"); + entity.Property(e => e.CommitHash) + .HasMaxLength(40) + .HasColumnName("commit_hash"); + entity.Property(e => e.Ref) + .HasMaxLength(256) + .HasColumnName("ref"); + entity.Property(e => e.RunId) + .HasMaxLength(64) + .HasColumnName("run_id"); + entity.Property(e => e.ReleaseDate) + .HasColumnName("release_date"); + entity.Property(e => e.Status) + .HasColumnName("status"); + entity.Property(e => e.DeclaredBoards) + .HasColumnName("declared_boards"); + entity.Property(e => e.CreatedAt) + .HasColumnName("created_at"); + + entity.HasOne(d => d.RepositoryNavigation).WithMany(p => p.FirmwareReleases) + .HasForeignKey(d => d.RepositoryId) + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_firmware_releases_repository"); + + entity.HasIndex(e => new { e.Version, e.Status }) + .HasDatabaseName("ix_firmware_releases_version_status"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.ReleaseId, e.BoardId, e.ArtifactType }) + .HasName("firmware_staged_artifacts_pkey"); + entity.ToTable("firmware_staged_artifacts"); + + entity.Property(e => e.ReleaseId) + .HasColumnName("release_id"); + entity.Property(e => e.BoardId).HasColumnName("board_id"); + entity.Property(e => e.ArtifactType) + .HasColumnName("artifact_type"); + entity.Property(e => e.HashSha256) + .HasColumnName("hash_sha256"); + entity.Property(e => e.FileSize) + .HasColumnName("file_size"); + + entity.HasOne(d => d.ReleaseNavigation).WithMany(p => p.StagedArtifacts) + .HasForeignKey(d => d.ReleaseId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_firmware_staged_artifacts_release"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.ReleaseId, e.Index }) + .HasName("firmware_staged_release_notes_pkey"); + entity.ToTable("firmware_staged_release_notes"); + + entity.Property(e => e.ReleaseId) + .HasColumnName("release_id"); + entity.Property(e => e.Index) + .HasColumnName("index"); + entity.Property(e => e.SectionType) + .HasColumnName("type"); + entity.Property(e => e.Title) + .HasMaxLength(256) + .HasColumnName("title"); + entity.Property(e => e.Content) + .HasColumnName("content"); + + entity.HasOne(d => d.ReleaseNavigation).WithMany(p => p.StagedReleaseNotes) + .HasForeignKey(d => d.ReleaseId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_firmware_staged_release_notes_release"); + }); + + // USB catalog entities + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("usb_devices_pkey"); + entity.ToTable("usb_devices"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Vid).HasColumnName("vid"); + entity.Property(e => e.Pid).HasColumnName("pid"); + entity.Property(e => e.Name).HasMaxLength(128).HasColumnName("name"); + + entity.HasIndex(e => new { e.Vid, e.Pid }) + .IsUnique() + .HasDatabaseName("ix_usb_devices_vid_pid"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("usb_serial_filters_pkey"); + entity.ToTable("usb_serial_filters"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Vid).HasColumnName("vid"); + entity.Property(e => e.Pid).HasColumnName("pid"); + entity.Property(e => e.Description).HasMaxLength(256).HasColumnName("description"); + + // Unique (vid, pid) with NULLS NOT DISTINCT so at most one vendor-wide + // (pid IS NULL) filter can exist per vid. Requires PostgreSQL 15+. + entity.HasIndex(e => new { e.Vid, e.Pid }) + .IsUnique() + .AreNullsDistinct(false) + .HasDatabaseName("ix_usb_serial_filters_vid_pid"); + }); + + // Chip ↔ UsbDevice M:N via FirmwareChipUsbDevice junction + modelBuilder.Entity() + .HasMany(c => c.UsbDevices) + .WithMany(d => d.Chips) + .UsingEntity( + right => right.HasOne(e => e.UsbDevice) + .WithMany() + .HasForeignKey(e => e.UsbDeviceId) + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_firmware_chip_usb_devices_usb_device"), + left => left.HasOne(e => e.Chip) + .WithMany() + .HasForeignKey(e => e.ChipId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_firmware_chip_usb_devices_chip"), + joinEntity => + { + joinEntity.HasKey(e => new { e.ChipId, e.UsbDeviceId }) + .HasName("firmware_chip_usb_devices_pkey"); + joinEntity.ToTable("firmware_chip_usb_devices"); + joinEntity.Property(e => e.ChipId).HasColumnName("chip_id"); + joinEntity.Property(e => e.UsbDeviceId).HasColumnName("usb_device_id"); + }); + + // Firmware advisories (manifest-facing, admin-managed) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("firmware_advisories_pkey"); + entity.ToTable("firmware_advisories"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Severity).HasColumnName("severity"); + entity.Property(e => e.Title).HasMaxLength(256).HasColumnName("title"); + entity.Property(e => e.Content).HasColumnName("content"); + entity.Property(e => e.AffectedVersions).HasMaxLength(128).HasColumnName("affected_versions"); + entity.Property(e => e.Url).HasMaxLength(512).HasColumnName("url"); + }); + + // Board ↔ UsbDevice M:N via FirmwareBoardUsbDevice junction + modelBuilder.Entity() + .HasMany(b => b.UsbDevices) + .WithMany(d => d.Boards) + .UsingEntity( + right => right.HasOne(e => e.UsbDevice) + .WithMany() + .HasForeignKey(e => e.UsbDeviceId) + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_firmware_board_usb_devices_usb_device"), + left => left.HasOne(e => e.Board) + .WithMany() + .HasForeignKey(e => e.BoardId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_firmware_board_usb_devices_board"), + joinEntity => + { + joinEntity.HasKey(e => new { e.BoardId, e.UsbDeviceId }) + .HasName("firmware_board_usb_devices_pkey"); + joinEntity.ToTable("firmware_board_usb_devices"); + joinEntity.Property(e => e.BoardId).HasColumnName("board_id"); + joinEntity.Property(e => e.UsbDeviceId).HasColumnName("usb_device_id"); + }); + } +} diff --git a/RepositoryServer/RepoServerDb/SourceRepository.cs b/RepositoryServer/RepoServerDb/SourceRepository.cs new file mode 100644 index 0000000..7c39f82 --- /dev/null +++ b/RepositoryServer/RepoServerDb/SourceRepository.cs @@ -0,0 +1,27 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +/// +/// Shared source-code repository reference. Today only firmware versions reference this; +/// desktop modules may adopt the same traceability later. Named SourceRepository +/// to avoid name collision with the desktop manifest DTO Repository. +/// Table: repositories. Doubles as the publish allowlist: rows are created only by an +/// administrator, and an OIDC token from an unregistered owner/repo pair is rejected. +/// +public class SourceRepository +{ + public Guid Id { get; set; } + public RepositoryProvider Provider { get; set; } + public string Owner { get; set; } = null!; + public string Repo { get; set; } = null!; + + /// + /// What this repository may publish. Empty means it is registered but cannot publish anything, + /// which is the safe default for a newly added row. + /// + public RepositoryScope[] Scopes { get; set; } = []; + + public virtual ICollection FirmwareVersions { get; set; } = new List(); + public virtual ICollection FirmwareReleases { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/UsbDevice.cs b/RepositoryServer/RepoServerDb/UsbDevice.cs new file mode 100644 index 0000000..1aa4770 --- /dev/null +++ b/RepositoryServer/RepoServerDb/UsbDevice.cs @@ -0,0 +1,17 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +/// +/// Recognition catalog entry — a specific USB VID+PID pair with a human-readable name. +/// Linked to firmware chips (native-USB modes) and boards (on-board USB-serial converters) +/// via and junctions. +/// +public class UsbDevice +{ + public Guid Id { get; set; } + public int Vid { get; set; } + public int Pid { get; set; } + public string Name { get; set; } = null!; + + public virtual ICollection Chips { get; set; } = new List(); + public virtual ICollection Boards { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/UsbSerialFilter.cs b/RepositoryServer/RepoServerDb/UsbSerialFilter.cs new file mode 100644 index 0000000..e082a0b --- /dev/null +++ b/RepositoryServer/RepoServerDb/UsbSerialFilter.cs @@ -0,0 +1,16 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +/// +/// WebSerial filter rule. Either a vendor-wide match (null ) or a specific +/// device match (both fields set). Consumed by the frontend port picker via +/// navigator.serial.requestPort({ filters }). +/// Unique constraint on (vid, pid) with PostgreSQL NULLS NOT DISTINCT +/// (declared as raw SQL in the migration — EF Core has no fluent shortcut for it). +/// +public class UsbSerialFilter +{ + public Guid Id { get; set; } + public int Vid { get; set; } + public int? Pid { get; set; } + public string? Description { get; set; } +} diff --git a/DesktopRepositoryServer/RepoServerDb/Version.cs b/RepositoryServer/RepoServerDb/Version.cs similarity index 88% rename from DesktopRepositoryServer/RepoServerDb/Version.cs rename to RepositoryServer/RepoServerDb/Version.cs index 09b2c04..74eabbf 100644 --- a/DesktopRepositoryServer/RepoServerDb/Version.cs +++ b/RepositoryServer/RepoServerDb/Version.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace OpenShock.Desktop.RepositoryServer.RepoServerDb; +namespace OpenShock.RepositoryServer.RepoServerDb; public partial class Version { diff --git a/DesktopRepositoryServer/DesktopRepositoryServer.csproj b/RepositoryServer/RepositoryServer.csproj similarity index 82% rename from DesktopRepositoryServer/DesktopRepositoryServer.csproj rename to RepositoryServer/RepositoryServer.csproj index 437b7a9..f4e5942 100644 --- a/DesktopRepositoryServer/DesktopRepositoryServer.csproj +++ b/RepositoryServer/RepositoryServer.csproj @@ -1,13 +1,16 @@ - OpenShock.Desktop.RepositoryServer - OpenShock.Desktop.RepositoryServer - Desktop.RepositoryServer + OpenShock.RepositoryServer + OpenShock.RepositoryServer + RepositoryServer + default + + @@ -38,4 +41,8 @@ + + + + \ No newline at end of file diff --git a/RepositoryServer/Services/BunnyCdnStorageService.cs b/RepositoryServer/Services/BunnyCdnStorageService.cs new file mode 100644 index 0000000..503e5e9 --- /dev/null +++ b/RepositoryServer/Services/BunnyCdnStorageService.cs @@ -0,0 +1,93 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using OpenShock.RepositoryServer.Config; + +namespace OpenShock.RepositoryServer.Services; + +public sealed class BunnyCdnStorageService : IStorageService +{ + private readonly HttpClient _httpClient; + private readonly string _storageUrl; + + public BunnyCdnStorageService(HttpClient httpClient, BunnyCdnStorageConfig config) + { + _httpClient = httpClient; + _storageUrl = config.StorageUrl.TrimEnd('/'); + + _httpClient.DefaultRequestHeaders.Add("AccessKey", config.ApiKey); + } + + /// + public async Task UploadFileAsync(string path, Stream content, CancellationToken cancellationToken = default) + { + var url = $"{_storageUrl}/{path}"; + + using var streamContent = new StreamContent(content); + streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + + var response = await _httpClient.PutAsync(url, streamContent, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + /// + public async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken = default) + { + // The Bunny storage API has no server-side copy, so the object is streamed back through the + // server. Firmware artifacts are a few MB, and this runs once per artifact at publish. + var response = await _httpClient.GetAsync( + $"{_storageUrl}/{sourcePath}", HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + await UploadFileAsync(destinationPath, stream, cancellationToken); + } + + /// + public async Task DeleteFileAsync(string path, CancellationToken cancellationToken = default) + { + var url = $"{_storageUrl}/{path}"; + var response = await _httpClient.DeleteAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + /// + public async Task DeleteDirectoryAsync(string prefix, CancellationToken cancellationToken = default) + { + // BunnyCDN supports deleting a directory path which removes all contents + var url = $"{_storageUrl}/{prefix.TrimEnd('/')}/"; + var response = await _httpClient.DeleteAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + /// + public async IAsyncEnumerable ListFilesAsync(string prefix = "", [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // BunnyCDN storage API: GET /{storageZone}/{path}/ returns JSON array of objects + var url = $"{_storageUrl}/{prefix.TrimEnd('/')}/"; + var response = await _httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var entries = await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); + if (entries == null) yield break; + + foreach (var entry in entries) + { + var isDirectory = entry.GetProperty("IsDirectory").GetBoolean(); + var objectName = entry.GetProperty("ObjectName").GetString()!; + var entryPath = string.IsNullOrEmpty(prefix) ? objectName : $"{prefix.TrimEnd('/')}/{objectName}"; + + if (isDirectory) + { + await foreach (var file in ListFilesAsync(entryPath, cancellationToken)) + { + yield return file; + } + } + else + { + yield return entryPath; + } + } + } +} diff --git a/RepositoryServer/Services/DiscordNotificationService.cs b/RepositoryServer/Services/DiscordNotificationService.cs new file mode 100644 index 0000000..320d80c --- /dev/null +++ b/RepositoryServer/Services/DiscordNotificationService.cs @@ -0,0 +1,119 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Services; + +public sealed class DiscordNotificationService : IDiscordNotificationService +{ + private const int ColorGreen = 0x2ECC71; + private const int ColorYellow = 0xF1C40F; + private const int ColorRed = 0xE74C3C; + + /// Ceiling on a fire-and-forget delivery, since it no longer inherits a request timeout. + private static readonly TimeSpan DeliveryTimeout = TimeSpan.FromSeconds(15); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + + public DiscordNotificationService( + IHttpClientFactory httpClientFactory, + IDbContextFactory dbFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _dbFactory = dbFactory; + _logger = logger; + } + + public Task NotifyFirmwareReleasePublishedAsync(string version, string channel, string commitHash, CancellationToken ct) => + DispatchAsync(DiscordNotificationEvent.FirmwareReleasePublished, + "Firmware release published", + $"Version `{version}` published on channel `{channel}`.\nCommit: `{commitHash[..Math.Min(7, commitHash.Length)]}`", + ColorGreen); + + public Task NotifyDesktopModuleVersionPublishedAsync(string moduleId, string version, string? commitHash, CancellationToken ct) => + DispatchAsync(DiscordNotificationEvent.DesktopModuleVersionPublished, + "Desktop module version published", + $"Module `{moduleId}` version `{version}` published." + + (!string.IsNullOrEmpty(commitHash) ? $"\nCommit: `{commitHash[..Math.Min(7, commitHash.Length)]}`" : string.Empty), + ColorGreen); + + public Task NotifyReleaseNotesNeedEditingAsync(Guid releaseId, string version, string channel, CancellationToken ct) => + DispatchAsync(DiscordNotificationEvent.ReleaseNotesNeedEditing, + "Release notes need editing", + $"Release `{releaseId}` (version `{version}`, channel `{channel}`) was created with an invalid changelog and needs manual review.", + ColorYellow); + + public Task NotifyStagedReleaseExpiredAsync(Guid releaseId, string version, string channel, CancellationToken ct) => + DispatchAsync(DiscordNotificationEvent.StagedReleaseExpired, + "Staged release expired", + $"Release `{releaseId}` (version `{version}`, channel `{channel}`) was aborted because its TTL expired.", + ColorRed); + + /// + /// Queues delivery to every enabled webhook subscribed to . + /// + /// + /// Deliberately takes no from the caller. This is fire-and-forget + /// work that outlives the request that triggered it, so inheriting the request's token meant a + /// notification was dropped the moment the response completed — including the "needs editing" + /// alert, which is the only signal that a release is stuck. For the same reason it resolves its + /// own and rather than capturing + /// request-scoped ones, which were disposed out from under it. + /// + private Task DispatchAsync(DiscordNotificationEvent notificationEvent, string title, string description, int color) + { + var payload = JsonSerializer.Serialize(new + { + embeds = new object[] { new { title, description, color } } + }); + + _ = Task.Run(async () => + { + try + { + using var timeout = new CancellationTokenSource(DeliveryTimeout); + + await using var db = await _dbFactory.CreateDbContextAsync(timeout.Token); + var urls = await db.DiscordWebhooks + .Where(w => w.Enabled && w.Events.Contains(notificationEvent)) + .Select(w => w.Url) + .ToListAsync(timeout.Token); + + if (urls.Count == 0) return; + + using var http = _httpClientFactory.CreateClient(nameof(DiscordNotificationService)); + + foreach (var url in urls) + { + try + { + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var response = await http.PostAsync(url, content, timeout.Token); + if (!response.IsSuccessStatusCode) + { + // The URL is a credential, so it is never logged. + _logger.LogWarning( + "Discord webhook returned {Status} for {Event}", + (int)response.StatusCode, notificationEvent); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to post Discord notification for {Event}", notificationEvent); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to dispatch Discord notifications for {Event}", notificationEvent); + } + }); + + return Task.CompletedTask; + } +} diff --git a/RepositoryServer/Services/IDiscordNotificationService.cs b/RepositoryServer/Services/IDiscordNotificationService.cs new file mode 100644 index 0000000..ac15770 --- /dev/null +++ b/RepositoryServer/Services/IDiscordNotificationService.cs @@ -0,0 +1,14 @@ +namespace OpenShock.RepositoryServer.Services; + +/// +/// Fire-and-forget Discord webhook notifications. All methods MUST swallow errors and +/// never fail the triggering operation (see firmware-api-spec.md §12). Implementations +/// return when no webhooks are configured. +/// +public interface IDiscordNotificationService +{ + Task NotifyFirmwareReleasePublishedAsync(string version, string channel, string commitHash, CancellationToken ct); + Task NotifyDesktopModuleVersionPublishedAsync(string moduleId, string version, string? commitHash, CancellationToken ct); + Task NotifyReleaseNotesNeedEditingAsync(Guid releaseId, string version, string channel, CancellationToken ct); + Task NotifyStagedReleaseExpiredAsync(Guid releaseId, string version, string channel, CancellationToken ct); +} diff --git a/RepositoryServer/Services/IStorageService.cs b/RepositoryServer/Services/IStorageService.cs new file mode 100644 index 0000000..42a90fb --- /dev/null +++ b/RepositoryServer/Services/IStorageService.cs @@ -0,0 +1,47 @@ +namespace OpenShock.RepositoryServer.Services; + +public interface IStorageService +{ + /// + /// Uploads a file to storage. + /// + /// Path relative to storage root, e.g. "1.0.0/board-name/app.bin" + /// File content stream + /// Cancellation token + Task UploadFileAsync(string path, Stream content, CancellationToken cancellationToken = default); + + /// + /// Copies a file within storage, overwriting the destination. + /// + /// + /// Used to promote a staged artifact to its public key at publish time. Artifacts are uploaded + /// under a staging prefix and only appear at their published path when the release is published, + /// so a live key is written exactly once and never by an in-progress release. + /// + /// Existing path relative to storage root. + /// Path to copy to, relative to storage root. + /// Cancellation token + Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken = default); + + /// + /// Deletes a file from storage. + /// + /// Path relative to storage root, e.g. "1.0.0/board-name/app.bin" + /// Cancellation token + Task DeleteFileAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Deletes all files under a directory prefix. + /// + /// Directory prefix, e.g. "1.0.0/" to delete an entire version + /// Cancellation token + Task DeleteDirectoryAsync(string prefix, CancellationToken cancellationToken = default); + + /// + /// Lists all file paths under a prefix. + /// + /// Directory prefix to list, e.g. "1.0.0/". Use empty string to list all. + /// Cancellation token + /// Relative file paths under the prefix. + IAsyncEnumerable ListFilesAsync(string prefix = "", CancellationToken cancellationToken = default); +} diff --git a/RepositoryServer/Services/LocalStorageService.cs b/RepositoryServer/Services/LocalStorageService.cs new file mode 100644 index 0000000..27d6aed --- /dev/null +++ b/RepositoryServer/Services/LocalStorageService.cs @@ -0,0 +1,71 @@ +using System.Runtime.CompilerServices; +using OpenShock.RepositoryServer.Config; + +namespace OpenShock.RepositoryServer.Services; + +public sealed class LocalStorageService : IStorageService +{ + private readonly string _basePath; + + public LocalStorageService(LocalStorageConfig config) + { + _basePath = Path.GetFullPath(config.BasePath); + Directory.CreateDirectory(_basePath); + } + + /// + public async Task UploadFileAsync(string path, Stream content, CancellationToken cancellationToken = default) + { + var fullPath = ResolvePath(path); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None); + await content.CopyToAsync(fileStream, cancellationToken); + } + + /// + public Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken = default) + { + var source = ResolvePath(sourcePath); + var destination = ResolvePath(destinationPath); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(source, destination, overwrite: true); + return Task.CompletedTask; + } + + /// + public Task DeleteFileAsync(string path, CancellationToken cancellationToken = default) + { + var fullPath = ResolvePath(path); + if (File.Exists(fullPath)) File.Delete(fullPath); + return Task.CompletedTask; + } + + /// + public Task DeleteDirectoryAsync(string prefix, CancellationToken cancellationToken = default) + { + var fullPath = ResolvePath(prefix); + if (Directory.Exists(fullPath)) Directory.Delete(fullPath, recursive: true); + return Task.CompletedTask; + } + + /// + public async IAsyncEnumerable ListFilesAsync(string prefix = "", [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var fullPath = ResolvePath(prefix); + if (!Directory.Exists(fullPath)) yield break; + + await Task.CompletedTask; // async enumerable requires at least one await + foreach (var file in Directory.EnumerateFiles(fullPath, "*", SearchOption.AllDirectories)) + { + yield return Path.GetRelativePath(_basePath, file).Replace('\\', '/'); + } + } + + private string ResolvePath(string path) + { + var fullPath = Path.GetFullPath(Path.Combine(_basePath, path)); + if (!fullPath.StartsWith(_basePath, StringComparison.Ordinal)) + throw new ArgumentException("Path traversal is not allowed.", nameof(path)); + return fullPath; + } +} diff --git a/RepositoryServer/Services/S3StorageService.cs b/RepositoryServer/Services/S3StorageService.cs new file mode 100644 index 0000000..927be7e --- /dev/null +++ b/RepositoryServer/Services/S3StorageService.cs @@ -0,0 +1,131 @@ +using System.Runtime.CompilerServices; +using Amazon.S3; +using Amazon.S3.Model; +using OpenShock.RepositoryServer.Config; + +namespace OpenShock.RepositoryServer.Services; + +public sealed class S3StorageService : IStorageService, IDisposable +{ + private readonly IAmazonS3 _s3Client; + private readonly string _bucketName; + private readonly string? _keyPrefix; + + public S3StorageService(S3StorageConfig config) + { + _bucketName = config.BucketName; + _keyPrefix = config.KeyPrefix?.TrimEnd('/'); + + var s3Config = new AmazonS3Config(); + + if (config.ServiceUrl != null) + { + s3Config.ServiceURL = config.ServiceUrl; + } + + if (config.Region != null) + { + s3Config.AuthenticationRegion = config.Region; + } + + _s3Client = new AmazonS3Client(config.AccessKey, config.SecretKey, s3Config); + } + + /// + public async Task UploadFileAsync(string path, Stream content, CancellationToken cancellationToken = default) + { + var request = new PutObjectRequest + { + BucketName = _bucketName, + Key = ResolveKey(path), + InputStream = content, + ContentType = "application/octet-stream", + }; + + await _s3Client.PutObjectAsync(request, cancellationToken); + } + + /// + public async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken = default) + { + // Server-side copy — the object never travels through this process. + var request = new CopyObjectRequest + { + SourceBucket = _bucketName, + SourceKey = ResolveKey(sourcePath), + DestinationBucket = _bucketName, + DestinationKey = ResolveKey(destinationPath), + }; + + await _s3Client.CopyObjectAsync(request, cancellationToken); + } + + /// + public async Task DeleteFileAsync(string path, CancellationToken cancellationToken = default) + { + await _s3Client.DeleteObjectAsync(_bucketName, ResolveKey(path), cancellationToken); + } + + /// + public async Task DeleteDirectoryAsync(string prefix, CancellationToken cancellationToken = default) + { + var resolvedPrefix = ResolveKey(prefix.TrimEnd('/') + "/"); + + var listRequest = new ListObjectsV2Request + { + BucketName = _bucketName, + Prefix = resolvedPrefix, + }; + + ListObjectsV2Response response; + do + { + response = await _s3Client.ListObjectsV2Async(listRequest, cancellationToken); + + if (response.S3Objects.Count > 0) + { + var deleteRequest = new DeleteObjectsRequest + { + BucketName = _bucketName, + Objects = response.S3Objects.Select(o => new KeyVersion { Key = o.Key }).ToList(), + }; + await _s3Client.DeleteObjectsAsync(deleteRequest, cancellationToken); + } + + listRequest.ContinuationToken = response.NextContinuationToken; + } while (response.IsTruncated == true); + } + + /// + public async IAsyncEnumerable ListFilesAsync(string prefix = "", [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var resolvedPrefix = ResolveKey(prefix); + + var listRequest = new ListObjectsV2Request + { + BucketName = _bucketName, + Prefix = resolvedPrefix, + }; + + ListObjectsV2Response response; + do + { + response = await _s3Client.ListObjectsV2Async(listRequest, cancellationToken); + + foreach (var obj in response.S3Objects) + { + // Strip key prefix to return paths relative to storage root + var relativePath = _keyPrefix != null + ? obj.Key[(_keyPrefix.Length + 1)..] + : obj.Key; + yield return relativePath; + } + + listRequest.ContinuationToken = response.NextContinuationToken; + } while (response.IsTruncated == true); + } + + private string ResolveKey(string path) => _keyPrefix != null ? $"{_keyPrefix}/{path}" : path; + + public void Dispose() => _s3Client.Dispose(); +} diff --git a/RepositoryServer/Services/StagedReleaseCleanupService.cs b/RepositoryServer/Services/StagedReleaseCleanupService.cs new file mode 100644 index 0000000..e2e7c1f --- /dev/null +++ b/RepositoryServer/Services/StagedReleaseCleanupService.cs @@ -0,0 +1,145 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; + +namespace OpenShock.RepositoryServer.Services; + +/// +/// Periodic cleanup of expired staged firmware releases. Runs every 5 minutes. Two TTLs: +/// StagedReleaseTtl (default 1h) for abandoned CI releases, EditingReleaseTtl +/// (default 7d) for releases waiting on janitor review. See firmware-api-spec.md §5.4. +/// +public sealed class StagedReleaseCleanupService : BackgroundService +{ + private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(5); + + private readonly IDbContextFactory _dbFactory; + private readonly IStorageService _storage; + private readonly ApiConfig _apiConfig; + private readonly IDiscordNotificationService _discord; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public StagedReleaseCleanupService( + IDbContextFactory dbFactory, + IStorageService storage, + ApiConfig apiConfig, + IDiscordNotificationService discord, + TimeProvider timeProvider, + ILogger logger) + { + _dbFactory = dbFactory; + _storage = storage; + _apiConfig = apiConfig; + _discord = discord; + _timeProvider = timeProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TickInterval, _timeProvider); + + // Fire once immediately, then on every tick. The DB may be unreachable at + // startup (migrations still running, Postgres warming up in tests, etc.) — + // errors are swallowed so the service keeps retrying on the timer. + await SafeTickAsync(stoppingToken); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + if (stoppingToken.IsCancellationRequested) return; + await SafeTickAsync(stoppingToken); + } + } + + private async Task SafeTickAsync(CancellationToken ct) + { + try + { + await TickAsync(ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // shutting down — no-op + } + catch (Exception ex) + { + _logger.LogError(ex, "Staged-release cleanup tick failed"); + } + } + + /// + /// Runs a single cleanup pass. Internal rather than private so tests can drive it deterministically: + /// returns at the first await inside + /// , so a test that started and stopped the service would race the very + /// tick it means to observe. + /// + internal async Task TickAsync(CancellationToken ct) + { + var now = _timeProvider.GetUtcNow(); + var stagedDeadline = now - _apiConfig.Firmware.StagedReleaseTtl; + var editingDeadline = now - _apiConfig.Firmware.EditingReleaseTtl; + + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + var expired = await db.FirmwareReleases + .Include(r => r.StagedArtifacts) + .Where(r => + (r.Status == ReleaseStatus.Staging && r.CreatedAt < stagedDeadline) || + (r.Status == ReleaseStatus.Editing && r.CreatedAt < editingDeadline)) + .ToListAsync(ct); + + if (expired.Count == 0) return; + + foreach (var release in expired) + { + try + { + // Claim the release with a conditional update before deleting anything. + // + // The rows were read moments ago, and publish is a concurrent operation: a release + // hitting its TTL exactly as CI publishes it would otherwise end up aborted in the + // database, live in the public API, and deleted from the CDN. Narrowing the UPDATE to + // the statuses we decided on makes the claim atomic — if publish won, this affects + // zero rows and we leave the artifacts alone. + var claimed = await db.FirmwareReleases + .Where(r => r.Id == release.Id && + (r.Status == ReleaseStatus.Staging || r.Status == ReleaseStatus.Editing)) + .ExecuteUpdateAsync( + setters => setters.SetProperty(r => r.Status, ReleaseStatus.Aborted), ct); + + if (claimed == 0) + { + _logger.LogInformation( + "Skipped expired release {ReleaseId} (version={Version}) — it left staging concurrently", + release.Id, release.Version); + continue; + } + + // Only ever touches this release's own staging prefix. Published artifacts live under + // {version}/... and are unreachable from here by construction, so an expiring release + // can never delete bytes a live version is serving. + await _storage.DeleteDirectoryAsync( + FirmwareArtifactFileNames.BuildStagingPrefix(release.Id), ct); + + // release.Status is still the pre-abort value — ExecuteUpdate bypasses the change + // tracker — which is what makes it possible to tell from the log which TTL fired. + _logger.LogInformation( + "Aborted expired release {ReleaseId} (version={Version}) after the {PriorStatus} TTL expired", + release.Id, release.Version, release.Status); + + await _discord.NotifyStagedReleaseExpiredAsync( + release.Id, + release.Version, + release.Channel.ToString().ToLowerInvariant(), + ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to clean up expired staged release {ReleaseId}", release.Id); + } + } + } +} diff --git a/DesktopRepositoryServer/Utils/ByteArrayHexConverter.cs b/RepositoryServer/Utils/ByteArrayHexConverter.cs similarity index 92% rename from DesktopRepositoryServer/Utils/ByteArrayHexConverter.cs rename to RepositoryServer/Utils/ByteArrayHexConverter.cs index 7c77b10..882093a 100644 --- a/DesktopRepositoryServer/Utils/ByteArrayHexConverter.cs +++ b/RepositoryServer/Utils/ByteArrayHexConverter.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace OpenShock.Desktop.RepositoryServer.Utils; +namespace OpenShock.RepositoryServer.Utils; public sealed class ByteArrayHexConverter : JsonConverter { diff --git a/RepositoryServer/Utils/CacheControlAttribute.cs b/RepositoryServer/Utils/CacheControlAttribute.cs new file mode 100644 index 0000000..40db68b --- /dev/null +++ b/RepositoryServer/Utils/CacheControlAttribute.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace OpenShock.RepositoryServer.Utils; + +/// +/// Applies a Cache-Control header per firmware-api-spec.md §8: public max-age on +/// 2xx responses, no-store on errors. Apply per-action on public read endpoints. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class CacheControlAttribute : ActionFilterAttribute +{ + public int MaxAgeSeconds { get; } + public bool Immutable { get; } + + public CacheControlAttribute(int maxAgeSeconds, bool immutable = false) + { + MaxAgeSeconds = maxAgeSeconds; + Immutable = immutable; + } + + public override void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is ObjectResult { StatusCode: >= 200 and < 300 } or StatusCodeResult { StatusCode: >= 200 and < 300 }) + { + var value = Immutable + ? $"public, max-age={MaxAgeSeconds}, immutable" + : $"public, max-age={MaxAgeSeconds}"; + context.HttpContext.Response.Headers["Cache-Control"] = value; + } + else + { + context.HttpContext.Response.Headers["Cache-Control"] = "no-store"; + } + + base.OnResultExecuting(context); + } +} diff --git a/RepositoryServer/Utils/ChangelogParser.cs b/RepositoryServer/Utils/ChangelogParser.cs new file mode 100644 index 0000000..799b157 --- /dev/null +++ b/RepositoryServer/Utils/ChangelogParser.cs @@ -0,0 +1,156 @@ +using OneOf; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; + +namespace OpenShock.RepositoryServer.Utils; + +public enum ChangelogParseError +{ + Empty, + NoHeadings, + AllSectionsEmpty +} + +/// +/// Parses the changelog markdown submitted by CI/CD into structured +/// entries. Pure function; see firmware-api-spec.md §5.3 +/// for the exact grammar. +/// +public static class ChangelogParser +{ + public static OneOf, ChangelogParseError> Parse(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return ChangelogParseError.Empty; + } + + var lines = input.Replace("\r\n", "\n").Split('\n'); + var notes = new List(); + var sawHeading = false; + + ReleaseNoteSectionType currentType = ReleaseNoteSectionType.Info; + string? currentTitle = null; + var buffer = new List(); + var bullets = new List(); + + void AddNote(string item) + { + var trimmed = item.Trim(); + if (trimmed.Length == 0) return; + + var (title, content) = ExtractTitle(trimmed, currentTitle); + notes.Add(new FirmwareReleaseNoteDto + { + Type = currentType.ToString().ToLowerInvariant(), + Title = title, + Content = content + }); + } + + void FlushSection() + { + // Prose and bullets can coexist in one section — see the "Features" section of the + // worked example in firmware-api-spec.md §5.3, where the introductory sentence is its + // own note followed by the bullet notes. Emitting prose first preserves that order. + if (buffer.Count > 0) + { + AddNote(string.Join("\n", buffer.Select(l => l.TrimEnd()))); + } + + foreach (var item in bullets) + { + AddNote(item); + } + + buffer.Clear(); + bullets.Clear(); + } + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd(); + + if (line.StartsWith("### ", StringComparison.Ordinal)) + { + if (sawHeading) + { + FlushSection(); + } + + sawHeading = true; + var heading = line[4..].Trim(); + (currentType, currentTitle) = MapHeading(heading); + continue; + } + + if (!sawHeading) continue; + if (string.IsNullOrWhiteSpace(line)) continue; + + // Indented bullets are flattened into siblings rather than dropped. The note DTO has no + // nesting, and real changelogs use nested lists freely — losing their content silently is + // worse than losing their depth. + var unindented = line.TrimStart(); + if (unindented.StartsWith("- ", StringComparison.Ordinal)) + { + bullets.Add(unindented[2..]); + } + else + { + buffer.Add(line); + } + } + + if (!sawHeading) + { + return ChangelogParseError.NoHeadings; + } + + // Flush the trailing section after the last heading. + FlushSection(); + + if (notes.Count == 0) + { + return ChangelogParseError.AllSectionsEmpty; + } + + IReadOnlyList readonlyNotes = notes; + return OneOf, ChangelogParseError>.FromT0(readonlyNotes); + } + + private static (ReleaseNoteSectionType type, string? title) MapHeading(string heading) => + heading.ToLowerInvariant() switch + { + "breaking" => (ReleaseNoteSectionType.Breaking, null), + "warning" => (ReleaseNoteSectionType.Warning, null), + "info" => (ReleaseNoteSectionType.Info, null), + _ => (ReleaseNoteSectionType.Section, heading) + }; + + private static (string? title, string content) ExtractTitle(string item, string? sectionTitle) + { + // "**Title** — content" → ("Title", "content") + // Both em dash (—) and simple hyphen separator " - " are accepted. + if (item.StartsWith("**", StringComparison.Ordinal)) + { + var closeIdx = item.IndexOf("**", 2, StringComparison.Ordinal); + if (closeIdx > 2) + { + var title = item.Substring(2, closeIdx - 2).Trim(); + var rest = item[(closeIdx + 2)..].TrimStart(); + + if (rest.StartsWith("—", StringComparison.Ordinal)) rest = rest[1..].TrimStart(); + else if (rest.StartsWith("–", StringComparison.Ordinal)) rest = rest[1..].TrimStart(); + else if (rest.StartsWith("- ", StringComparison.Ordinal)) rest = rest[2..]; + else if (rest.StartsWith("-", StringComparison.Ordinal)) rest = rest[1..].TrimStart(); + + if (title.Length > 0) + { + return (title, rest); + } + } + } + + return (sectionTitle, item); + } +} diff --git a/DesktopRepositoryServer/Utils/ConnectionDetailsFetcher.cs b/RepositoryServer/Utils/ConnectionDetailsFetcher.cs similarity index 95% rename from DesktopRepositoryServer/Utils/ConnectionDetailsFetcher.cs rename to RepositoryServer/Utils/ConnectionDetailsFetcher.cs index e321f5d..84bc6d9 100644 --- a/DesktopRepositoryServer/Utils/ConnectionDetailsFetcher.cs +++ b/RepositoryServer/Utils/ConnectionDetailsFetcher.cs @@ -1,7 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Net; -namespace OpenShock.Desktop.RepositoryServer.Utils; +namespace OpenShock.RepositoryServer.Utils; public static class ConnectionDetailsFetcher { diff --git a/RepositoryServer/Utils/DiscordNotificationEventExtensions.cs b/RepositoryServer/Utils/DiscordNotificationEventExtensions.cs new file mode 100644 index 0000000..4f189ce --- /dev/null +++ b/RepositoryServer/Utils/DiscordNotificationEventExtensions.cs @@ -0,0 +1,32 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.Utils; + +public static class DiscordNotificationEventExtensions +{ + public static string ToEventName(this DiscordNotificationEvent value) => value switch + { + DiscordNotificationEvent.FirmwareReleasePublished => "firmware_release_published", + DiscordNotificationEvent.DesktopModuleVersionPublished => "desktop_module_version_published", + DiscordNotificationEvent.ReleaseNotesNeedEditing => "release_notes_need_editing", + DiscordNotificationEvent.StagedReleaseExpired => "staged_release_expired", + _ => value.ToString().ToLowerInvariant() + }; + + public static bool TryParseEvent(string value, out DiscordNotificationEvent result) + { + switch (value.Trim().ToLowerInvariant()) + { + case "firmware_release_published": + result = DiscordNotificationEvent.FirmwareReleasePublished; return true; + case "desktop_module_version_published": + result = DiscordNotificationEvent.DesktopModuleVersionPublished; return true; + case "release_notes_need_editing": + result = DiscordNotificationEvent.ReleaseNotesNeedEditing; return true; + case "staged_release_expired": + result = DiscordNotificationEvent.StagedReleaseExpired; return true; + default: + result = default; return false; + } + } +} diff --git a/RepositoryServer/Utils/DiscordWebhookMasking.cs b/RepositoryServer/Utils/DiscordWebhookMasking.cs new file mode 100644 index 0000000..41934b7 --- /dev/null +++ b/RepositoryServer/Utils/DiscordWebhookMasking.cs @@ -0,0 +1,25 @@ +namespace OpenShock.RepositoryServer.Utils; + +public static class DiscordWebhookMasking +{ + /// + /// Renders a webhook URL safe to return from the API. + /// + /// + /// A Discord webhook URL is https://discord.com/api/webhooks/{id}/{token}, where the token + /// is the whole credential. The id is retained because it is what identifies the webhook in + /// Discord's own UI, which is what an operator needs to match a row against a channel. + /// + public static string Mask(string url) + { + if (string.IsNullOrWhiteSpace(url)) return string.Empty; + + var trimmed = url.TrimEnd('/'); + var lastSlash = trimmed.LastIndexOf('/'); + + // No path to speak of — reveal nothing rather than guessing at the shape. + if (lastSlash <= 0) return "***"; + + return $"{trimmed[..(lastSlash + 1)]}***"; + } +} diff --git a/RepositoryServer/Utils/EnumNaming.cs b/RepositoryServer/Utils/EnumNaming.cs new file mode 100644 index 0000000..3a0ace8 --- /dev/null +++ b/RepositoryServer/Utils/EnumNaming.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.Utils; + +/// +/// Shared enum-to-string formatting used on API responses so client-facing values match +/// the Postgres enum values (snake_case_lower) for the enums where the C# member names +/// diverge from the wire format. +/// +public static class EnumNaming +{ + public static string? FormatArchitecture(FirmwareChipArchitecture? architecture) + { + if (architecture is null) return null; + // RiscV → risc_v, Xtensa → xtensa + return JsonNamingPolicy.SnakeCaseLower.ConvertName(architecture.Value.ToString()); + } +} diff --git a/RepositoryServer/Utils/FirmwareArtifactFileNames.cs b/RepositoryServer/Utils/FirmwareArtifactFileNames.cs new file mode 100644 index 0000000..8b33df7 --- /dev/null +++ b/RepositoryServer/Utils/FirmwareArtifactFileNames.cs @@ -0,0 +1,57 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.Utils; + +public static class FirmwareArtifactFileNames +{ + public static string GetFileName(FirmwareArtifactType type) => type switch + { + FirmwareArtifactType.Merged => "firmware.bin", + FirmwareArtifactType.App => "app.bin", + FirmwareArtifactType.Bootloader => "bootloader.bin", + FirmwareArtifactType.Partitions => "partitions.bin", + FirmwareArtifactType.StaticFs => "staticfs.bin", + _ => $"{type.ToString().ToLowerInvariant()}.bin" + }; + + /// + /// Storage path for an artifact, relative to the CDN root: + /// {version}/{boardId}/{artifactType}.bin. + /// + /// + /// Deliberately keyed by the board id, not its name. Published versions are immutable, so + /// a path segment has to be immutable too: renaming a board whose name was baked into storage keys + /// would strand every artifact ever published for it, unrecoverably — nothing records the name a + /// blob was written under. The id never changes, so a rename is a pure metadata edit. + /// The board name remains the public identifier everywhere it is a label rather than a key: + /// routes, response fields, ingestion and errors. See firmware-api-spec.md §4.2. + /// + public static string BuildStoragePath(string version, Guid boardId, FirmwareArtifactType type) + => $"{version}/{boardId}/{GetFileName(type)}"; + + /// + /// Absolute CDN URL for an artifact. must already be trimmed of any + /// trailing slash. Consumers treat this as opaque and never reconstruct it. + /// + public static string BuildUrl(string cdnBase, string version, Guid boardId, FirmwareArtifactType type) + => $"{cdnBase}/{BuildStoragePath(version, boardId, type)}"; + + /// Storage prefix holding every artifact staged for one in-progress release. + public const string StagingRoot = "_staging"; + + /// All staged artifacts for a release, so abort and TTL cleanup can drop them wholesale. + public static string BuildStagingPrefix(Guid releaseId) => $"{StagingRoot}/{releaseId}"; + + /// + /// Staging path for an artifact: _staging/{releaseId}/{boardId}/{artifactType}.bin. + /// + /// + /// Uploads land here rather than at the published key, and are promoted by + /// PublishRelease. Keeping in-progress bytes off the public path is what makes a first + /// publish atomic: a published key is written exactly once, and an aborted or expired release can + /// be deleted wholesale without any chance of removing something a live version is serving. + /// Keyed by release id, so two releases for the same version can never collide. + /// + public static string BuildStagingPath(Guid releaseId, Guid boardId, FirmwareArtifactType type) + => $"{BuildStagingPrefix(releaseId)}/{boardId}/{GetFileName(type)}"; +} diff --git a/RepositoryServer/Utils/FirmwareBoardLookup.cs b/RepositoryServer/Utils/FirmwareBoardLookup.cs new file mode 100644 index 0000000..4b529d9 --- /dev/null +++ b/RepositoryServer/Utils/FirmwareBoardLookup.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Utils; + +/// +/// A board resolved from a public reference: the internal primary key plus the canonical name +/// as stored, which is what goes back out in responses and artifact paths. +/// +public readonly record struct ResolvedBoard(Guid Id, string Name); + +/// +/// Resolution of the public board identifier used by the single-board firmware endpoints. +/// +/// +/// This is the single chokepoint through which every public board reference passes, which is what +/// keeps board aliases an additive change: point alternate names at a canonical board here, and callers +/// keep receiving the canonical name with no change to storage layout or response shapes. +/// See firmware-api-spec.md §4.2. +/// +public static class FirmwareBoardLookup +{ + /// + /// Resolves a public board reference. The reference is either the board's + /// — what hubs compile in, e.g. "Wemos-D1-Mini-ESP32" — + /// or the raw for callers that already hold one (admin tooling). + /// Name matching is case-insensitive; the canonical stored name is returned either way. + /// + /// The resolved board, or null when no board matches. + public static async Task ResolveBoardAsync(this RepoServerContext db, string boardRef, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(boardRef)) + { + return null; + } + + if (Guid.TryParse(boardRef, out var boardId)) + { + return await db.FirmwareBoards + .Where(b => b.Id == boardId) + .Select(b => (ResolvedBoard?)new ResolvedBoard(b.Id, b.Name)) + .FirstOrDefaultAsync(ct); + } + + // ix_firmware_boards_name_lower is unique on lower(name), so this matches at most one row and + // uses the index. Names are ASCII-only by the create/update regex, so ToLowerInvariant here and + // SQL lower() fold identically regardless of database collation. + var lowered = boardRef.ToLowerInvariant(); + return await db.FirmwareBoards + .Where(b => b.Name.ToLower() == lowered) + .Select(b => (ResolvedBoard?)new ResolvedBoard(b.Id, b.Name)) + .FirstOrDefaultAsync(ct); + } + + /// + /// Bulk form of , for callers that declare a whole set of boards + /// at once (release init). Loads the board table once rather than issuing a query per reference — + /// it holds a handful of rows. + /// + /// + /// The boards that resolved, and the references that matched nothing — the latter in input order, + /// so the caller can name them back to the operator. + /// + public static async Task<(List Resolved, List Unknown)> ResolveBoardsAsync( + this RepoServerContext db, IEnumerable boardRefs, CancellationToken ct) + { + var all = await db.FirmwareBoards + .OrderBy(b => b.Name) + .Select(b => new ResolvedBoard(b.Id, b.Name)) + .ToListAsync(ct); + + var byId = all.ToDictionary(b => b.Id); + // Safe as an OrdinalIgnoreCase dictionary because lower(name) is unique — no key can collide. + var byName = all.ToDictionary(b => b.Name, StringComparer.OrdinalIgnoreCase); + + var resolved = new List(); + var unknown = new List(); + var seen = new HashSet(); + + foreach (var boardRef in boardRefs) + { + ResolvedBoard? match = Guid.TryParse(boardRef, out var id) + ? byId.TryGetValue(id, out var byIdMatch) ? byIdMatch : null + : byName.TryGetValue(boardRef, out var byNameMatch) ? byNameMatch : null; + + if (match is not { } board) + { + unknown.Add(boardRef); + continue; + } + + if (seen.Add(board.Id)) + { + resolved.Add(board); + } + } + + return (resolved, unknown); + } + +} diff --git a/RepositoryServer/Utils/FirmwareResponseMapper.cs b/RepositoryServer/Utils/FirmwareResponseMapper.cs new file mode 100644 index 0000000..f5e731a --- /dev/null +++ b/RepositoryServer/Utils/FirmwareResponseMapper.cs @@ -0,0 +1,64 @@ +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Utils; + +/// +/// Shared projection helpers for firmware read endpoints (latest, versions, single-board). +/// +public static class FirmwareResponseMapper +{ + public static FirmwareArtifactDto ToArtifactDto(FirmwareArtifact artifact, string version, string cdnBase) => new() + { + Type = artifact.ArtifactType.ToString().ToLowerInvariant(), + Url = FirmwareArtifactFileNames.BuildUrl(cdnBase, version, artifact.BoardId, artifact.ArtifactType), + Sha256Hash = Convert.ToHexString(artifact.HashSha256), + FileSize = artifact.FileSize + }; + + public static FirmwareReleaseDto ToReleaseDto(FirmwareVersion version, IEnumerable boardsById, string cdnBase) + { + var boardLookup = boardsById.ToDictionary(b => b.Id); + + var boardMap = new Dictionary(); + foreach (var artifact in version.Artifacts) + { + if (!boardLookup.TryGetValue(artifact.BoardId, out var board)) + continue; + + if (!boardMap.TryGetValue(board.Name, out var detail)) + { + detail = new FirmwareBoardDetailDto + { + Chip = new FirmwareChipRefDto { Name = board.ChipNavigation.Name }, + Discontinued = board.Discontinued, + Artifacts = new List() + }; + boardMap[board.Name] = detail; + } + + detail.Artifacts.Add(ToArtifactDto(artifact, version.Version, cdnBase)); + } + + var releaseNotes = version.ReleaseNotes + .OrderBy(n => n.Index) + .Select(n => new FirmwareReleaseNoteDto + { + Type = n.SectionType.ToString().ToLowerInvariant(), + Title = n.Title, + Content = n.Content + }) + .ToList(); + + return new FirmwareReleaseDto + { + Version = version.Version, + Channel = version.Channel.ToString().ToLowerInvariant(), + ReleaseDate = version.ReleaseDate, + Source = FirmwareSourceDto.From(version), + ReleaseNotes = releaseNotes, + Boards = boardMap + }; + } +} diff --git a/DesktopRepositoryServer/Utils/OpenShockEnricher.cs b/RepositoryServer/Utils/OpenShockEnricher.cs similarity index 97% rename from DesktopRepositoryServer/Utils/OpenShockEnricher.cs rename to RepositoryServer/Utils/OpenShockEnricher.cs index a08008c..5dc9340 100644 --- a/DesktopRepositoryServer/Utils/OpenShockEnricher.cs +++ b/RepositoryServer/Utils/OpenShockEnricher.cs @@ -5,7 +5,7 @@ using Serilog.Core; using Serilog.Events; -namespace OpenShock.Desktop.RepositoryServer.Utils; +namespace OpenShock.RepositoryServer.Utils; public sealed class OpenShockEnricher : ILogEventEnricher { diff --git a/RepositoryServer/Utils/ReleaseChannels.cs b/RepositoryServer/Utils/ReleaseChannels.cs new file mode 100644 index 0000000..ee44985 --- /dev/null +++ b/RepositoryServer/Utils/ReleaseChannels.cs @@ -0,0 +1,41 @@ +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Utils; + +/// +/// Channel visibility and the deterministic ordering used to pick "latest". +/// +public static class ReleaseChannels +{ + /// + /// Channels whose releases are visible to a subscriber of . + /// + /// + /// Channels cascade: a stable release is also the newest thing a beta or develop subscriber should + /// receive. CI already works this way — publishing a stable build advances the stable, beta and + /// develop pointers together. Without the cascade, strict per-channel equality would pin a beta hub + /// back to the last explicit release candidate and offer it a downgrade. + /// + public static ReleaseChannel[] VisibleTo(ReleaseChannel channel) => channel switch + { + ReleaseChannel.Stable => [ReleaseChannel.Stable], + ReleaseChannel.Beta => [ReleaseChannel.Stable, ReleaseChannel.Beta], + ReleaseChannel.Develop => [ReleaseChannel.Stable, ReleaseChannel.Beta, ReleaseChannel.Develop], + _ => [channel] + }; + + /// + /// Orders versions newest-first with a total order. + /// + /// + /// release_date is supplied by the client and is not unique — the spec's own example uses + /// midnight — so ordering by it alone leaves ties broken arbitrarily by the database. That let + /// /manifest, /latest/{channel} and /latest/{channel}/{board} disagree with + /// each other and flip between requests, which a hub sees as firmware flapping. It also made + /// keyset-free pagination unstable, duplicating and dropping rows across pages. Version is the + /// primary key, so adding it as a tiebreaker yields a total order. + /// + public static IQueryable OrderByNewest(this IQueryable query) => + query.OrderByDescending(v => v.ReleaseDate).ThenByDescending(v => v.Version); +} diff --git a/RepositoryServer/Utils/RepositoryScopeExtensions.cs b/RepositoryServer/Utils/RepositoryScopeExtensions.cs new file mode 100644 index 0000000..251f3e2 --- /dev/null +++ b/RepositoryServer/Utils/RepositoryScopeExtensions.cs @@ -0,0 +1,32 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.Utils; + +public static class RepositoryScopeExtensions +{ + /// + /// Wire form of a scope, used both as the claim value and in the admin API. + /// + public static string ToScopeClaim(this RepositoryScope scope) => scope switch + { + RepositoryScope.PublishFirmware => "publish_firmware", + RepositoryScope.PublishModules => "publish_modules", + _ => scope.ToString().ToLowerInvariant() + }; + + public static bool TryParseScope(string value, out RepositoryScope scope) + { + switch (value.Trim().ToLowerInvariant()) + { + case "publish_firmware": + scope = RepositoryScope.PublishFirmware; + return true; + case "publish_modules": + scope = RepositoryScope.PublishModules; + return true; + default: + scope = default; + return false; + } + } +} diff --git a/DesktopRepositoryServer/Utils/SemVersionConverter.cs b/RepositoryServer/Utils/SemVersionConverter.cs similarity index 95% rename from DesktopRepositoryServer/Utils/SemVersionConverter.cs rename to RepositoryServer/Utils/SemVersionConverter.cs index 5d0c044..46d22f4 100644 --- a/DesktopRepositoryServer/Utils/SemVersionConverter.cs +++ b/RepositoryServer/Utils/SemVersionConverter.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; using Semver; -namespace OpenShock.Desktop.RepositoryServer.Utils; +namespace OpenShock.RepositoryServer.Utils; public class SemVersionConverter : JsonConverter { diff --git a/RepositoryServer/Utils/SourceUrlBuilder.cs b/RepositoryServer/Utils/SourceUrlBuilder.cs new file mode 100644 index 0000000..8525b9e --- /dev/null +++ b/RepositoryServer/Utils/SourceUrlBuilder.cs @@ -0,0 +1,71 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.Utils; + +/// +/// Builds human-browsable URLs for source-traceability fields on firmware release +/// responses. Pure function, provider-switched. Unknown providers return null +/// so consumers can degrade gracefully per firmware-api-spec.md §10 ("Consumers should +/// handle unknown providers gracefully"). Today only GitHub is a known provider. +/// +public static class SourceUrlBuilder +{ + public static string? BuildCommitUrl(RepositoryProvider provider, string owner, string repo, string commitHash) + { + if (string.IsNullOrWhiteSpace(commitHash)) return null; + + return provider switch + { + RepositoryProvider.Github => $"https://github.com/{owner}/{repo}/commit/{commitHash}", + _ => null + }; + } + + /// + /// Builds a URL for a git ref. Handles refs/tags/NAME, refs/heads/NAME, + /// and bare refs. Returns null when is null/empty or for + /// unrecognized providers. + /// + public static string? BuildRefUrl(RepositoryProvider provider, string owner, string repo, string? refValue) + { + if (string.IsNullOrWhiteSpace(refValue)) return null; + + return provider switch + { + RepositoryProvider.Github => BuildGitHubRefUrl(owner, repo, refValue), + _ => null + }; + } + + public static string? BuildRunUrl(RepositoryProvider provider, string owner, string repo, string? runId) + { + if (string.IsNullOrWhiteSpace(runId)) return null; + + return provider switch + { + RepositoryProvider.Github => $"https://github.com/{owner}/{repo}/actions/runs/{runId}", + _ => null + }; + } + + private static string BuildGitHubRefUrl(string owner, string repo, string refValue) + { + const string tagPrefix = "refs/tags/"; + const string branchPrefix = "refs/heads/"; + + if (refValue.StartsWith(tagPrefix, StringComparison.Ordinal)) + { + var tag = refValue[tagPrefix.Length..]; + return $"https://github.com/{owner}/{repo}/releases/tag/{tag}"; + } + + if (refValue.StartsWith(branchPrefix, StringComparison.Ordinal)) + { + var branch = refValue[branchPrefix.Length..]; + return $"https://github.com/{owner}/{repo}/tree/{branch}"; + } + + // Bare ref — best effort: treat as a branch name. + return $"https://github.com/{owner}/{repo}/tree/{refValue}"; + } +} diff --git a/DesktopRepositoryServer/Utils/TrustedProxiesFetcher.cs b/RepositoryServer/Utils/TrustedProxiesFetcher.cs similarity index 98% rename from DesktopRepositoryServer/Utils/TrustedProxiesFetcher.cs rename to RepositoryServer/Utils/TrustedProxiesFetcher.cs index 40ede15..16e037f 100644 --- a/DesktopRepositoryServer/Utils/TrustedProxiesFetcher.cs +++ b/RepositoryServer/Utils/TrustedProxiesFetcher.cs @@ -1,6 +1,6 @@ using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; -namespace OpenShock.Desktop.RepositoryServer.Utils; +namespace OpenShock.RepositoryServer.Utils; public static class TrustedProxiesFetcher { diff --git a/RepositoryServer/Utils/UniqueViolation.cs b/RepositoryServer/Utils/UniqueViolation.cs new file mode 100644 index 0000000..46b8750 --- /dev/null +++ b/RepositoryServer/Utils/UniqueViolation.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace OpenShock.RepositoryServer.Utils; + +/// +/// Recognises specific unique-index violations so controllers can map them to a meaningful conflict +/// rather than letting them escape as a 500. +/// +/// +/// Board and chip names are unique on lower(name), which is a partial/expression index created +/// in a migration rather than in the EF model. Read-then-insert guards cannot close the race on their +/// own, so the database is the real arbiter and its rejection has to be translated here. +/// +public static class UniqueViolation +{ + public static bool IsOn(DbUpdateException ex, string constraintName) => + ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } pg + && pg.ConstraintName == constraintName; + + public const string BoardNameLower = "ix_firmware_boards_name_lower"; + public const string ChipNameLower = "ix_firmware_chips_name_lower"; + public const string RepositoryIdentityLower = "ix_repositories_provider_owner_repo_lower"; +} diff --git a/DesktopRepositoryServer/Utils/UriExtensions.cs b/RepositoryServer/Utils/UriExtensions.cs similarity index 85% rename from DesktopRepositoryServer/Utils/UriExtensions.cs rename to RepositoryServer/Utils/UriExtensions.cs index 0397cbf..8cc3ae5 100644 --- a/DesktopRepositoryServer/Utils/UriExtensions.cs +++ b/RepositoryServer/Utils/UriExtensions.cs @@ -1,4 +1,4 @@ -namespace OpenShock.Desktop.RepositoryServer.Utils; +namespace OpenShock.RepositoryServer.Utils; public static class UriExtensions { diff --git a/DesktopRepositoryServer/appsettings.Development.json b/RepositoryServer/appsettings.Development.json similarity index 75% rename from DesktopRepositoryServer/appsettings.Development.json rename to RepositoryServer/appsettings.Development.json index 24fbfd3..bea23c6 100644 --- a/DesktopRepositoryServer/appsettings.Development.json +++ b/RepositoryServer/appsettings.Development.json @@ -1,4 +1,12 @@ { + "Kestrel": { + "Endpoints": { + "Http": { + "Protocols": "Http1", + "Url": "http://*:5080" + } + } + }, "Serilog": { "MinimumLevel": { "Default": "Verbose", diff --git a/DesktopRepositoryServer/appsettings.json b/RepositoryServer/appsettings.json similarity index 71% rename from DesktopRepositoryServer/appsettings.json rename to RepositoryServer/appsettings.json index bde212b..8b76353 100644 --- a/DesktopRepositoryServer/appsettings.json +++ b/RepositoryServer/appsettings.json @@ -1,14 +1,24 @@ { "AllowedHosts": "*", + + "CiCd": { + "Audience": "openshock-repository-server" + }, "Kestrel": { "Endpoints": { "Http": { "Protocols": "Http1", "Url": "http://*:80" - }, - "Https": { - "Protocols": "Http1AndHttp2AndHttp3", - "Url": "https://*:443" + } + } + }, + + "Firmware": { + "CdnBaseUrl": "https://cdn.openshock.app/firmware", + "Storage": { + "Type": "Local", + "Local": { + "BasePath": "./cdn-storage" } } }, @@ -18,7 +28,7 @@ "Serilog.Sinks.Console", "Serilog.Sinks.Grafana.Loki", "Serilog.Sinks.OpenTelemetry", - "OpenShock.Desktop.RepositoryServer" + "OpenShock.RepositoryServer" ], "MinimumLevel": { "Default": "Warning", @@ -26,7 +36,7 @@ "Microsoft.Hosting.Lifetime": "Information", "Microsoft.AspNetCore.Hosting.Diagnostics": "Warning", "Serilog.AspNetCore.RequestLoggingMiddleware": "Information", - "OpenShock.Desktop.RepositoryServer.AuthenticationHandlers": "Warning", + "OpenShock.RepositoryServer.AuthenticationHandlers": "Warning", "OpenShock": "Information" } }, diff --git a/RepositoryServers.slnx b/RepositoryServers.slnx index 4acae06..1f9eb89 100644 --- a/RepositoryServers.slnx +++ b/RepositoryServers.slnx @@ -1,4 +1,4 @@  - - + + \ No newline at end of file diff --git a/docker/DesktopRepositoryServer.Dockerfile b/docker/RepositoryServer.Dockerfile similarity index 61% rename from docker/DesktopRepositoryServer.Dockerfile rename to docker/RepositoryServer.Dockerfile index ed4a10c..8d9965a 100644 --- a/docker/DesktopRepositoryServer.Dockerfile +++ b/docker/RepositoryServer.Dockerfile @@ -1,14 +1,14 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build WORKDIR /src -COPY --link DesktopRepositoryServer/*.csproj DesktopRepositoryServer/ +COPY --link RepositoryServer/*.csproj RepositoryServer/ COPY --link *.props . COPY --link *.json . -RUN dotnet restore DesktopRepositoryServer/DesktopRepositoryServer.csproj +RUN dotnet restore RepositoryServer/RepositoryServer.csproj -COPY --link DesktopRepositoryServer/. DesktopRepositoryServer/ +COPY --link RepositoryServer/. RepositoryServer/ -RUN dotnet publish --no-restore -c Release DesktopRepositoryServer/DesktopRepositoryServer.csproj -o /app +RUN dotnet publish --no-restore -c Release RepositoryServer/RepositoryServer.csproj -o /app # final is the final runtime stage for running the app FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 805a549..f6084ba 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -17,4 +17,4 @@ if [ ! -f "$CERT_PATH" ]; then rm key.pem cert.pem fi -exec dotnet OpenShock.Desktop.RepositoryServer.dll +exec dotnet OpenShock.RepositoryServer.dll diff --git a/firmware-api-spec.md b/firmware-api-spec.md new file mode 100644 index 0000000..d92d9e5 --- /dev/null +++ b/firmware-api-spec.md @@ -0,0 +1,1481 @@ +# Firmware API Specification + +Canonical contract for the repository server's firmware endpoints. All consumers must conform to the types and behaviors defined here. + +## 1. Consumers + +| Consumer | Primary endpoints | Constraints | +|----------|-------------------|-------------| +| **Web flashtool** (frontend) | manifest, latest, versions | Needs chip name for esptool-js, board discontinuation status, all artifact types for flashing | +| **ESP32 hub OTA** | latest/{channel}/{board}, versions/{version}/{board} | Constrained device. Needs artifact URLs + hashes for its board. Knows its own board name and version. | +| **Backend API** (server-to-server) | latest, versions | Structured data for programmatic use | +| **CLI / external tools** | all public endpoints | Structured data, scriptable, may automate flashing or version queries | +| **CI/CD pipeline** | releases (ingestion) | Publishes new firmware versions with artifacts and markdown changelog | + +## 2. Constants + +### Channels + +Fixed set, hardcoded across all consumers: + +``` +stable | beta | develop +``` + +### Artifact types + +``` +merged | app | bootloader | partitions | staticfs +``` + +### Release note section types + +``` +breaking | warning | info | section +``` + +`section` is a catch-all for custom headings (e.g. "Features", "Performance"). The `title` field carries the heading name. + +### Release status + +``` +staging | editing | published | archived | aborted +``` + +Shared workflow status for staged releases. Not firmware-specific — desktop modules may adopt the same staging workflow in the future. In the database, the PostgreSQL enum is `release_status`. + +- `staging` — CI/CD is actively creating the release and uploading artifacts. Changelog is valid. +- `editing` — Release notes need manual review or refinement before publish. Set when changelog validation fails with `?nofail`, or when a maintainer wants to restructure notes before releasing. Artifact uploads still work in this state. +- `published` — Live. Immutable. +- `archived` — Pushed to cold storage. Artifacts are no longer served from the CDN but are retained for historical record. Can be restored to `published` if needed. *(Future feature — not yet implemented.)* +- `aborted` — Cleaned up (manually or by the cleanup job). + +### Version format + +Standard semver: `1.5.1`, `1.5.1-beta.3`, `1.5.1-develop+21e43623` + +Parsed with `Semver.SemVersion` (strict mode). Build metadata (`+...`) is stored but ignored in comparisons. + +--- + +## 3. Shared Types + +All JSON uses **camelCase** property names. Enums are serialized as **lowercase strings**. Dates are **ISO 8601** with timezone. All database primary keys are **UUIDs** (serialized as lowercase hyphenated strings, e.g. `"550e8400-e29b-41d4-a716-446655440000"`). + +### FirmwareArtifact + +```jsonc +{ + "type": "merged", // artifact type (lowercase enum) + "url": "https://...", // absolute CDN URL + "sha256Hash": "a1b2c3d4...", // hex-encoded SHA-256 + "fileSize": 1572864 // bytes +} +``` + +CDN URL format: `{cdnBaseUrl}/{version}/{boardId}/{filename}` — the board **UUID**, not its name. +The name is the public identifier everywhere it acts as a label, but a storage key has to be immutable: +published versions never change, so a renameable path segment would strand artifacts. Consumers treat +`url` as opaque and never reconstruct it. + +Filenames by type: `merged` → `firmware.bin`, `app` → `app.bin`, `bootloader` → `bootloader.bin`, `partitions` → `partitions.bin`, `staticfs` → `staticfs.bin` + +### FirmwareReleaseNote + +```jsonc +{ + "type": "breaking", // section type (lowercase enum) + "title": "Config format", // optional — heading for the note + "content": "Changed config format to TOML" +} +``` + +Notes are ordered. The array preserves the order from the original changelog submission. + +### FirmwareChipRef + +Embedded chip reference within board responses. The `name` field must match esptool-js chip identifiers exactly (e.g. `"ESP32"`, `"ESP32-S3"`, `"ESP32-C3"`). + +```jsonc +{ + "name": "ESP32-S3" // public identifier, matches esptool-js chip name exactly +} +``` + +The chip's UUID is internal and never exposed. `name` is unique and externally pinned by esptool-js, +and no chip ever appears in a storage path, so a surrogate key buys nothing on the public surface. + +### FirmwareBoardDetail + +Per-board data within a release response. Includes chip info, discontinuation status, and all artifacts for that board in the given version. + +```jsonc +{ + "chip": { + "name": "ESP32-S3" + }, + "discontinued": false, // true = board is EOL, still served + "artifacts": [ + { "type": "merged", "url": "...", "sha256Hash": "...", "fileSize": 1572864 }, + { "type": "app", "url": "...", "sha256Hash": "...", "fileSize": 1048576 } + ] +} +``` + +Discontinued boards are **always included** in responses. Consumers decide whether to show or hide them. + +### Repository + +Reference to a source code repository. Stored in the `repositories` table and referenced by ID elsewhere. + +**Shared infrastructure**: The `repositories` table is not firmware-specific. It is shared across all domains — both firmware versions and desktop module versions reference it for source traceability. The DB entity is named `SourceRepository` (to avoid collision with the desktop manifest DTO) but the table is `repositories`. + +```jsonc +{ + "id": "a3f1b2c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c", + "provider": "github", + "owner": "openshock", + "repo": "firmware" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | guid | yes | DB primary key | +| `provider` | string | yes | `"github"` (extensible to `"gitlab"`, etc.) | +| `owner` | string | yes | Organization or user (e.g. `"openshock"`) | +| `repo` | string | yes | Repository name (e.g. `"firmware"`) | + +Unique constraint on `(provider, owner, repo)`. + +### FirmwareSource + +Source traceability for a firmware release. References a repository and includes build-specific fields plus server-constructed URLs. + +```jsonc +{ + "repository": { + "id": "a3f1b2c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c", + "provider": "github", + "owner": "openshock", + "repo": "firmware" + }, + "commitHash": "21e43623abcdef1234567890abcdef1234567890", + "ref": "refs/tags/v1.5.1", + "runId": "12345678901", + "commitUrl": "https://github.com/openshock/firmware/commit/21e43623abcdef1234567890abcdef1234567890", + "refUrl": "https://github.com/openshock/firmware/releases/tag/v1.5.1", + "runUrl": "https://github.com/openshock/firmware/actions/runs/12345678901" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `repository` | Repository | yes | Source repository | +| `commitHash` | string | yes | Full 40-char commit SHA | +| `ref` | string | no | Git ref (`refs/tags/v1.5.1`, `refs/heads/main`) | +| `runId` | string | no | CI/CD run identifier | +| `commitUrl` | string | yes | Full URL to the commit (server-constructed) | +| `refUrl` | string | no | Full URL to the tag/branch (server-constructed, present when `ref` is set) | +| `runUrl` | string | no | Full URL to the CI run (server-constructed, present when `runId` is set) | + +**URL construction per provider** (server-side): + +| Provider | commitUrl | refUrl (tag) | runUrl | +|----------|-----------|-------------|--------| +| `github` | `https://github.com/{owner}/{repo}/commit/{hash}` | `https://github.com/{owner}/{repo}/releases/tag/{tag}` | `https://github.com/{owner}/{repo}/actions/runs/{runId}` | + +**DB storage**: `firmware_versions` stores `repository_id` (FK → `repositories`), `commit_hash`, `ref`, `run_id`. URLs are constructed at response time, never stored. + +**Desktop modules**: The desktop `versions` table stores the same source fields (`repository_id`, `commit_hash`, `ref`, `run_id`). Desktop modules populated via CI/CD (`/v1/cicd/`) extract these from GitHub OIDC claims, same as firmware. The `FirmwareSource` response type is firmware-specific, but the underlying stored fields are a shared pattern. + +### FirmwareRelease + +Full release details including release notes. Used by both the latest endpoint and the specific version endpoint — same type, same shape. This is the rich response for frontends and API consumers. + +```jsonc +{ + "version": "1.5.1", + "channel": "stable", + "releaseDate": "2026-04-15T00:00:00Z", + "source": { + "repository": { "id": "a3f1b2c4-...", "provider": "github", "owner": "openshock", "repo": "firmware" }, + "commitHash": "21e43623abcdef1234567890abcdef1234567890", + "ref": "refs/tags/v1.5.1", + "runId": "12345678901", + "commitUrl": "https://github.com/openshock/firmware/commit/21e43623abcdef1234567890abcdef1234567890", + "refUrl": "https://github.com/openshock/firmware/releases/tag/v1.5.1", + "runUrl": "https://github.com/openshock/firmware/actions/runs/12345678901" + }, + "releaseNotes": [ + { "type": "breaking", "title": "Config format", "content": "Changed config format to TOML\nMultiple lines of content are concatenated into a single note." }, + { "type": "warning", "content": "Requires hub reset after update" }, + { "type": "info", "content": "Fixed WiFi reconnection" }, + { "type": "info", "content": "Improved battery life" } + ], + "boards": { + "Wemos-D1-Mini-ESP32": { + "chip": { "id": "esp32", "name": "ESP32" }, + "discontinued": false, + "artifacts": [ + { "type": "merged", "url": "https://firmware.openshock.org/1.5.1/3f1a5c8e-9d24-4b70-8e11-2c6b7a0f4d93/firmware.bin", "sha256Hash": "a1b2c3...", "fileSize": 1572864 } + ] + }, + "Pishock-Lite-2021": { + "chip": { "id": "esp32", "name": "ESP32" }, + "discontinued": true, + "artifacts": [ + { "type": "merged", "url": "https://firmware.openshock.org/1.5.1/Pishock-Lite-2021/firmware.bin", "sha256Hash": "d4e5f6...", "fileSize": 1572864 } + ] + } + } +} +``` + +### FirmwareVersionSummary + +Version info for paginated lists. Includes source traceability and release notes so consumers don't need per-version requests. + +```jsonc +{ + "version": "1.5.1", + "channel": "stable", + "releaseDate": "2026-04-15T00:00:00Z", + "source": { + "repository": { "id": "a3f1b2c4-...", "provider": "github", "owner": "openshock", "repo": "firmware" }, + "commitHash": "21e43623abcdef1234567890abcdef1234567890", + "ref": "refs/tags/v1.5.1", + "runId": "12345678901", + "commitUrl": "https://github.com/openshock/firmware/commit/21e43623abcdef1234567890abcdef1234567890", + "refUrl": "https://github.com/openshock/firmware/releases/tag/v1.5.1", + "runUrl": "https://github.com/openshock/firmware/actions/runs/12345678901" + }, + "releaseNotes": [ + { "type": "breaking", "title": "Config format", "content": "Changed config format to TOML\nMultiple lines of content are concatenated into a single note." }, + { "type": "info", "content": "Fixed WiFi reconnection" } + ] +} +``` + +### FirmwareBoardReleaseResponse + +Minimal response for a single board in a single version. No release notes, no chip info, no other boards — just what the firmware needs to download and flash. + +`boardId` is the board's canonical **name**, echoed back as stored — see §4.2 for why the name is the +public identifier. The board's UUID appears only inside artifact `url`s, as the immutable storage key. + +```jsonc +{ + "version": "1.5.1", + "boardId": "Wemos-D1-Mini-ESP32", + "artifacts": [ + { "type": "merged", "url": "https://firmware.openshock.org/1.5.1/3f1a5c8e-9d24-4b70-8e11-2c6b7a0f4d93/firmware.bin", "sha256Hash": "a1b2c3...", "fileSize": 1572864 }, + { "type": "app", "url": "https://firmware.openshock.org/1.5.1/3f1a5c8e-9d24-4b70-8e11-2c6b7a0f4d93/app.bin", "sha256Hash": "d4e5f6...", "fileSize": 1048576 }, + { "type": "staticfs", "url": "https://firmware.openshock.org/1.5.1/3f1a5c8e-9d24-4b70-8e11-2c6b7a0f4d93/staticfs.bin", "sha256Hash": "7a8b9c...", "fileSize": 262144 } + ] +} +``` + +Consumers should treat `url` as opaque and use it verbatim rather than reconstructing the path. + +### FirmwareManifestResponse + +Bootstrap payload. Aggregates channels, latest versions, boards, chips, USB device filters, and security advisories. + +```jsonc +{ + "channels": ["stable", "beta", "develop"], + "latest": { + "stable": "1.5.1", + "beta": "1.6.0-beta.3", + "develop": "1.6.0-develop+21e4" + }, + "boards": [ + { + "id": "OpenShock-Core-V1", + "name": "OpenShock Core V1", + "chipId": "esp32s3", + "chipName": "ESP32-S3", + "discontinued": false, + "usbDevices": [ + { "id": "b9e1...", "vid": 6790, "pid": 29970, "name": "CH9102" } + ] + } + ], + "chips": [ + { + "id": "esp32s3", + "name": "ESP32-S3", + "architecture": "xtensa", + "usbDevices": [ + { "id": "77ac...", "vid": 12346, "pid": 4097, "name": "ESP32-S3 USB-JTAG" } + ] + } + ], + "usbSerialFilters": [ + { "vid": 6790 }, + { "vid": 4292 }, + { "vid": 12346, "pid": 4097 } + ], + "usbDevices": [ + { "id": "b9e1...", "vid": 6790, "pid": 29970, "name": "CH9102" }, + { "id": "c3d4...", "vid": 4292, "pid": 60000, "name": "CP2102" }, + { "id": "77ac...", "vid": 12346, "pid": 4097, "name": "ESP32-S3 USB-JTAG" } + ], + "advisories": [ + { + "severity": "critical", + "title": "OTA bricking bug", + "content": "Versions before 1.4.0 have a bug that can brick the device during OTA. Update manually via USB.", + "affectedVersions": "<1.4.0", + "url": "https://github.com/openshock/firmware/issues/123" + } + ] +} +``` + +**Fields**: + +- `channels` — ordered list of available release channels +- `latest` — map of channel → latest version string. Channels with no published versions are omitted +- `boards` — all boards (including discontinued). `FirmwareBoardDto[]`. Each board carries its own `usbDevices` array listing **board-specific** USB identities (on-board USB-serial chips soldered to the PCB, board-unique VID/PIDs) +- `chips` — all chips. `FirmwareChipDto[]`. Each chip carries its own `usbDevices` array listing **chip-level** USB identities (native-USB modes inherent to the silicon, e.g. ESP32-S3's USB-JTAG) +- `usbSerialFilters` — WebSerial filter rules. Each entry has a required `vid` and optional `pid` (omitted for vendor-wide filters). Frontends map these to `navigator.serial.requestPort({ filters })`: `{ usbVendorId: vid }` for vendor-wide matches, `{ usbVendorId: vid, usbProductId: pid }` for specific matches. Broader than `usbDevices` — may include vendor-wide entries covering devices not individually cataloged +- `usbDevices` — recognition catalog of specific VID+PID pairs mapped to human-readable names. Used to label connected ports (e.g. "you plugged in a CH9102"), not for filtering. Every entry has both `vid` and `pid` + +**Board USB identity inheritance**: a board's full USB identity set is the union of `board.usbDevices` (board-specific) and `chips[board.chipId].usbDevices` (chip-inherited). The server does not merge these at response time — each is exposed on its own DTO so consumers can distinguish high-confidence board matches from low-confidence chip-family-only matches. For auto-detect, a match against `board.usbDevices` uniquely identifies the board; a match against only the chip's entries narrows to the chip family and the user must pick which board. +- `advisories` — security/compatibility warnings. `severity` is `critical`, `warning`, or `info`. `affectedVersions` is a semver range string. `url` is optional + +### FirmwareAdvisory + +```jsonc +{ + "severity": "critical", + "title": "OTA bricking bug", + "content": "Versions before 1.4.0 have a bug that can brick the device during OTA.", + "affectedVersions": "<1.4.0", + "url": "https://github.com/openshock/firmware/issues/123" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `severity` | string | yes | `critical`, `warning`, or `info` | +| `title` | string | yes | Short heading | +| `content` | string | yes | Human-readable description | +| `affectedVersions` | string | yes | Semver range (e.g. `"<1.4.0"`, `">=1.3.0 <1.4.2"`) | +| `url` | string | no | Link to issue or advisory | + +### FirmwareUsbSerialFilter + +WebSerial filter rule. Either a vendor-wide match (omit `pid`) or a specific device match (provide both). Used only by the frontend port picker. + +```jsonc +{ "vid": 6790 } +``` + +```jsonc +{ "vid": 12346, "pid": 4097 } +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `vid` | int | yes | USB Vendor ID (decimal, 0-65535) | +| `pid` | int | no | USB Product ID (decimal, 0-65535). Omit for a vendor-wide filter | + +Stored in the `usb_serial_filters` table with a unique constraint on `(vid, pid)` treating NULLs as equal — at most one vendor-wide row per VID. + +### FirmwareUsbDevice + +Recognition catalog entry. Always carries a specific `vid`+`pid` pair plus a human-readable name. Used for labeling connected ports and for linking USB identity to specific boards and chips. + +```jsonc +{ + "id": "b9e1a2c3-4d5e-6f7a-8b9c-0d1e2f3a4b5c", + "vid": 6790, + "pid": 29970, + "name": "CH9102" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | guid | yes | DB primary key — referenced by board/chip junctions | +| `vid` | int | yes | USB Vendor ID (decimal, 0-65535) | +| `pid` | int | yes | USB Product ID (decimal, 0-65535) | +| `name` | string | yes | Human-readable chip/converter name | + +Unique constraint on `(vid, pid)`. Linked to chips via `firmware_chip_usb_devices` and to boards via `firmware_board_usb_devices` (both M:N). + +**Chip-level vs board-level links**: + +- **Chip link** (`firmware_chip_usb_devices`) — native USB modes inherent to the silicon. Examples: ESP32-S3's USB-JTAG at `0x303A/0x1001`, ESP32-C3's USB-JTAG at `0x303A/0x1001`. Link once at the chip level and every board using that chip inherits it. +- **Board link** (`firmware_board_usb_devices`) — on-board USB-serial converters or board-unique VID/PIDs. Examples: PiShock's CH9102 at `0x1A86/0x7522`, a Wemos variant shipped with a CP2104. Describe what's soldered to this specific PCB. + +A given USB device should be linked at the level it actually belongs to — never both. Do not re-attach a chip-level entry onto every board using that chip; boards inherit it by virtue of their `chipId`. + +--- + +## 4. Public Endpoints + +### 4.1. Manifest + +``` +GET /2/firmware/manifest +``` + +Bootstrap endpoint. Returns everything a consumer needs before making version-specific requests: channels, latest versions, boards, chips, USB device filters, and security advisories. + +**200 OK** → `FirmwareManifestResponse` + +**Cache**: `Cache-Control: public, max-age=300` + +**Server implementation notes**: +- `channels` is the hardcoded channel list +- `latest` requires one query per channel (latest version by release date) +- `boards` and `chips` are queried from the database (boards include discontinued) +- Each board's inline `usbDevices` array is joined via `firmware_board_usb_devices` → `usb_devices` (board-specific only; chip-inherited entries are not duplicated here) +- Each chip's inline `usbDevices` array is joined via `firmware_chip_usb_devices` → `usb_devices` (chip-level native USB modes) +- `usbSerialFilters` is queried from the `usb_serial_filters` table +- `usbDevices` is queried from the `usb_devices` table +- `advisories` is served from server config (appsettings), not the database + +--- + +### 4.2. Latest release + +#### All boards + +``` +GET /2/firmware/latest/{channel} +``` + +Returns the most recent published release for a channel, with all boards, their artifacts, and release notes. For single-board queries, use `GET /latest/{channel}/{board}` instead. + +| Param | Location | Required | Description | +|-------|----------|----------|-------------| +| `channel` | path | yes | `stable`, `beta`, or `develop` | + +**200 OK** → `FirmwareRelease` + +**400** → invalid channel + +**404** → no versions exist for that channel + +**Cache**: `Cache-Control: public, max-age=300` + +**Channels cascade.** A request for a channel returns the newest release visible to that channel, +where `stable ⊆ beta ⊆ develop`: + +| Requested | Considers | +|-----------|-----------| +| `stable` | stable | +| `beta` | stable, beta | +| `develop` | stable, beta, develop | + +This matches how releases are actually produced: shipping a stable build advances the stable, beta and +develop pointers together, so a stable release *is* the newest thing a beta subscriber should receive. +Strict per-channel equality would pin a beta hub to the last explicit release candidate and offer it a +downgrade. The cascade applies to `/latest/*`, `/manifest`, and the `channel` filter on `/versions`. + +**Ordering is a total order.** `release_date` is client-supplied and not unique, so it is never the sole +sort key. Ties break on `version` descending, which is the primary key. Without that tiebreaker +`/manifest`, `/latest/{channel}` and `/latest/{channel}/{board}` could disagree with each other and flip +between requests — a hub sees that as firmware flapping — and offset pagination could duplicate and drop +rows across pages. + +**Server implementation notes**: +- Query: `FirmwareVersions` WHERE channel IN (visible channels), ORDER BY release_date DESC, version DESC, LIMIT 1 +- Join: `FirmwareArtifact` → `FirmwareBoard` → `FirmwareChip` to populate chip ref and discontinued flag +- Include: `FirmwareReleaseNotes` ordered by index + +#### Single board (lightweight) + +``` +GET /2/firmware/latest/{channel}/{board}?version={currentVersion} +``` + +Minimal response for a single board. No release notes, no other boards. Designed for ESP32 hubs. Supports an optional `version` query param — if the hub is already on the latest version, returns 204 instead. + +| Param | Location | Required | Description | +|-------|----------|----------|-------------| +| `channel` | path | yes | `stable`, `beta`, or `develop` | +| `board` | path | yes | Board **name** (e.g. `"Wemos-D1-Mini-ESP32"`) or board UUID | +| `version` | query | no | Hub's current semver string. If provided and matches latest, returns 204 | + +**Board identity**: the board **name** is the public identifier, everywhere — path segment, the +`boardId` field in responses, and the board segment of artifact URLs and storage keys. A hub sends its +name because that is the only thing it can know about itself: the name is its PlatformIO env, compiled +in as `OPENSHOCK_FW_BOARD`. + +The board's UUID remains the internal primary key and foreign-key target, and is still accepted on this +path segment for admin tooling that already holds one. Name matching is case-insensitive; the canonical +stored spelling comes back in the response. + +Board names are validated on create/update against `^[A-Za-z0-9][A-Za-z0-9._-]*$` so that they are safe +as URL path segments, and uniqueness is enforced case-insensitively via a unique index on `lower(name)`. +Without that index, `ESP32-Core` and `esp32-core` could coexist while resolution silently picked one — +the other could never receive an upload, and a hub compiled with its spelling would be served the wrong +board's firmware. + +**Renaming is safe.** Storage keys use the board UUID, so a name is a pure label — renaming a board +changes what clients call it without touching a single stored object. This is the whole reason the +storage key is not the name. + +**Aliases (planned)**: the resolution step is deliberately a single server-side chokepoint, so alternate +names can be added later without touching storage layout, response shapes, or hubs. An alias table maps +additional names onto a canonical board; resolution accepts any of them and always returns the canonical +name, and because consumers use `url` verbatim they never observe the difference. This — not renaming — +is the supported way to retire a board name: keep the canonical name, add an alias, and hubs compiled +with either keep updating. + +**200 OK** → `FirmwareBoardReleaseResponse` + +**204 No Content** → `version` was provided and matches the latest version (no update needed) + +**400** → invalid channel + +**404** → unknown board, no versions exist for that channel, or board has no artifacts in the latest version + +**Cache**: `Cache-Control: public, max-age=300` + +**Update logic**: +1. Find the latest version in the given channel (by release date, not semver order) +2. If `version` query param is provided, compare it against the latest version string +3. If **equal** → return 204 +4. Otherwise (different, or no `version` param) → return 200 with the board's artifacts + +String comparison (not semver) is intentional. This ensures rollbacks work correctly: if a version is pulled and an older version becomes "latest", hubs will update to it. + +**Edge cases**: +- Board is discontinued: still serves artifacts (discontinued boards are always included) +- Board exists but has no artifacts for this version: return 404 +- Hub sends a version that doesn't exist in the DB: still compares against latest, returns 200 if different + +--- + +### 4.3. Version history + +``` +GET /2/firmware/versions?channel={channel}&limit={n}&offset={n} +``` + +Paginated list of published releases, newest first. + +| Param | Location | Required | Default | Description | +|-------|----------|----------|---------|-------------| +| `channel` | query | no | all | Filter by channel | +| `limit` | query | no | 20 | Max results (clamped to 1-100) | +| `offset` | query | no | 0 | Skip N results | + +**200 OK** + +```jsonc +{ + "versions": [ + { + "version": "1.5.1", + "channel": "stable", + "releaseDate": "2026-04-15T00:00:00Z", + "source": { + "repository": { "id": "a3f1b2c4-...", "provider": "github", "owner": "openshock", "repo": "firmware" }, + "commitHash": "21e43623...", + "ref": "refs/tags/v1.5.1", + "runId": "12345678901", + "commitUrl": "https://github.com/openshock/firmware/commit/21e43623...", + "refUrl": "https://github.com/openshock/firmware/releases/tag/v1.5.1", + "runUrl": "https://github.com/openshock/firmware/actions/runs/12345678901" + }, + "releaseNotes": [...] + } + ], + "total": 42 +} +``` + +`versions` is `FirmwareVersionSummary[]`. `total` is the unfiltered count for the query (before limit/offset), for pagination. + +**400** → invalid channel + +**Cache**: `Cache-Control: public, max-age=3600` + +--- + +### 4.4. Specific version + +#### All boards + +``` +GET /2/firmware/versions/{version} +``` + +Full release details for a specific version including release notes. Same response shape as the latest endpoint. + +| Param | Location | Required | Description | +|-------|----------|----------|-------------| +| `version` | path | yes | Exact semver string | + +**200 OK** → `FirmwareRelease` + +**404** → version not found + +**Cache**: `Cache-Control: public, max-age=86400, immutable` + +Published versions are immutable — artifacts and release notes never change after publish. + +#### Single board (lightweight) + +``` +GET /2/firmware/versions/{version}/{board} +``` + +Minimal response for a single board. No release notes, no other boards. Used by hubs for directed +updates, where the backend names the exact version to install. + +| Param | Location | Required | Description | +|-------|----------|----------|-------------| +| `version` | path | yes | Exact semver string (e.g. `"1.5.1"`) | +| `board` | path | yes | Board name (e.g. `"Wemos-D1-Mini-ESP32"`), or board UUID — see §4.2 | + +**200 OK** → `FirmwareBoardReleaseResponse` + +**404** → version not found, board not found, or board has no artifacts in this version + +**Cache**: `Cache-Control: public, max-age=86400, immutable` + +--- + +## 5. CI/CD Ingestion + +### 5.1. Authentication + +All ingestion endpoints require a **GitHub OIDC token** passed as a Bearer token. No long-lived secrets. + +``` +Authorization: Bearer +``` + +**Server validation**: +1. Verify JWT signature against GitHub's JWKS at `https://token.actions.githubusercontent.com/.well-known/jwks` +2. Validate standard claims: `iss` == `https://token.actions.githubusercontent.com`, `aud` == configured audience, token not expired +3. Extract source identity from claims: `repository_owner`, `repository`, `sha`, `ref`, `run_id` +4. Look up the `repositories` table for a matching `(provider="github", owner, repo)` entry +5. Reject if no matching repository exists — only pre-registered repositories can publish + +**Registering allowed repositories**: Use the admin endpoint `PUT /2/firmware/admin/repositories` (see section 6) to add a repository to the DB. The OIDC handler matches incoming tokens against registered repositories — no config file changes needed to onboard a new source repo. + +On successful auth, the matched `repository_id` and extracted claims (`sha`, `ref`, `run_id`) are attached to the request context for use by the release endpoints. + +**Scopes**: registration records what a repository may publish — `publish_firmware`, +`publish_modules`, or both. Firmware and desktop ingestion share this one authentication scheme, so +without scopes a repository onboarded to publish desktop modules could also initialise and publish +firmware releases. A repository registered with no scopes is authenticated but cannot publish +anything. Scopes are entirely server-side: a publishing workflow neither sends nor sees them. + +**Per-release ownership**: authentication identifies *a* registered repository, not *which* release it +may act on. Every registered repository presents an equally valid principal, so `InitRelease` records +the caller's `repository_id` on the release, and upload, publish and abort each re-check it and return +**403** on mismatch. Without that check any registered repository could inject artifacts into another's +in-flight release, publish it under that repository's identity and commit hash, or abort it. + +The same rule applies to desktop module ingestion: a module carries an owning `repository_id`, and a +module with no owner assigned is closed to every publisher rather than open to any. + +**Server config** (appsettings — only the OIDC audience, not the repo list): + +```jsonc +{ + "CiCd": { + "Audience": "openshock-repository-server" + } +} +``` + +Deliberately not nested under `Firmware`: the scheme it configures also guards desktop module +publishing. + +**What the audience is for**: it makes a token non-transferable between services. `id-token: write` is +granted per job, so any action in a publishing workflow can mint a token; without this check a token +obtained for an unrelated vendor would still be a valid publishing credential here, since it carries +the same `repository` claim. It is not a secret — anyone can request it by name — so it authenticates +nothing about *who* is calling. That is the allowlist's job. + +**GitHub Actions workflow setup**: + +```yaml +permissions: + id-token: write + +steps: + - run: | + TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://repo.openshock.org" | jq -r .value) + # Use $TOKEN as Bearer token for all /releases endpoints +``` + +### 5.2. Release workflow + +Two-phase release process with staging, artifact upload, and publish. Abandoned staging releases are cleaned up automatically — see section 5.4. + +``` +POST /2/firmware/releases[?nofail] → InitRelease (creates staging release) +PUT /2/firmware/releases/{releaseId}/boards/{board} → UploadBoardArtifacts (multipart) +POST /2/firmware/releases/{releaseId}/publish → PublishRelease (promotes to live) +DELETE /2/firmware/releases/{releaseId} → AbortRelease (cleanup) +``` + +#### InitRelease + +``` +POST /2/firmware/releases[?nofail] +``` + +```jsonc +{ + "version": "1.5.1", + "channel": "stable", + "releaseDate": "2026-04-15T00:00:00Z", + "boards": ["OpenShock-Core-V1", "Wemos-D1-Mini-ESP32"], // board names (UUIDs also accepted) + "changelog": "### Breaking\n**Config format** — Changed to TOML\n\n### Warning\nRequires hub reset\n\n### Info\n- Fixed WiFi reconnection\n- Improved battery life" +} +``` + +The server populates `source` automatically from the OIDC token claims — CI does not send it. Fields extracted: +- `provider`: always `"github"` (determined by OIDC issuer) +- `owner`: from `repository_owner` claim +- `repo`: from `repository` claim (stripped of owner prefix) +- `commitHash`: from `sha` claim +- `ref`: from `ref` claim +- `runId`: from `run_id` claim + +The `changelog` field replaces the current `releaseNotes` structured array. See section 5.3 for parsing rules. + +**Changelog validation**: +1. Server parses the `changelog` markdown per section 5.3 rules +2. **If parsing succeeds**: release status = `staging`, return **201 Created** +3. **If parsing fails and `?nofail` is NOT present**: return **400** (`firmware/invalid-changelog`). CI/CD build fails. +4. **If parsing fails and `?nofail` IS present**: release status = `editing`. The release is created with best-effort release notes (whatever was parseable, or empty). Return **201 Created**. CI/CD can log a warning but the build proceeds. Artifact uploads work normally. Repo janitors must fix the changelog via the admin endpoint (section 6.5) before the release can be published. + +**201 Created**: + +```jsonc +{ + "id": "", + "status": "staging" // or "editing" +} +``` + +#### UploadBoardArtifacts + +``` +PUT /2/firmware/releases/{releaseId}/boards/{board} +Content-Type: multipart/form-data +``` + +Accepts uploads in both `staging` and `editing` status. Form fields: `app`, `staticfs`, `merged`, `bootloader`, `partitions` — each a binary file. Must include all artifact types required by the board's `requiredArtifactTypes` configuration. + +**Uploads are staged, not published.** Bytes land under an internal `_staging/{releaseId}/` prefix and +are copied to their published key only by `PublishRelease`. A published key is therefore written exactly +once, and an aborted or expired release is deleted wholesale from its own prefix with no possibility of +removing an object a live version is serving. The `url` in the response is the artifact's *eventual* +published URL — it does not resolve until the release is published. + +The whole request is verified before anything is written: every file is hashed and checked against the +manifest first, so a request containing one bad file leaves no partial state behind and does not disturb +artifacts already staged for that board. + +Additionally, a required `sha256` form field must be included containing a JSON object mapping each artifact type to its expected SHA-256 hex hash: + +```jsonc +// sha256 form field value (JSON string): +{ + "merged": "a1b2c3d4e5f6789...", + "app": "f6e5d4c3b2a1987..." +} +``` + +**Server validation**: +- Compute SHA-256 of each uploaded file and compare against the corresponding entry in the `sha256` manifest +- If any hash mismatches: return **400** (`firmware/sha256-mismatch`) with detail listing the mismatched artifact types and expected vs actual hashes +- The manifest must have exactly the same keys as the uploaded files — extra or missing entries are also 400 + +Size limit: 64 MB per request. + +#### PublishRelease + +``` +POST /2/firmware/releases/{releaseId}/publish +``` + +Validates all declared boards have their required artifacts uploaded, then atomically promotes staging data to the live `firmware_versions` / `firmware_artifacts` / `firmware_release_notes` tables. + +**Pre-publish checks**: +- Release status must be `staging`. If `editing`, return **409** (`firmware/release-notes-not-finalized`). Repo janitors must fix the release notes via the admin endpoint (section 6.5) first, which transitions the release back to `staging`. +- All declared boards have their required artifacts uploaded + +#### AbortRelease + +``` +DELETE /2/firmware/releases/{releaseId} +``` + +Cleans up CDN artifacts for all staged artifacts, then marks the release as aborted. + +**204 No Content** + +### 5.3. Changelog markdown format + +CI/CD submits a single markdown string. The server parses it into structured `FirmwareReleaseNote` entries. + +#### Input format + +```markdown +### Breaking +**Config format** — Changed config format to TOML +Multiple lines of content are concatenated into a single note. + +### Warning +Requires hub reset after update + +### Info +- Fixed WiFi reconnection +- Improved battery life +- **OTA** — Added automatic rollback on failed flash + +### Features +Custom sections become type "section" with the heading as title. +- New web dashboard +- Bluetooth pairing support +``` + +#### Parsing rules + +1. **Split on headings**: Each `### Heading` starts a new section +2. **Map heading to type**: + - `Breaking` → `breaking` + - `Warning` → `warning` + - `Info` → `info` + - Anything else → `section` (with heading stored as `title`) +3. **Extract items within a section**: + - Lines starting with `- ` are individual items (strip the `- ` prefix) + - Indented bullets are flattened into siblings — the note type carries no nesting, so depth is + discarded but content is preserved + - Non-bullet lines ("prose") are joined with newlines into a single item + - A section may contain both: the prose item is emitted first, then the bullet items + - Empty lines are ignored +4. **Extract title from items**: If an item starts with `**Title**`, extract it: + - `**Config format** — Changed to TOML` → `title: "Config format"`, `content: "Changed to TOML"` + - `**OTA** — Added rollback` → `title: "OTA"`, `content: "Added rollback"` + - `Fixed WiFi` → `title: null`, `content: "Fixed WiFi"` +5. **Ordering**: Notes are stored with sequential indices preserving the order: all items from the first section, then all items from the second section, etc. + +#### Resulting structured data (stored in DB, returned in API) + +```jsonc +[ + { "type": "breaking", "title": "Config format", "content": "Changed config format to TOML\nMultiple lines of content are concatenated into a single note." }, + { "type": "warning", "title": null, "content": "Requires hub reset after update" }, + { "type": "info", "title": null, "content": "Fixed WiFi reconnection" }, + { "type": "info", "title": null, "content": "Improved battery life" }, + { "type": "info", "title": "OTA", "content": "Added automatic rollback on failed flash" }, + { "type": "section", "title": "Features", "content": "Custom sections become type \"section\" with the heading as title." }, + { "type": "section", "title": "Features", "content": "New web dashboard" }, + { "type": "section", "title": "Features", "content": "Bluetooth pairing support" } +] +``` + +#### Validation errors + +The changelog is considered **invalid** if any of the following are true: + +- No `### Heading` found anywhere in the input +- All sections are empty (headings present but no content after any of them) +- The entire input is empty or whitespace-only + +Any other well-formed markdown that produces at least one note is considered valid. Unexpected formatting within a section (e.g. bold text not matching the `**Title** — content` pattern) is treated as literal content, not a validation error. + +### 5.4. Staged release cleanup + +A background job automatically aborts and cleans up staging releases that have been open longer than their TTL. + +**Configuration** (appsettings): + +```jsonc +{ + "Firmware": { + "StagedReleaseTtl": "01:00:00", + "EditingReleaseTtl": "7.00:00:00" + } +} +``` + +- `StagedReleaseTtl` — TTL for releases in `staging` status (default: 1 hour). These are normal CI releases where the pipeline abandoned or crashed before publish. +- `EditingReleaseTtl` — TTL for releases in `editing` status (default: 7 days). These are waiting for manual review by repo janitors. + +**Behavior**: +1. Runs on a periodic timer (every 5 minutes) +2. Queries `FirmwareReleases` where `Status` is `staging` or `editing` and `CreatedAt + TTL < now`, using the appropriate TTL based on status +3. For each expired release: deletes CDN artifacts for all staged artifacts (same cleanup as AbortRelease), then sets `Status = Aborted` +4. Logs each cleanup action with the release ID, version, and reason (TTL expired) + +**Interaction with AbortRelease**: The background job performs the same cleanup as a manual `DELETE /releases/{releaseId}`. If CI/CD calls AbortRelease before the TTL expires, the job has nothing to clean up. + +--- + +## 6. Admin Endpoints + +All admin endpoints require `AdminToken` authentication. + +### 6.1. Repositories + +> **Note**: Although scoped under `/2/firmware/admin/`, the `repositories` table is shared infrastructure. Repositories registered here are available for source traceability in both firmware versions and desktop module versions. See the `Repository` type in section 3. + +#### Upsert repository + +``` +PUT /2/firmware/admin/repositories +``` + +```jsonc +{ + "provider": "github", + "owner": "openshock", + "repo": "firmware", + "scopes": ["publish_firmware"] // publish_firmware | publish_modules +} +``` + +Registers a repository. `provider`, `owner` and `repo` are matched **case-insensitively** — GitHub +treats owner and repo names that way, and the casing in an OIDC token follows whatever the repository +is currently called, so an exact match would let a cosmetic rename silently de-authorize it. Scopes are +authoritative on re-registration: re-running onboarding with a different set widens or narrows the +grant. Omitting them registers the repository without letting it publish anything. Unique constraint on `(provider, owner, repo)`; idempotent, so re-running +onboarding is harmless. + +**This is the publish allowlist, not a bookkeeping table.** A GitHub OIDC token proves only that some +workflow somewhere on GitHub requested it — the issuer is shared by every repository, and the audience +is a plain string any workflow can ask for by name. Registration here is therefore the actual +authorization decision (§5.1), and deletion is a real revocation. + +**201 Created** → `Repository` (newly registered) + +**200 OK** → `Repository` (already registered) + +```jsonc +{ + "id": "a3f1b2c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c", + "provider": "github", + "owner": "openshock", + "repo": "firmware" +} +``` + +#### List repositories + +``` +GET /2/firmware/admin/repositories +``` + +**200 OK** → `Repository[]` + +#### Delete repository + +``` +DELETE /2/firmware/admin/repositories/{repositoryId} +``` + +Fails if any firmware versions reference this repository. + +**204 No Content** → deleted + +**409 Conflict** → repository is in use by firmware versions + +### 6.2. Boards + +#### Upsert board + +``` +PUT /2/firmware/admin/boards/{boardId} +``` + +```jsonc +{ + "name": "OpenShock Core V1", + "chipId": "esp32s3", + "requiredArtifactTypes": ["merged"] +} +``` + +**201 Created** + +#### Discontinue board + +``` +PATCH /2/firmware/admin/boards/{boardId}/discontinue +``` + +**200 OK** + +#### Delete board + +``` +DELETE /2/firmware/admin/boards/{boardId} +``` + +Fails if any firmware artifacts reference this board. + +**204 No Content** or **409 Conflict** + +### 6.3. Chips + +#### Upsert chip + +``` +PUT /2/firmware/admin/chips/{chipId} +``` + +```jsonc +{ + "name": "ESP32-S3", + "architecture": "xtensa" +} +``` + +**201 Created** + +#### Delete chip + +``` +DELETE /2/firmware/admin/chips/{chipId} +``` + +Fails if any boards reference this chip. + +**204 No Content** or **409 Conflict** + +### 6.4. Versions + +#### Delete version + +``` +DELETE /2/firmware/admin/versions/{version} +``` + +Deletes a firmware version and all its artifacts and release notes (cascade). + +**204 No Content** or **404 Not Found** + +### 6.5. Release Changelog + +#### Fix changelog + +``` +PUT /2/firmware/admin/releases/{releaseId}/changelog +``` + +```jsonc +{ + "changelog": "### Info\n- Fixed WiFi reconnection\n- Improved battery life" +} +``` + +Allows repo janitors to fix or refine release notes on a staging release. Typically used for releases created with `?nofail` (status = `editing`), but also works on `staging` releases to restructure notes before publish. + +**Behavior**: +1. Validates the release exists and is in `staging` or `editing` status +2. Parses the submitted changelog strictly (same rules as section 5.3 — no `?nofail` bypass here) +3. If valid: replaces all staged release notes, sets status to `staging` (if it was `editing`), returns **200 OK** +4. If the changelog is invalid: returns **400** (`firmware/invalid-changelog`) +5. If the release is not in `staging` or `editing` status: returns **409 Conflict** + +### 6.6. USB Serial Filters + +Broad WebSerial selectors consumed by the `usbSerialFilters` field in the manifest. Either vendor-wide (omit `pid`) or specific (provide both). + +#### Upsert filter + +``` +PUT /2/firmware/admin/usb-serial-filters +``` + +Vendor-wide: + +```jsonc +{ + "vid": 6790, + "description": "WCH — vendor-wide (CH340, CH9102, CH343, ...)" +} +``` + +Specific: + +```jsonc +{ + "vid": 12346, + "pid": 4097, + "description": "ESP32-S3 native USB-JTAG" +} +``` + +Unique constraint on `(vid, pid)` with NULLs treated as equal — at most one vendor-wide row per VID. `description` is admin-only and never surfaced in the public manifest. + +**201 Created** + +```jsonc +{ + "id": "", + "vid": 12346, + "pid": 4097, + "description": "ESP32-S3 native USB-JTAG" +} +``` + +#### List filters + +``` +GET /2/firmware/admin/usb-serial-filters +``` + +**200 OK** → array of filter rules (admin shape — includes `description`). + +#### Delete filter + +``` +DELETE /2/firmware/admin/usb-serial-filters/{id} +``` + +**204 No Content** + +### 6.7. USB Devices + +Recognition catalog entries — specific `(vid, pid)` pairs with a human-readable name. Consumed by the manifest `usbDevices` list and by each board's inline `usbDevices` array. Boards and chips attach to catalog entries via junction tables. + +#### Upsert USB device + +``` +PUT /2/firmware/admin/usb-devices +``` + +```jsonc +{ + "vid": 6790, + "pid": 29970, + "name": "CH9102" +} +``` + +Unique constraint on `(vid, pid)`. If the pair already exists, this updates `name` in place. + +**201 Created** → `FirmwareUsbDevice` with server-assigned `id`. + +#### List USB devices + +``` +GET /2/firmware/admin/usb-devices +``` + +**200 OK** → `FirmwareUsbDevice[]`. + +#### Delete USB device + +``` +DELETE /2/firmware/admin/usb-devices/{id} +``` + +Fails if the device is still linked to any chip or board — detach first. + +**204 No Content** → deleted + +**409 Conflict** → still referenced by `firmware_chip_usb_devices` or `firmware_board_usb_devices` + +#### Attach USB device to board + +``` +PUT /2/firmware/admin/boards/{boardId}/usb-devices/{usbDeviceId} +``` + +Creates a link in `firmware_board_usb_devices`. Idempotent. + +**204 No Content** + +#### Detach USB device from board + +``` +DELETE /2/firmware/admin/boards/{boardId}/usb-devices/{usbDeviceId} +``` + +**204 No Content** + +#### Attach USB device to chip + +``` +PUT /2/firmware/admin/chips/{chipId}/usb-devices/{usbDeviceId} +``` + +Creates a link in `firmware_chip_usb_devices`. Typically used for native-USB catalog entries (ESP32-S3 USB-JTAG, ESP32-C3 USB-JTAG, etc.). Idempotent. + +**204 No Content** + +#### Detach USB device from chip + +``` +DELETE /2/firmware/admin/chips/{chipId}/usb-devices/{usbDeviceId} +``` + +**204 No Content** + +--- + +## 7. Error Handling + +All errors use RFC 7807 problem details via `OpenShockProblem`: + +```jsonc +{ + "type": "firmware/version-not-found", + "title": "Version Not Found", + "status": 404, + "detail": "Firmware version '1.5.2' does not exist" +} +``` + +| Status | Usage | +|--------|-------| +| 204 | Successful DELETE with no response body | +| 400 | Invalid parameters (bad channel, invalid semver, malformed request, invalid changelog, SHA-256 mismatch) | +| 404 | Resource not found (version, board, chip, channel with no versions) | +| 409 | Conflict (release already staging, board in use, chip in use, release notes not finalized) | +| 429 | Rate limited | +| 500 | Server error | + +**Problem types specific to the release workflow**: + +| Problem type | Status | Description | +|-------------|--------|-------------| +| `firmware/invalid-changelog` | 400 | Changelog markdown failed validation (see section 5.3) | +| `firmware/sha256-mismatch` | 400 | Uploaded artifact hash does not match the provided SHA-256 manifest | +| `firmware/release-notes-not-finalized` | 409 | Cannot publish: release is in `editing` status — release notes must be finalized first | + +--- + +## 8. Caching Strategy + +| Endpoint | Cache-Control | Rationale | +|----------|---------------|-----------| +| `GET /manifest` | `public, max-age=300` | Contains latest versions; same refresh cadence | +| `GET /latest/{channel}` | `public, max-age=300` | New releases are infrequent; 5 min staleness is acceptable | +| `GET /latest/{channel}/{board}` | `public, max-age=300` | Same cadence as latest | +| `GET /versions` | `public, max-age=3600` | Historical list changes rarely | +| `GET /versions/{version}` | `public, max-age=86400, immutable` | Published versions never change | +| `GET /versions/{version}/{board}` | `public, max-age=86400, immutable` | Published versions never change | + +All cache headers apply to successful (2xx) responses only. Error responses should not be cached (`Cache-Control: no-store`). + +--- + +## 9. Consumer Integration Notes + +### 9.1. Web flashtool (frontend) + +**Bootstrap**: Call `/manifest` on page load → get channels, latest versions, boards, chips, USB VID/PIDs, and advisories. Use `usbDevices` as WebSerial filters for `navigator.serial.requestPort({ filters })`. Use `boards` and `chips` to populate the board picker. Show `advisories` as banners. + +**Primary flow**: Call `/latest/{channel}` → get `FirmwareRelease` → let user pick a board → use `chip.name` as the esptool-js chip target → download artifacts from URLs → flash. + +**Key fields**: +- `boards[boardName].chip.name` — pass directly to esptool-js as the chip identifier +- `boards[boardName].discontinued` — show a warning badge or hide the board in the UI +- `boards[boardName].artifacts` — all artifact types needed for flashing (`merged` for USB flashing a blank device, individual parts for OTA) +- `releaseNotes` — render grouped by `type`, use `title` as a bold prefix when present + +**Version picker**: Use `/versions?channel=stable&limit=20` for the version dropdown. Link "view details" to `/versions/{version}`. + +### 9.2. ESP32 hub OTA + +> **The hub must flash `app` + `staticfs`, never `merged`.** A `merged` artifact is a full-flash image +> built by `esptool merge_bin`, containing the bootloader at `0x1000`, the partition table at `0x8000`, +> the app at `0x10000` and the filesystem at its partition offset, padded to the full flash size. It is +> for USB flashing a blank device — writing it into an OTA app slot would write a bootloader image into +> the app partition and produce an unbootable slot. OTA is per-partition by construction. + +**Primary flow (auto-update)**: On boot or periodic timer, call `/latest/{channel}/{board}?version={current}`. +On 204, nothing to do. On 200, take the `app` and `staticfs` artifacts and flash each into its own +partition — verify SHA-256 before flashing, then reboot. + +**Directed update flow**: When the backend names a specific version to install, call +`/versions/{version}/{board}` and flash the same two artifacts. + +**Implementation guidance**: +- The hub knows its own board name — its PlatformIO env, compiled in as `OPENSHOCK_FW_BOARD` — and its + `channel`, which is runtime-configurable. Send the name as the `{board}` path segment; see §4.2. +- Parse: `version` (string), `boardId` (string), `artifacts[]`. Select by `type`: `app` → the next OTA + app partition (`esp_ota_get_next_update_partition`), `staticfs` → the filesystem partition. +- Treat `url` as opaque. Do not rebuild it from `version` and board name — the CDN layout is not part of + this contract. +- Verify `sha256Hash` over the downloaded bytes *before* marking a partition bootable. +- Use `fileSize` to bounds-check against partition size before writing, and to confirm the download ran + to completion. +- Flash the filesystem first, then the app. The app partition is made bootable last, so a failure part + way through leaves the previous app in place. +- On HTTP 204: already current, skip. +- On HTTP 404: board or channel unknown — log a warning, do not retry until the next cycle. +- On HTTP 4xx/5xx: transient — back off exponentially. + +**Version comparison is a string compare, not a semver compare** — see §4.2. The hub sends its current +version and lets the server decide; it must not skip an update because the offered version sorts lower, +or rollbacks will not reach it. + +**Memory budget**: The response is small — `artifacts` is typically 3-5 entries. Parse it with the +bundled cJSON rather than buffering a large document. + +### 9.3. Backend API (server-to-server) + +Uses the same public endpoints. Structured `releaseNotes` allow programmatic filtering (e.g. "are there any breaking changes?") without parsing markdown. + +- Use `/latest/{channel}` to check what's deployed +- Use `/versions/{version}` for specific version queries +- `source` provides full traceability: commit, ref, CI run — consumers construct URLs from the structured fields + +### 9.4. CLI / external tools + +All endpoints return structured JSON suitable for `jq` pipelines and scripting. + +Example workflows: +- `GET /latest/stable` | jq `.boards | keys[]` → list available boards +- `GET /versions?channel=stable&limit=5` → recent stable releases +- `GET /versions/1.5.1` | jq `.releaseNotes[] | select(.type == "breaking")` → breaking changes in a version +- `GET /latest/stable/MyBoard?version=1.4.0` → check if a specific board needs updating +- `GET /versions/1.5.1/MyBoard` → get artifacts for a specific version + +--- + +## 10. Schema Evolution + +### Adding fields + +New fields may be added to any response type at any time. Consumers **must** ignore unknown fields. This is the primary extension mechanism. + +### Removing / renaming fields + +Never remove or rename a field within an API version. If a field must change, add the new field alongside the old one, document the deprecation, and remove the old field in the next major API version. + +### New artifact types + +New values may be added to the `artifact type` enum (e.g. `filesystem`, `nvs`). Consumers that don't recognize an artifact type should skip it rather than fail. + +### New release note section types + +New values may be added to the `release note section type` enum. Consumers should render unknown types using the `type` string as a heading, same as `section` type. + +### New source providers + +New `provider` values may be added to `FirmwareSource` (e.g. `"gitlab"`, `"gitea"`). Each provider requires a corresponding OIDC validation handler on the server and a new repository entry via the admin API. Consumers should handle unknown providers gracefully when constructing URLs (e.g. skip linking). + +### New channels + +If a new channel is added (unlikely), it requires coordinated changes across all consumers. The channel set is considered fixed. + +### New release statuses + +New values may be added to the `release_status` enum. `archived` is planned but not yet implemented. Consumers should treat unknown statuses as non-publishable. + +### Cold storage (future) + +Published releases may eventually be moved to `archived` status, transferring artifacts from the CDN to cold storage (e.g. S3 Glacier, cheap object tier). Archived versions would: + +- Remain visible in `/versions` responses (with a flag or absent artifacts indicating archived state) +- Not be served by `/latest/{channel}` (only `published` versions are candidates) +- Be restorable to `published` via an admin endpoint, which copies artifacts back to the CDN +- Retain all metadata (release notes, source traceability) — only binary artifacts move + +This affects both firmware and desktop module releases. The `archived` status is already reserved in the enum for this purpose. + +--- + +## 11. Summary of Backend Changes Required + +| Area | Change | Scope | +|------|--------|-------| +| New `ManifestController` | `GET /manifest` — aggregate channels, latest versions, boards, chips, USB devices, advisories from config + DB | New controller + DTOs | +| `FirmwareConfig` | New config section: `advisories` served from appsettings | Config | +| New `usb_devices` table | `id` (GUID), `vid`, `pid`, `name`, unique `(vid, pid)` — recognition catalog | DB + migration | +| New `usb_serial_filters` table | `id` (GUID), `vid`, `pid` (nullable), `description`, unique `(vid, pid)` NULLS NOT DISTINCT — WebSerial filter rules | DB + migration | +| New `firmware_chip_usb_devices` junction | M:N chip → usb_device, cascade on chip delete, restrict on usb_device delete | DB + migration | +| New `firmware_board_usb_devices` junction | M:N board → usb_device, cascade on board delete, restrict on usb_device delete | DB + migration | +| New `FirmwareUsbSerialFilter` DTO | `vid` + optional `pid` (manifest shape); admin shape also carries `id` + `description` | New DTO | +| `FirmwareUsbDevice` DTO | Add `id` field (GUID); recognition-only catalog entry (always has `vid`+`pid`+`name`) | DTO | +| `FirmwareManifestResponse` | Split `usbDevices` → `usbSerialFilters` + `usbDevices`; both queried from DB | DTO + controller | +| `FirmwareBoardDto` | Add inline `usbDevices: FirmwareUsbDevice[]` array (board-specific only) joined via `firmware_board_usb_devices` | DTO + controller | +| `FirmwareChipDto` | Add inline `usbDevices: FirmwareUsbDevice[]` array (chip-level native USB modes) joined via `firmware_chip_usb_devices` | DTO + controller | +| New admin USB endpoints | `PUT`/`GET`/`DELETE /2/firmware/admin/usb-serial-filters` and `/usb-devices`, plus board/chip attach/detach under `/boards/{id}/usb-devices/{id}` and `/chips/{id}/usb-devices/{id}` | New controller | +| Seed migration | Insert initial filter rows (WCH vendor-wide, SiLabs vendor-wide, Espressif S2/S3/C3 native-USB PIDs) and device catalog rows (CH340, CH9102, CP2102/04/05, FT232, ESP32-S2/S3/C3 USB-JTAG) | DB + migration | +| `FirmwareLatestResponse` | Replace with `FirmwareRelease`: add `source` (structured), `releaseNotes`, reshape `artifacts` → `boards` with chip/discontinued | DTO + controller | +| `LatestController` | Join Board→Chip, include ReleaseNotes, build new response shape | Controller | +| `FirmwareVersionResponse` | Replace with `FirmwareRelease` (same type as latest) | DTO + controller | +| `VersionsController.GetVersion` | Same joins as latest controller, return `FirmwareRelease` | Controller | +| `FirmwareVersionSummary` | Add `source` and `releaseNotes` fields | DTO | +| `VersionsController.ListVersions` | Add limit/offset/total pagination, include release notes, wrap in `{ versions, total }` | Controller | +| `LatestController` | Add `GET /latest/{channel}/{board}?version=` returning `FirmwareBoardReleaseResponse`, with 204 support | Controller + new DTO | +| `VersionsController` | Add `GET /versions/{version}/{board}` returning `FirmwareBoardReleaseResponse` | Controller + new DTO | +| New `repositories` table | `id` (GUID), `provider`, `owner`, `repo` with unique constraint on `(provider, owner, repo)` | DB + migration | +| New `RepositoryDto` | Repository identity DTO | New DTO | +| New `FirmwareSourceDto` | Repository ref + commitHash, ref, runId + server-constructed URLs | New DTO | +| `firmware_versions` table | Replace `commit_hash`/`release_url` columns with `repository_id` FK, `commit_hash`, `ref`, `run_id` | DB + migration | +| New `GitHubOidcAuthentication` | Validate GitHub OIDC JWTs, extract source claims, match against `repositories` table | New auth handler | +| `FirmwareConfig.CiCd` | Config: `Audience` only (allowed repos managed via admin API, not config) | Config | +| New admin repository endpoints | `PUT`/`GET`/`DELETE /2/firmware/admin/repositories` — manage allowed CI/CD sources | New controller | +| `LatestController` (all boards) | Remove `?board` query param, return 400 for invalid channel (not 404) | Controller | +| `InitReleaseRequest` | Remove `commitHash`/`releaseUrl`, replace `releaseNotes` with `changelog` markdown. Add `?nofail` query param. Source populated from OIDC claims | DTO | +| `CreateFirmwareVersionRequest` | Remove (legacy CI/CD endpoint dropped) | Delete | +| New changelog parser | Parse heading-based markdown into `FirmwareReleaseNote` entries | New utility | +| `ReleasesController.InitRelease` | Call changelog parser, populate source from OIDC token claims | Controller | +| `CiCdController` | Remove (legacy CI/CD endpoint dropped) | Delete | +| Cache headers | Add `Cache-Control` to all public endpoints, `no-store` on errors | Middleware or per-action | +| New `FirmwareChipRefDto` | Small DTO with `id` + `name` | New DTO | +| New `FirmwareBoardDetailDto` | DTO with `chip`, `discontinued`, `artifacts[]` | New DTO | +| `ReleaseStatus.Editing` | New status for releases whose notes need manual review/refinement before publish | Enum value | +| Changelog parser validation | Strict validation with `?nofail` bypass. Invalid changelog → 400 or status = `editing` | New utility logic | +| `PublishRelease` status gate | Reject publish when status is `editing` (409) | Controller | +| Admin changelog endpoint | `PUT /admin/releases/{releaseId}/changelog` — fix malformed changelogs on staging releases | New controller action | +| SHA-256 manifest on upload | `sha256` form field required on `UploadBoardArtifacts`, server validates hashes | Controller | +| Staged release cleanup job | Background job aborts expired releases (1h staging, 7d editing) | New hosted service | +| DELETE → 204 | All DELETE endpoints return 204 No Content instead of 200 | Controllers | +| `FirmwareReleaseStatus` → `ReleaseStatus` | Rename enum — staging/published/aborted is a shared workflow concept | Enum rename | +| `repositories` table | Shared infrastructure for source traceability across firmware and desktop modules | DB + migration | +| Desktop `modules` table | Add `repository_id` FK → `repositories` | DB + migration | +| Desktop `versions` table | Add `release_date`, `repository_id` FK, `commit_hash`, `ref`, `run_id` for source traceability | DB + migration | +| V1 `CiCdController` | Populate source traceability fields from GitHub OIDC claims on desktop module publish | Controller | +| Discord notifications | `DiscordConfig` with webhook URLs, `IDiscordNotificationService` for admin channel notifications | Config + service | + +--- + +## 12. Server Notifications + +The server optionally sends Discord webhook notifications on certain events. This is a server-side implementation detail, not an API contract — consumers do not interact with this system. + +**Configuration** (appsettings): + +```jsonc +{ + "Discord": { + "WebhookUrls": [ + "https://discord.com/api/webhooks/..." + ] + } +} +``` + +If `Discord` is not configured or `WebhookUrls` is empty, all notifications are silently skipped. + +**Events**: + +| Event | Trigger | Embed color | +|-------|---------|-------------| +| Firmware release published | `PublishRelease` completes successfully | Green | +| Desktop module version published | V1 `CiCdController.PublishVersion` completes | Green | +| Release notes need editing | `InitRelease` with `?nofail` creates release in `editing` status | Yellow | +| Staged release expired | Cleanup job aborts an expired staging release | Red | + +**Behavior**: +- Notifications are fire-and-forget — errors are logged but never fail the triggering operation +- Each webhook URL receives the same payload +- Payloads use Discord embed format with color-coded severity and relevant metadata (version, channel, commit hash, release ID) diff --git a/global.json b/global.json index 802ab21..062a86b 100644 --- a/global.json +++ b/global.json @@ -1,4 +1,9 @@ { + "sdk": { + "version": "10.0.100", + "rollForward": "latestMinor", + "allowPrerelease": false + }, "test": { "runner": "Microsoft.Testing.Platform" } diff --git a/scripts/seed-catalog.sh b/scripts/seed-catalog.sh new file mode 100755 index 0000000..83e1625 --- /dev/null +++ b/scripts/seed-catalog.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# Bootstraps the firmware catalog: chips, boards, and the publishing allowlist. +# +# A fresh database has no chips and no boards, and InitRelease rejects any board it does not know, +# so the very first CI ingestion 404s until this has run. Every call is idempotent, so re-running +# after adding a board is safe. +# +# The board list mirrors the PlatformIO environments in the firmware repository — a board's name here +# must match its `[env:...]` name exactly, because that is what a hub compiles in as +# OPENSHOCK_FW_BOARD and sends as its board reference. +# +# Usage: +# REPO_SERVER_URL=https://repo.openshock.org \ +# ADMIN_TOKEN=... \ +# ./scripts/seed-catalog.sh +# +# Optionally also register the firmware repository as authorized to publish: +# PUBLISH_OWNER=OpenShock PUBLISH_REPO=firmware ./scripts/seed-catalog.sh +# +# PUBLISH_SCOPES defaults to publish_firmware. Use publish_modules for a desktop module repository, +# or a comma-separated pair to grant both. A repository registered with no scopes cannot publish. + +set -euo pipefail + +: "${REPO_SERVER_URL:?REPO_SERVER_URL must be set}" +: "${ADMIN_TOKEN:?ADMIN_TOKEN must be set}" + +BASE="${REPO_SERVER_URL%/}/2/firmware/admin" + +api() { + local method=$1 path=$2 body=${3:-} + local args=(-fsS -X "$method" "$BASE$path" -H "Authorization: $ADMIN_TOKEN") + if [ -n "$body" ]; then + args+=(-H "Content-Type: application/json" --data-binary "$body") + fi + curl "${args[@]}" +} + +# ---- Chips ------------------------------------------------------------------------------------- +# Names must match esptool-js chip identifiers exactly; the web flashtool passes them straight +# through. Architecture is xtensa for the ESP32/S2/S3 family, riscv for the C3. + +declare -A CHIP_ARCH=( + ["ESP32"]="xtensa" + ["ESP32-S2"]="xtensa" + ["ESP32-S3"]="xtensa" + ["ESP32-C3"]="riscv" +) + +declare -A CHIP_IDS=() + +echo "==> Chips" +for chip in "${!CHIP_ARCH[@]}"; do + payload=$(printf '{"name":"%s","architecture":"%s"}' "$chip" "${CHIP_ARCH[$chip]}") + # POST returns 201 with the new id; a duplicate name is rejected by the unique index, in which + # case fall back to looking the existing row up. + if id=$(api POST /chips "$payload" 2>/dev/null | jq -r '.id'); then + echo " created $chip" + else + id=$(curl -fsS "${REPO_SERVER_URL%/}/2/firmware/chips" | jq -r --arg n "$chip" '.[] | select(.name == $n) | .id') + echo " exists $chip" + fi + if [ -z "$id" ] || [ "$id" = "null" ]; then + echo "::error::could not resolve chip id for $chip" >&2 + exit 1 + fi + CHIP_IDS[$chip]=$id +done + +# ---- Boards ------------------------------------------------------------------------------------ +# name:chip. Keep in sync with [env:...] blocks in the firmware repo's platformio.ini. +# +# requiredArtifactTypes is app+staticfs: those are the two an OTA update must supply, and publish +# refuses a board that is missing them. 'merged' is additionally produced for USB flashing but is +# not required, since it is not part of the OTA path. + +BOARDS=( + "Wemos-D1-Mini-ESP32:ESP32" + "Wemos-Lolin-S2-Mini:ESP32-S2" + "Wemos-Lolin-S3:ESP32-S3" + "Wemos-Lolin-S3-Mini:ESP32-S3" + "Waveshare_esp32_s3_zero:ESP32-S3" + "Pishock-2023:ESP32" + "Pishock-Lite-2021:ESP32" + "Seeed-Xiao-ESP32C3:ESP32-C3" + "Seeed-Xiao-ESP32S3:ESP32-S3" + "DFRobot-Firebeetle2-ESP32E:ESP32" + "OpenShock-Core-V1:ESP32-S3" + "OpenShock-Core-V2:ESP32-S3" + "NodeMCU-32S:ESP32" +) + +echo "==> Boards" +for entry in "${BOARDS[@]}"; do + board="${entry%%:*}" + chip="${entry##*:}" + payload=$(printf '{"name":"%s","chipId":"%s","requiredArtifactTypes":["app","staticfs"]}' \ + "$board" "${CHIP_IDS[$chip]}") + if api POST /boards "$payload" >/dev/null 2>&1; then + echo " created $board ($chip)" + else + echo " exists $board ($chip)" + fi +done + +# ---- Publish allowlist ------------------------------------------------------------------------- +# Registration here is the authorization decision for CI publishing: a GitHub OIDC token proves only +# that some workflow somewhere on GitHub requested it. + +if [ -n "${PUBLISH_OWNER:-}" ] && [ -n "${PUBLISH_REPO:-}" ]; then + echo "==> Publish allowlist" + scopes_json=$(printf '%s' "${PUBLISH_SCOPES:-publish_firmware}" \ + | tr ',' '\n' | jq -R . | jq -sc 'map(select(length > 0))') + payload=$(jq -nc --arg o "$PUBLISH_OWNER" --arg r "$PUBLISH_REPO" --argjson s "$scopes_json" \ + '{provider: "github", owner: $o, repo: $r, scopes: $s}') + api PUT /repositories "$payload" >/dev/null + echo " registered $PUBLISH_OWNER/$PUBLISH_REPO with scopes ${PUBLISH_SCOPES:-publish_firmware}" +fi + +echo "==> Done"