diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index ed420d6..dcd91eb 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 @@ -72,7 +72,7 @@ jobs: image: ${{ matrix.image }} platforms: ${{ inputs.platforms || 'linux/amd64' }} tag-prefix: ${{ inputs.tag-prefix || 'rn' }} - + promote-image: name: Promote Image (${{ matrix.image }}) needs: [build, tests] @@ -82,8 +82,8 @@ jobs: fail-fast: false matrix: include: - - image: desktop-repository-server - + - image: repository-server + steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -101,7 +101,7 @@ jobs: image: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }} run-tag: ${{ inputs.tag-prefix || 'rn' }}-${{ github.run_number }}-a${{ github.run_attempt }} latest: ${{ inputs.latest || false }} - + deploy-prod-workflow: runs-on: ubuntu-latest needs: promote-image @@ -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 + token: ${{ secrets.GITOPS_PAT }} diff --git a/.github/workflows/ci-tag.yml b/.github/workflows/ci-tag.yml index b9f7de6..e5fb20c 100644 --- a/.github/workflows/ci-tag.yml +++ b/.github/workflows/ci-tag.yml @@ -8,13 +8,13 @@ name: ci-tag env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository_owner }}/api + IMAGE_NAME: ${{ github.repository_owner }}/repository-server jobs: - + build-release: runs-on: ubuntu-latest - + steps: - name: Checkout @@ -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: | @@ -48,25 +48,25 @@ jobs: type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') }} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}.{{patch}} - + - name: Build and push 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 }} platforms: linux/amd64,linux/arm64 - - + + release: runs-on: ubuntu-latest needs: build-release - + steps: - name: Create release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: ${{ github.ref_name }} - prerelease: ${{ contains(github.ref_name, '-rc') }} \ No newline at end of file + prerelease: ${{ contains(github.ref_name, '-rc') }} 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c9aea70 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# OpenShock Repository Server + +## Project Overview +Unified repository server for distributing OpenShock desktop app modules and firmware OTA updates. ASP.NET Core Web API with PostgreSQL/EF Core backend. + +## Tech Stack +- .NET 10, ASP.NET Core (SlimBuilder) +- PostgreSQL via Npgsql + EF Core (with DbContext pooling) +- Serilog (console + Grafana Loki) +- OpenTelemetry (Prometheus exporter) +- API Versioning (Asp.Versioning) — URL-based: `/v1/`, `/v2/firmware/` +- FlexLabs.EntityFrameworkCore.Upsert for upserts +- MiniValidation for config validation +- Semver NuGet package for semver parsing +- OneOf for discriminated parser results +- TUnit for tests +- Scalar for API docs UI +- Docker (Alpine) with self-signed HTTPS cert generation + +## Project Structure +``` +RepositoryServer/ # Main web API project + AuthenticationHandlers/ + AdminTokenAuthentication.cs # AdminToken scheme (static header compare) + GitHubOidcAuthentication.cs # JwtBearer + GitHub OIDC claim handling + # auto-upserts repositories rows on first use + Config/ # ApiConfig, DbConfig, RepoConfig, MetricsConfig, + # FirmwareConfig, FirmwareCiCdConfig, FirmwareAdvisoryConfig, DiscordConfig + Controllers/ + OpenShockControllerBase.cs # Shared base controller + V1/ # Desktop module endpoints (V1) + RepoController.cs # GET /v1/ — desktop module manifest + AdminController.cs # /v1/admin/... — desktop module CRUD + CiCdController.cs # PUT /v1/cicd/modules/{id}/versions/{v} — desktop zip upload + V2/Firmware/ # Firmware V2 endpoints + ManifestController.cs # GET /v2/firmware/manifest (bootstrap payload) + LatestController.cs # GET /v2/firmware/latest/{channel}[/{boardId}?version=] + VersionsController.cs # GET /v2/firmware/versions[/{version}[/{boardId}]] + BoardsController.cs # GET /v2/firmware/boards + ChipsController.cs # GET /v2/firmware/chips + ReleasesController.cs # POST/PUT/DELETE /v2/firmware/releases/... (CI/CD ingestion) + Admin/ # AdminToken-protected CRUD (split by concern) + RepositoriesController.cs # GET, DELETE /v2/firmware/admin/repositories + VersionsAdminController.cs # DELETE /v2/firmware/admin/versions/{v} + BoardsAdminController.cs # PUT, PATCH, DELETE /v2/firmware/admin/boards/{id} + # + USB attach/detach + ChipsAdminController.cs # PUT, DELETE /v2/firmware/admin/chips/{id} + USB attach/detach + ReleasesAdminController.cs # PUT /v2/firmware/admin/releases/{id}/changelog (fix) + UsbDevicesController.cs # PUT/GET/DELETE /v2/firmware/admin/usb-devices + UsbSerialFiltersController.cs # PUT/GET/DELETE /v2/firmware/admin/usb-serial-filters + Enums/ # ReleaseChannel, FirmwareArtifactType, FirmwareChipArchitecture, + # ReleaseNoteSectionType, ReleaseStatus, RepositoryProvider + Errors/ # AuthResultError, ExceptionError + ExceptionHandler/ # Global IExceptionHandler + RequestInfo logging + Migrations/ # EF Core migrations + Models/ # Desktop module DTOs + Models/Firmware/ # Firmware DTOs + FirmwareManifestResponse, FirmwareReleaseDto, FirmwareVersionSummary, + FirmwareBoardDetailDto, FirmwareChipRefDto, FirmwareBoardReleaseResponseDto, + FirmwareSourceDto, FirmwareAdvisoryDto, FirmwareUsbDeviceDto, + FirmwareUsbSerialFilterDto (+ admin variant), RepositoryDto, VersionListResponse, + InitReleaseRequest (+ response), FixChangelogRequest, Upsert* requests + Problems/ # RFC 7807 problem details (OpenShockProblem base) + RepoServerDb/ # EF Core entities + RepoServerContext + SourceRepository, FirmwareVersion, FirmwareRelease, FirmwareArtifact, FirmwareReleaseNote, + FirmwareStagedArtifact, FirmwareStagedReleaseNote, FirmwareBoard, FirmwareChip, + UsbDevice, UsbSerialFilter, FirmwareChipUsbDevice, FirmwareBoardUsbDevice + Services/ + IStorageService (+ BunnyCdn / Local / S3 impls) + IDiscordNotificationService + DiscordNotificationService (webhook, fire-and-forget) + StagedReleaseCleanupService # BackgroundService, PeriodicTimer 5 min, two TTLs + Utils/ + ChangelogParser # Markdown → FirmwareReleaseNoteDto[] (OneOf-based) + SourceUrlBuilder # provider-aware commit/ref/run URL construction + FirmwareResponseMapper # shared release/artifact response projections + CacheControlAttribute # filter per spec §8 + FirmwareArtifactFileNames, ByteArrayHexConverter, SemVersionConverter + Program.cs # App startup: schemes, DbContextPool, HttpClient, + # HostedService, storage, migrations, etc. + +RepositoryServer.Tests/ # TUnit test project (references the main project) + Utils/ + ChangelogParserTests + SourceUrlBuilderTests +docker/ + RepositoryServer.Dockerfile + entrypoint.sh # Cert gen + `dotnet OpenShock.RepositoryServer.dll` + appsettings.Container.json +``` + +## Namespace +`OpenShock.RepositoryServer` + +## Key Patterns +- **Auth**: Two schemes. `AdminTokenAuthentication` (header compare) for admin endpoints; + JwtBearer + `GitHubOidcAuthentication.Configure(...)` for CI/CD endpoints. The OIDC hook + validates a GitHub Actions OIDC JWT, then looks up (or inserts) a matching row in the + `repositories` table and attaches `AuthSchemas.CiCdClaims` to the principal. + No config allowlist — the DB is authoritative. +- **Error Handling**: `OpenShockProblem` base class → `ToObjectResult()` for RFC 7807 +- **Config**: Bind root config to `ApiConfig`, validated with MiniValidator, registered as singleton +- **Background jobs**: `StagedReleaseCleanupService` (`BackgroundService` with `PeriodicTimer`) + periodically aborts expired staging/editing releases per `FirmwareConfig.StagedReleaseTtl` + and `EditingReleaseTtl` +- **DB Migrations**: `MigrationOpenShockContext` subclass for migration tooling, auto-applied on startup +- **JSON**: CamelCase, custom `SemVersionConverter` + `ByteArrayHexConverter` +- **Upsert**: FlexLabs `.Upsert().On().RunAsync()` pattern +- **Caching**: `CacheControlAttribute` action filter — public max-age on 2xx, no-store on errors +- **CORS**: Allow all origins/methods/headers +- **Metrics**: Prometheus at `/metrics`, restricted to private networks +- **Notifications**: Fire-and-forget Discord webhooks via `IDiscordNotificationService`. + Empty `Discord.WebhookUrls` → no-op. + +## Database +- PostgreSQL (15+ required for `NULLS NOT DISTINCT` on `usb_serial_filters`), connection in `ApiConfig.Db.Conn` +- Shared table: `repositories` (PK: id, UNIQUE `(provider, owner, repo)`) +- Desktop tables: `modules` (PK: id), `versions` (composite PK: version+module, FK→modules CASCADE) +- Firmware tables: `firmware_chips`, `firmware_boards`, `firmware_versions`, + `firmware_artifacts`, `firmware_release_notes`, `firmware_releases`, + `firmware_staged_artifacts`, `firmware_staged_release_notes` +- USB catalog: `usb_devices`, `usb_serial_filters`, `firmware_chip_usb_devices`, + `firmware_board_usb_devices` +- Firmware enums: `release_channel`, `firmware_artifact_type`, `firmware_release_note_type`, + `firmware_chip_architecture`, `release_status`, `repository_provider` +- URL convention for firmware artifacts: `{CdnBaseUrl}/{version}/{boardId}/{artifactType}.bin` +- Source-traceability URLs (commit / ref / run) are built server-side per provider + (`Utils/SourceUrlBuilder`) and never stored + +## CI/CD +- `ci-build.yml`: Test + build Docker + promote image + deploy prod (on master) +- `ci-tag.yml`: Release builds on semver tags, multi-arch (amd64+arm64) +- Image: `ghcr.io/openshock/repository-server` +- GitOps dispatch: `update-repository-server-prod` → `openshock/kubernetes-cluster-gitops` + +## Commands +- Build: `dotnet build RepositoryServer/RepositoryServer.csproj` +- Test: `dotnet test --project RepositoryServer.Tests/RepositoryServer.Tests.csproj` + (Microsoft.Testing.Platform runner, opted in via `global.json`; the legacy positional + project path is rejected — use `--project`) +- Docker: `docker build -f docker/RepositoryServer.Dockerfile .` +- Migration: `dotnet ef migrations add --project RepositoryServer` 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/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/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/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..94ce84f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,6 +2,8 @@ true + + true net10.0 enable enable @@ -12,8 +14,10 @@ + + @@ -21,6 +25,9 @@ + + + @@ -35,5 +42,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/Tests/AdvisoriesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs new file mode 100644 index 0000000..1937c43 --- /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 = "/v2/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("/v2/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..54573e1 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/BoardsAdminControllerTests.cs @@ -0,0 +1,224 @@ +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 = "/v2/firmware/admin/boards"; + private const string ChipsPath = "/v2/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] + 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..5ae01d6 --- /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 = "/v2/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/LatestControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs new file mode 100644 index 0000000..fcd95d6 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs @@ -0,0 +1,173 @@ +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 +{ + [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("/v2/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("/v2/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("/v2/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(boardId.ToString()); + } + + [Test] + public async Task GetLatestForBoard_UnknownChannel_Returns400() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/v2/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($"/v2/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($"/v2/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($"/v2/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").GetGuid()).IsEqualTo(boardId); + 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($"/v2/firmware/latest/stable/{boardId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task GetLatestForBoard_UnknownBoard_Returns404() + { + await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync($"/v2/firmware/latest/stable/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + private async Task<(Guid boardId, Guid chipId, Guid repoId)> SeedReleaseAsync( + string version, ReleaseChannel channel) + { + 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, + 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 = 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..16f27af --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs @@ -0,0 +1,167 @@ +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("/v2/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("/v2/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); + await Assert.That(Guid.Parse(boards[0].GetProperty("id").GetString()!)) + .IsEqualTo(boardId); + 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("/v2/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..ecbb092 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs @@ -0,0 +1,105 @@ +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>( + $"/v2/firmware/boards?chipId={chipAId}"); + await Assert.That(boardsForChipA).IsNotNull(); + await Assert.That(boardsForChipA!).Count().IsEqualTo(1); + await Assert.That(boardsForChipA[0].ChipId).IsEqualTo(chipAId); + } + + [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>( + "/v2/firmware/boards?includeDiscontinued=true"); + var withoutDiscontinued = await client.GetFromJsonAsync>( + "/v2/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>("/v2/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/RepositoriesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs new file mode 100644 index 0000000..4a159e4 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs @@ -0,0 +1,133 @@ +using System.Net; +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 RepositoriesAdminControllerTests +{ + private const string BasePath = "/v2/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 Post_NotAllowed_ReturnsMethodNotAllowed() + { + using var client = Factory.CreateAdminClient(); + var response = await client.PostAsJsonAsync(BasePath, new { Provider = "github", Owner = "x", Repo = "y" }); + // Repositories are created automatically by the OIDC handler — manual creation + // is not exposed, so POST must be rejected. + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.MethodNotAllowed); + } +} diff --git a/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs new file mode 100644 index 0000000..4fca5cd --- /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 = "/v2/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..f9cb83d --- /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 = "/v2/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("/v2/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..89d03d1 --- /dev/null +++ b/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs @@ -0,0 +1,253 @@ +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 +{ + [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("/v2/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("/v2/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("/v2/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("/v2/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("/v2/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("/v2/firmware/versions?limit=2&offset=2"); + await Assert.That(page2.GetProperty("versions").GetArrayLength()).IsEqualTo(2); + + var page3 = await client.GetFromJsonAsync("/v2/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("/v2/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("/v2/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(boardId.ToString()); + } + + [Test] + public async Task GetVersion_ImmutableCacheHeader() + { + await SeedVersionWithNotesAsync("1.5.1"); + + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/v2/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($"/v2/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( + $"/v2/firmware/versions/1.5.1/{boardId}"); + + await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.1"); + await Assert.That(body.GetProperty("boardId").GetGuid()).IsEqualTo(boardId); + await Assert.That(body.GetProperty("artifacts").GetArrayLength()).IsGreaterThanOrEqualTo(1); + } + + 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 = "Sample-Board", + 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..9f4df6e --- /dev/null +++ b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs @@ -0,0 +1,153 @@ +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; + } + + /// + /// 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, + 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, + + ["Repo:CdnBaseUrl"] = "https://cdn-test.openshock.example/repo", + + ["Firmware:CdnBaseUrl"] = "https://cdn-test.openshock.example/firmware", + ["Firmware:CiCd:Audience"] = "openshock-repository-server-test", + ["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 => + { + // 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..7b4047a --- /dev/null +++ b/RepositoryServer.Tests/RepositoryServer.Tests.csproj @@ -0,0 +1,22 @@ + + + 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..5ec8832 --- /dev/null +++ b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs @@ -0,0 +1,234 @@ +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_BulletsIgnoreNonBulletBodyInSameSection() + { + // If bullets are present, non-bullet lines in the section are dropped. + var input = "### Info\nThis text is ignored\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(2); + await Assert.That(notes[0].Content).IsEqualTo("Actual item 1"); + await Assert.That(notes[1].Content).IsEqualTo("Actual item 2"); + } + + [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() + { + var input = string.Join("\n", + "### Breaking", + "**Config format** — Changed config format to TOML", + "", + "### Warning", + "Requires hub reset after update", + "", + "### Info", + "- Fixed WiFi reconnection", + "- Improved battery life", + "- **OTA** — Added automatic rollback on failed flash", + "", + "### Features", + "- 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(7); + + 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"); + + 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"); + + await Assert.That(notes[5].Type).IsEqualTo("section"); + await Assert.That(notes[5].Title).IsEqualTo("Features"); + await Assert.That(notes[5].Content).IsEqualTo("New web dashboard"); + await Assert.That(notes[6].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..d1f0ad4 --- /dev/null +++ b/RepositoryServer/AuthSchemas.cs @@ -0,0 +1,18 @@ +namespace OpenShock.RepositoryServer; + +public static class AuthSchemas +{ + public const string AdminToken = "AdminToken"; + public const string CiCdToken = "CiCdToken"; + + /// + /// 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"; + } +} 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..66cb126 --- /dev/null +++ b/RepositoryServer/AuthenticationHandlers/GitHubOidcAuthentication.cs @@ -0,0 +1,135 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +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 or auto-inserts the repositories row for this owner/repo pair. +/// 3. Attaches to the principal so controllers +/// can pull the matched repository id, commit SHA, ref, and run id. +/// +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 repositoryId = await GetOrCreateRepositoryAsync(db, owner, repo, context.HttpContext.RequestAborted); + + var identity = (ClaimsIdentity)principal.Identity!; + identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.RepositoryId, repositoryId.ToString())); + 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 static async Task GetOrCreateRepositoryAsync(RepoServerContext db, string owner, string repo, CancellationToken ct) + { + const RepositoryProvider provider = RepositoryProvider.Github; + + var existing = await db.Repositories + .Where(r => r.Provider == provider && r.Owner == owner && r.Repo == repo) + .Select(r => (Guid?)r.Id) + .FirstOrDefaultAsync(ct); + + if (existing is not null) + { + return existing.Value; + } + + var row = new SourceRepository + { + Id = Guid.NewGuid(), + Provider = provider, + Owner = owner, + Repo = repo + }; + + db.Repositories.Add(row); + + try + { + await db.SaveChangesAsync(ct); + return row.Id; + } + catch (DbUpdateException) + { + // Race: another concurrent token validation inserted the same (provider, owner, repo). + // Re-read and return. + db.Entry(row).State = EntityState.Detached; + + var raced = await db.Repositories + .Where(r => r.Provider == provider && r.Owner == owner && r.Repo == repo) + .Select(r => (Guid?)r.Id) + .FirstOrDefaultAsync(ct); + + if (raced is null) + { + throw; + } + + return raced.Value; + } + } +} diff --git a/DesktopRepositoryServer/Config/ApiConfig.cs b/RepositoryServer/Config/ApiConfig.cs similarity index 55% rename from DesktopRepositoryServer/Config/ApiConfig.cs rename to RepositoryServer/Config/ApiConfig.cs index b778351..4366758 100644 --- a/DesktopRepositoryServer/Config/ApiConfig.cs +++ b/RepositoryServer/Config/ApiConfig.cs @@ -1,11 +1,13 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; -namespace OpenShock.Desktop.RepositoryServer.Config; +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; } public MetricsConfig Metrics { get; init; } = new(); -} \ No newline at end of file + public DiscordConfig Discord { get; init; } = new(); +} 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/DiscordConfig.cs b/RepositoryServer/Config/DiscordConfig.cs new file mode 100644 index 0000000..6e91501 --- /dev/null +++ b/RepositoryServer/Config/DiscordConfig.cs @@ -0,0 +1,6 @@ +namespace OpenShock.RepositoryServer.Config; + +public sealed class DiscordConfig +{ + public List WebhookUrls { get; init; } = new(); +} diff --git a/RepositoryServer/Config/FirmwareCiCdConfig.cs b/RepositoryServer/Config/FirmwareCiCdConfig.cs new file mode 100644 index 0000000..e63cca1 --- /dev/null +++ b/RepositoryServer/Config/FirmwareCiCdConfig.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Config; + +public sealed class FirmwareCiCdConfig +{ + /// + /// Expected audience claim in the GitHub OIDC token. + /// Set this to match the 'audience' parameter in your GitHub Actions workflow. + /// + [Required(AllowEmptyStrings = false)] + public required string Audience { get; init; } +} diff --git a/RepositoryServer/Config/FirmwareConfig.cs b/RepositoryServer/Config/FirmwareConfig.cs new file mode 100644 index 0000000..edab810 --- /dev/null +++ b/RepositoryServer/Config/FirmwareConfig.cs @@ -0,0 +1,20 @@ +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; } + [Required] public required FirmwareCiCdConfig CiCd { 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 97% rename from DesktopRepositoryServer/ConfigurationExtensions.cs rename to RepositoryServer/ConfigurationExtensions.cs index 4f00d84..c3165cc 100644 --- a/DesktopRepositoryServer/ConfigurationExtensions.cs +++ b/RepositoryServer/ConfigurationExtensions.cs @@ -1,6 +1,6 @@ using System.Text; using System.Text.Json; -namespace OpenShock.Desktop.RepositoryServer; +namespace OpenShock.RepositoryServer; public static class ConfigurationExtensions { 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 84% rename from DesktopRepositoryServer/Controllers/AdminController.cs rename to RepositoryServer/Controllers/V1/AdminController.cs index b504330..8e201c1 100644 --- a/DesktopRepositoryServer/Controllers/AdminController.cs +++ b/RepositoryServer/Controllers/V1/AdminController.cs @@ -1,17 +1,19 @@ -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")] +[Route("/v{version:apiVersion}/admin")] [Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] public class AdminController : OpenShockControllerBase { @@ -34,9 +36,8 @@ public async Task CreateModule([FromBody] CreateModuleRequest cre IconUrl = createModuleRequest.IconUrl }; 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 +46,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 +82,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) { @@ -92,4 +93,4 @@ public async Task DeleteVersion([FromRoute] string moduleId, [Fro if(executed <= 0) return Problem(VersionError.VersionNotFound); return Ok(); } -} \ No newline at end of file +} diff --git a/RepositoryServer/Controllers/V1/CiCdController.cs b/RepositoryServer/Controllers/V1/CiCdController.cs new file mode 100644 index 0000000..e7e01b1 --- /dev/null +++ b/RepositoryServer/Controllers/V1/CiCdController.cs @@ -0,0 +1,137 @@ +using System.IO.Compression; +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("/v{version:apiVersion}/cicd")] +[Authorize(AuthenticationSchemes = AuthSchemas.CiCdToken)] +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); + } + + if (!await _db.Modules.AnyAsync(x => x.Id == moduleId)) + { + return Problem(ModuleError.ModuleNotFound); + } + + 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 76% rename from DesktopRepositoryServer/Controllers/RepoController.cs rename to RepositoryServer/Controllers/V1/RepoController.cs index 8b70475..6b31fb6 100644 --- a/DesktopRepositoryServer/Controllers/RepoController.cs +++ b/RepositoryServer/Controllers/V1/RepoController.cs @@ -1,18 +1,20 @@ 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}/")] +[Route("/v{version:apiVersion}/")] public sealed class RepoController : OpenShockControllerBase { private readonly RepoServerContext _db; @@ -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/Firmware/Admin/AdvisoriesController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs new file mode 100644 index 0000000..08d3c99 --- /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("/v{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..c087365 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs @@ -0,0 +1,185 @@ +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("/v{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); + await _db.SaveChangesAsync(ct); + + 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; + + await _db.SaveChangesAsync(ct); + 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..6b0149a --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs @@ -0,0 +1,142 @@ +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("/v{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); + await _db.SaveChangesAsync(ct); + + 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..4eaf99d --- /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("/v{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..1d888d0 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/RepositoriesController.cs @@ -0,0 +1,62 @@ +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; + +/// +/// Read-only + destructive admin for the shared repositories table. Rows are +/// auto-created by +/// on the first CI/CD request from a given owner/repo pair — there is no create/upsert +/// endpoint here by design. +/// +[ApiVersion("2.0")] +[ApiController] +[Route("/v{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)); + } + + [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..4581a5c --- /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("/v{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..3f40548 --- /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("/v{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..dc029af --- /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("/v{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..aa6cc54 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs @@ -0,0 +1,61 @@ +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("/v{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] Guid? chipId, + [FromQuery] bool includeDiscontinued = true, + CancellationToken ct = default) + { + IQueryable query = _db.FirmwareBoards + .Include(b => b.ChipNavigation) + .Include(b => b.UsbDevices); + + if (chipId is { } cid) + { + query = query.Where(b => b.ChipId == cid); + } + + 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 + { + Id = b.Id, + Name = b.Name, + ChipId = b.ChipId, + 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..e0f271b --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs @@ -0,0 +1,45 @@ +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("/v{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 + { + Id = c.Id, + 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..3b3f165 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs @@ -0,0 +1,110 @@ +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("/v{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 latest = await _db.FirmwareVersions + .Where(v => v.Channel == firmwareChannel) + .OrderByDescending(v => v.ReleaseDate) + .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)); + } + + [HttpGet("{channel}/{boardId:guid}")] + [CacheControl(300)] + public async Task GetLatestForBoard( + [FromRoute] string channel, + [FromRoute] Guid boardId, + [FromQuery] string? version, + CancellationToken ct) + { + if (!Enum.TryParse(channel, true, out var firmwareChannel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + + var latestVersion = await _db.FirmwareVersions + .Where(v => v.Channel == firmwareChannel) + .OrderByDescending(v => v.ReleaseDate) + .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 == boardId) + .ToListAsync(ct); + + if (artifacts.Count == 0) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + return Ok(new FirmwareBoardReleaseResponseDto + { + Version = latestVersion, + BoardId = boardId, + 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..7428243 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs @@ -0,0 +1,133 @@ +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("/v{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 version = await _db.FirmwareVersions + .Where(v => v.Channel == ch) + .OrderByDescending(v => v.ReleaseDate) + .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 + { + Id = b.Id, + Name = b.Name, + ChipId = b.ChipId, + 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 + { + Id = c.Id, + 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..8afff60 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs @@ -0,0 +1,509 @@ +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 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("/v{version:apiVersion}/firmware/releases")] +[Authorize(AuthenticationSchemes = AuthSchemas.CiCdToken)] +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; + + public ReleasesController( + RepoServerContext db, + IStorageService storage, + ApiConfig apiConfig, + IDiscordNotificationService discord, + TimeProvider timeProvider) + { + _db = db; + _storage = storage; + _apiConfig = apiConfig; + _discord = discord; + _timeProvider = timeProvider; + } + + // ---- 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); + } + + 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 boardIds = request.Boards.ToHashSet(); + var existingBoardCount = await _db.FirmwareBoards.CountAsync(b => boardIds.Contains(b.Id), ct); + if (existingBoardCount != boardIds.Count) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + // 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, + ReleaseDate = request.ReleaseDate, + Status = status, + DeclaredBoards = request.Boards.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, + }); + } + + await _db.SaveChangesAsync(ct); + + 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 ---- + + [HttpPut("{releaseId:guid}/boards/{boardId:guid}")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(64 * 1024 * 1024)] + public async Task UploadBoardArtifacts( + [FromRoute] Guid releaseId, + [FromRoute] Guid boardId, + 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); + } + + if (!release.DeclaredBoards.Contains(boardId)) + { + return Problem(FirmwareError.FirmwareBoardNotDeclared); + } + + var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId, ct); + if (board is null) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + 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 (board.RequiredArtifactTypes.Length > 0) + { + var missingRequired = board.RequiredArtifactTypes + .Where(r => !uploadedByType.ContainsKey(r)) + .Select(r => r.ToString().ToLowerInvariant()) + .ToList(); + if (missingRequired.Count > 0) + { + return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingRequired)); + } + } + + // Remove any previously staged artifacts for this board in this release. + await _db.FirmwareStagedArtifacts + .Where(a => a.ReleaseId == releaseId && a.BoardId == boardId) + .ExecuteDeleteAsync(ct); + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + var uploadedArtifacts = new List(); + 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; + } + + var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifactType); + var cdnPath = $"{release.Version}/{boardId}/{cdnFileName}"; + + await using var uploadStream = new MemoryStream(bytes); + await _storage.UploadFileAsync(cdnPath, uploadStream, ct); + + _db.FirmwareStagedArtifacts.Add(new FirmwareStagedArtifact + { + ReleaseId = releaseId, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = Convert.FromHexString(actual), + FileSize = bytes.Length, + }); + + uploadedArtifacts.Add(new FirmwareArtifactDto + { + Type = artifactType.ToString().ToLowerInvariant(), + Url = $"{cdnBase}/{release.Version}/{boardId}/{cdnFileName}", + Sha256Hash = actual, + FileSize = bytes.Length, + }); + } + + if (mismatches.Count > 0) + { + return Problem(FirmwareError.FirmwareSha256Mismatch(string.Join("; ", mismatches))); + } + + await _db.SaveChangesAsync(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 (release.Status == ReleaseStatus.Editing) + { + return Problem(FirmwareError.FirmwareReleaseNotesNotFinalized); + } + + if (release.Status != ReleaseStatus.Staging) + { + return Problem(FirmwareError.FirmwareReleaseNotStaging); + } + + var uploadedBoardIds = release.StagedArtifacts.Select(a => a.BoardId).Distinct().ToHashSet(); + var missingBoards = release.DeclaredBoards.Where(b => !uploadedBoardIds.Contains(b)).ToList(); + if (missingBoards.Count > 0) + { + return Problem(FirmwareError.FirmwareReleaseIncomplete(missingBoards)); + } + + var boards = await _db.FirmwareBoards + .Where(b => release.DeclaredBoards.Contains(b.Id)) + .ToDictionaryAsync(b => b.Id, ct); + + 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(boardId, missingTypes)); + } + } + + 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; + + await _db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + 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 (release.Status != ReleaseStatus.Staging && release.Status != ReleaseStatus.Editing) + { + return Problem(FirmwareError.FirmwareReleaseNotEditable); + } + + foreach (var artifact in release.StagedArtifacts) + { + var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifact.ArtifactType); + var cdnPath = $"{release.Version}/{artifact.BoardId}/{cdnFileName}"; + await _storage.DeleteFileAsync(cdnPath, 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); + + 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..781975e --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs @@ -0,0 +1,141 @@ +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("/v{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); + } + query = query.Where(v => v.Channel == firmwareChannel); + } + + 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 + .OrderByDescending(v => v.ReleaseDate) + .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)); + } + + [HttpGet("{firmwareVersion}/{boardId:guid}")] + [CacheControl(86400, immutable: true)] + public async Task GetVersionForBoard( + [FromRoute] string firmwareVersion, + [FromRoute] Guid boardId, + CancellationToken ct) + { + var exists = await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion, ct); + if (!exists) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var artifacts = await _db.FirmwareArtifacts + .Where(a => a.Version == firmwareVersion && a.BoardId == boardId) + .ToListAsync(ct); + + if (artifacts.Count == 0) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + return Ok(new FirmwareBoardReleaseResponseDto + { + Version = firmwareVersion, + BoardId = boardId, + 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/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/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/20260804112228_UnifiedRepositoryServer.Designer.cs b/RepositoryServer/Migrations/20260804112228_UnifiedRepositoryServer.Designer.cs new file mode 100644 index 0000000..0771b56 --- /dev/null +++ b/RepositoryServer/Migrations/20260804112228_UnifiedRepositoryServer.Designer.cs @@ -0,0 +1,770 @@ +// +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("20260804112228_UnifiedRepositoryServer")] + partial class UnifiedRepositoryServer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "advisory_severity", new[] { "critical", "info", "warning" }); + 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.UseIdentityByDefaultColumns(modelBuilder); + + 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") + .IsUnique() + .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") + .IsUnique() + .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("SourceUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("source_url"); + + b.HasKey("Id") + .HasName("modules_pkey"); + + 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.HasKey("Id") + .HasName("repositories_pkey"); + + b.HasIndex("Provider", "Owner", "Repo") + .IsUnique() + .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.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/20260804112228_UnifiedRepositoryServer.cs b/RepositoryServer/Migrations/20260804112228_UnifiedRepositoryServer.cs new file mode 100644 index 0000000..89cd622 --- /dev/null +++ b/RepositoryServer/Migrations/20260804112228_UnifiedRepositoryServer.cs @@ -0,0 +1,436 @@ +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: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"); + + 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) + }, + 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_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", + unique: true); + + 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", + unique: true); + + 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" }, + unique: true); + + 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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + 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.AlterDatabase() + .OldAnnotation("Npgsql:Enum:advisory_severity", "critical,info,warning") + .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"); + } + } +} diff --git a/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs b/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs new file mode 100644 index 0000000..27553eb --- /dev/null +++ b/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs @@ -0,0 +1,767 @@ +// +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.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "advisory_severity", new[] { "critical", "info", "warning" }); + 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.UseIdentityByDefaultColumns(modelBuilder); + + 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") + .IsUnique() + .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") + .IsUnique() + .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("SourceUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("source_url"); + + b.HasKey("Id") + .HasName("modules_pkey"); + + 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.HasKey("Id") + .HasName("repositories_pkey"); + + b.HasIndex("Provider", "Owner", "Repo") + .IsUnique() + .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.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/DesktopRepositoryServer/Models/CreateModuleRequest.cs b/RepositoryServer/Models/CreateModuleRequest.cs similarity index 80% rename from DesktopRepositoryServer/Models/CreateModuleRequest.cs rename to RepositoryServer/Models/CreateModuleRequest.cs index 3ad6327..6e42f39 100644 --- a/DesktopRepositoryServer/Models/CreateModuleRequest.cs +++ b/RepositoryServer/Models/CreateModuleRequest.cs @@ -1,4 +1,4 @@ -namespace OpenShock.Desktop.RepositoryServer.Models; +namespace OpenShock.RepositoryServer.Models; public class CreateModuleRequest { 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/Firmware/CreateFirmwareBoardRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs new file mode 100644 index 0000000..8cbb1e8 --- /dev/null +++ b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class CreateFirmwareBoardRequest +{ + [Required(AllowEmptyStrings = false)] + [MaxLength(128)] + 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..9ffc4e2 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs @@ -0,0 +1,16 @@ +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 chipId. +/// +public sealed record FirmwareBoardDto +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public required Guid ChipId { get; init; } + 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..c091b9c --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareBoardReleaseResponseDto.cs @@ -0,0 +1,12 @@ +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; } + public required Guid 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..fe5110b --- /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 virtue +/// of their chipId — they are not duplicated on the board DTO. +/// +public sealed record FirmwareChipDto +{ + public required Guid Id { get; init; } + 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..9af446a --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareChipRefDto.cs @@ -0,0 +1,11 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Slim chip reference embedded in per-board release responses. The +/// field must match esptool-js chip identifiers exactly (e.g. "ESP32-S3"). +/// +public sealed record FirmwareChipRefDto +{ + public required Guid Id { get; init; } + 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..02513d9 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareReleaseDto.cs @@ -0,0 +1,15 @@ +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; } + 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..e305a08 --- /dev/null +++ b/RepositoryServer/Models/Firmware/InitReleaseRequest.cs @@ -0,0 +1,27 @@ +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; } + + [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..db6756c --- /dev/null +++ b/RepositoryServer/Models/Firmware/RepositoryDto.cs @@ -0,0 +1,23 @@ +using OpenShock.RepositoryServer.Enums; + +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; } + + public static RepositoryDto From(RepoServerDb.SourceRepository r) => new() + { + Id = r.Id, + Provider = r.Provider.ToString().ToLowerInvariant(), + Owner = r.Owner, + Repo = r.Repo + }; +} 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/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/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..b39af75 --- /dev/null +++ b/RepositoryServer/Problems/FirmwareError.cs @@ -0,0 +1,47 @@ +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(Guid boardId, IEnumerable missing) => + new("Firmware.MissingRequiredArtifacts", $"Board '{boardId}' is missing required artifact types: {string.Join(", ", missing)}"); + 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 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..a37ce78 --- /dev/null +++ b/RepositoryServer/Problems/ModuleError.cs @@ -0,0 +1,13 @@ +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 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 85% rename from DesktopRepositoryServer/Problems/VersionError.cs rename to RepositoryServer/Problems/VersionError.cs index acd3e73..0ede902 100644 --- a/DesktopRepositoryServer/Problems/VersionError.cs +++ b/RepositoryServer/Problems/VersionError.cs @@ -1,4 +1,4 @@ -namespace OpenShock.Desktop.RepositoryServer.Problems; +namespace OpenShock.RepositoryServer.Problems; public static class VersionError { diff --git a/DesktopRepositoryServer/Program.cs b/RepositoryServer/Program.cs similarity index 67% rename from DesktopRepositoryServer/Program.cs rename to RepositoryServer/Program.cs index 8c6222a..56acb17 100644 --- a/DesktopRepositoryServer/Program.cs +++ b/RepositoryServer/Program.cs @@ -1,25 +1,26 @@ -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.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 +40,7 @@ // Since we use slim builders, this allows for HTTPS builder.WebHost.UseKestrelHttpsConfiguration(); - + builder.WebHost.ConfigureKestrel(serverOptions => { serverOptions.Limits.RequestHeadersTimeout = TimeSpan.FromMilliseconds(3000); @@ -52,10 +53,13 @@ // <---- 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.Firmware.CiCd.Audience); + }); builder.Services.AddAuthorization(); @@ -128,23 +132,53 @@ .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 ----> +builder.Services.AddHttpClient(); + +// <---- 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 +188,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 +225,7 @@ // global cors policy app.UseCors(); - + app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromMinutes(1) @@ -200,12 +234,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 +250,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/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/DesktopRepositoryServer/RepoServerDb/Module.cs b/RepositoryServer/RepoServerDb/Module.cs similarity index 87% rename from DesktopRepositoryServer/RepoServerDb/Module.cs rename to RepositoryServer/RepoServerDb/Module.cs index 78e0925..42dc48d 100644 --- a/DesktopRepositoryServer/RepoServerDb/Module.cs +++ b/RepositoryServer/RepoServerDb/Module.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace OpenShock.Desktop.RepositoryServer.RepoServerDb; +namespace OpenShock.RepositoryServer.RepoServerDb; public partial class Module { diff --git a/RepositoryServer/RepoServerDb/RepoServerContext.cs b/RepositoryServer/RepoServerDb/RepoServerContext.cs new file mode 100644 index 0000000..d1f0ddd --- /dev/null +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -0,0 +1,500 @@ +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("advisory_severity"); + } + + 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; } + + 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.HasIndex(e => new { e.Provider, e.Owner, e.Repo }) + .IsUnique() + .HasDatabaseName("ix_repositories_provider_owner_repo"); + }); + + // 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"); + }); + + 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"); + + entity.HasIndex(e => e.Name) + .IsUnique() + .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"); + + entity.HasIndex(e => e.Name) + .IsUnique() + .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..def96e1 --- /dev/null +++ b/RepositoryServer/RepoServerDb/SourceRepository.cs @@ -0,0 +1,21 @@ +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. Rows are created automatically when an OIDC token from +/// a new owner/repo pair is accepted — there is no manual upsert endpoint. +/// +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!; + + 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 85% rename from DesktopRepositoryServer/DesktopRepositoryServer.csproj rename to RepositoryServer/RepositoryServer.csproj index 437b7a9..f918fc5 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 + + diff --git a/RepositoryServer/Services/BunnyCdnStorageService.cs b/RepositoryServer/Services/BunnyCdnStorageService.cs new file mode 100644 index 0000000..211d4fd --- /dev/null +++ b/RepositoryServer/Services/BunnyCdnStorageService.cs @@ -0,0 +1,80 @@ +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 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..a218e58 --- /dev/null +++ b/RepositoryServer/Services/DiscordNotificationService.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using OpenShock.RepositoryServer.Config; + +namespace OpenShock.RepositoryServer.Services; + +public sealed class DiscordNotificationService : IDiscordNotificationService +{ + private const int ColorGreen = 0x2ECC71; + private const int ColorYellow = 0xF1C40F; + private const int ColorRed = 0xE74C3C; + + private readonly HttpClient _http; + private readonly DiscordConfig _config; + private readonly ILogger _logger; + + public DiscordNotificationService(HttpClient http, ApiConfig apiConfig, ILogger logger) + { + _http = http; + _config = apiConfig.Discord; + _logger = logger; + } + + public Task NotifyFirmwareReleasePublishedAsync(string version, string channel, string commitHash, CancellationToken ct) => + PostEmbedsAsync("Firmware release published", + $"Version `{version}` published on channel `{channel}`.\nCommit: `{commitHash[..Math.Min(7, commitHash.Length)]}`", + ColorGreen, ct); + + public Task NotifyDesktopModuleVersionPublishedAsync(string moduleId, string version, string? commitHash, CancellationToken ct) => + PostEmbedsAsync("Desktop module version published", + $"Module `{moduleId}` version `{version}` published." + + (!string.IsNullOrEmpty(commitHash) ? $"\nCommit: `{commitHash[..Math.Min(7, commitHash.Length)]}`" : string.Empty), + ColorGreen, ct); + + public Task NotifyReleaseNotesNeedEditingAsync(Guid releaseId, string version, string channel, CancellationToken ct) => + PostEmbedsAsync("Release notes need editing", + $"Release `{releaseId}` (version `{version}`, channel `{channel}`) was created with an invalid changelog and needs manual review.", + ColorYellow, ct); + + public Task NotifyStagedReleaseExpiredAsync(Guid releaseId, string version, string channel, CancellationToken ct) => + PostEmbedsAsync("Staged release expired", + $"Release `{releaseId}` (version `{version}`, channel `{channel}`) was aborted because its TTL expired.", + ColorRed, ct); + + private Task PostEmbedsAsync(string title, string description, int color, CancellationToken ct) + { + if (_config.WebhookUrls.Count == 0) + { + return Task.CompletedTask; + } + + var payload = JsonSerializer.Serialize(new + { + embeds = new object[] + { + new { title, description, color } + } + }); + + foreach (var url in _config.WebhookUrls) + { + _ = Task.Run(async () => + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json") + }; + using var response = await _http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Discord webhook returned {Status}", (int)response.StatusCode); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to post Discord notification"); + } + }, ct); + } + + 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..b3362cb --- /dev/null +++ b/RepositoryServer/Services/IStorageService.cs @@ -0,0 +1,34 @@ +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); + + /// + /// 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..6cc88df --- /dev/null +++ b/RepositoryServer/Services/LocalStorageService.cs @@ -0,0 +1,61 @@ +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 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..15665d6 --- /dev/null +++ b/RepositoryServer/Services/S3StorageService.cs @@ -0,0 +1,116 @@ +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 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..3126056 --- /dev/null +++ b/RepositoryServer/Services/StagedReleaseCleanupService.cs @@ -0,0 +1,120 @@ +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"); + } + } + + private 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 + { + foreach (var artifact in release.StagedArtifacts) + { + var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifact.ArtifactType); + var cdnPath = $"{release.Version}/{artifact.BoardId}/{cdnFileName}"; + await _storage.DeleteFileAsync(cdnPath, ct); + } + + release.Status = ReleaseStatus.Aborted; + await db.SaveChangesAsync(ct); + + _logger.LogInformation( + "Aborted expired staged release {ReleaseId} (version={Version} status={Status}) after TTL expiry", + 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..b53c90b --- /dev/null +++ b/RepositoryServer/Utils/ChangelogParser.cs @@ -0,0 +1,155 @@ +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 FlushSection() + { + if (bullets.Count > 0) + { + foreach (var item in bullets) + { + var trimmed = item.Trim(); + if (trimmed.Length == 0) continue; + var (title, content) = ExtractTitle(trimmed, currentTitle); + notes.Add(new FirmwareReleaseNoteDto + { + Type = currentType.ToString().ToLowerInvariant(), + Title = title, + Content = content + }); + } + } + else if (buffer.Count > 0) + { + var combined = string.Join("\n", buffer.Select(l => l.TrimEnd())).Trim(); + if (combined.Length > 0) + { + var (title, content) = ExtractTitle(combined, currentTitle); + notes.Add(new FirmwareReleaseNoteDto + { + Type = currentType.ToString().ToLowerInvariant(), + Title = title, + Content = content + }); + } + } + + 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; + + if (line.StartsWith("- ", StringComparison.Ordinal)) + { + bullets.Add(line[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/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..a711cbe --- /dev/null +++ b/RepositoryServer/Utils/FirmwareArtifactFileNames.cs @@ -0,0 +1,17 @@ +using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.RepoServerDb; + +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" + }; +} diff --git a/RepositoryServer/Utils/FirmwareResponseMapper.cs b/RepositoryServer/Utils/FirmwareResponseMapper.cs new file mode 100644 index 0000000..e06d0c1 --- /dev/null +++ b/RepositoryServer/Utils/FirmwareResponseMapper.cs @@ -0,0 +1,68 @@ +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 = $"{cdnBase}/{version}/{artifact.BoardId}/{FirmwareArtifactFileNames.GetFileName(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(artifact.BoardId, out var detail)) + { + detail = new FirmwareBoardDetailDto + { + Chip = new FirmwareChipRefDto + { + Id = board.ChipNavigation.Id, + Name = board.ChipNavigation.Name + }, + Discontinued = board.Discontinued, + Artifacts = new List() + }; + boardMap[artifact.BoardId] = 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/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/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 69% rename from DesktopRepositoryServer/appsettings.json rename to RepositoryServer/appsettings.json index bde212b..36de95c 100644 --- a/DesktopRepositoryServer/appsettings.json +++ b/RepositoryServer/appsettings.json @@ -1,14 +1,25 @@ { "AllowedHosts": "*", + + "CiCd": { + "Audience": "openshock-repository-server", + "RepositoryOwner": "OpenShock" + }, "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 +29,7 @@ "Serilog.Sinks.Console", "Serilog.Sinks.Grafana.Loki", "Serilog.Sinks.OpenTelemetry", - "OpenShock.Desktop.RepositoryServer" + "OpenShock.RepositoryServer" ], "MinimumLevel": { "Default": "Warning", @@ -26,7 +37,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..131431d --- /dev/null +++ b/firmware-api-spec.md @@ -0,0 +1,1355 @@ +# 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}/{boardId}, versions/{version}/{boardId} | Constrained device. Needs artifact URLs + hashes for its board. Knows its own board ID 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}` + +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 +{ + "id": "esp32s3", // internal chip ID (DB primary key) + "name": "ESP32-S3" // display name, matches esptool-js chip name +} +``` + +### 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": { + "id": "esp32s3", + "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" }, + { "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/Wemos-D1-Mini-ESP32/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" }, + { "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. + +```jsonc +{ + "version": "1.5.1", + "boardId": "OpenShock-Core-V1", + "artifacts": [ + { "type": "merged", "url": "https://firmware.openshock.org/1.5.1/OpenShock-Core-V1/firmware.bin", "sha256Hash": "a1b2c3...", "fileSize": 1572864 }, + { "type": "app", "url": "https://firmware.openshock.org/1.5.1/OpenShock-Core-V1/app.bin", "sha256Hash": "d4e5f6...", "fileSize": 1048576 } + ] +} +``` + +### 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}/{boardId}` 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` + +**Server implementation notes**: +- Query: `FirmwareVersions` WHERE channel, ORDER BY release_date 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}/{boardId}?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` | +| `boardId` | path | yes | Hub's board ID (e.g. `"OpenShock-Core-V1"`) | +| `version` | query | no | Hub's current semver string. If provided and matches latest, returns 204 | + +**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}/{boardId} +``` + +Minimal response for a single board. No release notes, no other boards. + +| Param | Location | Required | Description | +|-------|----------|----------|-------------| +| `version` | path | yes | Exact semver string (e.g. `"1.5.1"`) | +| `boardId` | path | yes | Board ID (e.g. `"OpenShock-Core-V1"`) | + +**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. + +**Server config** (appsettings — only the OIDC audience, not the repo list): + +```jsonc +{ + "Firmware": { + "CiCd": { + "Audience": "https://repo.openshock.org" + } + } +} +``` + +**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/{boardId} → 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"], + "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/{boardId} +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. + +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) + - If no bullet points, the entire section body is one item + - 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" }, + { "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" +} +``` + +Creates or updates a repository entry. Unique constraint on `(provider, owner, repo)` — if it already exists, this is a no-op. + +**201 Created** → `Repository` + +```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}/{boardId}` | `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}/{boardId}` | `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[boardId].chip.name` — pass directly to esptool-js as the chip identifier +- `boards[boardId].discontinued` — show a warning badge or hide the board in the UI +- `boards[boardId].artifacts` — all artifact types needed for flashing (merged for simple flash, individual parts for advanced) +- `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 + +**Primary flow (auto-update)**: On boot or periodic timer, call `/latest/{channel}/{boardId}?version={current}` → if 200, pick the `merged` artifact from the `artifacts` array → download → verify SHA-256 → flash → reboot. If 204, do nothing. + +**Directed update flow**: When the backend tells the hub to install a specific version, call `/versions/{version}/{boardId}` → pick the `merged` artifact → download → verify → flash → reboot. + +**Implementation guidance**: +- The hub must know its own `boardId` (compiled in at build time) and `channel` (configurable via app settings) +- Parse: `version` (string), `boardId` (string), `artifacts[]` array — find the entry where `type` is `"merged"` +- Use `sha256Hash` to verify the downloaded binary before flashing +- Use `fileSize` to pre-allocate OTA partition space and validate download completeness +- On HTTP 204: no update needed, skip +- On HTTP 404: board or channel unknown — log warning, do not retry until next cycle +- On HTTP 4xx/5xx: transient error — back off exponentially + +**Memory budget**: The JSON response is small. The `artifacts` array typically has 1-3 entries. + +### 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}/{boardId}?version=` returning `FirmwareBoardReleaseResponse`, with 204 support | Controller + new DTO | +| `VersionsController` | Add `GET /versions/{version}/{boardId}` 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..909df9a 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,10 @@ { + "sdk": { + "version": "10.0.0", + "rollForward": "latestMajor", + "allowPrerelease": false + }, "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +}