From 8eb522ff5980af9ea42ec39b40606322d10924ac Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 12:08:11 +0100 Subject: [PATCH 01/15] Unify DesktopRepositoryServer into RepositoryServer and add firmware support Rename DesktopRepositoryServer to RepositoryServer with unified namespace OpenShock.RepositoryServer. Reorganize controllers into V1 (backwards-compat desktop endpoints at /v1/) and V2 (desktop at /v2/desktop/, firmware at /v2/firmware/). Add full firmware repository with chips, boards, versions, artifacts, and release notes backed by PostgreSQL enums and EF Core entities. Includes public OTA endpoints and admin CRUD with token auth. Update Docker, CI/CD workflows, and generate AddFirmwareTables migration. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci-build.yml | 20 +- .github/workflows/ci-tag.yml | 20 +- CLAUDE.md | 86 +++++ .../MigrationOpenShockContextModelSnapshot.cs | 118 ------ .../RepoServerDb/RepoServerContext.cs | 130 ------- PLAN.md | 309 ++++++++++++++++ .../RepositoryServer.Tests.csproj | 8 +- .../UnitTest1.cs | 2 +- .../AuthSchemas.cs | 2 +- .../AdminTokenAuthentication.cs | 8 +- .../Config/ApiConfig.cs | 3 +- .../Config/DbConfig.cs | 2 +- RepositoryServer/Config/FirmwareConfig.cs | 8 + .../Config/MetricsConfig.cs | 4 +- .../Config/RepoConfig.cs | 2 +- .../ConfigurationExtensions.cs | 2 +- .../Controllers/OpenShockControllerBase.cs | 4 +- .../Controllers/V1}/AdminController.cs | 36 +- .../Controllers/V1}/RepoController.cs | 27 +- .../Controllers/V2/Desktop/AdminController.cs | 95 +++++ .../Controllers/V2/Desktop/RepoController.cs | 61 +++ .../V2/Firmware/AdminController.cs | 280 ++++++++++++++ .../V2/Firmware/BoardsController.cs | 49 +++ .../V2/Firmware/ChipsController.cs | 35 ++ .../V2/Firmware/LatestController.cs | 75 ++++ .../V2/Firmware/VersionsController.cs | 113 ++++++ .../Errors/AuthResultError.cs | 4 +- .../Errors/ExceptionError.cs | 4 +- .../ExceptionHandler/ExceptionHandler.cs | 4 +- .../ExceptionHandler/RequestInfo.cs | 2 +- .../20250315184152_Initial.Designer.cs | 14 +- .../Migrations/20250315184152_Initial.cs | 2 +- ...260211110324_AddFirmwareTables.Designer.cs | 350 ++++++++++++++++++ .../20260211110324_AddFirmwareTables.cs | 171 +++++++++ .../MigrationOpenShockContextModelSnapshot.cs | 347 +++++++++++++++++ .../Models/CreateModuleRequest.cs | 2 +- .../Models/CreateModuleVersionRequest.cs | 4 +- .../Firmware/CreateFirmwareBoardRequest.cs | 7 + .../Firmware/CreateFirmwareChipRequest.cs | 7 + .../Firmware/CreateFirmwareVersionRequest.cs | 11 + .../Models/Firmware/FirmwareArtifactDto.cs | 9 + .../Models/Firmware/FirmwareArtifactUpload.cs | 7 + .../Models/Firmware/FirmwareBoardArtifact.cs | 8 + .../Models/Firmware/FirmwareBoardDto.cs | 10 + .../Models/Firmware/FirmwareChipDto.cs | 8 + .../Models/Firmware/FirmwareLatestResponse.cs | 9 + .../Models/Firmware/FirmwareReleaseNoteDto.cs | 8 + .../Firmware/FirmwareReleaseNoteUpload.cs | 8 + .../Firmware/FirmwareVersionResponse.cs | 12 + .../Models/Firmware/FirmwareVersionSummary.cs | 8 + .../Models/Module.cs | 2 +- .../Models/Repository.cs | 2 +- .../Models/Version.cs | 4 +- .../Problems/ExceptionProblem.cs | 2 +- RepositoryServer/Problems/FirmwareError.cs | 15 + .../Problems/ModuleError.cs | 2 +- .../Problems/OpenShockProblem.cs | 2 +- .../Problems/ValidationProblem.cs | 2 +- .../Problems/VersionError.cs | 2 +- .../Program.cs | 14 +- .../Properties/launchSettings.json | 0 .../DesignTimeDbContextFactory.cs | 11 + .../RepoServerDb/FirmwareArtifact.cs | 13 + .../RepoServerDb/FirmwareArtifactType.cs | 10 + .../RepoServerDb/FirmwareBoard.cs | 12 + .../RepoServerDb/FirmwareChannel.cs | 8 + RepositoryServer/RepoServerDb/FirmwareChip.cs | 10 + .../RepoServerDb/FirmwareChipArchitecture.cs | 7 + .../RepoServerDb/FirmwareReleaseNote.cs | 12 + .../RepoServerDb/FirmwareReleaseNoteType.cs | 9 + .../RepoServerDb/FirmwareVersion.cs | 13 + .../RepoServerDb/Module.cs | 2 +- .../RepoServerDb/RepoServerContext.cs | 263 +++++++++++++ .../RepoServerDb/Version.cs | 2 +- .../RepositoryServer.csproj | 6 +- .../Utils/ByteArrayHexConverter.cs | 2 +- .../Utils/ConnectionDetailsFetcher.cs | 2 +- .../Utils/OpenShockEnricher.cs | 2 +- .../Utils/SemVersionConverter.cs | 2 +- .../Utils/TrustedProxiesFetcher.cs | 2 +- .../Utils/UriExtensions.cs | 2 +- .../appsettings.Development.json | 0 .../appsettings.json | 8 +- RepositoryServers.slnx | 4 +- ...Dockerfile => RepositoryServer.Dockerfile} | 8 +- docker/entrypoint.sh | 2 +- 86 files changed, 2604 insertions(+), 370 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs delete mode 100644 DesktopRepositoryServer/RepoServerDb/RepoServerContext.cs create mode 100644 PLAN.md rename DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj => RepositoryServer.Tests/RepositoryServer.Tests.csproj (57%) rename {DesktopRepositoryServer.Tests => RepositoryServer.Tests}/UnitTest1.cs (69%) rename {DesktopRepositoryServer => RepositoryServer}/AuthSchemas.cs (63%) rename {DesktopRepositoryServer => RepositoryServer}/AuthenticationHandlers/AdminTokenAuthentication.cs (90%) rename {DesktopRepositoryServer => RepositoryServer}/Config/ApiConfig.cs (73%) rename {DesktopRepositoryServer => RepositoryServer}/Config/DbConfig.cs (83%) create mode 100644 RepositoryServer/Config/FirmwareConfig.cs rename {DesktopRepositoryServer => RepositoryServer}/Config/MetricsConfig.cs (59%) rename {DesktopRepositoryServer => RepositoryServer}/Config/RepoConfig.cs (80%) rename {DesktopRepositoryServer => RepositoryServer}/ConfigurationExtensions.cs (97%) rename {DesktopRepositoryServer => RepositoryServer}/Controllers/OpenShockControllerBase.cs (71%) rename {DesktopRepositoryServer/Controllers => RepositoryServer/Controllers/V1}/AdminController.cs (85%) rename {DesktopRepositoryServer/Controllers => RepositoryServer/Controllers/V1}/RepoController.cs (76%) create mode 100644 RepositoryServer/Controllers/V2/Desktop/AdminController.cs create mode 100644 RepositoryServer/Controllers/V2/Desktop/RepoController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/AdminController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/BoardsController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/ChipsController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/LatestController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/VersionsController.cs rename {DesktopRepositoryServer => RepositoryServer}/Errors/AuthResultError.cs (77%) rename {DesktopRepositoryServer => RepositoryServer}/Errors/ExceptionError.cs (51%) rename {DesktopRepositoryServer => RepositoryServer}/ExceptionHandler/ExceptionHandler.cs (95%) rename {DesktopRepositoryServer => RepositoryServer}/ExceptionHandler/RequestInfo.cs (87%) rename {DesktopRepositoryServer => RepositoryServer}/Migrations/20250315184152_Initial.Designer.cs (87%) rename {DesktopRepositoryServer => RepositoryServer}/Migrations/20250315184152_Initial.cs (98%) create mode 100644 RepositoryServer/Migrations/20260211110324_AddFirmwareTables.Designer.cs create mode 100644 RepositoryServer/Migrations/20260211110324_AddFirmwareTables.cs create mode 100644 RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs rename {DesktopRepositoryServer => RepositoryServer}/Models/CreateModuleRequest.cs (80%) rename {DesktopRepositoryServer => RepositoryServer}/Models/CreateModuleVersionRequest.cs (77%) create mode 100644 RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs create mode 100644 RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs create mode 100644 RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareArtifactDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareArtifactUpload.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareBoardArtifact.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareBoardDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareChipDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareReleaseNoteDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareReleaseNoteUpload.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs rename {DesktopRepositoryServer => RepositoryServer}/Models/Module.cs (87%) rename {DesktopRepositoryServer => RepositoryServer}/Models/Repository.cs (86%) rename {DesktopRepositoryServer => RepositoryServer}/Models/Version.cs (76%) rename {DesktopRepositoryServer => RepositoryServer}/Problems/ExceptionProblem.cs (84%) create mode 100644 RepositoryServer/Problems/FirmwareError.cs rename {DesktopRepositoryServer => RepositoryServer}/Problems/ModuleError.cs (79%) rename {DesktopRepositoryServer => RepositoryServer}/Problems/OpenShockProblem.cs (95%) rename {DesktopRepositoryServer => RepositoryServer}/Problems/ValidationProblem.cs (96%) rename {DesktopRepositoryServer => RepositoryServer}/Problems/VersionError.cs (85%) rename {DesktopRepositoryServer => RepositoryServer}/Program.cs (94%) rename {DesktopRepositoryServer => RepositoryServer}/Properties/launchSettings.json (100%) create mode 100644 RepositoryServer/RepoServerDb/DesignTimeDbContextFactory.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareArtifact.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareArtifactType.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareBoard.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareChannel.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareChip.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareChipArchitecture.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareReleaseNoteType.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareVersion.cs rename {DesktopRepositoryServer => RepositoryServer}/RepoServerDb/Module.cs (87%) create mode 100644 RepositoryServer/RepoServerDb/RepoServerContext.cs rename {DesktopRepositoryServer => RepositoryServer}/RepoServerDb/Version.cs (88%) rename DesktopRepositoryServer/DesktopRepositoryServer.csproj => RepositoryServer/RepositoryServer.csproj (91%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/ByteArrayHexConverter.cs (92%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/ConnectionDetailsFetcher.cs (95%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/OpenShockEnricher.cs (97%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/SemVersionConverter.cs (95%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/TrustedProxiesFetcher.cs (98%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/UriExtensions.cs (85%) rename {DesktopRepositoryServer => RepositoryServer}/appsettings.Development.json (100%) rename {DesktopRepositoryServer => RepositoryServer}/appsettings.json (83%) rename docker/{DesktopRepositoryServer.Dockerfile => RepositoryServer.Dockerfile} (61%) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index ccb33aa..46e4e5b 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@v6 @@ -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 @@ -113,8 +113,8 @@ jobs: with: token: ${{ secrets.GITOPS_PAT }} repository: 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 }}" - } \ No newline at end of file + } diff --git a/.github/workflows/ci-tag.yml b/.github/workflows/ci-tag.yml index 404d52c..a7164c7 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 @@ -42,7 +42,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/desktop-repository-server + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/repository-server flavor: | latest=false tags: | @@ -50,25 +50,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@v6 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@v2 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..97b2df9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# 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/desktop/`, `/v2/firmware/` +- FlexLabs.EntityFrameworkCore.Upsert for upserts +- MiniValidation for config validation +- Semver NuGet package for semver parsing +- TUnit for tests +- Scalar for API docs UI +- Docker (Alpine) with self-signed HTTPS cert generation + +## Project Structure +``` +RepositoryServer/ # Main web API project + AuthenticationHandlers/ # Custom AdminToken auth handler + Config/ # ApiConfig, DbConfig, RepoConfig, MetricsConfig, FirmwareConfig + Controllers/ + OpenShockControllerBase.cs # Shared base controller + V1/ # Legacy desktop endpoints (backwards compat) + RepoController.cs # GET /v1/ — desktop module manifest + AdminController.cs # /v1/admin/... — desktop module CRUD + V2/ + Desktop/ # Same as V1 under new prefix + RepoController.cs # GET /v2/desktop/ + AdminController.cs # /v2/desktop/admin/... + Firmware/ + LatestController.cs # GET /v2/firmware/latest/{channel} + VersionsController.cs # GET /v2/firmware/versions[/{version}] + BoardsController.cs # GET /v2/firmware/boards + ChipsController.cs # GET /v2/firmware/chips + AdminController.cs # /v2/firmware/admin/... CRUD + Errors/ # Static error factories (AuthResultError, ExceptionError) + ExceptionHandler/ # Global IExceptionHandler + RequestInfo logging + Migrations/ # EF Core migrations (Initial + AddFirmwareTables) + Models/ # Desktop module DTOs + Models/Firmware/ # Firmware DTOs (response + admin request) + Problems/ # RFC 7807 problem details (OpenShockProblem base) + RepoServerDb/ # EF Core entities + RepoServerContext + Utils/ # ByteArrayHexConverter, SemVersionConverter, etc. + Program.cs # App startup and middleware pipeline + +RepositoryServer.Tests/ # TUnit test project +docker/ + RepositoryServer.Dockerfile + entrypoint.sh # Cert gen + `dotnet OpenShock.RepositoryServer.dll` + appsettings.Container.json +``` + +## Namespace +`OpenShock.RepositoryServer` (renamed from `OpenShock.Desktop.RepositoryServer`) + +## Key Patterns +- **Auth**: Custom `AdminTokenAuthentication` handler, `AuthSchemas.AdminToken` scheme +- **Error Handling**: `OpenShockProblem` base class → `ToObjectResult()` for RFC 7807 +- **Config**: Bind root config to `ApiConfig`, validated with MiniValidator, registered as singleton +- **DB Migrations**: `MigrationOpenShockContext` subclass for migration tooling, auto-applied on startup +- **JSON**: CamelCase, custom `SemVersionConverter` + `ByteArrayHexConverter` +- **Upsert**: FlexLabs `.Upsert().On().RunAsync()` pattern +- **CORS**: Allow all origins/methods/headers +- **Metrics**: Prometheus at `/metrics`, restricted to private networks + +## Database +- PostgreSQL, connection in `ApiConfig.Db.Conn` +- 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 enums: `firmware_channel`, `firmware_artifact_type`, `firmware_release_note_type`, `firmware_chip_architecture` +- URL convention for firmware artifacts: `{CdnBaseUrl}/{version}/{boardId}/{artifactType}.bin` + +## 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 RepositoryServer.Tests/RepositoryServer.Tests.csproj` +- Docker: `docker build -f docker/RepositoryServer.Dockerfile .` +- Migration: `dotnet ef migrations add --project RepositoryServer` 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/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/PLAN.md b/PLAN.md new file mode 100644 index 0000000..67d84cc --- /dev/null +++ b/PLAN.md @@ -0,0 +1,309 @@ +# Plan: Merge DesktopRepositoryServer into unified RepositoryServer + Add Firmware Support + +## Status: ALL STEPS COMPLETED + +All 8 steps have been implemented. Build succeeds with 0 errors, tests pass. + +## Context + +The OpenShock/RepositoryServers repo currently has a `DesktopRepositoryServer` for distributing desktop app modules. We need to: +1. Refactor it into a single unified `RepositoryServer` application (not separate apps per product) +2. Add firmware repository endpoints for OTA updates, frontend flashing, and release notes + +The existing desktop server is a .NET 10 ASP.NET Core app with PostgreSQL/EF Core, admin token auth, RFC 7807 error handling, and Docker deployment. + +**Routing decision:** Keep existing v1 desktop endpoints at `/v1/` unchanged for backwards compatibility. New v2 API introduces `/v2/desktop/` and `/v2/firmware/` prefixes. + +**Channel strategy:** Explicit channel field set by admin when creating a firmware version (not derived from semver). + +## Step 1: Rename and restructure the project [DONE] + +Rename `DesktopRepositoryServer/` → `RepositoryServer/` and update namespaces. + +**Files to rename/modify:** +- `DesktopRepositoryServer/` → `RepositoryServer/` +- `DesktopRepositoryServer.Tests/` → `RepositoryServer.Tests/` +- `RepositoryServers.slnx` — update project paths +- `docker/DesktopRepositoryServer.Dockerfile` → `docker/RepositoryServer.Dockerfile` +- `docker/entrypoint.sh` — update DLL name +- `.github/workflows/ci-build.yml` — update matrix entries +- `.github/workflows/ci-tag.yml` — update image names + +**Namespace changes:** +- `OpenShock.Desktop.RepositoryServer` → `OpenShock.RepositoryServer` +- Update all `using` statements, `.csproj` RootNamespace/AssemblyName +- `appsettings.json` Serilog `Using` and `Override` sections + +## Step 2: Reorganize controllers with versioned routing + +``` +RepositoryServer/ + Controllers/ + OpenShockControllerBase.cs # Shared base (keep as-is, update namespace) + V1/ + RepoController.cs # GET /v1/ — existing desktop manifest (unchanged behavior) + AdminController.cs # /v1/admin/... — existing desktop admin (unchanged behavior) + V2/ + Desktop/ + RepoController.cs # GET /v2/desktop/ — same as v1 but under new prefix + AdminController.cs # /v2/desktop/admin/... + Firmware/ + LatestController.cs # GET /v2/firmware/latest/{channel} + VersionsController.cs # GET /v2/firmware/versions[/{version}] + ChipsController.cs # GET /v2/firmware/chips + AdminController.cs # /v2/firmware/admin/versions/{version} +``` + +Route attributes: +- V1 controllers: `[ApiVersion("1.0")]` `[Route("/v{version:apiVersion}/")]` — unchanged +- V2 Desktop: `[ApiVersion("2.0")]` `[Route("/v{version:apiVersion}/desktop")]` +- V2 Firmware: `[ApiVersion("2.0")]` `[Route("/v{version:apiVersion}/firmware")]` + +## Step 3: Add firmware database entities + +New entities in `RepoServerDb/`: + +**PostgreSQL Enums:** +- `firmware_channel`: "stable", "beta", "develop" +- `firmware_artifact_type`: "merged", "app", "bootloader", "partitions", "staticfs" +- `firmware_release_note_type`: "warning", "info", "breaking", "section" +- `firmware_chip_architecture`: "xtensa", "riscv" + +**`FirmwareChip`** (table: `firmware_chips`) +- `Id` (PK, varchar(32): "ESP32", "ESP32-S2", "ESP32-S3", "ESP32-C3") +- `Name` (varchar(64), display name: "ESP32", "ESP32-S2", etc.) +- `Architecture` (firmware_chip_architecture enum, nullable) + +**`FirmwareBoard`** (table: `firmware_boards`) +- `Id` (PK, varchar(64): "OpenShock-Core-V2", "OpenShock-Pishock-Plus") +- `ChipId` (FK → firmware_chips, NOT NULL) +- `Name` (varchar(128), display name: "OpenShock Core V2") +- `Discontinued` (bool, default false) + +**`FirmwareVersion`** (table: `firmware_versions`) +- `Version` (PK, varchar(64), semver string) +- `Channel` (firmware_channel enum) — explicitly set by admin +- `ReleaseDate` (DateTimeOffset) +- `CommitHash` (varchar(40)) +- `ReleaseUrl` (varchar(256), nullable) — GitHub release URL + +**`FirmwareArtifact`** (table: `firmware_artifacts`) +- `Version` + `BoardId` + `ArtifactType` (composite PK) +- `Version` (FK → firmware_versions, CASCADE) +- `BoardId` (FK → firmware_boards, CASCADE) +- `ArtifactType` (firmware_artifact_type enum) +- `HashSha256` (bytea, NOT NULL) +- `FileSize` (int64, NOT NULL) + +**`FirmwareReleaseNote`** (table: `firmware_release_notes`) +- `Version` + `Index` (composite PK) +- `Version` (FK → firmware_versions, CASCADE) +- `Index` (int, ordering) +- `Type` (firmware_release_note_type enum) +- `Title` (varchar(256), nullable — for section headers) +- `Content` (text, NOT NULL) + +**URL Generation Convention:** +Binary URLs are generated as: `{cdnBaseUrl}/{version}/{boardId}/{artifactType}.bin` +Example: `https://cdn.openshock.app/firmware/1.5.0-rc.6/OpenShock-Core-V2/firmware.bin` + +**EF Core Configuration:** +- Map enums using Npgsql's `HasPostgresEnum()` in `OnModelCreating` +- Configure composite keys and cascade deletes +- Add to `RepoServerContext` as new DbSets alongside existing Module/Version + +**Configuration (appsettings.json):** +```json +{ + "Firmware": { + "CdnBaseUrl": "https://cdn.openshock.app/firmware" + } +} +``` + +## Step 4: Add firmware models (DTOs) + +New files in `Models/Firmware/`: + +**Response DTOs:** +- `FirmwareLatestResponse.cs` — lightweight for OTA clients: + - `Version` (string) + - `Channel` (string) + - `ReleaseDate` (DateTimeOffset) + - `Artifacts` (Dictionary) — board ID → merged artifact only + +- `FirmwareVersionResponse.cs` — full detail for version endpoint: + - `Version` (string) + - `Channel` (string) + - `ReleaseDate` (DateTimeOffset) + - `CommitHash` (string) + - `ReleaseUrl` (string, nullable) + - `Artifacts` (Dictionary>) — board ID → all artifact types + - `ReleaseNotes` (List) + +- `FirmwareVersionSummary.cs` — for list endpoint: + - `Version` (string) + - `Channel` (string) + - `ReleaseDate` (DateTimeOffset) + +- `FirmwareBoardArtifact.cs` — single artifact (for latest endpoint): + - `Url` (string) — generated from convention + - `Sha256Hash` (string) + - `FileSize` (long) + +- `FirmwareArtifactDto.cs` — artifact with type (for version detail): + - `Type` (string) — "merged", "app", etc. + - `Url` (string) — generated from convention + - `Sha256Hash` (string) + - `FileSize` (long) + +- `FirmwareReleaseNoteDto.cs` — release note item: + - `Type` (string) + - `Title` (string, nullable) + - `Content` (string) + +- `FirmwareBoardDto.cs` — board info: + - `Id` (string) + - `Name` (string) + - `ChipId` (string) + - `ChipName` (string) + - `Discontinued` (bool) + +- `FirmwareChipDto.cs` — chip info: + - `Id` (string) + - `Name` (string) + - `Architecture` (string, nullable) + +**Admin Request DTOs:** +- `CreateFirmwareVersionRequest.cs` — create/update version: + - `Channel` (string) + - `ReleaseDate` (DateTimeOffset) + - `CommitHash` (string) + - `ReleaseUrl` (string, nullable) + - `Artifacts` (Dictionary>) — boardId → artifactType → metadata + - `ReleaseNotes` (List) + +- `FirmwareArtifactUpload.cs` — artifact metadata (no URL): + - `Sha256Hash` (string) + - `FileSize` (long) + +- `FirmwareReleaseNoteUpload.cs`: + - `Type` (string) + - `Title` (string, nullable) + - `Content` (string) + +- `CreateFirmwareBoardRequest.cs`: + - `Id` (string) + - `Name` (string) + - `ChipId` (string) + +- `CreateFirmwareChipRequest.cs`: + - `Id` (string) + - `Name` (string) + - `Architecture` (string, nullable) + +## Step 5: Add firmware controllers + +**Public Endpoints:** + +**`LatestController`** — `GET /v2/firmware/latest/{channel}` +- Returns latest version for a channel with **merged artifacts only** (for OTA) +- Query: `firmware_versions` WHERE channel = X, ORDER BY release_date DESC, LIMIT 1 +- JOIN `firmware_artifacts` WHERE artifact_type = 'merged' +- Response: `FirmwareLatestResponse` with board → merged artifact map +- Lightweight, cacheable, optimized for OTA clients polling for updates + +**`VersionsController`** +- `GET /v2/firmware/versions?channel={channel}` — version list, optional channel filter + - Returns: List +- `GET /v2/firmware/versions/{version}` — full metadata + - Returns: `FirmwareVersionResponse` with **all artifact types**, release notes + - JOIN artifacts, boards, chips to include full board info + +**`BoardsController`** — `GET /v2/firmware/boards` +- Query params: `?chipId={chipId}`, `?includeDiscontinued=false` +- Returns: List with chip info joined +- Used by frontend for device selection UI + +**`ChipsController`** — `GET /v2/firmware/chips` +- Returns: List +- Returns all chips (from firmware_chips table) + +**Admin Endpoints:** + +**`AdminController`** — `/v2/firmware/admin` + +Version Management: +- `PUT /v2/firmware/admin/versions/{version}` — upsert version + artifacts + notes + - Transactional: replaces all artifacts and notes for the version + - Validates: board IDs exist, channel is valid, semver format + - Body: `CreateFirmwareVersionRequest` +- `DELETE /v2/firmware/admin/versions/{version}` — cascade delete version + +Board Management: +- `GET /v2/firmware/admin/boards` — list all boards (including discontinued) +- `PUT /v2/firmware/admin/boards/{id}` — upsert board + - Body: `CreateFirmwareBoardRequest` + - Validates: chip ID exists +- `PATCH /v2/firmware/admin/boards/{id}/discontinue` — set discontinued=true +- `DELETE /v2/firmware/admin/boards/{id}` — delete board (fails if artifacts exist) + +Chip Management: +- `GET /v2/firmware/admin/chips` — list all chips +- `PUT /v2/firmware/admin/chips/{id}` — upsert chip + - Body: `CreateFirmwareChipRequest` +- `DELETE /v2/firmware/admin/chips/{id}` — delete chip (fails if boards exist) + +## Step 6: Add firmware problem types + +`Problems/FirmwareError.cs`: +- `FirmwareVersionNotFound` (404) +- `FirmwareBoardNotFound` (404) +- `FirmwareChipNotFound` (404) +- `FirmwareInvalidChannel` (400) +- `FirmwareInvalidSemver` (400) +- `FirmwareInvalidArtifactType` (400) +- `FirmwareBoardInUse` (409) — cannot delete board with artifacts +- `FirmwareChipInUse` (409) — cannot delete chip with boards + +## Step 7: EF Core migration + +```bash +dotnet ef migrations add AddFirmwareTables --project RepositoryServer +``` + +**Migration should include:** +- CREATE TYPE statements for PostgreSQL enums (firmware_channel, firmware_artifact_type, etc.) +- CREATE TABLE statements for chips, boards, versions, artifacts, release_notes +- Foreign key constraints with CASCADE deletes +- Composite primary keys +- Indexes on commonly queried fields (channel, release_date, board_id) + +## Step 8: Update Docker and CI + +- `docker/RepositoryServer.Dockerfile` — update COPY paths and DLL name +- `docker/entrypoint.sh` — `exec dotnet OpenShock.RepositoryServer.dll` +- `ci-build.yml` — update matrix: dockerfile, image name, test project path +- `ci-tag.yml` — update image name to `repository-server` +- Update deploy dispatch event type to `update-repository-server-prod` + +## Verification + +1. `dotnet build` — project compiles +2. `dotnet test` — existing tests pass +3. Existing v1 desktop endpoint still works at `/v1/` +4. New v2 desktop endpoints respond at `/v2/desktop/` +5. New v2 firmware endpoints respond (empty results / proper 404s): + - `GET /v2/firmware/chips` returns empty array + - `GET /v2/firmware/boards` returns empty array + - `GET /v2/firmware/versions` returns empty array + - `GET /v2/firmware/latest/stable` returns 404 FirmwareVersionNotFound +6. Admin can create chips, boards, versions: + - `PUT /v2/firmware/admin/chips/ESP32` succeeds + - `PUT /v2/firmware/admin/boards/OpenShock-Core-V2` succeeds + - `PUT /v2/firmware/admin/versions/1.0.0` succeeds +7. URL generation works correctly: + - Artifact URLs follow convention: `{cdnBase}/{version}/{boardId}/{type}.bin` + - Configuration value from appsettings is used +8. Docker build: `docker build -f docker/RepositoryServer.Dockerfile .` +9. PostgreSQL enums are created correctly in database diff --git a/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj b/RepositoryServer.Tests/RepositoryServer.Tests.csproj similarity index 57% rename from DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj rename to RepositoryServer.Tests/RepositoryServer.Tests.csproj index 6b40f31..0c006b7 100644 --- a/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj +++ b/RepositoryServer.Tests/RepositoryServer.Tests.csproj @@ -6,10 +6,10 @@ false true 13 - OpenShock.Desktop.RepositoryServer.Tests - OpenShock.Desktop.RepositoryServer.Tests - OpenShock.Desktop.RepositoryServer.Tests - OpenShock.Desktop.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests diff --git a/DesktopRepositoryServer.Tests/UnitTest1.cs b/RepositoryServer.Tests/UnitTest1.cs similarity index 69% rename from DesktopRepositoryServer.Tests/UnitTest1.cs rename to RepositoryServer.Tests/UnitTest1.cs index 0361efd..8448f03 100644 --- a/DesktopRepositoryServer.Tests/UnitTest1.cs +++ b/RepositoryServer.Tests/UnitTest1.cs @@ -1,4 +1,4 @@ -namespace OpenShock.Desktop.RepositoryServer.Tests; +namespace OpenShock.RepositoryServer.Tests; public class UnitTest1 { diff --git a/DesktopRepositoryServer/AuthSchemas.cs b/RepositoryServer/AuthSchemas.cs similarity index 63% rename from DesktopRepositoryServer/AuthSchemas.cs rename to RepositoryServer/AuthSchemas.cs index 52da677..04a8a23 100644 --- a/DesktopRepositoryServer/AuthSchemas.cs +++ b/RepositoryServer/AuthSchemas.cs @@ -1,4 +1,4 @@ -namespace OpenShock.Desktop.RepositoryServer; +namespace OpenShock.RepositoryServer; public static class AuthSchemas { 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/DesktopRepositoryServer/Config/ApiConfig.cs b/RepositoryServer/Config/ApiConfig.cs similarity index 73% rename from DesktopRepositoryServer/Config/ApiConfig.cs rename to RepositoryServer/Config/ApiConfig.cs index b778351..2ba0063 100644 --- a/DesktopRepositoryServer/Config/ApiConfig.cs +++ b/RepositoryServer/Config/ApiConfig.cs @@ -1,11 +1,12 @@ 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 diff --git a/DesktopRepositoryServer/Config/DbConfig.cs b/RepositoryServer/Config/DbConfig.cs similarity index 83% rename from DesktopRepositoryServer/Config/DbConfig.cs rename to RepositoryServer/Config/DbConfig.cs index e416a5e..91f9b55 100644 --- a/DesktopRepositoryServer/Config/DbConfig.cs +++ b/RepositoryServer/Config/DbConfig.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace OpenShock.Desktop.RepositoryServer.Config; +namespace OpenShock.RepositoryServer.Config; public class DbConfig { diff --git a/RepositoryServer/Config/FirmwareConfig.cs b/RepositoryServer/Config/FirmwareConfig.cs new file mode 100644 index 0000000..ce34c00 --- /dev/null +++ b/RepositoryServer/Config/FirmwareConfig.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Config; + +public sealed class FirmwareConfig +{ + [Required(AllowEmptyStrings = false)] public required string CdnBaseUrl { get; init; } +} 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 80% rename from DesktopRepositoryServer/Config/RepoConfig.cs rename to RepositoryServer/Config/RepoConfig.cs index a1f138e..3edd160 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 { 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 85% rename from DesktopRepositoryServer/Controllers/AdminController.cs rename to RepositoryServer/Controllers/V1/AdminController.cs index b504330..d91c565 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,28 @@ 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(); } - [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 +81,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 +92,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/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 577be19..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,11 +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, @@ -52,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/Desktop/AdminController.cs b/RepositoryServer/Controllers/V2/Desktop/AdminController.cs new file mode 100644 index 0000000..4694a30 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Desktop/AdminController.cs @@ -0,0 +1,95 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Models; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; +using Semver; +using Module = OpenShock.RepositoryServer.RepoServerDb.Module; +using Version = OpenShock.RepositoryServer.RepoServerDb.Version; + +namespace OpenShock.RepositoryServer.Controllers.V2.Desktop; + +[ApiVersion("2.0")] +[ApiController] +[Route("/v{version:apiVersion}/desktop/admin")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class AdminController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public AdminController(RepoServerContext db) + { + _db = db; + } + + [HttpPut("modules/{moduleId}")] + public async Task CreateModule([FromBody] CreateModuleRequest createModuleRequest, [FromRoute] string moduleId) + { + var module = new Module + { + Id = moduleId.ToLowerInvariant(), + Name = createModuleRequest.Name, + Description = createModuleRequest.Description, + SourceUrl = createModuleRequest.SourceUrl, + 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(); + } + + [HttpDelete("modules/{moduleId}")] + 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(); + } + + [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, + VersionName = moduleVersion, + ZipUrl = createModuleVersionRequest.ZipUrl, + HashSha256 = createModuleVersionRequest.Sha256Hash, + ChangelogUrl = createModuleVersionRequest.ChangelogUrl, + ReleaseUrl = createModuleVersionRequest.ReleaseUrl + }; + + 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) + { + var executed = await _db.Versions.Where(x => x.Module == moduleId && x.VersionName == moduleVersion).ExecuteDeleteAsync(); + if(executed <= 0) return Problem(VersionError.VersionNotFound); + return Ok(); + } +} diff --git a/RepositoryServer/Controllers/V2/Desktop/RepoController.cs b/RepositoryServer/Controllers/V2/Desktop/RepoController.cs new file mode 100644 index 0000000..c9c4a9b --- /dev/null +++ b/RepositoryServer/Controllers/V2/Desktop/RepoController.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Models; +using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; +using Semver; +using Module = OpenShock.RepositoryServer.Models.Module; +using Version = OpenShock.RepositoryServer.Models.Version; + +namespace OpenShock.RepositoryServer.Controllers.V2.Desktop; + +[ApiVersion("2.0")] +[ApiController] +[Route("/v{version:apiVersion}/desktop")] +public sealed class RepoController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + private readonly ApiConfig _apiConfig; + + 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, + Description = x.Description, + IconUrl = x.IconUrl, + SourceUrl = x.SourceUrl, + Versions = x.Versions.ToImmutableDictionary( + y => SemVersion.Parse(y.VersionName, SemVersionStyles.Strict, 64), y => new Version + { + Url = y.ZipUrl, + Sha256Hash = y.HashSha256, + ChangelogUrl = y.ChangelogUrl, + ReleaseUrl = y.ReleaseUrl + }) + }); + + var repository = new Repository + { + Id = _apiConfig.Repo.Id, + Name = _apiConfig.Repo.Name, + Author = _apiConfig.Repo.Author, + Homepage = _apiConfig.Repo.Homepage, + Modules = modules + }; + + return Ok(repository); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/AdminController.cs b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs new file mode 100644 index 0000000..e63c6ae --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs @@ -0,0 +1,280 @@ +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; +using Semver; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; + +[ApiVersion("2.0")] +[ApiController] +[Route("/v{version:apiVersion}/firmware/admin")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class AdminController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public AdminController(RepoServerContext db) + { + _db = db; + } + + // ---- Version Management ---- + + [HttpPut("versions/{firmwareVersion}")] + public async Task UpsertVersion([FromRoute] string firmwareVersion, [FromBody] CreateFirmwareVersionRequest request) + { + if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Any, out _)) + { + return Problem(FirmwareError.FirmwareInvalidSemver); + } + + if (!Enum.TryParse(request.Channel, true, out var channel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + + // Validate all board IDs exist + var boardIds = request.Artifacts.Keys.ToHashSet(); + var existingBoards = await _db.FirmwareBoards + .Where(b => boardIds.Contains(b.Id)) + .Select(b => b.Id) + .ToHashSetAsync(); + + var missingBoards = boardIds.Except(existingBoards).ToList(); + if (missingBoards.Count > 0) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + // Validate artifact types + foreach (var (boardId, artifactTypes) in request.Artifacts) + { + foreach (var artifactType in artifactTypes.Keys) + { + if (!Enum.TryParse(artifactType, true, out _)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + } + } + + // Validate release note types + foreach (var note in request.ReleaseNotes) + { + if (!Enum.TryParse(note.Type, true, out _)) + { + return Problem(new OpenShockProblem("Firmware.InvalidReleaseNoteType", "The release note type provided is not valid")); + } + } + + await using var transaction = await _db.Database.BeginTransactionAsync(); + + // Upsert the firmware version + var existingVersion = await _db.FirmwareVersions.FirstOrDefaultAsync(v => v.Version == firmwareVersion); + if (existingVersion != null) + { + existingVersion.Channel = channel; + existingVersion.ReleaseDate = request.ReleaseDate; + existingVersion.CommitHash = request.CommitHash; + existingVersion.ReleaseUrl = request.ReleaseUrl; + } + else + { + _db.FirmwareVersions.Add(new FirmwareVersion + { + Version = firmwareVersion, + Channel = channel, + ReleaseDate = request.ReleaseDate, + CommitHash = request.CommitHash, + ReleaseUrl = request.ReleaseUrl + }); + } + await _db.SaveChangesAsync(); + + // Replace all artifacts for this version + await _db.FirmwareArtifacts.Where(a => a.Version == firmwareVersion).ExecuteDeleteAsync(); + foreach (var (boardId, artifactTypes) in request.Artifacts) + { + foreach (var (artifactTypeStr, upload) in artifactTypes) + { + var artifactType = Enum.Parse(artifactTypeStr, true); + _db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = firmwareVersion, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = Convert.FromHexString(upload.Sha256Hash), + FileSize = upload.FileSize + }); + } + } + + // Replace all release notes for this version + await _db.FirmwareReleaseNotes.Where(n => n.Version == firmwareVersion).ExecuteDeleteAsync(); + for (var i = 0; i < request.ReleaseNotes.Count; i++) + { + var note = request.ReleaseNotes[i]; + _db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote + { + Version = firmwareVersion, + Index = i, + Type = Enum.Parse(note.Type, true), + Title = note.Title, + Content = note.Content + }); + } + + await _db.SaveChangesAsync(); + await transaction.CommitAsync(); + + return Created(); + } + + [HttpDelete("versions/{firmwareVersion}")] + public async Task DeleteVersion([FromRoute] string firmwareVersion) + { + var deleted = await _db.FirmwareVersions.Where(v => v.Version == firmwareVersion).ExecuteDeleteAsync(); + if (deleted <= 0) return Problem(FirmwareError.FirmwareVersionNotFound); + return Ok(); + } + + // ---- Board Management ---- + + [HttpGet("boards")] + public async Task ListBoards() + { + var boards = await _db.FirmwareBoards + .Include(b => b.ChipNavigation) + .Select(b => new FirmwareBoardDto + { + Id = b.Id, + Name = b.Name, + ChipId = b.ChipId, + ChipName = b.ChipNavigation.Name, + Discontinued = b.Discontinued + }) + .ToListAsync(); + + return Ok(boards); + } + + [HttpPut("boards/{boardId}")] + public async Task UpsertBoard([FromRoute] string boardId, [FromBody] CreateFirmwareBoardRequest request) + { + if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId)) + { + return Problem(FirmwareError.FirmwareChipNotFound); + } + + var existing = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); + if (existing != null) + { + existing.Name = request.Name; + existing.ChipId = request.ChipId; + } + else + { + _db.FirmwareBoards.Add(new FirmwareBoard + { + Id = boardId, + Name = request.Name, + ChipId = request.ChipId, + Discontinued = false + }); + } + + await _db.SaveChangesAsync(); + return Created(); + } + + [HttpPatch("boards/{boardId}/discontinue")] + public async Task DiscontinueBoard([FromRoute] string boardId) + { + var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); + if (board == null) return Problem(FirmwareError.FirmwareBoardNotFound); + + board.Discontinued = true; + await _db.SaveChangesAsync(); + return Ok(); + } + + [HttpDelete("boards/{boardId}")] + public async Task DeleteBoard([FromRoute] string boardId) + { + if (await _db.FirmwareArtifacts.AnyAsync(a => a.BoardId == boardId)) + { + return Problem(FirmwareError.FirmwareBoardInUse); + } + + var deleted = await _db.FirmwareBoards.Where(b => b.Id == boardId).ExecuteDeleteAsync(); + if (deleted <= 0) return Problem(FirmwareError.FirmwareBoardNotFound); + return Ok(); + } + + // ---- Chip Management ---- + + [HttpGet("chips")] + public async Task ListChips() + { + var chips = await _db.FirmwareChips + .Select(c => new FirmwareChipDto + { + Id = c.Id, + Name = c.Name, + Architecture = c.Architecture != null ? c.Architecture.Value.ToString().ToLowerInvariant() : null + }) + .ToListAsync(); + + return Ok(chips); + } + + [HttpPut("chips/{chipId}")] + public async Task UpsertChip([FromRoute] string chipId, [FromBody] CreateFirmwareChipRequest request) + { + FirmwareChipArchitecture? architecture = null; + if (request.Architecture != null) + { + if (!Enum.TryParse(request.Architecture, true, out var parsed)) + { + return Problem(new OpenShockProblem("Firmware.InvalidArchitecture", "The architecture provided is not valid")); + } + architecture = parsed; + } + + var existing = await _db.FirmwareChips.FirstOrDefaultAsync(c => c.Id == chipId); + if (existing != null) + { + existing.Name = request.Name; + existing.Architecture = architecture; + } + else + { + _db.FirmwareChips.Add(new FirmwareChip + { + Id = chipId, + Name = request.Name, + Architecture = architecture + }); + } + + await _db.SaveChangesAsync(); + return Created(); + } + + [HttpDelete("chips/{chipId}")] + public async Task DeleteChip([FromRoute] string chipId) + { + if (await _db.FirmwareBoards.AnyAsync(b => b.ChipId == chipId)) + { + return Problem(FirmwareError.FirmwareChipInUse); + } + + var deleted = await _db.FirmwareChips.Where(c => c.Id == chipId).ExecuteDeleteAsync(); + if (deleted <= 0) return Problem(FirmwareError.FirmwareChipNotFound); + return Ok(); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs new file mode 100644 index 0000000..e50d8ce --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs @@ -0,0 +1,49 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +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] + public async Task ListBoards([FromQuery] string? chipId, [FromQuery] bool includeDiscontinued = false) + { + IQueryable query = _db.FirmwareBoards.Include(b => b.ChipNavigation); + + if (!string.IsNullOrWhiteSpace(chipId)) + { + query = query.Where(b => b.ChipId == chipId); + } + + if (!includeDiscontinued) + { + query = query.Where(b => !b.Discontinued); + } + + var boards = await query + .Select(b => new FirmwareBoardDto + { + Id = b.Id, + Name = b.Name, + ChipId = b.ChipId, + ChipName = b.ChipNavigation.Name, + Discontinued = b.Discontinued + }) + .ToListAsync(); + + 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..6fd2c53 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs @@ -0,0 +1,35 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +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] + public async Task ListChips() + { + var chips = await _db.FirmwareChips + .Select(c => new FirmwareChipDto + { + Id = c.Id, + Name = c.Name, + Architecture = c.Architecture != null ? c.Architecture.Value.ToString().ToLowerInvariant() : null + }) + .ToListAsync(); + + 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..ccfdd50 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs @@ -0,0 +1,75 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; + +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}")] + public async Task GetLatest([FromRoute] string channel) + { + 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) + .Include(v => v.Artifacts.Where(a => a.ArtifactType == FirmwareArtifactType.Merged)) + .FirstOrDefaultAsync(); + + if (latestVersion == null) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + var artifacts = new Dictionary(); + + foreach (var artifact in latestVersion.Artifacts) + { + artifacts[artifact.BoardId] = new FirmwareBoardArtifact + { + Url = $"{cdnBase}/{latestVersion.Version}/{artifact.BoardId}/{GetArtifactFileName(artifact.ArtifactType)}", + Sha256Hash = Convert.ToHexString(artifact.HashSha256), + FileSize = artifact.FileSize + }; + } + + return Ok(new FirmwareLatestResponse + { + Version = latestVersion.Version, + Channel = latestVersion.Channel.ToString().ToLowerInvariant(), + ReleaseDate = latestVersion.ReleaseDate, + Artifacts = artifacts + }); + } + + private static string GetArtifactFileName(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/Controllers/V2/Firmware/VersionsController.cs b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs new file mode 100644 index 0000000..2b2b461 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs @@ -0,0 +1,113 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Config; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.Problems; +using OpenShock.RepositoryServer.RepoServerDb; + +namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; + +[ApiVersion("2.0")] +[ApiController] +[Route("/v{version:apiVersion}/firmware/versions")] +public sealed class VersionsController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + private readonly ApiConfig _apiConfig; + + public VersionsController(RepoServerContext db, ApiConfig apiConfig) + { + _db = db; + _apiConfig = apiConfig; + } + + [HttpGet] + public async Task ListVersions([FromQuery] string? channel) + { + IQueryable query = _db.FirmwareVersions; + + if (!string.IsNullOrWhiteSpace(channel)) + { + if (!Enum.TryParse(channel, true, out var firmwareChannel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + query = query.Where(v => v.Channel == firmwareChannel); + } + + var versions = await query + .OrderByDescending(v => v.ReleaseDate) + .Select(v => new FirmwareVersionSummary + { + Version = v.Version, + Channel = v.Channel.ToString().ToLowerInvariant(), + ReleaseDate = v.ReleaseDate + }) + .ToListAsync(); + + return Ok(versions); + } + + [HttpGet("{firmwareVersion}")] + public async Task GetVersion([FromRoute] string firmwareVersion) + { + var version = await _db.FirmwareVersions + .Include(v => v.Artifacts) + .Include(v => v.ReleaseNotes.OrderBy(n => n.Index)) + .FirstOrDefaultAsync(v => v.Version == firmwareVersion); + + if (version == null) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + + var artifacts = new Dictionary>(); + foreach (var artifact in version.Artifacts) + { + if (!artifacts.TryGetValue(artifact.BoardId, out var list)) + { + list = new List(); + artifacts[artifact.BoardId] = list; + } + + list.Add(new FirmwareArtifactDto + { + Type = artifact.ArtifactType.ToString().ToLowerInvariant(), + Url = $"{cdnBase}/{version.Version}/{artifact.BoardId}/{GetArtifactFileName(artifact.ArtifactType)}", + Sha256Hash = Convert.ToHexString(artifact.HashSha256), + FileSize = artifact.FileSize + }); + } + + var releaseNotes = version.ReleaseNotes.Select(n => new FirmwareReleaseNoteDto + { + Type = n.Type.ToString().ToLowerInvariant(), + Title = n.Title, + Content = n.Content + }).ToList(); + + return Ok(new FirmwareVersionResponse + { + Version = version.Version, + Channel = version.Channel.ToString().ToLowerInvariant(), + ReleaseDate = version.ReleaseDate, + CommitHash = version.CommitHash, + ReleaseUrl = version.ReleaseUrl, + Artifacts = artifacts, + ReleaseNotes = releaseNotes + }); + } + + private static string GetArtifactFileName(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/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 95% rename from DesktopRepositoryServer/ExceptionHandler/ExceptionHandler.cs rename to RepositoryServer/ExceptionHandler/ExceptionHandler.cs index 65b2410..841313a 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 { 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/20260211110324_AddFirmwareTables.Designer.cs b/RepositoryServer/Migrations/20260211110324_AddFirmwareTables.Designer.cs new file mode 100644 index 0000000..5130e56 --- /dev/null +++ b/RepositoryServer/Migrations/20260211110324_AddFirmwareTables.Designer.cs @@ -0,0 +1,350 @@ +// +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.RepoServerDb; + +#nullable disable + +namespace OpenShock.RepositoryServer.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20260211110324_AddFirmwareTables")] + partial class AddFirmwareTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_artifact_type", new[] { "app", "bootloader", "merged", "partitions", "static_fs" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_artifact_type", "firmware_artifact_type", new[] { "merged", "app", "bootloader", "partitions", "static_fs" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_channel", new[] { "beta", "develop", "stable" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_channel", "firmware_channel", new[] { "stable", "beta", "develop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_chip_architecture", new[] { "risc_v", "xtensa" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_chip_architecture", "firmware_chip_architecture", new[] { "xtensa", "risc_v" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_release_note_type", new[] { "breaking", "info", "section", "warning" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_release_note_type", "firmware_release_note_type", new[] { "warning", "info", "breaking", "section" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareArtifact", b => + { + b.Property("Version") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("BoardId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .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") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("id"); + + b.Property("ChipId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .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.HasKey("Id") + .HasName("firmware_boards_pkey"); + + b.HasIndex("ChipId"); + + b.ToTable("firmware_boards", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .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.ToTable("firmware_chips", (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("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("firmware_release_note_type") + .HasColumnName("type"); + + b.HasKey("Version", "Index") + .HasName("firmware_release_notes_pkey"); + + b.ToTable("firmware_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("firmware_channel") + .HasColumnName("channel"); + + b.Property("CommitHash") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("commit_hash"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("ReleaseUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("release_url"); + + 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.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.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.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.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.FirmwareVersion", b => + { + b.Navigation("Artifacts"); + + b.Navigation("ReleaseNotes"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => + { + b.Navigation("Versions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RepositoryServer/Migrations/20260211110324_AddFirmwareTables.cs b/RepositoryServer/Migrations/20260211110324_AddFirmwareTables.cs new file mode 100644 index 0000000..9e25e86 --- /dev/null +++ b/RepositoryServer/Migrations/20260211110324_AddFirmwareTables.cs @@ -0,0 +1,171 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using OpenShock.RepositoryServer.RepoServerDb; + +#nullable disable + +namespace OpenShock.RepositoryServer.Migrations +{ + /// + public partial class AddFirmwareTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:firmware_artifact_type", "app,bootloader,merged,partitions,static_fs") + .Annotation("Npgsql:Enum:firmware_artifact_type.firmware_artifact_type", "merged,app,bootloader,partitions,static_fs") + .Annotation("Npgsql:Enum:firmware_channel", "beta,develop,stable") + .Annotation("Npgsql:Enum:firmware_channel.firmware_channel", "stable,beta,develop") + .Annotation("Npgsql:Enum:firmware_chip_architecture", "risc_v,xtensa") + .Annotation("Npgsql:Enum:firmware_chip_architecture.firmware_chip_architecture", "xtensa,risc_v") + .Annotation("Npgsql:Enum:firmware_release_note_type", "breaking,info,section,warning") + .Annotation("Npgsql:Enum:firmware_release_note_type.firmware_release_note_type", "warning,info,breaking,section"); + + migrationBuilder.CreateTable( + name: "firmware_chips", + columns: table => new + { + id = table.Column(type: "character varying(32)", maxLength: 32, 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: "firmware_versions", + columns: table => new + { + version = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + channel = table.Column(type: "firmware_channel", nullable: false), + release_date = table.Column(type: "timestamp with time zone", nullable: false), + commit_hash = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + release_url = table.Column(type: "character varying(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("firmware_versions_pkey", x => x.version); + }); + + migrationBuilder.CreateTable( + name: "firmware_boards", + columns: table => new + { + id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + chip_id = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + discontinued = table.Column(type: "boolean", nullable: false, defaultValue: 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_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.CreateTable( + name: "firmware_artifacts", + columns: table => new + { + version = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + board_id = table.Column(type: "character varying(64)", maxLength: 64, 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.CreateIndex( + name: "IX_firmware_artifacts_board_id", + table: "firmware_artifacts", + column: "board_id"); + + migrationBuilder.CreateIndex( + name: "IX_firmware_boards_chip_id", + table: "firmware_boards", + column: "chip_id"); + + 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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "firmware_artifacts"); + + migrationBuilder.DropTable( + name: "firmware_release_notes"); + + migrationBuilder.DropTable( + name: "firmware_boards"); + + migrationBuilder.DropTable( + name: "firmware_versions"); + + migrationBuilder.DropTable( + name: "firmware_chips"); + + migrationBuilder.AlterDatabase() + .OldAnnotation("Npgsql:Enum:firmware_artifact_type", "app,bootloader,merged,partitions,static_fs") + .OldAnnotation("Npgsql:Enum:firmware_artifact_type.firmware_artifact_type", "merged,app,bootloader,partitions,static_fs") + .OldAnnotation("Npgsql:Enum:firmware_channel", "beta,develop,stable") + .OldAnnotation("Npgsql:Enum:firmware_channel.firmware_channel", "stable,beta,develop") + .OldAnnotation("Npgsql:Enum:firmware_chip_architecture", "risc_v,xtensa") + .OldAnnotation("Npgsql:Enum:firmware_chip_architecture.firmware_chip_architecture", "xtensa,risc_v") + .OldAnnotation("Npgsql:Enum:firmware_release_note_type", "breaking,info,section,warning") + .OldAnnotation("Npgsql:Enum:firmware_release_note_type.firmware_release_note_type", "warning,info,breaking,section"); + } + } +} diff --git a/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs b/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs new file mode 100644 index 0000000..ae11dbd --- /dev/null +++ b/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs @@ -0,0 +1,347 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.RepositoryServer.RepoServerDb; + +#nullable disable + +namespace OpenShock.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, "firmware_artifact_type", new[] { "app", "bootloader", "merged", "partitions", "static_fs" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_artifact_type", "firmware_artifact_type", new[] { "merged", "app", "bootloader", "partitions", "static_fs" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_channel", new[] { "beta", "develop", "stable" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_channel", "firmware_channel", new[] { "stable", "beta", "develop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_chip_architecture", new[] { "risc_v", "xtensa" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_chip_architecture", "firmware_chip_architecture", new[] { "xtensa", "risc_v" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_release_note_type", new[] { "breaking", "info", "section", "warning" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_release_note_type", "firmware_release_note_type", new[] { "warning", "info", "breaking", "section" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareArtifact", b => + { + b.Property("Version") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.Property("BoardId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .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") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("id"); + + b.Property("ChipId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .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.HasKey("Id") + .HasName("firmware_boards_pkey"); + + b.HasIndex("ChipId"); + + b.ToTable("firmware_boards", (string)null); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareChip", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .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.ToTable("firmware_chips", (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("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("firmware_release_note_type") + .HasColumnName("type"); + + b.HasKey("Version", "Index") + .HasName("firmware_release_notes_pkey"); + + b.ToTable("firmware_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("firmware_channel") + .HasColumnName("channel"); + + b.Property("CommitHash") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("commit_hash"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("ReleaseUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("release_url"); + + 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.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.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.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.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.FirmwareVersion", b => + { + b.Navigation("Artifacts"); + + b.Navigation("ReleaseNotes"); + }); + + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b => + { + b.Navigation("Versions"); + }); +#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..087e202 --- /dev/null +++ b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs @@ -0,0 +1,7 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class CreateFirmwareBoardRequest +{ + public required string Name { get; init; } + public required string ChipId { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs new file mode 100644 index 0000000..299b08a --- /dev/null +++ b/RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs @@ -0,0 +1,7 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class CreateFirmwareChipRequest +{ + public required string Name { get; init; } + public string? Architecture { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs new file mode 100644 index 0000000..2b057c2 --- /dev/null +++ b/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs @@ -0,0 +1,11 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class CreateFirmwareVersionRequest +{ + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } + public required string CommitHash { get; init; } + public string? ReleaseUrl { get; init; } + public required Dictionary> Artifacts { get; init; } + public required List ReleaseNotes { 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/FirmwareArtifactUpload.cs b/RepositoryServer/Models/Firmware/FirmwareArtifactUpload.cs new file mode 100644 index 0000000..a456194 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareArtifactUpload.cs @@ -0,0 +1,7 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareArtifactUpload +{ + public required string Sha256Hash { get; init; } + public required long FileSize { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareBoardArtifact.cs b/RepositoryServer/Models/Firmware/FirmwareBoardArtifact.cs new file mode 100644 index 0000000..2abb512 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareBoardArtifact.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareBoardArtifact +{ + public required string Url { get; init; } + public required string Sha256Hash { get; init; } + public required long FileSize { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs new file mode 100644 index 0000000..8440296 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs @@ -0,0 +1,10 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareBoardDto +{ + public required string Id { get; init; } + public required string Name { get; init; } + public required string ChipId { get; init; } + public required string ChipName { get; init; } + public required bool Discontinued { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareChipDto.cs b/RepositoryServer/Models/Firmware/FirmwareChipDto.cs new file mode 100644 index 0000000..574b0d5 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareChipDto.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareChipDto +{ + public required string Id { get; init; } + public required string Name { get; init; } + public string? Architecture { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs b/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs new file mode 100644 index 0000000..a651339 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs @@ -0,0 +1,9 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareLatestResponse +{ + public required string Version { get; init; } + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } + public required Dictionary Artifacts { 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/FirmwareReleaseNoteUpload.cs b/RepositoryServer/Models/Firmware/FirmwareReleaseNoteUpload.cs new file mode 100644 index 0000000..b9e60d6 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareReleaseNoteUpload.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareReleaseNoteUpload +{ + public required string Type { get; init; } + public string? Title { get; init; } + public required string Content { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs b/RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs new file mode 100644 index 0000000..5eaec89 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs @@ -0,0 +1,12 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareVersionResponse +{ + public required string Version { get; init; } + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } + public required string CommitHash { get; init; } + public string? ReleaseUrl { get; init; } + public required Dictionary> Artifacts { get; init; } + public required List ReleaseNotes { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs b/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs new file mode 100644 index 0000000..f9ea106 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareVersionSummary +{ + public required string Version { get; init; } + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { 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/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..9a505f7 --- /dev/null +++ b/RepositoryServer/Problems/FirmwareError.cs @@ -0,0 +1,15 @@ +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 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); +} diff --git a/DesktopRepositoryServer/Problems/ModuleError.cs b/RepositoryServer/Problems/ModuleError.cs similarity index 79% rename from DesktopRepositoryServer/Problems/ModuleError.cs rename to RepositoryServer/Problems/ModuleError.cs index 9f9496d..f37b59c 100644 --- a/DesktopRepositoryServer/Problems/ModuleError.cs +++ b/RepositoryServer/Problems/ModuleError.cs @@ -1,6 +1,6 @@ using System.Net; -namespace OpenShock.Desktop.RepositoryServer.Problems; +namespace OpenShock.RepositoryServer.Problems; public static class ModuleError { 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 94% rename from DesktopRepositoryServer/Program.cs rename to RepositoryServer/Program.cs index f4e1e68..c1c0156 100644 --- a/DesktopRepositoryServer/Program.cs +++ b/RepositoryServer/Program.cs @@ -7,16 +7,16 @@ 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.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); 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/FirmwareArtifact.cs b/RepositoryServer/RepoServerDb/FirmwareArtifact.cs new file mode 100644 index 0000000..55f310e --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareArtifact.cs @@ -0,0 +1,13 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareArtifact +{ + public string Version { get; set; } = null!; + public string BoardId { get; set; } = null!; + 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/FirmwareArtifactType.cs b/RepositoryServer/RepoServerDb/FirmwareArtifactType.cs new file mode 100644 index 0000000..ed18adc --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareArtifactType.cs @@ -0,0 +1,10 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +public enum FirmwareArtifactType +{ + Merged, + App, + Bootloader, + Partitions, + StaticFs +} diff --git a/RepositoryServer/RepoServerDb/FirmwareBoard.cs b/RepositoryServer/RepoServerDb/FirmwareBoard.cs new file mode 100644 index 0000000..46e3442 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareBoard.cs @@ -0,0 +1,12 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareBoard +{ + public string Id { get; set; } = null!; + public string ChipId { get; set; } = null!; + public string Name { get; set; } = null!; + public bool Discontinued { get; set; } + + public virtual FirmwareChip ChipNavigation { get; set; } = null!; + public virtual ICollection Artifacts { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/FirmwareChannel.cs b/RepositoryServer/RepoServerDb/FirmwareChannel.cs new file mode 100644 index 0000000..c1f2822 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareChannel.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +public enum FirmwareChannel +{ + Stable, + Beta, + Develop +} diff --git a/RepositoryServer/RepoServerDb/FirmwareChip.cs b/RepositoryServer/RepoServerDb/FirmwareChip.cs new file mode 100644 index 0000000..34b4797 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareChip.cs @@ -0,0 +1,10 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareChip +{ + public string Id { get; set; } = null!; + public string Name { get; set; } = null!; + public FirmwareChipArchitecture? Architecture { get; set; } + + public virtual ICollection Boards { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/FirmwareChipArchitecture.cs b/RepositoryServer/RepoServerDb/FirmwareChipArchitecture.cs new file mode 100644 index 0000000..0716add --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareChipArchitecture.cs @@ -0,0 +1,7 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +public enum FirmwareChipArchitecture +{ + Xtensa, + RiscV +} diff --git a/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs b/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs new file mode 100644 index 0000000..5037274 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs @@ -0,0 +1,12 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareReleaseNote +{ + public string Version { get; set; } = null!; + public int Index { get; set; } + public FirmwareReleaseNoteType Type { 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/FirmwareReleaseNoteType.cs b/RepositoryServer/RepoServerDb/FirmwareReleaseNoteType.cs new file mode 100644 index 0000000..d65ca13 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareReleaseNoteType.cs @@ -0,0 +1,9 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +public enum FirmwareReleaseNoteType +{ + Warning, + Info, + Breaking, + Section +} diff --git a/RepositoryServer/RepoServerDb/FirmwareVersion.cs b/RepositoryServer/RepoServerDb/FirmwareVersion.cs new file mode 100644 index 0000000..0aaed1d --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareVersion.cs @@ -0,0 +1,13 @@ +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareVersion +{ + public string Version { get; set; } = null!; + public FirmwareChannel Channel { get; set; } + public DateTimeOffset ReleaseDate { get; set; } + public string CommitHash { get; set; } = null!; + public string? ReleaseUrl { get; set; } + + 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..be5bb03 --- /dev/null +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using EntityFramework.Exceptions.PostgreSQL; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +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) + { + ConfigureOptionsBuilder(optionsBuilder, "Host=localhost;Database=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 => + { + npgsqlBuilder.MapEnum("firmware_channel"); + npgsqlBuilder.MapEnum("firmware_artifact_type"); + npgsqlBuilder.MapEnum("firmware_release_note_type"); + npgsqlBuilder.MapEnum("firmware_chip_architecture"); + }); + + optionsBuilder.UseExceptionProcessor(); + + if (debug) + { + optionsBuilder.EnableSensitiveDataLogging(); + optionsBuilder.EnableDetailedErrors(); + } + } + + // 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 FirmwareArtifacts { get; set; } + public virtual DbSet FirmwareReleaseNotes { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // PostgreSQL enum mappings + modelBuilder.HasPostgresEnum("firmware_channel"); + modelBuilder.HasPostgresEnum("firmware_artifact_type"); + modelBuilder.HasPostgresEnum("firmware_release_note_type"); + modelBuilder.HasPostgresEnum("firmware_chip_architecture"); + + // Desktop module entities (unchanged) + 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) + .HasMaxLength(32) + .HasColumnName("id"); + entity.Property(e => e.Name) + .HasMaxLength(64) + .HasColumnName("name"); + entity.Property(e => e.Architecture) + .HasColumnName("architecture"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("firmware_boards_pkey"); + entity.ToTable("firmware_boards"); + + entity.Property(e => e.Id) + .HasMaxLength(64) + .HasColumnName("id"); + entity.Property(e => e.ChipId) + .HasMaxLength(32) + .HasColumnName("chip_id"); + entity.Property(e => e.Name) + .HasMaxLength(128) + .HasColumnName("name"); + entity.Property(e => e.Discontinued) + .HasDefaultValue(false) + .HasColumnName("discontinued"); + + entity.HasOne(d => d.ChipNavigation).WithMany(p => p.Boards) + .HasForeignKey(d => d.ChipId) + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_firmware_boards_chip"); + }); + + 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.CommitHash) + .HasMaxLength(40) + .HasColumnName("commit_hash"); + entity.Property(e => e.ReleaseUrl) + .HasMaxLength(256) + .HasColumnName("release_url"); + + 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) + .HasMaxLength(64) + .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.Type) + .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"); + }); + } +} 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 91% rename from DesktopRepositoryServer/DesktopRepositoryServer.csproj rename to RepositoryServer/RepositoryServer.csproj index 1f0e193..1bec1d8 100644 --- a/DesktopRepositoryServer/DesktopRepositoryServer.csproj +++ b/RepositoryServer/RepositoryServer.csproj @@ -1,8 +1,8 @@ - OpenShock.Desktop.RepositoryServer - OpenShock.Desktop.RepositoryServer - Desktop.RepositoryServer + OpenShock.RepositoryServer + OpenShock.RepositoryServer + RepositoryServer 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/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/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/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 100% rename from DesktopRepositoryServer/appsettings.Development.json rename to RepositoryServer/appsettings.Development.json diff --git a/DesktopRepositoryServer/appsettings.json b/RepositoryServer/appsettings.json similarity index 83% rename from DesktopRepositoryServer/appsettings.json rename to RepositoryServer/appsettings.json index 89258a8..52b94ed 100644 --- a/DesktopRepositoryServer/appsettings.json +++ b/RepositoryServer/appsettings.json @@ -13,11 +13,15 @@ } }, + "Firmware": { + "CdnBaseUrl": "https://cdn.openshock.app/firmware" + }, + "Serilog": { "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Grafana.Loki", - "OpenShock.Desktop.RepositoryServer" + "OpenShock.RepositoryServer" ], "MinimumLevel": { "Default": "Warning", @@ -25,7 +29,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 From 2bd7eaf2469d41d834bf3ac11619545fa94dd88b Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Mon, 30 Mar 2026 10:12:06 +0200 Subject: [PATCH 02/15] Improvements --- .claude/settings.local.json | 25 ++ CLAUDE.md | 7 +- PLAN.md | 309 ------------------ .../Controllers/V2/Desktop/AdminController.cs | 95 ------ .../Controllers/V2/Desktop/RepoController.cs | 61 ---- .../V2/Firmware/AdminController.cs | 117 ++----- RepositoryServer/Problems/FirmwareError.cs | 2 + 7 files changed, 61 insertions(+), 555 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 PLAN.md delete mode 100644 RepositoryServer/Controllers/V2/Desktop/AdminController.cs delete mode 100644 RepositoryServer/Controllers/V2/Desktop/RepoController.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0f385a9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,25 @@ +{ + "permissions": { + "allow": [ + "Bash(git mv:*)", + "Bash(find:*)", + "Bash(dotnet build:*)", + "Bash(dotnet ef migrations add:*)", + "Bash(dotnet test:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git config:*)", + "Bash(git push:*)", + "Bash(git -C /src/openshock/firmware remote -v)", + "Bash(git remote set-url:*)", + "Bash(git -C /src/openshock/API pull)", + "Bash(git -C /src/openshock/API checkout develop)", + "Bash(git -C /src/openshock/API pull origin develop)", + "Bash(ls:*)", + "Bash(git checkout:*)", + "Bash(gh pr create:*)", + "Bash(rm /src/openshock/repositoryservers/RepositoryServer/Controllers/V2/Desktop/AdminController.cs /src/openshock/repositoryservers/RepositoryServer/Controllers/V2/Desktop/RepoController.cs)", + "Bash(rmdir /src/openshock/repositoryservers/RepositoryServer/Controllers/V2/Desktop)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 97b2df9..e45f458 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Unified repository server for distributing OpenShock desktop app modules and fir - PostgreSQL via Npgsql + EF Core (with DbContext pooling) - Serilog (console + Grafana Loki) - OpenTelemetry (Prometheus exporter) -- API Versioning (Asp.Versioning) — URL-based: `/v1/`, `/v2/desktop/`, `/v2/firmware/` +- API Versioning (Asp.Versioning) — URL-based: `/v1/`, `/v2/firmware/` - FlexLabs.EntityFrameworkCore.Upsert for upserts - MiniValidation for config validation - Semver NuGet package for semver parsing @@ -23,13 +23,10 @@ RepositoryServer/ # Main web API project Config/ # ApiConfig, DbConfig, RepoConfig, MetricsConfig, FirmwareConfig Controllers/ OpenShockControllerBase.cs # Shared base controller - V1/ # Legacy desktop endpoints (backwards compat) + V1/ # Desktop endpoints RepoController.cs # GET /v1/ — desktop module manifest AdminController.cs # /v1/admin/... — desktop module CRUD V2/ - Desktop/ # Same as V1 under new prefix - RepoController.cs # GET /v2/desktop/ - AdminController.cs # /v2/desktop/admin/... Firmware/ LatestController.cs # GET /v2/firmware/latest/{channel} VersionsController.cs # GET /v2/firmware/versions[/{version}] diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 67d84cc..0000000 --- a/PLAN.md +++ /dev/null @@ -1,309 +0,0 @@ -# Plan: Merge DesktopRepositoryServer into unified RepositoryServer + Add Firmware Support - -## Status: ALL STEPS COMPLETED - -All 8 steps have been implemented. Build succeeds with 0 errors, tests pass. - -## Context - -The OpenShock/RepositoryServers repo currently has a `DesktopRepositoryServer` for distributing desktop app modules. We need to: -1. Refactor it into a single unified `RepositoryServer` application (not separate apps per product) -2. Add firmware repository endpoints for OTA updates, frontend flashing, and release notes - -The existing desktop server is a .NET 10 ASP.NET Core app with PostgreSQL/EF Core, admin token auth, RFC 7807 error handling, and Docker deployment. - -**Routing decision:** Keep existing v1 desktop endpoints at `/v1/` unchanged for backwards compatibility. New v2 API introduces `/v2/desktop/` and `/v2/firmware/` prefixes. - -**Channel strategy:** Explicit channel field set by admin when creating a firmware version (not derived from semver). - -## Step 1: Rename and restructure the project [DONE] - -Rename `DesktopRepositoryServer/` → `RepositoryServer/` and update namespaces. - -**Files to rename/modify:** -- `DesktopRepositoryServer/` → `RepositoryServer/` -- `DesktopRepositoryServer.Tests/` → `RepositoryServer.Tests/` -- `RepositoryServers.slnx` — update project paths -- `docker/DesktopRepositoryServer.Dockerfile` → `docker/RepositoryServer.Dockerfile` -- `docker/entrypoint.sh` — update DLL name -- `.github/workflows/ci-build.yml` — update matrix entries -- `.github/workflows/ci-tag.yml` — update image names - -**Namespace changes:** -- `OpenShock.Desktop.RepositoryServer` → `OpenShock.RepositoryServer` -- Update all `using` statements, `.csproj` RootNamespace/AssemblyName -- `appsettings.json` Serilog `Using` and `Override` sections - -## Step 2: Reorganize controllers with versioned routing - -``` -RepositoryServer/ - Controllers/ - OpenShockControllerBase.cs # Shared base (keep as-is, update namespace) - V1/ - RepoController.cs # GET /v1/ — existing desktop manifest (unchanged behavior) - AdminController.cs # /v1/admin/... — existing desktop admin (unchanged behavior) - V2/ - Desktop/ - RepoController.cs # GET /v2/desktop/ — same as v1 but under new prefix - AdminController.cs # /v2/desktop/admin/... - Firmware/ - LatestController.cs # GET /v2/firmware/latest/{channel} - VersionsController.cs # GET /v2/firmware/versions[/{version}] - ChipsController.cs # GET /v2/firmware/chips - AdminController.cs # /v2/firmware/admin/versions/{version} -``` - -Route attributes: -- V1 controllers: `[ApiVersion("1.0")]` `[Route("/v{version:apiVersion}/")]` — unchanged -- V2 Desktop: `[ApiVersion("2.0")]` `[Route("/v{version:apiVersion}/desktop")]` -- V2 Firmware: `[ApiVersion("2.0")]` `[Route("/v{version:apiVersion}/firmware")]` - -## Step 3: Add firmware database entities - -New entities in `RepoServerDb/`: - -**PostgreSQL Enums:** -- `firmware_channel`: "stable", "beta", "develop" -- `firmware_artifact_type`: "merged", "app", "bootloader", "partitions", "staticfs" -- `firmware_release_note_type`: "warning", "info", "breaking", "section" -- `firmware_chip_architecture`: "xtensa", "riscv" - -**`FirmwareChip`** (table: `firmware_chips`) -- `Id` (PK, varchar(32): "ESP32", "ESP32-S2", "ESP32-S3", "ESP32-C3") -- `Name` (varchar(64), display name: "ESP32", "ESP32-S2", etc.) -- `Architecture` (firmware_chip_architecture enum, nullable) - -**`FirmwareBoard`** (table: `firmware_boards`) -- `Id` (PK, varchar(64): "OpenShock-Core-V2", "OpenShock-Pishock-Plus") -- `ChipId` (FK → firmware_chips, NOT NULL) -- `Name` (varchar(128), display name: "OpenShock Core V2") -- `Discontinued` (bool, default false) - -**`FirmwareVersion`** (table: `firmware_versions`) -- `Version` (PK, varchar(64), semver string) -- `Channel` (firmware_channel enum) — explicitly set by admin -- `ReleaseDate` (DateTimeOffset) -- `CommitHash` (varchar(40)) -- `ReleaseUrl` (varchar(256), nullable) — GitHub release URL - -**`FirmwareArtifact`** (table: `firmware_artifacts`) -- `Version` + `BoardId` + `ArtifactType` (composite PK) -- `Version` (FK → firmware_versions, CASCADE) -- `BoardId` (FK → firmware_boards, CASCADE) -- `ArtifactType` (firmware_artifact_type enum) -- `HashSha256` (bytea, NOT NULL) -- `FileSize` (int64, NOT NULL) - -**`FirmwareReleaseNote`** (table: `firmware_release_notes`) -- `Version` + `Index` (composite PK) -- `Version` (FK → firmware_versions, CASCADE) -- `Index` (int, ordering) -- `Type` (firmware_release_note_type enum) -- `Title` (varchar(256), nullable — for section headers) -- `Content` (text, NOT NULL) - -**URL Generation Convention:** -Binary URLs are generated as: `{cdnBaseUrl}/{version}/{boardId}/{artifactType}.bin` -Example: `https://cdn.openshock.app/firmware/1.5.0-rc.6/OpenShock-Core-V2/firmware.bin` - -**EF Core Configuration:** -- Map enums using Npgsql's `HasPostgresEnum()` in `OnModelCreating` -- Configure composite keys and cascade deletes -- Add to `RepoServerContext` as new DbSets alongside existing Module/Version - -**Configuration (appsettings.json):** -```json -{ - "Firmware": { - "CdnBaseUrl": "https://cdn.openshock.app/firmware" - } -} -``` - -## Step 4: Add firmware models (DTOs) - -New files in `Models/Firmware/`: - -**Response DTOs:** -- `FirmwareLatestResponse.cs` — lightweight for OTA clients: - - `Version` (string) - - `Channel` (string) - - `ReleaseDate` (DateTimeOffset) - - `Artifacts` (Dictionary) — board ID → merged artifact only - -- `FirmwareVersionResponse.cs` — full detail for version endpoint: - - `Version` (string) - - `Channel` (string) - - `ReleaseDate` (DateTimeOffset) - - `CommitHash` (string) - - `ReleaseUrl` (string, nullable) - - `Artifacts` (Dictionary>) — board ID → all artifact types - - `ReleaseNotes` (List) - -- `FirmwareVersionSummary.cs` — for list endpoint: - - `Version` (string) - - `Channel` (string) - - `ReleaseDate` (DateTimeOffset) - -- `FirmwareBoardArtifact.cs` — single artifact (for latest endpoint): - - `Url` (string) — generated from convention - - `Sha256Hash` (string) - - `FileSize` (long) - -- `FirmwareArtifactDto.cs` — artifact with type (for version detail): - - `Type` (string) — "merged", "app", etc. - - `Url` (string) — generated from convention - - `Sha256Hash` (string) - - `FileSize` (long) - -- `FirmwareReleaseNoteDto.cs` — release note item: - - `Type` (string) - - `Title` (string, nullable) - - `Content` (string) - -- `FirmwareBoardDto.cs` — board info: - - `Id` (string) - - `Name` (string) - - `ChipId` (string) - - `ChipName` (string) - - `Discontinued` (bool) - -- `FirmwareChipDto.cs` — chip info: - - `Id` (string) - - `Name` (string) - - `Architecture` (string, nullable) - -**Admin Request DTOs:** -- `CreateFirmwareVersionRequest.cs` — create/update version: - - `Channel` (string) - - `ReleaseDate` (DateTimeOffset) - - `CommitHash` (string) - - `ReleaseUrl` (string, nullable) - - `Artifacts` (Dictionary>) — boardId → artifactType → metadata - - `ReleaseNotes` (List) - -- `FirmwareArtifactUpload.cs` — artifact metadata (no URL): - - `Sha256Hash` (string) - - `FileSize` (long) - -- `FirmwareReleaseNoteUpload.cs`: - - `Type` (string) - - `Title` (string, nullable) - - `Content` (string) - -- `CreateFirmwareBoardRequest.cs`: - - `Id` (string) - - `Name` (string) - - `ChipId` (string) - -- `CreateFirmwareChipRequest.cs`: - - `Id` (string) - - `Name` (string) - - `Architecture` (string, nullable) - -## Step 5: Add firmware controllers - -**Public Endpoints:** - -**`LatestController`** — `GET /v2/firmware/latest/{channel}` -- Returns latest version for a channel with **merged artifacts only** (for OTA) -- Query: `firmware_versions` WHERE channel = X, ORDER BY release_date DESC, LIMIT 1 -- JOIN `firmware_artifacts` WHERE artifact_type = 'merged' -- Response: `FirmwareLatestResponse` with board → merged artifact map -- Lightweight, cacheable, optimized for OTA clients polling for updates - -**`VersionsController`** -- `GET /v2/firmware/versions?channel={channel}` — version list, optional channel filter - - Returns: List -- `GET /v2/firmware/versions/{version}` — full metadata - - Returns: `FirmwareVersionResponse` with **all artifact types**, release notes - - JOIN artifacts, boards, chips to include full board info - -**`BoardsController`** — `GET /v2/firmware/boards` -- Query params: `?chipId={chipId}`, `?includeDiscontinued=false` -- Returns: List with chip info joined -- Used by frontend for device selection UI - -**`ChipsController`** — `GET /v2/firmware/chips` -- Returns: List -- Returns all chips (from firmware_chips table) - -**Admin Endpoints:** - -**`AdminController`** — `/v2/firmware/admin` - -Version Management: -- `PUT /v2/firmware/admin/versions/{version}` — upsert version + artifacts + notes - - Transactional: replaces all artifacts and notes for the version - - Validates: board IDs exist, channel is valid, semver format - - Body: `CreateFirmwareVersionRequest` -- `DELETE /v2/firmware/admin/versions/{version}` — cascade delete version - -Board Management: -- `GET /v2/firmware/admin/boards` — list all boards (including discontinued) -- `PUT /v2/firmware/admin/boards/{id}` — upsert board - - Body: `CreateFirmwareBoardRequest` - - Validates: chip ID exists -- `PATCH /v2/firmware/admin/boards/{id}/discontinue` — set discontinued=true -- `DELETE /v2/firmware/admin/boards/{id}` — delete board (fails if artifacts exist) - -Chip Management: -- `GET /v2/firmware/admin/chips` — list all chips -- `PUT /v2/firmware/admin/chips/{id}` — upsert chip - - Body: `CreateFirmwareChipRequest` -- `DELETE /v2/firmware/admin/chips/{id}` — delete chip (fails if boards exist) - -## Step 6: Add firmware problem types - -`Problems/FirmwareError.cs`: -- `FirmwareVersionNotFound` (404) -- `FirmwareBoardNotFound` (404) -- `FirmwareChipNotFound` (404) -- `FirmwareInvalidChannel` (400) -- `FirmwareInvalidSemver` (400) -- `FirmwareInvalidArtifactType` (400) -- `FirmwareBoardInUse` (409) — cannot delete board with artifacts -- `FirmwareChipInUse` (409) — cannot delete chip with boards - -## Step 7: EF Core migration - -```bash -dotnet ef migrations add AddFirmwareTables --project RepositoryServer -``` - -**Migration should include:** -- CREATE TYPE statements for PostgreSQL enums (firmware_channel, firmware_artifact_type, etc.) -- CREATE TABLE statements for chips, boards, versions, artifacts, release_notes -- Foreign key constraints with CASCADE deletes -- Composite primary keys -- Indexes on commonly queried fields (channel, release_date, board_id) - -## Step 8: Update Docker and CI - -- `docker/RepositoryServer.Dockerfile` — update COPY paths and DLL name -- `docker/entrypoint.sh` — `exec dotnet OpenShock.RepositoryServer.dll` -- `ci-build.yml` — update matrix: dockerfile, image name, test project path -- `ci-tag.yml` — update image name to `repository-server` -- Update deploy dispatch event type to `update-repository-server-prod` - -## Verification - -1. `dotnet build` — project compiles -2. `dotnet test` — existing tests pass -3. Existing v1 desktop endpoint still works at `/v1/` -4. New v2 desktop endpoints respond at `/v2/desktop/` -5. New v2 firmware endpoints respond (empty results / proper 404s): - - `GET /v2/firmware/chips` returns empty array - - `GET /v2/firmware/boards` returns empty array - - `GET /v2/firmware/versions` returns empty array - - `GET /v2/firmware/latest/stable` returns 404 FirmwareVersionNotFound -6. Admin can create chips, boards, versions: - - `PUT /v2/firmware/admin/chips/ESP32` succeeds - - `PUT /v2/firmware/admin/boards/OpenShock-Core-V2` succeeds - - `PUT /v2/firmware/admin/versions/1.0.0` succeeds -7. URL generation works correctly: - - Artifact URLs follow convention: `{cdnBase}/{version}/{boardId}/{type}.bin` - - Configuration value from appsettings is used -8. Docker build: `docker build -f docker/RepositoryServer.Dockerfile .` -9. PostgreSQL enums are created correctly in database diff --git a/RepositoryServer/Controllers/V2/Desktop/AdminController.cs b/RepositoryServer/Controllers/V2/Desktop/AdminController.cs deleted file mode 100644 index 4694a30..0000000 --- a/RepositoryServer/Controllers/V2/Desktop/AdminController.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using OpenShock.RepositoryServer.Models; -using OpenShock.RepositoryServer.Problems; -using OpenShock.RepositoryServer.RepoServerDb; -using Semver; -using Module = OpenShock.RepositoryServer.RepoServerDb.Module; -using Version = OpenShock.RepositoryServer.RepoServerDb.Version; - -namespace OpenShock.RepositoryServer.Controllers.V2.Desktop; - -[ApiVersion("2.0")] -[ApiController] -[Route("/v{version:apiVersion}/desktop/admin")] -[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] -public class AdminController : OpenShockControllerBase -{ - private readonly RepoServerContext _db; - - public AdminController(RepoServerContext db) - { - _db = db; - } - - [HttpPut("modules/{moduleId}")] - public async Task CreateModule([FromBody] CreateModuleRequest createModuleRequest, [FromRoute] string moduleId) - { - var module = new Module - { - Id = moduleId.ToLowerInvariant(), - Name = createModuleRequest.Name, - Description = createModuleRequest.Description, - SourceUrl = createModuleRequest.SourceUrl, - 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(); - } - - [HttpDelete("modules/{moduleId}")] - 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(); - } - - [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, - VersionName = moduleVersion, - ZipUrl = createModuleVersionRequest.ZipUrl, - HashSha256 = createModuleVersionRequest.Sha256Hash, - ChangelogUrl = createModuleVersionRequest.ChangelogUrl, - ReleaseUrl = createModuleVersionRequest.ReleaseUrl - }; - - 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) - { - var executed = await _db.Versions.Where(x => x.Module == moduleId && x.VersionName == moduleVersion).ExecuteDeleteAsync(); - if(executed <= 0) return Problem(VersionError.VersionNotFound); - return Ok(); - } -} diff --git a/RepositoryServer/Controllers/V2/Desktop/RepoController.cs b/RepositoryServer/Controllers/V2/Desktop/RepoController.cs deleted file mode 100644 index c9c4a9b..0000000 --- a/RepositoryServer/Controllers/V2/Desktop/RepoController.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Collections.Immutable; -using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using OpenShock.RepositoryServer.Config; -using OpenShock.RepositoryServer.Models; -using OpenShock.RepositoryServer.RepoServerDb; -using OpenShock.RepositoryServer.Utils; -using Semver; -using Module = OpenShock.RepositoryServer.Models.Module; -using Version = OpenShock.RepositoryServer.Models.Version; - -namespace OpenShock.RepositoryServer.Controllers.V2.Desktop; - -[ApiVersion("2.0")] -[ApiController] -[Route("/v{version:apiVersion}/desktop")] -public sealed class RepoController : OpenShockControllerBase -{ - private readonly RepoServerContext _db; - private readonly ApiConfig _apiConfig; - - 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, - Description = x.Description, - IconUrl = x.IconUrl, - SourceUrl = x.SourceUrl, - Versions = x.Versions.ToImmutableDictionary( - y => SemVersion.Parse(y.VersionName, SemVersionStyles.Strict, 64), y => new Version - { - Url = y.ZipUrl, - Sha256Hash = y.HashSha256, - ChangelogUrl = y.ChangelogUrl, - ReleaseUrl = y.ReleaseUrl - }) - }); - - var repository = new Repository - { - Id = _apiConfig.Repo.Id, - Name = _apiConfig.Repo.Name, - Author = _apiConfig.Repo.Author, - Homepage = _apiConfig.Repo.Homepage, - Modules = modules - }; - - return Ok(repository); - } -} diff --git a/RepositoryServer/Controllers/V2/Firmware/AdminController.cs b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs index e63c6ae..49a1395 100644 --- a/RepositoryServer/Controllers/V2/Firmware/AdminController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs @@ -27,7 +27,7 @@ public AdminController(RepoServerContext db) [HttpPut("versions/{firmwareVersion}")] public async Task UpsertVersion([FromRoute] string firmwareVersion, [FromBody] CreateFirmwareVersionRequest request) { - if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Any, out _)) + if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) { return Problem(FirmwareError.FirmwareInvalidSemver); } @@ -67,33 +67,23 @@ public async Task UpsertVersion([FromRoute] string firmwareVersio { if (!Enum.TryParse(note.Type, true, out _)) { - return Problem(new OpenShockProblem("Firmware.InvalidReleaseNoteType", "The release note type provided is not valid")); + return Problem(FirmwareError.FirmwareInvalidReleaseNoteType); } } await using var transaction = await _db.Database.BeginTransactionAsync(); // Upsert the firmware version - var existingVersion = await _db.FirmwareVersions.FirstOrDefaultAsync(v => v.Version == firmwareVersion); - if (existingVersion != null) + var versionEntity = new FirmwareVersion { - existingVersion.Channel = channel; - existingVersion.ReleaseDate = request.ReleaseDate; - existingVersion.CommitHash = request.CommitHash; - existingVersion.ReleaseUrl = request.ReleaseUrl; - } - else - { - _db.FirmwareVersions.Add(new FirmwareVersion - { - Version = firmwareVersion, - Channel = channel, - ReleaseDate = request.ReleaseDate, - CommitHash = request.CommitHash, - ReleaseUrl = request.ReleaseUrl - }); - } - await _db.SaveChangesAsync(); + Version = firmwareVersion, + Channel = channel, + ReleaseDate = request.ReleaseDate, + CommitHash = request.CommitHash, + ReleaseUrl = request.ReleaseUrl + }; + var executed = await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(); + if (executed <= 0) throw new Exception("Failed to upsert firmware version"); // Replace all artifacts for this version await _db.FirmwareArtifacts.Where(a => a.Version == firmwareVersion).ExecuteDeleteAsync(); @@ -144,24 +134,6 @@ public async Task DeleteVersion([FromRoute] string firmwareVersio // ---- Board Management ---- - [HttpGet("boards")] - public async Task ListBoards() - { - var boards = await _db.FirmwareBoards - .Include(b => b.ChipNavigation) - .Select(b => new FirmwareBoardDto - { - Id = b.Id, - Name = b.Name, - ChipId = b.ChipId, - ChipName = b.ChipNavigation.Name, - Discontinued = b.Discontinued - }) - .ToListAsync(); - - return Ok(boards); - } - [HttpPut("boards/{boardId}")] public async Task UpsertBoard([FromRoute] string boardId, [FromBody] CreateFirmwareBoardRequest request) { @@ -170,24 +142,22 @@ public async Task UpsertBoard([FromRoute] string boardId, [FromBo return Problem(FirmwareError.FirmwareChipNotFound); } - var existing = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); - if (existing != null) - { - existing.Name = request.Name; - existing.ChipId = request.ChipId; - } - else + var board = new FirmwareBoard { - _db.FirmwareBoards.Add(new FirmwareBoard + Id = boardId, + Name = request.Name, + ChipId = request.ChipId, + Discontinued = false + }; + var executed = await _db.FirmwareBoards.Upsert(board).On(b => b.Id) + .WhenMatched(b => new FirmwareBoard { - Id = boardId, - Name = request.Name, - ChipId = request.ChipId, - Discontinued = false - }); - } + Name = board.Name, + ChipId = board.ChipId + }) + .RunAsync(); + if (executed <= 0) throw new Exception("Failed to upsert firmware board"); - await _db.SaveChangesAsync(); return Created(); } @@ -217,21 +187,6 @@ public async Task DeleteBoard([FromRoute] string boardId) // ---- Chip Management ---- - [HttpGet("chips")] - public async Task ListChips() - { - var chips = await _db.FirmwareChips - .Select(c => new FirmwareChipDto - { - Id = c.Id, - Name = c.Name, - Architecture = c.Architecture != null ? c.Architecture.Value.ToString().ToLowerInvariant() : null - }) - .ToListAsync(); - - return Ok(chips); - } - [HttpPut("chips/{chipId}")] public async Task UpsertChip([FromRoute] string chipId, [FromBody] CreateFirmwareChipRequest request) { @@ -240,28 +195,20 @@ public async Task UpsertChip([FromRoute] string chipId, [FromBody { if (!Enum.TryParse(request.Architecture, true, out var parsed)) { - return Problem(new OpenShockProblem("Firmware.InvalidArchitecture", "The architecture provided is not valid")); + return Problem(FirmwareError.FirmwareInvalidArchitecture); } architecture = parsed; } - var existing = await _db.FirmwareChips.FirstOrDefaultAsync(c => c.Id == chipId); - if (existing != null) - { - existing.Name = request.Name; - existing.Architecture = architecture; - } - else + var chip = new FirmwareChip { - _db.FirmwareChips.Add(new FirmwareChip - { - Id = chipId, - Name = request.Name, - Architecture = architecture - }); - } + Id = chipId, + Name = request.Name, + Architecture = architecture + }; + var executed = await _db.FirmwareChips.Upsert(chip).On(c => c.Id).RunAsync(); + if (executed <= 0) throw new Exception("Failed to upsert firmware chip"); - await _db.SaveChangesAsync(); return Created(); } diff --git a/RepositoryServer/Problems/FirmwareError.cs b/RepositoryServer/Problems/FirmwareError.cs index 9a505f7..130091d 100644 --- a/RepositoryServer/Problems/FirmwareError.cs +++ b/RepositoryServer/Problems/FirmwareError.cs @@ -10,6 +10,8 @@ public static class FirmwareError 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 FirmwareInvalidReleaseNoteType => new("Firmware.InvalidReleaseNoteType", "The release note 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); } From f8b5fae8187d93848e6dcf5762db21b200bbb4c2 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Mon, 30 Mar 2026 10:57:24 +0200 Subject: [PATCH 03/15] Add firmware upload endpoint with CDN storage, fix LatestController - Add PUT /v2/firmware/admin/versions/{version}/boards/{board}/upload endpoint that accepts multipart binary uploads, computes SHA256, uploads to BunnyCDN via HTTP API, and stores artifact metadata - Add CdnStorageService wrapping BunnyCDN Storage HTTP API - Extend FirmwareConfig with CdnStorageUrl and CdnStorageApiKey - Update LatestController to return all artifact types (not just Merged) and add optional ?board= query filter - Unify FirmwareLatestResponse to use List matching FirmwareVersionResponse shape - Make artifacts optional in CreateFirmwareVersionRequest - Extract shared GetArtifactFileName into FirmwareArtifactFileNames util - Delete unused FirmwareBoardArtifact model Co-Authored-By: Claude Opus 4.6 (1M context) --- RepositoryServer/Config/FirmwareConfig.cs | 4 +- .../V2/Firmware/AdminController.cs | 181 +++++++++++++++--- .../V2/Firmware/LatestController.cs | 50 +++-- .../V2/Firmware/VersionsController.cs | 12 +- .../Firmware/CreateFirmwareVersionRequest.cs | 6 +- .../Models/Firmware/FirmwareBoardArtifact.cs | 8 - .../Models/Firmware/FirmwareLatestResponse.cs | 2 +- RepositoryServer/Program.cs | 5 + .../Services/CdnStorageService.cs | 34 ++++ .../Utils/FirmwareArtifactFileNames.cs | 16 ++ RepositoryServer/appsettings.json | 4 +- 11 files changed, 247 insertions(+), 75 deletions(-) delete mode 100644 RepositoryServer/Models/Firmware/FirmwareBoardArtifact.cs create mode 100644 RepositoryServer/Services/CdnStorageService.cs create mode 100644 RepositoryServer/Utils/FirmwareArtifactFileNames.cs diff --git a/RepositoryServer/Config/FirmwareConfig.cs b/RepositoryServer/Config/FirmwareConfig.cs index ce34c00..f6fc280 100644 --- a/RepositoryServer/Config/FirmwareConfig.cs +++ b/RepositoryServer/Config/FirmwareConfig.cs @@ -1,8 +1,10 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace OpenShock.RepositoryServer.Config; public sealed class FirmwareConfig { [Required(AllowEmptyStrings = false)] public required string CdnBaseUrl { get; init; } + [Required(AllowEmptyStrings = false)] public required string CdnStorageUrl { get; init; } + [Required(AllowEmptyStrings = false)] public required string CdnStorageApiKey { get; init; } } diff --git a/RepositoryServer/Controllers/V2/Firmware/AdminController.cs b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs index 49a1395..6ccabc4 100644 --- a/RepositoryServer/Controllers/V2/Firmware/AdminController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs @@ -1,10 +1,14 @@ -using Asp.Versioning; +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.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; @@ -16,10 +20,14 @@ namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; public class AdminController : OpenShockControllerBase { private readonly RepoServerContext _db; + private readonly CdnStorageService _cdn; + private readonly ApiConfig _apiConfig; - public AdminController(RepoServerContext db) + public AdminController(RepoServerContext db, CdnStorageService cdn, ApiConfig apiConfig) { _db = db; + _cdn = cdn; + _apiConfig = apiConfig; } // ---- Version Management ---- @@ -37,27 +45,29 @@ public async Task UpsertVersion([FromRoute] string firmwareVersio return Problem(FirmwareError.FirmwareInvalidChannel); } - // Validate all board IDs exist - var boardIds = request.Artifacts.Keys.ToHashSet(); - var existingBoards = await _db.FirmwareBoards - .Where(b => boardIds.Contains(b.Id)) - .Select(b => b.Id) - .ToHashSetAsync(); - - var missingBoards = boardIds.Except(existingBoards).ToList(); - if (missingBoards.Count > 0) + // Validate artifacts if provided + if (request.Artifacts is { Count: > 0 }) { - return Problem(FirmwareError.FirmwareBoardNotFound); - } + var boardIds = request.Artifacts.Keys.ToHashSet(); + var existingBoards = await _db.FirmwareBoards + .Where(b => boardIds.Contains(b.Id)) + .Select(b => b.Id) + .ToHashSetAsync(); - // Validate artifact types - foreach (var (boardId, artifactTypes) in request.Artifacts) - { - foreach (var artifactType in artifactTypes.Keys) + var missingBoards = boardIds.Except(existingBoards).ToList(); + if (missingBoards.Count > 0) { - if (!Enum.TryParse(artifactType, true, out _)) + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + foreach (var (boardId, artifactTypes) in request.Artifacts) + { + foreach (var artifactType in artifactTypes.Keys) { - return Problem(FirmwareError.FirmwareInvalidArtifactType); + if (!Enum.TryParse(artifactType, true, out _)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } } } } @@ -85,21 +95,24 @@ public async Task UpsertVersion([FromRoute] string firmwareVersio var executed = await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(); if (executed <= 0) throw new Exception("Failed to upsert firmware version"); - // Replace all artifacts for this version - await _db.FirmwareArtifacts.Where(a => a.Version == firmwareVersion).ExecuteDeleteAsync(); - foreach (var (boardId, artifactTypes) in request.Artifacts) + // Replace artifacts only if provided in the request + if (request.Artifacts is { Count: > 0 }) { - foreach (var (artifactTypeStr, upload) in artifactTypes) + await _db.FirmwareArtifacts.Where(a => a.Version == firmwareVersion).ExecuteDeleteAsync(); + foreach (var (boardId, artifactTypes) in request.Artifacts) { - var artifactType = Enum.Parse(artifactTypeStr, true); - _db.FirmwareArtifacts.Add(new FirmwareArtifact + foreach (var (artifactTypeStr, upload) in artifactTypes) { - Version = firmwareVersion, - BoardId = boardId, - ArtifactType = artifactType, - HashSha256 = Convert.FromHexString(upload.Sha256Hash), - FileSize = upload.FileSize - }); + var artifactType = Enum.Parse(artifactTypeStr, true); + _db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = firmwareVersion, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = Convert.FromHexString(upload.Sha256Hash), + FileSize = upload.FileSize + }); + } } } @@ -132,6 +145,112 @@ public async Task DeleteVersion([FromRoute] string firmwareVersio return Ok(); } + // ---- Board Artifact Upload ---- + + private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase) + { + ["app"] = FirmwareArtifactType.App, + ["staticfs"] = FirmwareArtifactType.StaticFs, + ["merged"] = FirmwareArtifactType.Merged, + ["bootloader"] = FirmwareArtifactType.Bootloader, + ["partitions"] = FirmwareArtifactType.Partitions, + }; + + /// + /// Uploads firmware binary artifacts for a specific board+version. + /// Accepts multipart/form-data with file fields named: app, staticfs, merged, bootloader, partitions. + /// Each file is hashed, uploaded to CDN, and recorded in the database. + /// + [HttpPut("versions/{firmwareVersion}/boards/{boardId}/upload")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(64 * 1024 * 1024)] // 64 MB + public async Task UploadBoardArtifacts( + [FromRoute] string firmwareVersion, + [FromRoute] string boardId) + { + if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) + { + return Problem(FirmwareError.FirmwareInvalidSemver); + } + + if (!await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion)) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + if (!await _db.FirmwareBoards.AnyAsync(b => b.Id == boardId)) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var files = Request.Form.Files; + if (files.Count == 0) + { + return BadRequest(new { error = "No files uploaded. Expected file fields: app, staticfs, merged, bootloader, partitions" }); + } + + var uploadedArtifacts = new List(); + + foreach (var file in files) + { + var fieldName = file.Name.ToLowerInvariant(); + if (!ArtifactFieldNames.TryGetValue(fieldName, out var artifactType)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + + // Read file into memory for hashing + CDN upload + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream); + var fileBytes = memoryStream.ToArray(); + var fileSize = fileBytes.Length; + + // Compute SHA256 hash + var hashBytes = SHA256.HashData(fileBytes); + + // Upload to CDN + var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifactType); + var cdnPath = $"{firmwareVersion}/{boardId}/{cdnFileName}"; + + using var uploadStream = new MemoryStream(fileBytes); + await _cdn.UploadFileAsync(cdnPath, uploadStream); + + // Upsert artifact record in DB + var existing = await _db.FirmwareArtifacts.FirstOrDefaultAsync(a => + a.Version == firmwareVersion && a.BoardId == boardId && a.ArtifactType == artifactType); + + if (existing != null) + { + existing.HashSha256 = hashBytes; + existing.FileSize = fileSize; + } + else + { + _db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = firmwareVersion, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = hashBytes, + FileSize = fileSize, + }); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + uploadedArtifacts.Add(new FirmwareArtifactDto + { + Type = artifactType.ToString().ToLowerInvariant(), + Url = $"{cdnBase}/{firmwareVersion}/{boardId}/{cdnFileName}", + Sha256Hash = Convert.ToHexString(hashBytes), + FileSize = fileSize, + }); + } + + await _db.SaveChangesAsync(); + + return Ok(uploadedArtifacts); + } + // ---- Board Management ---- [HttpPut("boards/{boardId}")] diff --git a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs index ccfdd50..d63b4f7 100644 --- a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs @@ -1,10 +1,11 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using OpenShock.RepositoryServer.Config; using OpenShock.RepositoryServer.Models.Firmware; using OpenShock.RepositoryServer.Problems; using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; @@ -23,18 +24,30 @@ public LatestController(RepoServerContext db, ApiConfig apiConfig) } [HttpGet("{channel}")] - public async Task GetLatest([FromRoute] string channel) + public async Task GetLatest([FromRoute] string channel, [FromQuery] string? board) { if (!Enum.TryParse(channel, true, out var firmwareChannel)) { return Problem(FirmwareError.FirmwareInvalidChannel); } - var latestVersion = await _db.FirmwareVersions + var query = _db.FirmwareVersions .Where(v => v.Channel == firmwareChannel) - .OrderByDescending(v => v.ReleaseDate) - .Include(v => v.Artifacts.Where(a => a.ArtifactType == FirmwareArtifactType.Merged)) - .FirstOrDefaultAsync(); + .OrderByDescending(v => v.ReleaseDate); + + FirmwareVersion? latestVersion; + if (!string.IsNullOrWhiteSpace(board)) + { + latestVersion = await query + .Include(v => v.Artifacts.Where(a => a.BoardId == board)) + .FirstOrDefaultAsync(); + } + else + { + latestVersion = await query + .Include(v => v.Artifacts) + .FirstOrDefaultAsync(); + } if (latestVersion == null) { @@ -42,16 +55,23 @@ public async Task GetLatest([FromRoute] string channel) } var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); - var artifacts = new Dictionary(); + var artifacts = new Dictionary>(); foreach (var artifact in latestVersion.Artifacts) { - artifacts[artifact.BoardId] = new FirmwareBoardArtifact + if (!artifacts.TryGetValue(artifact.BoardId, out var list)) { - Url = $"{cdnBase}/{latestVersion.Version}/{artifact.BoardId}/{GetArtifactFileName(artifact.ArtifactType)}", + list = new List(); + artifacts[artifact.BoardId] = list; + } + + list.Add(new FirmwareArtifactDto + { + Type = artifact.ArtifactType.ToString().ToLowerInvariant(), + Url = $"{cdnBase}/{latestVersion.Version}/{artifact.BoardId}/{FirmwareArtifactFileNames.GetFileName(artifact.ArtifactType)}", Sha256Hash = Convert.ToHexString(artifact.HashSha256), FileSize = artifact.FileSize - }; + }); } return Ok(new FirmwareLatestResponse @@ -62,14 +82,4 @@ public async Task GetLatest([FromRoute] string channel) Artifacts = artifacts }); } - - private static string GetArtifactFileName(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/Controllers/V2/Firmware/VersionsController.cs b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs index 2b2b461..8b4a276 100644 --- a/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs @@ -5,6 +5,7 @@ using OpenShock.RepositoryServer.Models.Firmware; using OpenShock.RepositoryServer.Problems; using OpenShock.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.Utils; namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; @@ -76,7 +77,7 @@ public async Task GetVersion([FromRoute] string firmwareVersion) list.Add(new FirmwareArtifactDto { Type = artifact.ArtifactType.ToString().ToLowerInvariant(), - Url = $"{cdnBase}/{version.Version}/{artifact.BoardId}/{GetArtifactFileName(artifact.ArtifactType)}", + Url = $"{cdnBase}/{version.Version}/{artifact.BoardId}/{FirmwareArtifactFileNames.GetFileName(artifact.ArtifactType)}", Sha256Hash = Convert.ToHexString(artifact.HashSha256), FileSize = artifact.FileSize }); @@ -101,13 +102,4 @@ public async Task GetVersion([FromRoute] string firmwareVersion) }); } - private static string GetArtifactFileName(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/Models/Firmware/CreateFirmwareVersionRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs index 2b057c2..5fa7d27 100644 --- a/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs +++ b/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs @@ -1,4 +1,4 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; +namespace OpenShock.RepositoryServer.Models.Firmware; public sealed class CreateFirmwareVersionRequest { @@ -6,6 +6,6 @@ public sealed class CreateFirmwareVersionRequest public required DateTimeOffset ReleaseDate { get; init; } public required string CommitHash { get; init; } public string? ReleaseUrl { get; init; } - public required Dictionary> Artifacts { get; init; } - public required List ReleaseNotes { get; init; } + public Dictionary>? Artifacts { get; init; } + public List ReleaseNotes { get; init; } = []; } diff --git a/RepositoryServer/Models/Firmware/FirmwareBoardArtifact.cs b/RepositoryServer/Models/Firmware/FirmwareBoardArtifact.cs deleted file mode 100644 index 2abb512..0000000 --- a/RepositoryServer/Models/Firmware/FirmwareBoardArtifact.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; - -public sealed class FirmwareBoardArtifact -{ - public required string Url { get; init; } - public required string Sha256Hash { get; init; } - public required long FileSize { get; init; } -} diff --git a/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs b/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs index a651339..6e11ac5 100644 --- a/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs +++ b/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs @@ -5,5 +5,5 @@ public sealed class FirmwareLatestResponse public required string Version { get; init; } public required string Channel { get; init; } public required DateTimeOffset ReleaseDate { get; init; } - public required Dictionary Artifacts { get; init; } + public required Dictionary> Artifacts { get; init; } } diff --git a/RepositoryServer/Program.cs b/RepositoryServer/Program.cs index c1c0156..c882994 100644 --- a/RepositoryServer/Program.cs +++ b/RepositoryServer/Program.cs @@ -12,6 +12,7 @@ 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; @@ -128,6 +129,10 @@ .AddHttpClientInstrumentation() .AddPrometheusExporter()); +// <---- CDN Storage Service ----> +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); + // <---- Postgres EF Core ----> builder.Services.AddDbContextPool(dbBuilder => diff --git a/RepositoryServer/Services/CdnStorageService.cs b/RepositoryServer/Services/CdnStorageService.cs new file mode 100644 index 0000000..cb2079b --- /dev/null +++ b/RepositoryServer/Services/CdnStorageService.cs @@ -0,0 +1,34 @@ +using OpenShock.RepositoryServer.Config; + +namespace OpenShock.RepositoryServer.Services; + +public sealed class CdnStorageService +{ + private readonly HttpClient _httpClient; + private readonly string _storageUrl; + + public CdnStorageService(HttpClient httpClient, ApiConfig config) + { + _httpClient = httpClient; + _storageUrl = config.Firmware.CdnStorageUrl.TrimEnd('/'); + + _httpClient.DefaultRequestHeaders.Add("AccessKey", config.Firmware.CdnStorageApiKey); + } + + /// + /// Uploads a file to BunnyCDN storage. + /// + /// Path relative to storage zone root, e.g. "1.0.0/board-name/app.bin" + /// File content stream + /// Cancellation token + 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(); + } +} diff --git a/RepositoryServer/Utils/FirmwareArtifactFileNames.cs b/RepositoryServer/Utils/FirmwareArtifactFileNames.cs new file mode 100644 index 0000000..b076716 --- /dev/null +++ b/RepositoryServer/Utils/FirmwareArtifactFileNames.cs @@ -0,0 +1,16 @@ +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/appsettings.json b/RepositoryServer/appsettings.json index 52b94ed..069c683 100644 --- a/RepositoryServer/appsettings.json +++ b/RepositoryServer/appsettings.json @@ -14,7 +14,9 @@ }, "Firmware": { - "CdnBaseUrl": "https://cdn.openshock.app/firmware" + "CdnBaseUrl": "https://cdn.openshock.app/firmware", + "CdnStorageUrl": "https://ny.storage.bunnycdn.com/openshock-firmware", + "CdnStorageApiKey": "" }, "Serilog": { From 94ff3f22ab74446f6d58772d95ac945238e799d0 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Mon, 30 Mar 2026 11:00:34 +0200 Subject: [PATCH 04/15] Remove claude local settings and gitignore it Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 25 ------------------------- .gitignore | 3 ++- 2 files changed, 2 insertions(+), 26 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 0f385a9..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git mv:*)", - "Bash(find:*)", - "Bash(dotnet build:*)", - "Bash(dotnet ef migrations add:*)", - "Bash(dotnet test:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git config:*)", - "Bash(git push:*)", - "Bash(git -C /src/openshock/firmware remote -v)", - "Bash(git remote set-url:*)", - "Bash(git -C /src/openshock/API pull)", - "Bash(git -C /src/openshock/API checkout develop)", - "Bash(git -C /src/openshock/API pull origin develop)", - "Bash(ls:*)", - "Bash(git checkout:*)", - "Bash(gh pr create:*)", - "Bash(rm /src/openshock/repositoryservers/RepositoryServer/Controllers/V2/Desktop/AdminController.cs /src/openshock/repositoryservers/RepositoryServer/Controllers/V2/Desktop/RepoController.cs)", - "Bash(rmdir /src/openshock/repositoryservers/RepositoryServer/Controllers/V2/Desktop)" - ] - } -} 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 From 69091f77406ff98783153c753bd6c369aa5a53f1 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 31 Mar 2026 15:01:36 +0200 Subject: [PATCH 05/15] More work --- Directory.Packages.props | 4 +- RepositoryServer/AuthSchemas.cs | 1 + RepositoryServer/Config/ApiConfig.cs | 1 + RepositoryServer/Config/CiCdAuthConfig.cs | 26 ++ RepositoryServer/Config/FirmwareConfig.cs | 3 +- RepositoryServer/Config/RepoConfig.cs | 1 + RepositoryServer/Config/StorageConfig.cs | 53 ++++ .../Controllers/V1/AdminController.cs | 1 + .../Controllers/V1/CiCdController.cs | 137 +++++++++ .../V2/Firmware/AdminController.cs | 262 ++--------------- .../Controllers/V2/Firmware/CiCdController.cs | 274 ++++++++++++++++++ .../ExceptionHandler/ExceptionHandler.cs | 1 + .../Firmware/CreateFirmwareBoardRequest.cs | 1 + RepositoryServer/Problems/FirmwareError.cs | 2 + RepositoryServer/Problems/ModuleError.cs | 5 + RepositoryServer/Program.cs | 90 ++++-- .../RepoServerDb/FirmwareBoard.cs | 1 + .../RepoServerDb/RepoServerContext.cs | 29 +- RepositoryServer/RepositoryServer.csproj | 2 + .../Services/BunnyCdnStorageService.cs | 80 +++++ .../Services/CdnStorageService.cs | 34 --- RepositoryServer/Services/IStorageService.cs | 34 +++ .../Services/LocalStorageService.cs | 61 ++++ RepositoryServer/Services/S3StorageService.cs | 116 ++++++++ RepositoryServer/appsettings.Development.json | 8 + RepositoryServer/appsettings.json | 17 +- 26 files changed, 943 insertions(+), 301 deletions(-) create mode 100644 RepositoryServer/Config/CiCdAuthConfig.cs create mode 100644 RepositoryServer/Config/StorageConfig.cs create mode 100644 RepositoryServer/Controllers/V1/CiCdController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/CiCdController.cs create mode 100644 RepositoryServer/Services/BunnyCdnStorageService.cs delete mode 100644 RepositoryServer/Services/CdnStorageService.cs create mode 100644 RepositoryServer/Services/IStorageService.cs create mode 100644 RepositoryServer/Services/LocalStorageService.cs create mode 100644 RepositoryServer/Services/S3StorageService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2d6c063..989bf0c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,8 +12,10 @@ + - + + diff --git a/RepositoryServer/AuthSchemas.cs b/RepositoryServer/AuthSchemas.cs index 04a8a23..a310fca 100644 --- a/RepositoryServer/AuthSchemas.cs +++ b/RepositoryServer/AuthSchemas.cs @@ -3,4 +3,5 @@ public static class AuthSchemas { public const string AdminToken = "AdminToken"; + public const string CiCdToken = "CiCdToken"; } \ No newline at end of file diff --git a/RepositoryServer/Config/ApiConfig.cs b/RepositoryServer/Config/ApiConfig.cs index 2ba0063..442ec25 100644 --- a/RepositoryServer/Config/ApiConfig.cs +++ b/RepositoryServer/Config/ApiConfig.cs @@ -6,6 +6,7 @@ public class ApiConfig { [Required] public required DbConfig Db { get; init; } [Required] public required string AdminToken { get; init; } + [Required] public required CiCdAuthConfig CiCd { get; init; } [Required] public required RepoConfig Repo { get; init; } [Required] public required FirmwareConfig Firmware { get; init; } public MetricsConfig Metrics { get; init; } = new(); diff --git a/RepositoryServer/Config/CiCdAuthConfig.cs b/RepositoryServer/Config/CiCdAuthConfig.cs new file mode 100644 index 0000000..f704c70 --- /dev/null +++ b/RepositoryServer/Config/CiCdAuthConfig.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Config; + +public sealed class CiCdAuthConfig +{ + /// + /// 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; } + + /// + /// GitHub organization or user that owns the repositories allowed to authenticate. + /// Validated against the 'repository_owner' claim in the OIDC token. + /// + [Required(AllowEmptyStrings = false)] + public required string RepositoryOwner { get; init; } + + /// + /// Optional list of specific repositories allowed to authenticate (e.g. "openshock/firmware"). + /// If null or empty, any repository under the owner is allowed. + /// + public List? AllowedRepositories { get; init; } +} diff --git a/RepositoryServer/Config/FirmwareConfig.cs b/RepositoryServer/Config/FirmwareConfig.cs index f6fc280..6b5fdd4 100644 --- a/RepositoryServer/Config/FirmwareConfig.cs +++ b/RepositoryServer/Config/FirmwareConfig.cs @@ -5,6 +5,5 @@ namespace OpenShock.RepositoryServer.Config; public sealed class FirmwareConfig { [Required(AllowEmptyStrings = false)] public required string CdnBaseUrl { get; init; } - [Required(AllowEmptyStrings = false)] public required string CdnStorageUrl { get; init; } - [Required(AllowEmptyStrings = false)] public required string CdnStorageApiKey { get; init; } + [Required] public required StorageConfig Storage { get; init; } } diff --git a/RepositoryServer/Config/RepoConfig.cs b/RepositoryServer/Config/RepoConfig.cs index 3edd160..e27ed25 100644 --- a/RepositoryServer/Config/RepoConfig.cs +++ b/RepositoryServer/Config/RepoConfig.cs @@ -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/RepositoryServer/Controllers/V1/AdminController.cs b/RepositoryServer/Controllers/V1/AdminController.cs index d91c565..8e201c1 100644 --- a/RepositoryServer/Controllers/V1/AdminController.cs +++ b/RepositoryServer/Controllers/V1/AdminController.cs @@ -52,6 +52,7 @@ public async Task DeleteModule([FromRoute] string moduleId) 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) { 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/RepositoryServer/Controllers/V2/Firmware/AdminController.cs b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs index 6ccabc4..6bd17e6 100644 --- a/RepositoryServer/Controllers/V2/Firmware/AdminController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs @@ -1,15 +1,10 @@ -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.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; @@ -20,123 +15,14 @@ namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; public class AdminController : OpenShockControllerBase { private readonly RepoServerContext _db; - private readonly CdnStorageService _cdn; - private readonly ApiConfig _apiConfig; - public AdminController(RepoServerContext db, CdnStorageService cdn, ApiConfig apiConfig) + public AdminController(RepoServerContext db) { _db = db; - _cdn = cdn; - _apiConfig = apiConfig; } // ---- Version Management ---- - [HttpPut("versions/{firmwareVersion}")] - public async Task UpsertVersion([FromRoute] string firmwareVersion, [FromBody] CreateFirmwareVersionRequest request) - { - if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) - { - return Problem(FirmwareError.FirmwareInvalidSemver); - } - - if (!Enum.TryParse(request.Channel, true, out var channel)) - { - return Problem(FirmwareError.FirmwareInvalidChannel); - } - - // Validate artifacts if provided - if (request.Artifacts is { Count: > 0 }) - { - var boardIds = request.Artifacts.Keys.ToHashSet(); - var existingBoards = await _db.FirmwareBoards - .Where(b => boardIds.Contains(b.Id)) - .Select(b => b.Id) - .ToHashSetAsync(); - - var missingBoards = boardIds.Except(existingBoards).ToList(); - if (missingBoards.Count > 0) - { - return Problem(FirmwareError.FirmwareBoardNotFound); - } - - foreach (var (boardId, artifactTypes) in request.Artifacts) - { - foreach (var artifactType in artifactTypes.Keys) - { - if (!Enum.TryParse(artifactType, true, out _)) - { - return Problem(FirmwareError.FirmwareInvalidArtifactType); - } - } - } - } - - // Validate release note types - foreach (var note in request.ReleaseNotes) - { - if (!Enum.TryParse(note.Type, true, out _)) - { - return Problem(FirmwareError.FirmwareInvalidReleaseNoteType); - } - } - - await using var transaction = await _db.Database.BeginTransactionAsync(); - - // Upsert the firmware version - var versionEntity = new FirmwareVersion - { - Version = firmwareVersion, - Channel = channel, - ReleaseDate = request.ReleaseDate, - CommitHash = request.CommitHash, - ReleaseUrl = request.ReleaseUrl - }; - var executed = await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(); - if (executed <= 0) throw new Exception("Failed to upsert firmware version"); - - // Replace artifacts only if provided in the request - if (request.Artifacts is { Count: > 0 }) - { - await _db.FirmwareArtifacts.Where(a => a.Version == firmwareVersion).ExecuteDeleteAsync(); - foreach (var (boardId, artifactTypes) in request.Artifacts) - { - foreach (var (artifactTypeStr, upload) in artifactTypes) - { - var artifactType = Enum.Parse(artifactTypeStr, true); - _db.FirmwareArtifacts.Add(new FirmwareArtifact - { - Version = firmwareVersion, - BoardId = boardId, - ArtifactType = artifactType, - HashSha256 = Convert.FromHexString(upload.Sha256Hash), - FileSize = upload.FileSize - }); - } - } - } - - // Replace all release notes for this version - await _db.FirmwareReleaseNotes.Where(n => n.Version == firmwareVersion).ExecuteDeleteAsync(); - for (var i = 0; i < request.ReleaseNotes.Count; i++) - { - var note = request.ReleaseNotes[i]; - _db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote - { - Version = firmwareVersion, - Index = i, - Type = Enum.Parse(note.Type, true), - Title = note.Title, - Content = note.Content - }); - } - - await _db.SaveChangesAsync(); - await transaction.CommitAsync(); - - return Created(); - } - [HttpDelete("versions/{firmwareVersion}")] public async Task DeleteVersion([FromRoute] string firmwareVersion) { @@ -145,138 +31,52 @@ public async Task DeleteVersion([FromRoute] string firmwareVersio return Ok(); } - // ---- Board Artifact Upload ---- - - private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase) - { - ["app"] = FirmwareArtifactType.App, - ["staticfs"] = FirmwareArtifactType.StaticFs, - ["merged"] = FirmwareArtifactType.Merged, - ["bootloader"] = FirmwareArtifactType.Bootloader, - ["partitions"] = FirmwareArtifactType.Partitions, - }; + // ---- Board Management ---- - /// - /// Uploads firmware binary artifacts for a specific board+version. - /// Accepts multipart/form-data with file fields named: app, staticfs, merged, bootloader, partitions. - /// Each file is hashed, uploaded to CDN, and recorded in the database. - /// - [HttpPut("versions/{firmwareVersion}/boards/{boardId}/upload")] - [Consumes("multipart/form-data")] - [RequestSizeLimit(64 * 1024 * 1024)] // 64 MB - public async Task UploadBoardArtifacts( - [FromRoute] string firmwareVersion, - [FromRoute] string boardId) + [HttpPut("boards/{boardId}")] + public async Task UpsertBoard([FromRoute] string boardId, [FromBody] CreateFirmwareBoardRequest request) { - if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) - { - return Problem(FirmwareError.FirmwareInvalidSemver); - } - - if (!await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion)) - { - return Problem(FirmwareError.FirmwareVersionNotFound); - } - - if (!await _db.FirmwareBoards.AnyAsync(b => b.Id == boardId)) - { - return Problem(FirmwareError.FirmwareBoardNotFound); - } - - var files = Request.Form.Files; - if (files.Count == 0) + if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId)) { - return BadRequest(new { error = "No files uploaded. Expected file fields: app, staticfs, merged, bootloader, partitions" }); + return Problem(FirmwareError.FirmwareChipNotFound); } - var uploadedArtifacts = new List(); - - foreach (var file in files) + // Parse and validate required artifact types + var requiredArtifactTypes = Array.Empty(); + if (request.RequiredArtifactTypes is { Count: > 0 }) { - var fieldName = file.Name.ToLowerInvariant(); - if (!ArtifactFieldNames.TryGetValue(fieldName, out var artifactType)) - { - return Problem(FirmwareError.FirmwareInvalidArtifactType); - } - - // Read file into memory for hashing + CDN upload - using var memoryStream = new MemoryStream(); - await file.CopyToAsync(memoryStream); - var fileBytes = memoryStream.ToArray(); - var fileSize = fileBytes.Length; - - // Compute SHA256 hash - var hashBytes = SHA256.HashData(fileBytes); - - // Upload to CDN - var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifactType); - var cdnPath = $"{firmwareVersion}/{boardId}/{cdnFileName}"; - - using var uploadStream = new MemoryStream(fileBytes); - await _cdn.UploadFileAsync(cdnPath, uploadStream); - - // Upsert artifact record in DB - var existing = await _db.FirmwareArtifacts.FirstOrDefaultAsync(a => - a.Version == firmwareVersion && a.BoardId == boardId && a.ArtifactType == artifactType); - - if (existing != null) + var parsed = new List(); + foreach (var typeStr in request.RequiredArtifactTypes) { - existing.HashSha256 = hashBytes; - existing.FileSize = fileSize; - } - else - { - _db.FirmwareArtifacts.Add(new FirmwareArtifact + if (!Enum.TryParse(typeStr, true, out var artifactType)) { - Version = firmwareVersion, - BoardId = boardId, - ArtifactType = artifactType, - HashSha256 = hashBytes, - FileSize = fileSize, - }); + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + parsed.Add(artifactType); } - - var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); - uploadedArtifacts.Add(new FirmwareArtifactDto - { - Type = artifactType.ToString().ToLowerInvariant(), - Url = $"{cdnBase}/{firmwareVersion}/{boardId}/{cdnFileName}", - Sha256Hash = Convert.ToHexString(hashBytes), - FileSize = fileSize, - }); + requiredArtifactTypes = parsed.Distinct().ToArray(); } - await _db.SaveChangesAsync(); - - return Ok(uploadedArtifacts); - } - - // ---- Board Management ---- - - [HttpPut("boards/{boardId}")] - public async Task UpsertBoard([FromRoute] string boardId, [FromBody] CreateFirmwareBoardRequest request) - { - if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId)) + var existing = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); + if (existing != null) { - return Problem(FirmwareError.FirmwareChipNotFound); + existing.Name = request.Name; + existing.ChipId = request.ChipId; + existing.RequiredArtifactTypes = requiredArtifactTypes; } - - var board = new FirmwareBoard + else { - Id = boardId, - Name = request.Name, - ChipId = request.ChipId, - Discontinued = false - }; - var executed = await _db.FirmwareBoards.Upsert(board).On(b => b.Id) - .WhenMatched(b => new FirmwareBoard + _db.FirmwareBoards.Add(new FirmwareBoard { - Name = board.Name, - ChipId = board.ChipId - }) - .RunAsync(); - if (executed <= 0) throw new Exception("Failed to upsert firmware board"); + Id = boardId, + Name = request.Name, + ChipId = request.ChipId, + Discontinued = false, + RequiredArtifactTypes = requiredArtifactTypes + }); + } + await _db.SaveChangesAsync(); return Created(); } diff --git a/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs b/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs new file mode 100644 index 0000000..b5a5295 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs @@ -0,0 +1,274 @@ +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.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/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; + } + + // ---- Version Publishing ---- + + [HttpPut("versions/{firmwareVersion}")] + public async Task PublishVersion([FromRoute] string firmwareVersion, [FromBody] CreateFirmwareVersionRequest request) + { + if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) + { + return Problem(FirmwareError.FirmwareInvalidSemver); + } + + if (!Enum.TryParse(request.Channel, true, out var channel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + + // Validate artifacts if provided + if (request.Artifacts is { Count: > 0 }) + { + var boardIds = request.Artifacts.Keys.ToHashSet(); + var boards = await _db.FirmwareBoards + .Where(b => boardIds.Contains(b.Id)) + .ToDictionaryAsync(b => b.Id); + + var missingBoardsCount = boardIds.Except(boards.Keys).Count(); + if (missingBoardsCount > 0) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + foreach (var (boardId, artifactTypes) in request.Artifacts) + { + if (artifactTypes.Keys.Any(artifactType => !Enum.TryParse(artifactType, true, out _))) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + + // Validate all required artifact types are present + var board = boards[boardId]; + if (board.RequiredArtifactTypes.Length > 0) + { + var providedTypes = artifactTypes.Keys + .Select(k => Enum.Parse(k, true)) + .ToHashSet(); + var missingTypes = board.RequiredArtifactTypes + .Where(r => !providedTypes.Contains(r)) + .Select(r => r.ToString().ToLowerInvariant()) + .ToList(); + if (missingTypes.Count > 0) + { + return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); + } + } + } + } + + // Validate release note types + if (request.ReleaseNotes.Any(note => !Enum.TryParse(note.Type, true, out _))) + { + return Problem(FirmwareError.FirmwareInvalidReleaseNoteType); + } + + await using var transaction = await _db.Database.BeginTransactionAsync(); + + // Upsert the firmware version + var versionEntity = new FirmwareVersion + { + Version = firmwareVersion, + Channel = channel, + ReleaseDate = request.ReleaseDate, + CommitHash = request.CommitHash, + ReleaseUrl = request.ReleaseUrl + }; + var executed = await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(); + if (executed <= 0) throw new Exception("Failed to upsert firmware version"); + + // Replace artifacts only if provided in the request + if (request.Artifacts is { Count: > 0 }) + { + await _db.FirmwareArtifacts.Where(a => a.Version == firmwareVersion).ExecuteDeleteAsync(); + foreach (var (boardId, artifactTypes) in request.Artifacts) + { + foreach (var (artifactTypeStr, upload) in artifactTypes) + { + var artifactType = Enum.Parse(artifactTypeStr, true); + _db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = firmwareVersion, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = Convert.FromHexString(upload.Sha256Hash), + FileSize = upload.FileSize + }); + } + } + } + + // Replace all release notes for this version + await _db.FirmwareReleaseNotes.Where(n => n.Version == firmwareVersion).ExecuteDeleteAsync(); + for (var i = 0; i < request.ReleaseNotes.Count; i++) + { + var note = request.ReleaseNotes[i]; + _db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote + { + Version = firmwareVersion, + Index = i, + Type = Enum.Parse(note.Type, true), + Title = note.Title, + Content = note.Content + }); + } + + await _db.SaveChangesAsync(); + await transaction.CommitAsync(); + + return Created(); + } + + // ---- Board Artifact Upload ---- + + private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase) + { + ["app"] = FirmwareArtifactType.App, + ["staticfs"] = FirmwareArtifactType.StaticFs, + ["merged"] = FirmwareArtifactType.Merged, + ["bootloader"] = FirmwareArtifactType.Bootloader, + ["partitions"] = FirmwareArtifactType.Partitions, + }; + + /// + /// Uploads firmware binary artifacts for a specific board+version. + /// Accepts multipart/form-data with file fields named: app, staticfs, merged, bootloader, partitions. + /// Each file is hashed, uploaded to CDN, and recorded in the database. + /// + [HttpPut("versions/{firmwareVersion}/boards/{boardId}/upload")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(64 * 1024 * 1024)] // 64 MB + public async Task UploadBoardArtifacts( + [FromRoute] string firmwareVersion, + [FromRoute] string boardId) + { + if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) + { + return Problem(FirmwareError.FirmwareInvalidSemver); + } + + if (!await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion)) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); + if (board == null) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var files = Request.Form.Files; + if (files.Count == 0) + { + return BadRequest(new { error = "No files uploaded. Expected file fields: app, staticfs, merged, bootloader, partitions" }); + } + + // Validate all required artifact types are present in the upload + if (board.RequiredArtifactTypes.Length > 0) + { + var uploadedTypes = files + .Select(f => f.Name.ToLowerInvariant()) + .Where(ArtifactFieldNames.ContainsKey) + .Select(n => ArtifactFieldNames[n]) + .ToHashSet(); + var missingTypes = board.RequiredArtifactTypes + .Where(r => !uploadedTypes.Contains(r)) + .Select(r => r.ToString().ToLowerInvariant()) + .ToList(); + if (missingTypes.Count > 0) + { + return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); + } + } + + var uploadedArtifacts = new List(); + + foreach (var file in files) + { + var fieldName = file.Name.ToLowerInvariant(); + if (!ArtifactFieldNames.TryGetValue(fieldName, out var artifactType)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + + // Read file into memory for hashing + CDN upload + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream); + var fileBytes = memoryStream.ToArray(); + var fileSize = fileBytes.Length; + + // Compute SHA256 hash + var hashBytes = SHA256.HashData(fileBytes); + + // Upload to storage + var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifactType); + var cdnPath = $"{firmwareVersion}/{boardId}/{cdnFileName}"; + + using var uploadStream = new MemoryStream(fileBytes); + await _storage.UploadFileAsync(cdnPath, uploadStream); + + // Upsert artifact record in DB + var existing = await _db.FirmwareArtifacts.FirstOrDefaultAsync(a => + a.Version == firmwareVersion && a.BoardId == boardId && a.ArtifactType == artifactType); + + if (existing != null) + { + existing.HashSha256 = hashBytes; + existing.FileSize = fileSize; + } + else + { + _db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = firmwareVersion, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = hashBytes, + FileSize = fileSize, + }); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + uploadedArtifacts.Add(new FirmwareArtifactDto + { + Type = artifactType.ToString().ToLowerInvariant(), + Url = $"{cdnBase}/{firmwareVersion}/{boardId}/{cdnFileName}", + Sha256Hash = Convert.ToHexString(hashBytes), + FileSize = fileSize, + }); + } + + await _db.SaveChangesAsync(); + + return Ok(uploadedArtifacts); + } +} diff --git a/RepositoryServer/ExceptionHandler/ExceptionHandler.cs b/RepositoryServer/ExceptionHandler/ExceptionHandler.cs index 841313a..d8c6ba6 100644 --- a/RepositoryServer/ExceptionHandler/ExceptionHandler.cs +++ b/RepositoryServer/ExceptionHandler/ExceptionHandler.cs @@ -18,6 +18,7 @@ public OpenShockExceptionHandler(IProblemDetailsService problemDetailsService, I public async ValueTask TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken) { context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + _logger.LogError(exception, "Unhandled exception for {Method} {Path}", context.Request.Method, context.Request.Path); await PrintRequestInfo(context); var responseObject = ExceptionError.Exception; diff --git a/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs index 087e202..c22314c 100644 --- a/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs +++ b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs @@ -4,4 +4,5 @@ public sealed class CreateFirmwareBoardRequest { public required string Name { get; init; } public required string ChipId { get; init; } + public List? RequiredArtifactTypes { get; init; } } diff --git a/RepositoryServer/Problems/FirmwareError.cs b/RepositoryServer/Problems/FirmwareError.cs index 130091d..8cbcc3d 100644 --- a/RepositoryServer/Problems/FirmwareError.cs +++ b/RepositoryServer/Problems/FirmwareError.cs @@ -14,4 +14,6 @@ public static class FirmwareError public static OpenShockProblem FirmwareInvalidArchitecture => new("Firmware.InvalidArchitecture", "The architecture provided is not valid"); public static OpenShockProblem FirmwareBoardInUse => new("Firmware.BoardInUse", "Cannot delete board that has associated artifacts", HttpStatusCode.Conflict); public static OpenShockProblem FirmwareChipInUse => new("Firmware.ChipInUse", "Cannot delete chip that has associated boards", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareMissingRequiredArtifacts(string boardId, IEnumerable missing) => + new("Firmware.MissingRequiredArtifacts", $"Board '{boardId}' is missing required artifact types: {string.Join(", ", missing)}"); } diff --git a/RepositoryServer/Problems/ModuleError.cs b/RepositoryServer/Problems/ModuleError.cs index f37b59c..a37ce78 100644 --- a/RepositoryServer/Problems/ModuleError.cs +++ b/RepositoryServer/Problems/ModuleError.cs @@ -5,4 +5,9 @@ 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/RepositoryServer/Program.cs b/RepositoryServer/Program.cs index c882994..fb16026 100644 --- a/RepositoryServer/Program.cs +++ b/RepositoryServer/Program.cs @@ -1,10 +1,11 @@ -using System.Text.Json; +using System.Net; +using System.Security.Claims; +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.RepositoryServer; @@ -53,10 +54,44 @@ // <---- ASP.NET ----> builder.Services.AddExceptionHandler(); -builder.Services.AddAuthenticationCore(); -new AuthenticationBuilder(builder.Services) +builder.Services.AddAuthentication() .AddScheme( - AuthSchemas.AdminToken, _ => { }); + AuthSchemas.AdminToken, _ => { }) + .AddJwtBearer(AuthSchemas.CiCdToken, options => + { + options.Authority = "https://token.actions.githubusercontent.com"; + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = "https://token.actions.githubusercontent.com", + ValidateAudience = true, + ValidAudience = config.CiCd.Audience, + ValidateLifetime = true, + }; + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + var owner = context.Principal?.FindFirstValue("repository_owner"); + if (!string.Equals(owner, config.CiCd.RepositoryOwner, StringComparison.OrdinalIgnoreCase)) + { + context.Fail("Repository owner is not allowed."); + return Task.CompletedTask; + } + + if (config.CiCd.AllowedRepositories is { Count: > 0 }) + { + var repo = context.Principal?.FindFirstValue("repository"); + if (repo == null || !config.CiCd.AllowedRepositories.Contains(repo, StringComparer.OrdinalIgnoreCase)) + { + context.Fail("Repository is not allowed."); + } + } + + return Task.CompletedTask; + } + }; + }); builder.Services.AddAuthorization(); @@ -129,12 +164,37 @@ .AddHttpClientInstrumentation() .AddPrometheusExporter()); -// <---- CDN Storage Service ----> -builder.Services.AddHttpClient(); -builder.Services.AddSingleton(); +// <---- Storage Service ----> +switch (config.Firmware.Storage.Type) +{ + 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}"); +} // <---- Postgres EF Core ----> - + builder.Services.AddDbContextPool(dbBuilder => { RepoServerContext.ConfigureOptionsBuilder(dbBuilder, config.Db.Conn, config.Db.Debug); @@ -142,13 +202,7 @@ 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); }); @@ -205,7 +259,7 @@ 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 => { diff --git a/RepositoryServer/RepoServerDb/FirmwareBoard.cs b/RepositoryServer/RepoServerDb/FirmwareBoard.cs index 46e3442..1e47888 100644 --- a/RepositoryServer/RepoServerDb/FirmwareBoard.cs +++ b/RepositoryServer/RepoServerDb/FirmwareBoard.cs @@ -6,6 +6,7 @@ public class FirmwareBoard public string ChipId { get; set; } = null!; public string Name { get; set; } = null!; 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(); diff --git a/RepositoryServer/RepoServerDb/RepoServerContext.cs b/RepositoryServer/RepoServerDb/RepoServerContext.cs index be5bb03..cdaf384 100644 --- a/RepositoryServer/RepoServerDb/RepoServerContext.cs +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -32,7 +32,11 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (_migrationTool) { - ConfigureOptionsBuilder(optionsBuilder, "Host=localhost;Database=repo-server;Username=openshock;Password=openshock", true); + // Migration tooling: plain connection string, no MapEnum (to avoid duplicate enum schemas) + optionsBuilder.UseNpgsql("Host=localhost;Database=repo-server;Username=openshock;Password=openshock"); + optionsBuilder.UseExceptionProcessor(); + optionsBuilder.EnableSensitiveDataLogging(); + optionsBuilder.EnableDetailedErrors(); return; } if(string.IsNullOrWhiteSpace(_connectionString)) @@ -60,14 +64,18 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde { optionsBuilder.UseNpgsql(connectionString, npgsqlBuilder => { - npgsqlBuilder.MapEnum("firmware_channel"); - npgsqlBuilder.MapEnum("firmware_artifact_type"); - npgsqlBuilder.MapEnum("firmware_release_note_type"); - npgsqlBuilder.MapEnum("firmware_chip_architecture"); + npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); }); 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(); @@ -89,10 +97,11 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde protected override void OnModelCreating(ModelBuilder modelBuilder) { // PostgreSQL enum mappings - modelBuilder.HasPostgresEnum("firmware_channel"); - modelBuilder.HasPostgresEnum("firmware_artifact_type"); - modelBuilder.HasPostgresEnum("firmware_release_note_type"); - modelBuilder.HasPostgresEnum("firmware_chip_architecture"); + modelBuilder + .HasPostgresEnum("firmware_channel", ["stable", "beta", "develop"]) + .HasPostgresEnum("firmware_artifact_type", ["merged", "app", "bootloader", "partitions", "static_fs"]) + .HasPostgresEnum("firmware_release_note_type", ["warning", "info", "breaking", "section"]) + .HasPostgresEnum("firmware_chip_architecture", ["xtensa", "risc_v"]); // Desktop module entities (unchanged) modelBuilder.Entity(entity => @@ -177,6 +186,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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) diff --git a/RepositoryServer/RepositoryServer.csproj b/RepositoryServer/RepositoryServer.csproj index 1bec1d8..6f4ef16 100644 --- a/RepositoryServer/RepositoryServer.csproj +++ b/RepositoryServer/RepositoryServer.csproj @@ -6,8 +6,10 @@ + + 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/CdnStorageService.cs b/RepositoryServer/Services/CdnStorageService.cs deleted file mode 100644 index cb2079b..0000000 --- a/RepositoryServer/Services/CdnStorageService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using OpenShock.RepositoryServer.Config; - -namespace OpenShock.RepositoryServer.Services; - -public sealed class CdnStorageService -{ - private readonly HttpClient _httpClient; - private readonly string _storageUrl; - - public CdnStorageService(HttpClient httpClient, ApiConfig config) - { - _httpClient = httpClient; - _storageUrl = config.Firmware.CdnStorageUrl.TrimEnd('/'); - - _httpClient.DefaultRequestHeaders.Add("AccessKey", config.Firmware.CdnStorageApiKey); - } - - /// - /// Uploads a file to BunnyCDN storage. - /// - /// Path relative to storage zone root, e.g. "1.0.0/board-name/app.bin" - /// File content stream - /// Cancellation token - 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(); - } -} 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/appsettings.Development.json b/RepositoryServer/appsettings.Development.json index 24fbfd3..bea23c6 100644 --- a/RepositoryServer/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/RepositoryServer/appsettings.json b/RepositoryServer/appsettings.json index 069c683..df30236 100644 --- a/RepositoryServer/appsettings.json +++ b/RepositoryServer/appsettings.json @@ -1,22 +1,27 @@ { "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", - "CdnStorageUrl": "https://ny.storage.bunnycdn.com/openshock-firmware", - "CdnStorageApiKey": "" + "Storage": { + "Type": "Local", + "Local": { + "BasePath": "./cdn-storage" + } + } }, "Serilog": { From f547e2e747f4a8c6fd8f2ba36527b88a6a5178d3 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 31 Mar 2026 15:59:08 +0200 Subject: [PATCH 06/15] Implement firmware repo --- .github/workflows/ci-build.yml | 20 +- .github/workflows/ci-tag.yml | 20 +- .gitignore | 3 +- CLAUDE.md | 83 ++++++ DesktopRepositoryServer/AuthSchemas.cs | 6 - .../Problems/ModuleError.cs | 8 - .../RepoServerDb/RepoServerContext.cs | 130 -------- Directory.Packages.props | 4 +- .../RepositoryServer.Tests.csproj | 8 +- .../UnitTest1.cs | 2 +- RepositoryServer/AuthSchemas.cs | 7 + .../AdminTokenAuthentication.cs | 8 +- .../Config/ApiConfig.cs | 4 +- RepositoryServer/Config/CiCdAuthConfig.cs | 26 ++ .../Config/DbConfig.cs | 2 +- RepositoryServer/Config/FirmwareConfig.cs | 9 + .../Config/MetricsConfig.cs | 4 +- .../Config/RepoConfig.cs | 3 +- RepositoryServer/Config/StorageConfig.cs | 53 ++++ .../ConfigurationExtensions.cs | 2 +- .../Controllers/OpenShockControllerBase.cs | 4 +- .../Controllers/V1}/AdminController.cs | 37 +-- .../Controllers/V1/CiCdController.cs | 137 +++++++++ .../Controllers/V1}/RepoController.cs | 27 +- .../V2/Firmware/AdminController.cs | 147 +++++++++ .../V2/Firmware/BoardsController.cs | 49 +++ .../V2/Firmware/ChipsController.cs | 35 +++ .../Controllers/V2/Firmware/CiCdController.cs | 279 ++++++++++++++++++ .../V2/Firmware/LatestController.cs | 86 ++++++ .../V2/Firmware/VersionsController.cs | 106 +++++++ .../Enums/FirmwareArtifactType.cs | 10 + RepositoryServer/Enums/FirmwareChannel.cs | 8 + .../Enums/FirmwareChipArchitecture.cs | 7 + .../Enums/FirmwareReleaseNoteType.cs | 9 + .../Errors/AuthResultError.cs | 4 +- .../Errors/ExceptionError.cs | 4 +- .../ExceptionHandler/ExceptionHandler.cs | 5 +- .../ExceptionHandler/RequestInfo.cs | 2 +- .../20250315184152_Initial.Designer.cs | 14 +- .../Migrations/20250315184152_Initial.cs | 2 +- .../MigrationOpenShockContextModelSnapshot.cs | 2 +- .../Models/CreateModuleRequest.cs | 2 +- .../Models/CreateModuleVersionRequest.cs | 4 +- .../Firmware/CreateFirmwareBoardRequest.cs | 8 + .../Firmware/CreateFirmwareChipRequest.cs | 7 + .../Firmware/CreateFirmwareVersionRequest.cs | 11 + .../Models/Firmware/FirmwareArtifactDto.cs | 9 + .../Models/Firmware/FirmwareArtifactUpload.cs | 7 + .../Models/Firmware/FirmwareBoardDto.cs | 10 + .../Models/Firmware/FirmwareChipDto.cs | 8 + .../Models/Firmware/FirmwareLatestResponse.cs | 9 + .../Models/Firmware/FirmwareReleaseNoteDto.cs | 8 + .../Firmware/FirmwareReleaseNoteUpload.cs | 8 + .../Firmware/FirmwareVersionResponse.cs | 12 + .../Models/Firmware/FirmwareVersionSummary.cs | 8 + .../Models/Module.cs | 2 +- .../Models/Repository.cs | 2 +- .../Models/Version.cs | 4 +- .../Problems/ExceptionProblem.cs | 2 +- RepositoryServer/Problems/FirmwareError.cs | 20 ++ RepositoryServer/Problems/ModuleError.cs | 13 + .../Problems/OpenShockProblem.cs | 2 +- .../Problems/ValidationProblem.cs | 2 +- .../Problems/VersionError.cs | 2 +- .../Program.cs | 103 +++++-- .../Properties/launchSettings.json | 0 .../DesignTimeDbContextFactory.cs | 11 + .../RepoServerDb/FirmwareArtifact.cs | 15 + .../RepoServerDb/FirmwareBoard.cs | 15 + RepositoryServer/RepoServerDb/FirmwareChip.cs | 12 + .../RepoServerDb/FirmwareReleaseNote.cs | 14 + .../RepoServerDb/FirmwareVersion.cs | 15 + .../RepoServerDb/Module.cs | 2 +- .../RepoServerDb/RepoServerContext.cs | 275 +++++++++++++++++ .../RepoServerDb/Version.cs | 2 +- .../RepositoryServer.csproj | 8 +- .../Services/BunnyCdnStorageService.cs | 80 +++++ RepositoryServer/Services/IStorageService.cs | 34 +++ .../Services/LocalStorageService.cs | 61 ++++ RepositoryServer/Services/S3StorageService.cs | 116 ++++++++ .../Utils/ByteArrayHexConverter.cs | 2 +- .../Utils/ConnectionDetailsFetcher.cs | 2 +- .../Utils/FirmwareArtifactFileNames.cs | 17 ++ .../Utils/OpenShockEnricher.cs | 2 +- .../Utils/SemVersionConverter.cs | 2 +- .../Utils/TrustedProxiesFetcher.cs | 2 +- .../Utils/UriExtensions.cs | 2 +- .../appsettings.Development.json | 8 + .../appsettings.json | 23 +- RepositoryServers.slnx | 4 +- ...Dockerfile => RepositoryServer.Dockerfile} | 8 +- docker/entrypoint.sh | 2 +- 92 files changed, 2087 insertions(+), 286 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 DesktopRepositoryServer/AuthSchemas.cs delete mode 100644 DesktopRepositoryServer/Problems/ModuleError.cs delete mode 100644 DesktopRepositoryServer/RepoServerDb/RepoServerContext.cs rename DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj => RepositoryServer.Tests/RepositoryServer.Tests.csproj (57%) rename {DesktopRepositoryServer.Tests => RepositoryServer.Tests}/UnitTest1.cs (69%) create mode 100644 RepositoryServer/AuthSchemas.cs rename {DesktopRepositoryServer => RepositoryServer}/AuthenticationHandlers/AdminTokenAuthentication.cs (90%) rename {DesktopRepositoryServer => RepositoryServer}/Config/ApiConfig.cs (63%) create mode 100644 RepositoryServer/Config/CiCdAuthConfig.cs rename {DesktopRepositoryServer => RepositoryServer}/Config/DbConfig.cs (83%) create mode 100644 RepositoryServer/Config/FirmwareConfig.cs rename {DesktopRepositoryServer => RepositoryServer}/Config/MetricsConfig.cs (59%) rename {DesktopRepositoryServer => RepositoryServer}/Config/RepoConfig.cs (68%) create mode 100644 RepositoryServer/Config/StorageConfig.cs rename {DesktopRepositoryServer => RepositoryServer}/ConfigurationExtensions.cs (97%) rename {DesktopRepositoryServer => RepositoryServer}/Controllers/OpenShockControllerBase.cs (71%) rename {DesktopRepositoryServer/Controllers => RepositoryServer/Controllers/V1}/AdminController.cs (84%) create mode 100644 RepositoryServer/Controllers/V1/CiCdController.cs rename {DesktopRepositoryServer/Controllers => RepositoryServer/Controllers/V1}/RepoController.cs (76%) create mode 100644 RepositoryServer/Controllers/V2/Firmware/AdminController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/BoardsController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/ChipsController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/CiCdController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/LatestController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/VersionsController.cs create mode 100644 RepositoryServer/Enums/FirmwareArtifactType.cs create mode 100644 RepositoryServer/Enums/FirmwareChannel.cs create mode 100644 RepositoryServer/Enums/FirmwareChipArchitecture.cs create mode 100644 RepositoryServer/Enums/FirmwareReleaseNoteType.cs rename {DesktopRepositoryServer => RepositoryServer}/Errors/AuthResultError.cs (77%) rename {DesktopRepositoryServer => RepositoryServer}/Errors/ExceptionError.cs (51%) rename {DesktopRepositoryServer => RepositoryServer}/ExceptionHandler/ExceptionHandler.cs (90%) rename {DesktopRepositoryServer => RepositoryServer}/ExceptionHandler/RequestInfo.cs (87%) rename {DesktopRepositoryServer => RepositoryServer}/Migrations/20250315184152_Initial.Designer.cs (87%) rename {DesktopRepositoryServer => RepositoryServer}/Migrations/20250315184152_Initial.cs (98%) rename {DesktopRepositoryServer => RepositoryServer}/Migrations/MigrationOpenShockContextModelSnapshot.cs (98%) rename {DesktopRepositoryServer => RepositoryServer}/Models/CreateModuleRequest.cs (80%) rename {DesktopRepositoryServer => RepositoryServer}/Models/CreateModuleVersionRequest.cs (77%) create mode 100644 RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs create mode 100644 RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs create mode 100644 RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareArtifactDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareArtifactUpload.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareBoardDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareChipDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareReleaseNoteDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareReleaseNoteUpload.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs rename {DesktopRepositoryServer => RepositoryServer}/Models/Module.cs (87%) rename {DesktopRepositoryServer => RepositoryServer}/Models/Repository.cs (86%) rename {DesktopRepositoryServer => RepositoryServer}/Models/Version.cs (76%) rename {DesktopRepositoryServer => RepositoryServer}/Problems/ExceptionProblem.cs (84%) create mode 100644 RepositoryServer/Problems/FirmwareError.cs create mode 100644 RepositoryServer/Problems/ModuleError.cs rename {DesktopRepositoryServer => RepositoryServer}/Problems/OpenShockProblem.cs (95%) rename {DesktopRepositoryServer => RepositoryServer}/Problems/ValidationProblem.cs (96%) rename {DesktopRepositoryServer => RepositoryServer}/Problems/VersionError.cs (85%) rename {DesktopRepositoryServer => RepositoryServer}/Program.cs (63%) rename {DesktopRepositoryServer => RepositoryServer}/Properties/launchSettings.json (100%) create mode 100644 RepositoryServer/RepoServerDb/DesignTimeDbContextFactory.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareArtifact.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareBoard.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareChip.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareVersion.cs rename {DesktopRepositoryServer => RepositoryServer}/RepoServerDb/Module.cs (87%) create mode 100644 RepositoryServer/RepoServerDb/RepoServerContext.cs rename {DesktopRepositoryServer => RepositoryServer}/RepoServerDb/Version.cs (88%) rename DesktopRepositoryServer/DesktopRepositoryServer.csproj => RepositoryServer/RepositoryServer.csproj (86%) create mode 100644 RepositoryServer/Services/BunnyCdnStorageService.cs create mode 100644 RepositoryServer/Services/IStorageService.cs create mode 100644 RepositoryServer/Services/LocalStorageService.cs create mode 100644 RepositoryServer/Services/S3StorageService.cs rename {DesktopRepositoryServer => RepositoryServer}/Utils/ByteArrayHexConverter.cs (92%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/ConnectionDetailsFetcher.cs (95%) create mode 100644 RepositoryServer/Utils/FirmwareArtifactFileNames.cs rename {DesktopRepositoryServer => RepositoryServer}/Utils/OpenShockEnricher.cs (97%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/SemVersionConverter.cs (95%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/TrustedProxiesFetcher.cs (98%) rename {DesktopRepositoryServer => RepositoryServer}/Utils/UriExtensions.cs (85%) rename {DesktopRepositoryServer => RepositoryServer}/appsettings.Development.json (75%) rename {DesktopRepositoryServer => RepositoryServer}/appsettings.json (68%) rename docker/{DesktopRepositoryServer.Dockerfile => RepositoryServer.Dockerfile} (61%) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index ccb33aa..46e4e5b 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@v6 @@ -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 @@ -113,8 +113,8 @@ jobs: with: token: ${{ secrets.GITOPS_PAT }} repository: 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 }}" - } \ No newline at end of file + } diff --git a/.github/workflows/ci-tag.yml b/.github/workflows/ci-tag.yml index 404d52c..a7164c7 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 @@ -42,7 +42,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/desktop-repository-server + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/repository-server flavor: | latest=false tags: | @@ -50,25 +50,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@v6 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@v2 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..e45f458 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# 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 +- TUnit for tests +- Scalar for API docs UI +- Docker (Alpine) with self-signed HTTPS cert generation + +## Project Structure +``` +RepositoryServer/ # Main web API project + AuthenticationHandlers/ # Custom AdminToken auth handler + Config/ # ApiConfig, DbConfig, RepoConfig, MetricsConfig, FirmwareConfig + Controllers/ + OpenShockControllerBase.cs # Shared base controller + V1/ # Desktop endpoints + RepoController.cs # GET /v1/ — desktop module manifest + AdminController.cs # /v1/admin/... — desktop module CRUD + V2/ + Firmware/ + LatestController.cs # GET /v2/firmware/latest/{channel} + VersionsController.cs # GET /v2/firmware/versions[/{version}] + BoardsController.cs # GET /v2/firmware/boards + ChipsController.cs # GET /v2/firmware/chips + AdminController.cs # /v2/firmware/admin/... CRUD + Errors/ # Static error factories (AuthResultError, ExceptionError) + ExceptionHandler/ # Global IExceptionHandler + RequestInfo logging + Migrations/ # EF Core migrations (Initial + AddFirmwareTables) + Models/ # Desktop module DTOs + Models/Firmware/ # Firmware DTOs (response + admin request) + Problems/ # RFC 7807 problem details (OpenShockProblem base) + RepoServerDb/ # EF Core entities + RepoServerContext + Utils/ # ByteArrayHexConverter, SemVersionConverter, etc. + Program.cs # App startup and middleware pipeline + +RepositoryServer.Tests/ # TUnit test project +docker/ + RepositoryServer.Dockerfile + entrypoint.sh # Cert gen + `dotnet OpenShock.RepositoryServer.dll` + appsettings.Container.json +``` + +## Namespace +`OpenShock.RepositoryServer` (renamed from `OpenShock.Desktop.RepositoryServer`) + +## Key Patterns +- **Auth**: Custom `AdminTokenAuthentication` handler, `AuthSchemas.AdminToken` scheme +- **Error Handling**: `OpenShockProblem` base class → `ToObjectResult()` for RFC 7807 +- **Config**: Bind root config to `ApiConfig`, validated with MiniValidator, registered as singleton +- **DB Migrations**: `MigrationOpenShockContext` subclass for migration tooling, auto-applied on startup +- **JSON**: CamelCase, custom `SemVersionConverter` + `ByteArrayHexConverter` +- **Upsert**: FlexLabs `.Upsert().On().RunAsync()` pattern +- **CORS**: Allow all origins/methods/headers +- **Metrics**: Prometheus at `/metrics`, restricted to private networks + +## Database +- PostgreSQL, connection in `ApiConfig.Db.Conn` +- 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 enums: `firmware_channel`, `firmware_artifact_type`, `firmware_release_note_type`, `firmware_chip_architecture` +- URL convention for firmware artifacts: `{CdnBaseUrl}/{version}/{boardId}/{artifactType}.bin` + +## 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 RepositoryServer.Tests/RepositoryServer.Tests.csproj` +- Docker: `docker build -f docker/RepositoryServer.Dockerfile .` +- Migration: `dotnet ef migrations add --project RepositoryServer` 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/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 2d6c063..989bf0c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,8 +12,10 @@ + - + + diff --git a/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj b/RepositoryServer.Tests/RepositoryServer.Tests.csproj similarity index 57% rename from DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj rename to RepositoryServer.Tests/RepositoryServer.Tests.csproj index 6b40f31..0c006b7 100644 --- a/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj +++ b/RepositoryServer.Tests/RepositoryServer.Tests.csproj @@ -6,10 +6,10 @@ false true 13 - OpenShock.Desktop.RepositoryServer.Tests - OpenShock.Desktop.RepositoryServer.Tests - OpenShock.Desktop.RepositoryServer.Tests - OpenShock.Desktop.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests + OpenShock.RepositoryServer.Tests diff --git a/DesktopRepositoryServer.Tests/UnitTest1.cs b/RepositoryServer.Tests/UnitTest1.cs similarity index 69% rename from DesktopRepositoryServer.Tests/UnitTest1.cs rename to RepositoryServer.Tests/UnitTest1.cs index 0361efd..8448f03 100644 --- a/DesktopRepositoryServer.Tests/UnitTest1.cs +++ b/RepositoryServer.Tests/UnitTest1.cs @@ -1,4 +1,4 @@ -namespace OpenShock.Desktop.RepositoryServer.Tests; +namespace OpenShock.RepositoryServer.Tests; public class UnitTest1 { diff --git a/RepositoryServer/AuthSchemas.cs b/RepositoryServer/AuthSchemas.cs new file mode 100644 index 0000000..a310fca --- /dev/null +++ b/RepositoryServer/AuthSchemas.cs @@ -0,0 +1,7 @@ +namespace OpenShock.RepositoryServer; + +public static class AuthSchemas +{ + public const string AdminToken = "AdminToken"; + public const string CiCdToken = "CiCdToken"; +} \ No newline at end of file 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/DesktopRepositoryServer/Config/ApiConfig.cs b/RepositoryServer/Config/ApiConfig.cs similarity index 63% rename from DesktopRepositoryServer/Config/ApiConfig.cs rename to RepositoryServer/Config/ApiConfig.cs index b778351..442ec25 100644 --- a/DesktopRepositoryServer/Config/ApiConfig.cs +++ b/RepositoryServer/Config/ApiConfig.cs @@ -1,11 +1,13 @@ 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 CiCdAuthConfig CiCd { 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 diff --git a/RepositoryServer/Config/CiCdAuthConfig.cs b/RepositoryServer/Config/CiCdAuthConfig.cs new file mode 100644 index 0000000..f704c70 --- /dev/null +++ b/RepositoryServer/Config/CiCdAuthConfig.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Config; + +public sealed class CiCdAuthConfig +{ + /// + /// 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; } + + /// + /// GitHub organization or user that owns the repositories allowed to authenticate. + /// Validated against the 'repository_owner' claim in the OIDC token. + /// + [Required(AllowEmptyStrings = false)] + public required string RepositoryOwner { get; init; } + + /// + /// Optional list of specific repositories allowed to authenticate (e.g. "openshock/firmware"). + /// If null or empty, any repository under the owner is allowed. + /// + public List? AllowedRepositories { get; init; } +} diff --git a/DesktopRepositoryServer/Config/DbConfig.cs b/RepositoryServer/Config/DbConfig.cs similarity index 83% rename from DesktopRepositoryServer/Config/DbConfig.cs rename to RepositoryServer/Config/DbConfig.cs index e416a5e..91f9b55 100644 --- a/DesktopRepositoryServer/Config/DbConfig.cs +++ b/RepositoryServer/Config/DbConfig.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace OpenShock.Desktop.RepositoryServer.Config; +namespace OpenShock.RepositoryServer.Config; public class DbConfig { diff --git a/RepositoryServer/Config/FirmwareConfig.cs b/RepositoryServer/Config/FirmwareConfig.cs new file mode 100644 index 0000000..6b5fdd4 --- /dev/null +++ b/RepositoryServer/Config/FirmwareConfig.cs @@ -0,0 +1,9 @@ +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; } +} 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 577be19..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,11 +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, @@ -52,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/AdminController.cs b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs new file mode 100644 index 0000000..08a7c77 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs @@ -0,0 +1,147 @@ +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; + +[ApiVersion("2.0")] +[ApiController] +[Route("/v{version:apiVersion}/firmware/admin")] +[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] +public class AdminController : OpenShockControllerBase +{ + private readonly RepoServerContext _db; + + public AdminController(RepoServerContext db) + { + _db = db; + } + + // ---- Version Management ---- + + [HttpDelete("versions/{firmwareVersion}")] + public async Task DeleteVersion([FromRoute] string firmwareVersion) + { + var deleted = await _db.FirmwareVersions.Where(v => v.Version == firmwareVersion).ExecuteDeleteAsync(); + if (deleted <= 0) return Problem(FirmwareError.FirmwareVersionNotFound); + return Ok(); + } + + // ---- Board Management ---- + + [HttpPut("boards/{boardId}")] + public async Task UpsertBoard([FromRoute] string boardId, [FromBody] CreateFirmwareBoardRequest request) + { + if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId)) + { + return Problem(FirmwareError.FirmwareChipNotFound); + } + + // Parse and validate required artifact types + var requiredArtifactTypes = Array.Empty(); + if (request.RequiredArtifactTypes is { Count: > 0 }) + { + var parsed = new List(); + foreach (var typeStr in request.RequiredArtifactTypes) + { + if (!Enum.TryParse(typeStr, true, out var artifactType)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + parsed.Add(artifactType); + } + requiredArtifactTypes = parsed.Distinct().ToArray(); + } + + var existing = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); + if (existing != null) + { + existing.Name = request.Name; + existing.ChipId = request.ChipId; + existing.RequiredArtifactTypes = requiredArtifactTypes; + } + else + { + _db.FirmwareBoards.Add(new FirmwareBoard + { + Id = boardId, + Name = request.Name, + ChipId = request.ChipId, + Discontinued = false, + RequiredArtifactTypes = requiredArtifactTypes + }); + } + + await _db.SaveChangesAsync(); + return Created(); + } + + [HttpPatch("boards/{boardId}/discontinue")] + public async Task DiscontinueBoard([FromRoute] string boardId) + { + var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); + if (board == null) return Problem(FirmwareError.FirmwareBoardNotFound); + + board.Discontinued = true; + await _db.SaveChangesAsync(); + return Ok(); + } + + [HttpDelete("boards/{boardId}")] + public async Task DeleteBoard([FromRoute] string boardId) + { + if (await _db.FirmwareArtifacts.AnyAsync(a => a.BoardId == boardId)) + { + return Problem(FirmwareError.FirmwareBoardInUse); + } + + var deleted = await _db.FirmwareBoards.Where(b => b.Id == boardId).ExecuteDeleteAsync(); + if (deleted <= 0) return Problem(FirmwareError.FirmwareBoardNotFound); + return Ok(); + } + + // ---- Chip Management ---- + + [HttpPut("chips/{chipId}")] + public async Task UpsertChip([FromRoute] string chipId, [FromBody] CreateFirmwareChipRequest request) + { + FirmwareChipArchitecture? architecture = null; + if (request.Architecture != null) + { + if (!Enum.TryParse(request.Architecture, true, out var parsed)) + { + return Problem(FirmwareError.FirmwareInvalidArchitecture); + } + architecture = parsed; + } + + var chip = new FirmwareChip + { + Id = chipId, + Name = request.Name, + Architecture = architecture + }; + var executed = await _db.FirmwareChips.Upsert(chip).On(c => c.Id).RunAsync(); + if (executed <= 0) throw new Exception("Failed to upsert firmware chip"); + + return Created(); + } + + [HttpDelete("chips/{chipId}")] + public async Task DeleteChip([FromRoute] string chipId) + { + if (await _db.FirmwareBoards.AnyAsync(b => b.ChipId == chipId)) + { + return Problem(FirmwareError.FirmwareChipInUse); + } + + var deleted = await _db.FirmwareChips.Where(c => c.Id == chipId).ExecuteDeleteAsync(); + if (deleted <= 0) return Problem(FirmwareError.FirmwareChipNotFound); + return Ok(); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs new file mode 100644 index 0000000..e50d8ce --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs @@ -0,0 +1,49 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +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] + public async Task ListBoards([FromQuery] string? chipId, [FromQuery] bool includeDiscontinued = false) + { + IQueryable query = _db.FirmwareBoards.Include(b => b.ChipNavigation); + + if (!string.IsNullOrWhiteSpace(chipId)) + { + query = query.Where(b => b.ChipId == chipId); + } + + if (!includeDiscontinued) + { + query = query.Where(b => !b.Discontinued); + } + + var boards = await query + .Select(b => new FirmwareBoardDto + { + Id = b.Id, + Name = b.Name, + ChipId = b.ChipId, + ChipName = b.ChipNavigation.Name, + Discontinued = b.Discontinued + }) + .ToListAsync(); + + 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..6fd2c53 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs @@ -0,0 +1,35 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Models.Firmware; +using OpenShock.RepositoryServer.RepoServerDb; + +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] + public async Task ListChips() + { + var chips = await _db.FirmwareChips + .Select(c => new FirmwareChipDto + { + Id = c.Id, + Name = c.Name, + Architecture = c.Architecture != null ? c.Architecture.Value.ToString().ToLowerInvariant() : null + }) + .ToListAsync(); + + return Ok(chips); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs b/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs new file mode 100644 index 0000000..2908833 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs @@ -0,0 +1,279 @@ +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.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/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; + } + + // ---- Version Publishing ---- + + [HttpPut("versions/{firmwareVersion}")] + public async Task PublishVersion([FromRoute] string firmwareVersion, [FromBody] CreateFirmwareVersionRequest request) + { + if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) + { + return Problem(FirmwareError.FirmwareInvalidSemver); + } + + if (!Enum.TryParse(request.Channel, true, out var channel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + + // Validate artifacts if provided + if (request.Artifacts is { Count: > 0 }) + { + var boardIds = request.Artifacts.Keys.ToHashSet(); + var boards = await _db.FirmwareBoards + .Where(b => boardIds.Contains(b.Id)) + .ToDictionaryAsync(b => b.Id); + + var missingBoardsCount = boardIds.Except(boards.Keys).Count(); + if (missingBoardsCount > 0) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + foreach (var (boardId, artifactTypes) in request.Artifacts) + { + if (artifactTypes.Keys.Any(artifactType => !Enum.TryParse(artifactType, true, out _))) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + + // Validate all required artifact types are present + var board = boards[boardId]; + if (board.RequiredArtifactTypes.Length > 0) + { + var providedTypes = artifactTypes.Keys + .Select(k => Enum.Parse(k, true)) + .ToHashSet(); + var missingTypes = board.RequiredArtifactTypes + .Where(r => !providedTypes.Contains(r)) + .Select(r => r.ToString().ToLowerInvariant()) + .ToList(); + if (missingTypes.Count > 0) + { + return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); + } + } + } + } + + // Validate release note types + if (request.ReleaseNotes.Any(note => !Enum.TryParse(note.Type, true, out _))) + { + return Problem(FirmwareError.FirmwareInvalidReleaseNoteType); + } + + await using var transaction = await _db.Database.BeginTransactionAsync(); + + // Upsert the firmware version + var versionEntity = new FirmwareVersion + { + Version = firmwareVersion, + Channel = channel, + ReleaseDate = request.ReleaseDate, + CommitHash = request.CommitHash, + ReleaseUrl = request.ReleaseUrl + }; + var executed = await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(); + if (executed <= 0) throw new Exception("Failed to upsert firmware version"); + + // Replace artifacts only if provided in the request + if (request.Artifacts is { Count: > 0 }) + { + await _db.FirmwareArtifacts.Where(a => a.Version == firmwareVersion).ExecuteDeleteAsync(); + foreach (var (boardId, artifactTypes) in request.Artifacts) + { + foreach (var (artifactTypeStr, upload) in artifactTypes) + { + var artifactType = Enum.Parse(artifactTypeStr, true); + _db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = firmwareVersion, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = Convert.FromHexString(upload.Sha256Hash), + FileSize = upload.FileSize + }); + } + } + } + + // Replace all release notes for this version + await _db.FirmwareReleaseNotes.Where(n => n.Version == firmwareVersion).ExecuteDeleteAsync(); + for (var i = 0; i < request.ReleaseNotes.Count; i++) + { + var note = request.ReleaseNotes[i]; + _db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote + { + Version = firmwareVersion, + Index = i, + Type = Enum.Parse(note.Type, true), + Title = note.Title, + Content = note.Content + }); + } + + await _db.SaveChangesAsync(); + await transaction.CommitAsync(); + + return CreatedAtAction( + nameof(VersionsController.GetVersion), + "Versions", + new { firmwareVersion }, + null); + } + + // ---- Board Artifact Upload ---- + + private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase) + { + ["app"] = FirmwareArtifactType.App, + ["staticfs"] = FirmwareArtifactType.StaticFs, + ["merged"] = FirmwareArtifactType.Merged, + ["bootloader"] = FirmwareArtifactType.Bootloader, + ["partitions"] = FirmwareArtifactType.Partitions, + }; + + /// + /// Uploads firmware binary artifacts for a specific board+version. + /// Accepts multipart/form-data with file fields named: app, staticfs, merged, bootloader, partitions. + /// Each file is hashed, uploaded to CDN, and recorded in the database. + /// + [HttpPut("versions/{firmwareVersion}/boards/{boardId}/upload")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(64 * 1024 * 1024)] // 64 MB + public async Task UploadBoardArtifacts( + [FromRoute] string firmwareVersion, + [FromRoute] string boardId) + { + if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) + { + return Problem(FirmwareError.FirmwareInvalidSemver); + } + + if (!await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion)) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); + if (board == null) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var files = Request.Form.Files; + if (files.Count == 0) + { + return BadRequest(new { error = "No files uploaded. Expected file fields: app, staticfs, merged, bootloader, partitions" }); + } + + // Validate all required artifact types are present in the upload + if (board.RequiredArtifactTypes.Length > 0) + { + var uploadedTypes = files + .Select(f => f.Name.ToLowerInvariant()) + .Where(ArtifactFieldNames.ContainsKey) + .Select(n => ArtifactFieldNames[n]) + .ToHashSet(); + var missingTypes = board.RequiredArtifactTypes + .Where(r => !uploadedTypes.Contains(r)) + .Select(r => r.ToString().ToLowerInvariant()) + .ToList(); + if (missingTypes.Count > 0) + { + return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); + } + } + + var uploadedArtifacts = new List(); + + foreach (var file in files) + { + var fieldName = file.Name.ToLowerInvariant(); + if (!ArtifactFieldNames.TryGetValue(fieldName, out var artifactType)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + + // Read file into memory for hashing + CDN upload + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream); + var fileBytes = memoryStream.ToArray(); + var fileSize = fileBytes.Length; + + // Compute SHA256 hash + var hashBytes = SHA256.HashData(fileBytes); + + // Upload to storage + var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifactType); + var cdnPath = $"{firmwareVersion}/{boardId}/{cdnFileName}"; + + using var uploadStream = new MemoryStream(fileBytes); + await _storage.UploadFileAsync(cdnPath, uploadStream); + + // Upsert artifact record in DB + var existing = await _db.FirmwareArtifacts.FirstOrDefaultAsync(a => + a.Version == firmwareVersion && a.BoardId == boardId && a.ArtifactType == artifactType); + + if (existing != null) + { + existing.HashSha256 = hashBytes; + existing.FileSize = fileSize; + } + else + { + _db.FirmwareArtifacts.Add(new FirmwareArtifact + { + Version = firmwareVersion, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = hashBytes, + FileSize = fileSize, + }); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + uploadedArtifacts.Add(new FirmwareArtifactDto + { + Type = artifactType.ToString().ToLowerInvariant(), + Url = $"{cdnBase}/{firmwareVersion}/{boardId}/{cdnFileName}", + Sha256Hash = Convert.ToHexString(hashBytes), + FileSize = fileSize, + }); + } + + await _db.SaveChangesAsync(); + + return Ok(uploadedArtifacts); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs new file mode 100644 index 0000000..b403039 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs @@ -0,0 +1,86 @@ +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}")] + public async Task GetLatest([FromRoute] string channel, [FromQuery] string? board) + { + if (!Enum.TryParse(channel, true, out var firmwareChannel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + + var query = _db.FirmwareVersions + .Where(v => v.Channel == firmwareChannel) + .OrderByDescending(v => v.ReleaseDate); + + FirmwareVersion? latestVersion; + if (!string.IsNullOrWhiteSpace(board)) + { + latestVersion = await query + .Include(v => v.Artifacts.Where(a => a.BoardId == board)) + .FirstOrDefaultAsync(); + } + else + { + latestVersion = await query + .Include(v => v.Artifacts) + .FirstOrDefaultAsync(); + } + + if (latestVersion == null) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + var artifacts = new Dictionary>(); + + foreach (var artifact in latestVersion.Artifacts) + { + if (!artifacts.TryGetValue(artifact.BoardId, out var list)) + { + list = new List(); + artifacts[artifact.BoardId] = list; + } + + list.Add(new FirmwareArtifactDto + { + Type = artifact.ArtifactType.ToString().ToLowerInvariant(), + Url = $"{cdnBase}/{latestVersion.Version}/{artifact.BoardId}/{FirmwareArtifactFileNames.GetFileName(artifact.ArtifactType)}", + Sha256Hash = Convert.ToHexString(artifact.HashSha256), + FileSize = artifact.FileSize + }); + } + + return Ok(new FirmwareLatestResponse + { + Version = latestVersion.Version, + Channel = latestVersion.Channel.ToString().ToLowerInvariant(), + ReleaseDate = latestVersion.ReleaseDate, + Artifacts = artifacts + }); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs new file mode 100644 index 0000000..e5eca15 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs @@ -0,0 +1,106 @@ +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 readonly RepoServerContext _db; + private readonly ApiConfig _apiConfig; + + public VersionsController(RepoServerContext db, ApiConfig apiConfig) + { + _db = db; + _apiConfig = apiConfig; + } + + [HttpGet] + public async Task ListVersions([FromQuery] string? channel) + { + IQueryable query = _db.FirmwareVersions; + + if (!string.IsNullOrWhiteSpace(channel)) + { + if (!Enum.TryParse(channel, true, out var firmwareChannel)) + { + return Problem(FirmwareError.FirmwareInvalidChannel); + } + query = query.Where(v => v.Channel == firmwareChannel); + } + + var versions = await query + .OrderByDescending(v => v.ReleaseDate) + .Select(v => new FirmwareVersionSummary + { + Version = v.Version, + Channel = v.Channel.ToString().ToLowerInvariant(), + ReleaseDate = v.ReleaseDate + }) + .ToListAsync(); + + return Ok(versions); + } + + [HttpGet("{firmwareVersion}")] + public async Task GetVersion([FromRoute] string firmwareVersion) + { + var version = await _db.FirmwareVersions + .Include(v => v.Artifacts) + .Include(v => v.ReleaseNotes.OrderBy(n => n.Index)) + .FirstOrDefaultAsync(v => v.Version == firmwareVersion); + + if (version == null) + { + return Problem(FirmwareError.FirmwareVersionNotFound); + } + + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + + var artifacts = new Dictionary>(); + foreach (var artifact in version.Artifacts) + { + if (!artifacts.TryGetValue(artifact.BoardId, out var list)) + { + list = new List(); + artifacts[artifact.BoardId] = list; + } + + list.Add(new FirmwareArtifactDto + { + Type = artifact.ArtifactType.ToString().ToLowerInvariant(), + Url = $"{cdnBase}/{version.Version}/{artifact.BoardId}/{FirmwareArtifactFileNames.GetFileName(artifact.ArtifactType)}", + Sha256Hash = Convert.ToHexString(artifact.HashSha256), + FileSize = artifact.FileSize + }); + } + + var releaseNotes = version.ReleaseNotes.Select(n => new FirmwareReleaseNoteDto + { + Type = n.Type.ToString().ToLowerInvariant(), + Title = n.Title, + Content = n.Content + }).ToList(); + + return Ok(new FirmwareVersionResponse + { + Version = version.Version, + Channel = version.Channel.ToString().ToLowerInvariant(), + ReleaseDate = version.ReleaseDate, + CommitHash = version.CommitHash, + ReleaseUrl = version.ReleaseUrl, + Artifacts = artifacts, + ReleaseNotes = releaseNotes + }); + } + +} 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/FirmwareChannel.cs b/RepositoryServer/Enums/FirmwareChannel.cs new file mode 100644 index 0000000..532fd44 --- /dev/null +++ b/RepositoryServer/Enums/FirmwareChannel.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Enums; + +public enum FirmwareChannel +{ + Stable, + Beta, + Develop +} 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/FirmwareReleaseNoteType.cs b/RepositoryServer/Enums/FirmwareReleaseNoteType.cs new file mode 100644 index 0000000..c44983c --- /dev/null +++ b/RepositoryServer/Enums/FirmwareReleaseNoteType.cs @@ -0,0 +1,9 @@ +namespace OpenShock.RepositoryServer.Enums; + +public enum FirmwareReleaseNoteType +{ + Warning, + Info, + Breaking, + Section +} 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 65b2410..d8c6ba6 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 { @@ -18,6 +18,7 @@ public OpenShockExceptionHandler(IProblemDetailsService problemDetailsService, I public async ValueTask TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken) { context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + _logger.LogError(exception, "Unhandled exception for {Method} {Path}", context.Request.Method, context.Request.Path); await PrintRequestInfo(context); var responseObject = ExceptionError.Exception; 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/DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs b/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs similarity index 98% rename from DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs rename to RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs index 000bd3e..ac9f8f1 100644 --- a/DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs +++ b/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using OpenShock.Desktop.RepositoryServer.RepoServerDb; +using OpenShock.RepositoryServer.RepoServerDb; #nullable disable 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..c22314c --- /dev/null +++ b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class CreateFirmwareBoardRequest +{ + public required string Name { get; init; } + public required string 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..299b08a --- /dev/null +++ b/RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs @@ -0,0 +1,7 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class CreateFirmwareChipRequest +{ + public required string Name { get; init; } + public string? Architecture { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs new file mode 100644 index 0000000..5fa7d27 --- /dev/null +++ b/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs @@ -0,0 +1,11 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class CreateFirmwareVersionRequest +{ + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } + public required string CommitHash { get; init; } + public string? ReleaseUrl { get; init; } + public Dictionary>? Artifacts { get; init; } + public List ReleaseNotes { 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/FirmwareArtifactUpload.cs b/RepositoryServer/Models/Firmware/FirmwareArtifactUpload.cs new file mode 100644 index 0000000..a456194 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareArtifactUpload.cs @@ -0,0 +1,7 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareArtifactUpload +{ + public required string Sha256Hash { get; init; } + public required long FileSize { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs new file mode 100644 index 0000000..8440296 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs @@ -0,0 +1,10 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareBoardDto +{ + public required string Id { get; init; } + public required string Name { get; init; } + public required string ChipId { get; init; } + public required string ChipName { get; init; } + public required bool Discontinued { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareChipDto.cs b/RepositoryServer/Models/Firmware/FirmwareChipDto.cs new file mode 100644 index 0000000..574b0d5 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareChipDto.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareChipDto +{ + public required string Id { get; init; } + public required string Name { get; init; } + public string? Architecture { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs b/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs new file mode 100644 index 0000000..6e11ac5 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs @@ -0,0 +1,9 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareLatestResponse +{ + public required string Version { get; init; } + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } + public required Dictionary> Artifacts { 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/FirmwareReleaseNoteUpload.cs b/RepositoryServer/Models/Firmware/FirmwareReleaseNoteUpload.cs new file mode 100644 index 0000000..b9e60d6 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareReleaseNoteUpload.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareReleaseNoteUpload +{ + public required string Type { get; init; } + public string? Title { get; init; } + public required string Content { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs b/RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs new file mode 100644 index 0000000..5eaec89 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs @@ -0,0 +1,12 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareVersionResponse +{ + public required string Version { get; init; } + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } + public required string CommitHash { get; init; } + public string? ReleaseUrl { get; init; } + public required Dictionary> Artifacts { get; init; } + public required List ReleaseNotes { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs b/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs new file mode 100644 index 0000000..f9ea106 --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class FirmwareVersionSummary +{ + public required string Version { get; init; } + public required string Channel { get; init; } + public required DateTimeOffset ReleaseDate { 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/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..b5f25d4 --- /dev/null +++ b/RepositoryServer/Problems/FirmwareError.cs @@ -0,0 +1,20 @@ +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 FirmwareInvalidReleaseNoteType => new("Firmware.InvalidReleaseNoteType", "The release note type provided is not valid"); + public static OpenShockProblem FirmwareInvalidArchitecture => new("Firmware.InvalidArchitecture", "The architecture provided is not valid"); + public static OpenShockProblem FirmwareBoardInUse => new("Firmware.BoardInUse", "Cannot delete board that has associated artifacts", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareChipInUse => new("Firmware.ChipInUse", "Cannot delete chip that has associated boards", HttpStatusCode.Conflict); + public static OpenShockProblem FirmwareMissingRequiredArtifacts(string 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); +} 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 63% rename from DesktopRepositoryServer/Program.cs rename to RepositoryServer/Program.cs index f4e1e68..fb16026 100644 --- a/DesktopRepositoryServer/Program.cs +++ b/RepositoryServer/Program.cs @@ -1,22 +1,24 @@ -using System.Text.Json; +using System.Net; +using System.Security.Claims; +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); @@ -52,10 +54,44 @@ // <---- ASP.NET ----> builder.Services.AddExceptionHandler(); -builder.Services.AddAuthenticationCore(); -new AuthenticationBuilder(builder.Services) +builder.Services.AddAuthentication() .AddScheme( - AuthSchemas.AdminToken, _ => { }); + AuthSchemas.AdminToken, _ => { }) + .AddJwtBearer(AuthSchemas.CiCdToken, options => + { + options.Authority = "https://token.actions.githubusercontent.com"; + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = "https://token.actions.githubusercontent.com", + ValidateAudience = true, + ValidAudience = config.CiCd.Audience, + ValidateLifetime = true, + }; + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + var owner = context.Principal?.FindFirstValue("repository_owner"); + if (!string.Equals(owner, config.CiCd.RepositoryOwner, StringComparison.OrdinalIgnoreCase)) + { + context.Fail("Repository owner is not allowed."); + return Task.CompletedTask; + } + + if (config.CiCd.AllowedRepositories is { Count: > 0 }) + { + var repo = context.Principal?.FindFirstValue("repository"); + if (repo == null || !config.CiCd.AllowedRepositories.Contains(repo, StringComparer.OrdinalIgnoreCase)) + { + context.Fail("Repository is not allowed."); + } + } + + return Task.CompletedTask; + } + }; + }); builder.Services.AddAuthorization(); @@ -128,8 +164,37 @@ .AddHttpClientInstrumentation() .AddPrometheusExporter()); +// <---- Storage Service ----> +switch (config.Firmware.Storage.Type) +{ + 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}"); +} + // <---- Postgres EF Core ----> - + builder.Services.AddDbContextPool(dbBuilder => { RepoServerContext.ConfigureOptionsBuilder(dbBuilder, config.Db.Conn, config.Db.Debug); @@ -137,13 +202,7 @@ 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); }); @@ -200,7 +259,7 @@ 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 => { 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/FirmwareArtifact.cs b/RepositoryServer/RepoServerDb/FirmwareArtifact.cs new file mode 100644 index 0000000..6066eb9 --- /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 string BoardId { get; set; } = null!; + 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..9a1b648 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareBoard.cs @@ -0,0 +1,15 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareBoard +{ + public string Id { get; set; } = null!; + public string ChipId { get; set; } = null!; + public string Name { get; set; } = null!; + 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(); +} diff --git a/RepositoryServer/RepoServerDb/FirmwareChip.cs b/RepositoryServer/RepoServerDb/FirmwareChip.cs new file mode 100644 index 0000000..cd351cb --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareChip.cs @@ -0,0 +1,12 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareChip +{ + public string Id { get; set; } = null!; + public string Name { get; set; } = null!; + public FirmwareChipArchitecture? Architecture { get; set; } + + public virtual ICollection Boards { get; set; } = new List(); +} diff --git a/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs b/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs new file mode 100644 index 0000000..99e299b --- /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 FirmwareReleaseNoteType Type { 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/FirmwareVersion.cs b/RepositoryServer/RepoServerDb/FirmwareVersion.cs new file mode 100644 index 0000000..e696fe7 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareVersion.cs @@ -0,0 +1,15 @@ +using OpenShock.RepositoryServer.Enums; + +namespace OpenShock.RepositoryServer.RepoServerDb; + +public class FirmwareVersion +{ + public string Version { get; set; } = null!; + public FirmwareChannel Channel { get; set; } + public DateTimeOffset ReleaseDate { get; set; } + public string CommitHash { get; set; } = null!; + public string? ReleaseUrl { get; set; } + + 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..06078dd --- /dev/null +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using EntityFramework.Exceptions.PostgreSQL; +using Microsoft.EntityFrameworkCore; +using Npgsql; +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) + { + // Migration tooling: plain connection string, no MapEnum (to avoid duplicate enum schemas) + optionsBuilder.UseNpgsql("Host=localhost;Database=repo-server;Username=openshock;Password=openshock"); + 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) + { + } + + public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilder, string connectionString, + bool debug) + { + optionsBuilder.UseNpgsql(connectionString, npgsqlBuilder => + { + npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); + }); + + 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(); + } + } + + // 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 FirmwareArtifacts { get; set; } + public virtual DbSet FirmwareReleaseNotes { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // PostgreSQL enum mappings + modelBuilder + .HasPostgresEnum("firmware_channel", ["stable", "beta", "develop"]) + .HasPostgresEnum("firmware_artifact_type", ["merged", "app", "bootloader", "partitions", "static_fs"]) + .HasPostgresEnum("firmware_release_note_type", ["warning", "info", "breaking", "section"]) + .HasPostgresEnum("firmware_chip_architecture", ["xtensa", "risc_v"]); + + // Desktop module entities (unchanged) + 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) + .HasMaxLength(32) + .HasColumnName("id"); + entity.Property(e => e.Name) + .HasMaxLength(64) + .HasColumnName("name"); + entity.Property(e => e.Architecture) + .HasColumnName("architecture"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("firmware_boards_pkey"); + entity.ToTable("firmware_boards"); + + entity.Property(e => e.Id) + .HasMaxLength(64) + .HasColumnName("id"); + entity.Property(e => e.ChipId) + .HasMaxLength(32) + .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"); + }); + + 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.CommitHash) + .HasMaxLength(40) + .HasColumnName("commit_hash"); + entity.Property(e => e.ReleaseUrl) + .HasMaxLength(256) + .HasColumnName("release_url"); + + 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) + .HasMaxLength(64) + .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.Type) + .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"); + }); + } +} 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 86% rename from DesktopRepositoryServer/DesktopRepositoryServer.csproj rename to RepositoryServer/RepositoryServer.csproj index 1f0e193..6f4ef16 100644 --- a/DesktopRepositoryServer/DesktopRepositoryServer.csproj +++ b/RepositoryServer/RepositoryServer.csproj @@ -1,13 +1,15 @@ - OpenShock.Desktop.RepositoryServer - OpenShock.Desktop.RepositoryServer - Desktop.RepositoryServer + OpenShock.RepositoryServer + OpenShock.RepositoryServer + RepositoryServer + + 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/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/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/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/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/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/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 68% rename from DesktopRepositoryServer/appsettings.json rename to RepositoryServer/appsettings.json index 89258a8..df30236 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" } } }, @@ -17,7 +28,7 @@ "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Grafana.Loki", - "OpenShock.Desktop.RepositoryServer" + "OpenShock.RepositoryServer" ], "MinimumLevel": { "Default": "Warning", @@ -25,7 +36,7 @@ "Microsoft.Hosting.Lifetime": "Information", "Microsoft.AspNetCore.Hosting.Diagnostics": "Warning", "Serilog.AspNetCore.RequestLoggingMiddleware": "Information", - "OpenShock.Desktop.RepositoryServer.AuthenticationHandlers": "Warning", + "OpenShock.RepositoryServer.AuthenticationHandlers": "Warning", "OpenShock": "Information" } }, diff --git a/RepositoryServers.slnx b/RepositoryServers.slnx index 4acae06..1f9eb89 100644 --- a/RepositoryServers.slnx +++ b/RepositoryServers.slnx @@ -1,4 +1,4 @@  - - + + \ No newline at end of file diff --git a/docker/DesktopRepositoryServer.Dockerfile b/docker/RepositoryServer.Dockerfile similarity index 61% rename from docker/DesktopRepositoryServer.Dockerfile rename to docker/RepositoryServer.Dockerfile index ed4a10c..8d9965a 100644 --- a/docker/DesktopRepositoryServer.Dockerfile +++ b/docker/RepositoryServer.Dockerfile @@ -1,14 +1,14 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build WORKDIR /src -COPY --link DesktopRepositoryServer/*.csproj DesktopRepositoryServer/ +COPY --link RepositoryServer/*.csproj RepositoryServer/ COPY --link *.props . COPY --link *.json . -RUN dotnet restore DesktopRepositoryServer/DesktopRepositoryServer.csproj +RUN dotnet restore RepositoryServer/RepositoryServer.csproj -COPY --link DesktopRepositoryServer/. DesktopRepositoryServer/ +COPY --link RepositoryServer/. RepositoryServer/ -RUN dotnet publish --no-restore -c Release DesktopRepositoryServer/DesktopRepositoryServer.csproj -o /app +RUN dotnet publish --no-restore -c Release RepositoryServer/RepositoryServer.csproj -o /app # final is the final runtime stage for running the app FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 805a549..f6084ba 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -17,4 +17,4 @@ if [ ! -f "$CERT_PATH" ]; then rm key.pem cert.pem fi -exec dotnet OpenShock.Desktop.RepositoryServer.dll +exec dotnet OpenShock.RepositoryServer.dll From 9a4143767014da60f335a3c07678ee83be7f0ea1 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 1 Apr 2026 07:56:42 +0200 Subject: [PATCH 07/15] weeeee --- .../Controllers/V2/Firmware/CiCdController.cs | 279 ------------- .../V2/Firmware/LatestController.cs | 2 +- .../V2/Firmware/ReleasesController.cs | 376 ++++++++++++++++++ .../V2/Firmware/VersionsController.cs | 4 +- .../Enums/FirmwareReleaseStatus.cs | 8 + .../{FirmwareChannel.cs => ReleaseChannel.cs} | 2 +- ...eNoteType.cs => ReleaseNoteSectionType.cs} | 2 +- .../Firmware/CreateFirmwareVersionRequest.cs | 4 +- .../Models/Firmware/InitReleaseRequest.cs | 12 + .../Models/Firmware/InitReleaseResponse.cs | 6 + RepositoryServer/Problems/FirmwareError.cs | 8 + .../RepoServerDb/FirmwareRelease.cs | 19 + .../RepoServerDb/FirmwareReleaseNote.cs | 2 +- .../RepoServerDb/FirmwareStagedArtifact.cs | 14 + .../RepoServerDb/FirmwareStagedReleaseNote.cs | 14 + .../RepoServerDb/FirmwareVersion.cs | 2 +- .../RepoServerDb/RepoServerContext.cs | 92 ++++- 17 files changed, 553 insertions(+), 293 deletions(-) delete mode 100644 RepositoryServer/Controllers/V2/Firmware/CiCdController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs create mode 100644 RepositoryServer/Enums/FirmwareReleaseStatus.cs rename RepositoryServer/Enums/{FirmwareChannel.cs => ReleaseChannel.cs} (75%) rename RepositoryServer/Enums/{FirmwareReleaseNoteType.cs => ReleaseNoteSectionType.cs} (73%) create mode 100644 RepositoryServer/Models/Firmware/InitReleaseRequest.cs create mode 100644 RepositoryServer/Models/Firmware/InitReleaseResponse.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareRelease.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareStagedReleaseNote.cs diff --git a/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs b/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs deleted file mode 100644 index 2908833..0000000 --- a/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs +++ /dev/null @@ -1,279 +0,0 @@ -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.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/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; - } - - // ---- Version Publishing ---- - - [HttpPut("versions/{firmwareVersion}")] - public async Task PublishVersion([FromRoute] string firmwareVersion, [FromBody] CreateFirmwareVersionRequest request) - { - if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) - { - return Problem(FirmwareError.FirmwareInvalidSemver); - } - - if (!Enum.TryParse(request.Channel, true, out var channel)) - { - return Problem(FirmwareError.FirmwareInvalidChannel); - } - - // Validate artifacts if provided - if (request.Artifacts is { Count: > 0 }) - { - var boardIds = request.Artifacts.Keys.ToHashSet(); - var boards = await _db.FirmwareBoards - .Where(b => boardIds.Contains(b.Id)) - .ToDictionaryAsync(b => b.Id); - - var missingBoardsCount = boardIds.Except(boards.Keys).Count(); - if (missingBoardsCount > 0) - { - return Problem(FirmwareError.FirmwareBoardNotFound); - } - - foreach (var (boardId, artifactTypes) in request.Artifacts) - { - if (artifactTypes.Keys.Any(artifactType => !Enum.TryParse(artifactType, true, out _))) - { - return Problem(FirmwareError.FirmwareInvalidArtifactType); - } - - // Validate all required artifact types are present - var board = boards[boardId]; - if (board.RequiredArtifactTypes.Length > 0) - { - var providedTypes = artifactTypes.Keys - .Select(k => Enum.Parse(k, true)) - .ToHashSet(); - var missingTypes = board.RequiredArtifactTypes - .Where(r => !providedTypes.Contains(r)) - .Select(r => r.ToString().ToLowerInvariant()) - .ToList(); - if (missingTypes.Count > 0) - { - return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); - } - } - } - } - - // Validate release note types - if (request.ReleaseNotes.Any(note => !Enum.TryParse(note.Type, true, out _))) - { - return Problem(FirmwareError.FirmwareInvalidReleaseNoteType); - } - - await using var transaction = await _db.Database.BeginTransactionAsync(); - - // Upsert the firmware version - var versionEntity = new FirmwareVersion - { - Version = firmwareVersion, - Channel = channel, - ReleaseDate = request.ReleaseDate, - CommitHash = request.CommitHash, - ReleaseUrl = request.ReleaseUrl - }; - var executed = await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(); - if (executed <= 0) throw new Exception("Failed to upsert firmware version"); - - // Replace artifacts only if provided in the request - if (request.Artifacts is { Count: > 0 }) - { - await _db.FirmwareArtifacts.Where(a => a.Version == firmwareVersion).ExecuteDeleteAsync(); - foreach (var (boardId, artifactTypes) in request.Artifacts) - { - foreach (var (artifactTypeStr, upload) in artifactTypes) - { - var artifactType = Enum.Parse(artifactTypeStr, true); - _db.FirmwareArtifacts.Add(new FirmwareArtifact - { - Version = firmwareVersion, - BoardId = boardId, - ArtifactType = artifactType, - HashSha256 = Convert.FromHexString(upload.Sha256Hash), - FileSize = upload.FileSize - }); - } - } - } - - // Replace all release notes for this version - await _db.FirmwareReleaseNotes.Where(n => n.Version == firmwareVersion).ExecuteDeleteAsync(); - for (var i = 0; i < request.ReleaseNotes.Count; i++) - { - var note = request.ReleaseNotes[i]; - _db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote - { - Version = firmwareVersion, - Index = i, - Type = Enum.Parse(note.Type, true), - Title = note.Title, - Content = note.Content - }); - } - - await _db.SaveChangesAsync(); - await transaction.CommitAsync(); - - return CreatedAtAction( - nameof(VersionsController.GetVersion), - "Versions", - new { firmwareVersion }, - null); - } - - // ---- Board Artifact Upload ---- - - private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase) - { - ["app"] = FirmwareArtifactType.App, - ["staticfs"] = FirmwareArtifactType.StaticFs, - ["merged"] = FirmwareArtifactType.Merged, - ["bootloader"] = FirmwareArtifactType.Bootloader, - ["partitions"] = FirmwareArtifactType.Partitions, - }; - - /// - /// Uploads firmware binary artifacts for a specific board+version. - /// Accepts multipart/form-data with file fields named: app, staticfs, merged, bootloader, partitions. - /// Each file is hashed, uploaded to CDN, and recorded in the database. - /// - [HttpPut("versions/{firmwareVersion}/boards/{boardId}/upload")] - [Consumes("multipart/form-data")] - [RequestSizeLimit(64 * 1024 * 1024)] // 64 MB - public async Task UploadBoardArtifacts( - [FromRoute] string firmwareVersion, - [FromRoute] string boardId) - { - if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) - { - return Problem(FirmwareError.FirmwareInvalidSemver); - } - - if (!await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion)) - { - return Problem(FirmwareError.FirmwareVersionNotFound); - } - - var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); - if (board == null) - { - return Problem(FirmwareError.FirmwareBoardNotFound); - } - - var files = Request.Form.Files; - if (files.Count == 0) - { - return BadRequest(new { error = "No files uploaded. Expected file fields: app, staticfs, merged, bootloader, partitions" }); - } - - // Validate all required artifact types are present in the upload - if (board.RequiredArtifactTypes.Length > 0) - { - var uploadedTypes = files - .Select(f => f.Name.ToLowerInvariant()) - .Where(ArtifactFieldNames.ContainsKey) - .Select(n => ArtifactFieldNames[n]) - .ToHashSet(); - var missingTypes = board.RequiredArtifactTypes - .Where(r => !uploadedTypes.Contains(r)) - .Select(r => r.ToString().ToLowerInvariant()) - .ToList(); - if (missingTypes.Count > 0) - { - return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); - } - } - - var uploadedArtifacts = new List(); - - foreach (var file in files) - { - var fieldName = file.Name.ToLowerInvariant(); - if (!ArtifactFieldNames.TryGetValue(fieldName, out var artifactType)) - { - return Problem(FirmwareError.FirmwareInvalidArtifactType); - } - - // Read file into memory for hashing + CDN upload - using var memoryStream = new MemoryStream(); - await file.CopyToAsync(memoryStream); - var fileBytes = memoryStream.ToArray(); - var fileSize = fileBytes.Length; - - // Compute SHA256 hash - var hashBytes = SHA256.HashData(fileBytes); - - // Upload to storage - var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifactType); - var cdnPath = $"{firmwareVersion}/{boardId}/{cdnFileName}"; - - using var uploadStream = new MemoryStream(fileBytes); - await _storage.UploadFileAsync(cdnPath, uploadStream); - - // Upsert artifact record in DB - var existing = await _db.FirmwareArtifacts.FirstOrDefaultAsync(a => - a.Version == firmwareVersion && a.BoardId == boardId && a.ArtifactType == artifactType); - - if (existing != null) - { - existing.HashSha256 = hashBytes; - existing.FileSize = fileSize; - } - else - { - _db.FirmwareArtifacts.Add(new FirmwareArtifact - { - Version = firmwareVersion, - BoardId = boardId, - ArtifactType = artifactType, - HashSha256 = hashBytes, - FileSize = fileSize, - }); - } - - var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); - uploadedArtifacts.Add(new FirmwareArtifactDto - { - Type = artifactType.ToString().ToLowerInvariant(), - Url = $"{cdnBase}/{firmwareVersion}/{boardId}/{cdnFileName}", - Sha256Hash = Convert.ToHexString(hashBytes), - FileSize = fileSize, - }); - } - - await _db.SaveChangesAsync(); - - return Ok(uploadedArtifacts); - } -} diff --git a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs index b403039..8d7724a 100644 --- a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs @@ -27,7 +27,7 @@ public LatestController(RepoServerContext db, ApiConfig apiConfig) [HttpGet("{channel}")] public async Task GetLatest([FromRoute] string channel, [FromQuery] string? board) { - if (!Enum.TryParse(channel, true, out var firmwareChannel)) + if (!Enum.TryParse(channel, true, out var firmwareChannel)) { return Problem(FirmwareError.FirmwareInvalidChannel); } diff --git a/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs new file mode 100644 index 0000000..9edb3b4 --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs @@ -0,0 +1,376 @@ +using System.Security.Cryptography; +using Asp.Versioning; +using FlexLabs.EntityFrameworkCore.Upsert; +using Microsoft.AspNetCore.Authorization; +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.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 readonly RepoServerContext _db; + private readonly IStorageService _storage; + private readonly ApiConfig _apiConfig; + + public ReleasesController(RepoServerContext db, IStorageService storage, ApiConfig apiConfig) + { + _db = db; + _storage = storage; + _apiConfig = apiConfig; + } + + // ---- Init Release ---- + + [HttpPost] + public async Task InitRelease([FromBody] InitReleaseRequest request, CancellationToken ct) + { + 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); + } + + // Check no existing staging release for this version + var existingStaging = await _db.FirmwareReleases + .AnyAsync(r => r.Version == request.Version && r.Status == FirmwareReleaseStatus.Staging, ct); + if (existingStaging) + { + return Problem(FirmwareError.FirmwareReleaseAlreadyStaging); + } + + // Validate all declared boards exist + 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); + } + + // Validate release note types + foreach (var note in request.ReleaseNotes) + { + if (!Enum.TryParse(note.Type, true, out _)) + { + return Problem(FirmwareError.FirmwareInvalidReleaseNoteType); + } + } + + var release = new FirmwareRelease + { + Id = Guid.NewGuid(), + Version = request.Version, + Channel = channel, + CommitHash = request.CommitHash, + ReleaseUrl = request.ReleaseUrl, + ReleaseDate = request.ReleaseDate, + Status = FirmwareReleaseStatus.Staging, + DeclaredBoards = request.Boards.ToArray(), + CreatedAt = DateTimeOffset.UtcNow, + }; + + _db.FirmwareReleases.Add(release); + + // Stage release notes + for (var i = 0; i < request.ReleaseNotes.Count; i++) + { + var note = request.ReleaseNotes[i]; + _db.FirmwareStagedReleaseNotes.Add(new FirmwareStagedReleaseNote + { + ReleaseId = release.Id, + Index = i, + SectionType = Enum.Parse(note.Type, true), + Title = note.Title, + Content = note.Content, + }); + } + + await _db.SaveChangesAsync(ct); + + return Created((string?)null, new InitReleaseResponse { Id = release.Id }); + } + + // ---- Upload Board Artifacts ---- + + private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase) + { + ["app"] = FirmwareArtifactType.App, + ["staticfs"] = FirmwareArtifactType.StaticFs, + ["merged"] = FirmwareArtifactType.Merged, + ["bootloader"] = FirmwareArtifactType.Bootloader, + ["partitions"] = FirmwareArtifactType.Partitions, + }; + + [HttpPut("{releaseId:guid}/boards/{boardId}")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(64 * 1024 * 1024)] + public async Task UploadBoardArtifacts( + [FromRoute] Guid releaseId, + [FromRoute] string boardId, + CancellationToken ct) + { + var release = await _db.FirmwareReleases.FirstOrDefaultAsync(r => r.Id == releaseId, ct); + if (release == null) + { + return Problem(FirmwareError.FirmwareReleaseNotFound); + } + + if (release.Status != FirmwareReleaseStatus.Staging) + { + return Problem(FirmwareError.FirmwareReleaseNotStaging); + } + + if (!release.DeclaredBoards.Contains(boardId)) + { + return Problem(FirmwareError.FirmwareBoardNotDeclared); + } + + var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId, ct); + if (board == null) + { + return Problem(FirmwareError.FirmwareBoardNotFound); + } + + var files = Request.Form.Files; + if (files.Count == 0) + { + return BadRequest(new { error = "No files uploaded. Expected file fields: app, staticfs, merged, bootloader, partitions" }); + } + + // Validate all required artifact types are present + if (board.RequiredArtifactTypes.Length > 0) + { + var uploadedTypes = files + .Select(f => f.Name.ToLowerInvariant()) + .Where(ArtifactFieldNames.ContainsKey) + .Select(n => ArtifactFieldNames[n]) + .ToHashSet(); + var missingTypes = board.RequiredArtifactTypes + .Where(r => !uploadedTypes.Contains(r)) + .Select(r => r.ToString().ToLowerInvariant()) + .ToList(); + if (missingTypes.Count > 0) + { + return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); + } + } + + // 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 uploadedArtifacts = new List(); + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + + foreach (var file in files) + { + var fieldName = file.Name.ToLowerInvariant(); + if (!ArtifactFieldNames.TryGetValue(fieldName, out var artifactType)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream, ct); + var fileBytes = memoryStream.ToArray(); + + var hashBytes = SHA256.HashData(fileBytes); + + // Upload to CDN at final path + var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifactType); + var cdnPath = $"{release.Version}/{boardId}/{cdnFileName}"; + + using var uploadStream = new MemoryStream(fileBytes); + await _storage.UploadFileAsync(cdnPath, uploadStream, ct); + + // Stage artifact record + _db.FirmwareStagedArtifacts.Add(new FirmwareStagedArtifact + { + ReleaseId = releaseId, + BoardId = boardId, + ArtifactType = artifactType, + HashSha256 = hashBytes, + FileSize = fileBytes.Length, + }); + + uploadedArtifacts.Add(new FirmwareArtifactDto + { + Type = artifactType.ToString().ToLowerInvariant(), + Url = $"{cdnBase}/{release.Version}/{boardId}/{cdnFileName}", + Sha256Hash = Convert.ToHexString(hashBytes), + FileSize = fileBytes.Length, + }); + } + + 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 == null) + { + return Problem(FirmwareError.FirmwareReleaseNotFound); + } + + if (release.Status != FirmwareReleaseStatus.Staging) + { + return Problem(FirmwareError.FirmwareReleaseNotStaging); + } + + // Check all declared boards have staged artifacts + 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)); + } + + // Validate required artifact types per board + 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); + + // Upsert firmware version + var versionEntity = new FirmwareVersion + { + Version = release.Version, + Channel = release.Channel, + ReleaseDate = release.ReleaseDate, + CommitHash = release.CommitHash, + ReleaseUrl = release.ReleaseUrl, + }; + await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(ct); + + // Replace artifacts + 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, + }); + } + + // Replace release notes + 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 = FirmwareReleaseStatus.Published; + + await _db.SaveChangesAsync(ct); + await transaction.CommitAsync(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 == null) + { + return Problem(FirmwareError.FirmwareReleaseNotFound); + } + + if (release.Status != FirmwareReleaseStatus.Staging) + { + return Problem(FirmwareError.FirmwareReleaseNotStaging); + } + + // Delete uploaded CDN files for each staged artifact + 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 = FirmwareReleaseStatus.Aborted; + + await _db.SaveChangesAsync(ct); + + return Ok(); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs index e5eca15..1edef1f 100644 --- a/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs @@ -31,7 +31,7 @@ public async Task ListVersions([FromQuery] string? channel) if (!string.IsNullOrWhiteSpace(channel)) { - if (!Enum.TryParse(channel, true, out var firmwareChannel)) + if (!Enum.TryParse(channel, true, out var firmwareChannel)) { return Problem(FirmwareError.FirmwareInvalidChannel); } @@ -86,7 +86,7 @@ public async Task GetVersion([FromRoute] string firmwareVersion) var releaseNotes = version.ReleaseNotes.Select(n => new FirmwareReleaseNoteDto { - Type = n.Type.ToString().ToLowerInvariant(), + Type = n.SectionType.ToString().ToLowerInvariant(), Title = n.Title, Content = n.Content }).ToList(); diff --git a/RepositoryServer/Enums/FirmwareReleaseStatus.cs b/RepositoryServer/Enums/FirmwareReleaseStatus.cs new file mode 100644 index 0000000..7ae4674 --- /dev/null +++ b/RepositoryServer/Enums/FirmwareReleaseStatus.cs @@ -0,0 +1,8 @@ +namespace OpenShock.RepositoryServer.Enums; + +public enum FirmwareReleaseStatus +{ + Staging, + Published, + Aborted +} diff --git a/RepositoryServer/Enums/FirmwareChannel.cs b/RepositoryServer/Enums/ReleaseChannel.cs similarity index 75% rename from RepositoryServer/Enums/FirmwareChannel.cs rename to RepositoryServer/Enums/ReleaseChannel.cs index 532fd44..f39d321 100644 --- a/RepositoryServer/Enums/FirmwareChannel.cs +++ b/RepositoryServer/Enums/ReleaseChannel.cs @@ -1,6 +1,6 @@ namespace OpenShock.RepositoryServer.Enums; -public enum FirmwareChannel +public enum ReleaseChannel { Stable, Beta, diff --git a/RepositoryServer/Enums/FirmwareReleaseNoteType.cs b/RepositoryServer/Enums/ReleaseNoteSectionType.cs similarity index 73% rename from RepositoryServer/Enums/FirmwareReleaseNoteType.cs rename to RepositoryServer/Enums/ReleaseNoteSectionType.cs index c44983c..88e6a71 100644 --- a/RepositoryServer/Enums/FirmwareReleaseNoteType.cs +++ b/RepositoryServer/Enums/ReleaseNoteSectionType.cs @@ -1,6 +1,6 @@ namespace OpenShock.RepositoryServer.Enums; -public enum FirmwareReleaseNoteType +public enum ReleaseNoteSectionType { Warning, Info, diff --git a/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs index 5fa7d27..8026fe7 100644 --- a/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs +++ b/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs @@ -3,9 +3,7 @@ namespace OpenShock.RepositoryServer.Models.Firmware; public sealed class CreateFirmwareVersionRequest { public required string Channel { get; init; } - public required DateTimeOffset ReleaseDate { get; init; } public required string CommitHash { get; init; } - public string? ReleaseUrl { get; init; } - public Dictionary>? Artifacts { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } public List ReleaseNotes { get; init; } = []; } diff --git a/RepositoryServer/Models/Firmware/InitReleaseRequest.cs b/RepositoryServer/Models/Firmware/InitReleaseRequest.cs new file mode 100644 index 0000000..977ff17 --- /dev/null +++ b/RepositoryServer/Models/Firmware/InitReleaseRequest.cs @@ -0,0 +1,12 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class InitReleaseRequest +{ + public required string Version { get; init; } + public required string Channel { get; init; } + public required string CommitHash { get; init; } + public required DateTimeOffset ReleaseDate { get; init; } + public string? ReleaseUrl { get; init; } + public required List Boards { get; init; } + public List ReleaseNotes { get; init; } = []; +} diff --git a/RepositoryServer/Models/Firmware/InitReleaseResponse.cs b/RepositoryServer/Models/Firmware/InitReleaseResponse.cs new file mode 100644 index 0000000..7924f76 --- /dev/null +++ b/RepositoryServer/Models/Firmware/InitReleaseResponse.cs @@ -0,0 +1,6 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +public sealed class InitReleaseResponse +{ + public required Guid Id { get; init; } +} diff --git a/RepositoryServer/Problems/FirmwareError.cs b/RepositoryServer/Problems/FirmwareError.cs index b5f25d4..e8257fb 100644 --- a/RepositoryServer/Problems/FirmwareError.cs +++ b/RepositoryServer/Problems/FirmwareError.cs @@ -17,4 +17,12 @@ public static class FirmwareError public static OpenShockProblem FirmwareMissingRequiredArtifacts(string 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 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)}"); } diff --git a/RepositoryServer/RepoServerDb/FirmwareRelease.cs b/RepositoryServer/RepoServerDb/FirmwareRelease.cs new file mode 100644 index 0000000..193de79 --- /dev/null +++ b/RepositoryServer/RepoServerDb/FirmwareRelease.cs @@ -0,0 +1,19 @@ +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 string CommitHash { get; set; } = null!; + public string? ReleaseUrl { get; set; } + public DateTimeOffset ReleaseDate { get; set; } + public FirmwareReleaseStatus Status { get; set; } + public string[] DeclaredBoards { get; set; } = []; + public DateTimeOffset CreatedAt { get; set; } + + 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 index 99e299b..9c216eb 100644 --- a/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs +++ b/RepositoryServer/RepoServerDb/FirmwareReleaseNote.cs @@ -6,7 +6,7 @@ public class FirmwareReleaseNote { public string Version { get; set; } = null!; public int Index { get; set; } - public FirmwareReleaseNoteType Type { get; set; } + public ReleaseNoteSectionType SectionType { get; set; } public string? Title { get; set; } public string Content { get; set; } = null!; diff --git a/RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs b/RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs new file mode 100644 index 0000000..6232a03 --- /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 string BoardId { get; set; } = null!; + 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 index e696fe7..bf26222 100644 --- a/RepositoryServer/RepoServerDb/FirmwareVersion.cs +++ b/RepositoryServer/RepoServerDb/FirmwareVersion.cs @@ -5,7 +5,7 @@ namespace OpenShock.RepositoryServer.RepoServerDb; public class FirmwareVersion { public string Version { get; set; } = null!; - public FirmwareChannel Channel { get; set; } + public ReleaseChannel Channel { get; set; } public DateTimeOffset ReleaseDate { get; set; } public string CommitHash { get; set; } = null!; public string? ReleaseUrl { get; set; } diff --git a/RepositoryServer/RepoServerDb/RepoServerContext.cs b/RepositoryServer/RepoServerDb/RepoServerContext.cs index 06078dd..6443a4b 100644 --- a/RepositoryServer/RepoServerDb/RepoServerContext.cs +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -65,10 +65,11 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde { optionsBuilder.UseNpgsql(connectionString, npgsqlBuilder => { - npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); - npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); }); optionsBuilder.UseExceptionProcessor(); @@ -94,6 +95,9 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public virtual DbSet FirmwareVersions { get; set; } public virtual DbSet FirmwareArtifacts { get; set; } public virtual DbSet FirmwareReleaseNotes { get; set; } + public virtual DbSet FirmwareReleases { get; set; } + public virtual DbSet FirmwareStagedArtifacts { get; set; } + public virtual DbSet FirmwareStagedReleaseNotes { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -102,7 +106,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasPostgresEnum("firmware_channel", ["stable", "beta", "develop"]) .HasPostgresEnum("firmware_artifact_type", ["merged", "app", "bootloader", "partitions", "static_fs"]) .HasPostgresEnum("firmware_release_note_type", ["warning", "info", "breaking", "section"]) - .HasPostgresEnum("firmware_chip_architecture", ["xtensa", "risc_v"]); + .HasPostgresEnum("firmware_chip_architecture", ["xtensa", "risc_v"]) + .HasPostgresEnum("firmware_release_status", ["staging", "published", "aborted"]); // Desktop module entities (unchanged) modelBuilder.Entity(entity => @@ -258,7 +263,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("version"); entity.Property(e => e.Index) .HasColumnName("index"); - entity.Property(e => e.Type) + entity.Property(e => e.SectionType) .HasColumnName("type"); entity.Property(e => e.Title) .HasMaxLength(256) @@ -271,5 +276,84 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .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.CommitHash) + .HasMaxLength(40) + .HasColumnName("commit_hash"); + entity.Property(e => e.ReleaseUrl) + .HasMaxLength(256) + .HasColumnName("release_url"); + 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.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) + .HasMaxLength(64) + .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"); + }); } } From d880fe762a13ecaf53f41ce86c97e2585b80b3ad Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 17 Apr 2026 19:48:03 +0200 Subject: [PATCH 08/15] Implement stuff --- CLAUDE.md | 102 +- .../RepositoryServer.Tests.csproj | 7 +- RepositoryServer.Tests/UnitTest1.cs | 10 - .../Utils/ChangelogParserTests.cs | 151 ++ .../Utils/SourceUrlBuilderTests.cs | 63 + RepositoryServer/AuthSchemas.cs | 15 +- .../GitHubOidcAuthentication.cs | 135 ++ RepositoryServer/Config/ApiConfig.cs | 6 +- RepositoryServer/Config/CiCdAuthConfig.cs | 26 - RepositoryServer/Config/DiscordConfig.cs | 6 + .../Config/FirmwareAdvisoryConfig.cs | 12 + RepositoryServer/Config/FirmwareCiCdConfig.cs | 13 + RepositoryServer/Config/FirmwareConfig.cs | 17 + .../Controllers/V1/CiCdController.cs | 1 + .../Firmware/Admin/BoardsAdminController.cs | 148 ++ .../V2/Firmware/Admin/ChipsAdminController.cs | 115 ++ .../Firmware/Admin/ReleasesAdminController.cs | 77 + .../Firmware/Admin/RepositoriesController.cs | 62 + .../V2/Firmware/Admin/UsbDevicesController.cs | 95 ++ .../Admin/UsbSerialFiltersController.cs | 87 ++ .../Firmware/Admin/VersionsAdminController.cs | 37 + .../V2/Firmware/AdminController.cs | 147 -- .../V2/Firmware/BoardsController.cs | 24 +- .../V2/Firmware/ChipsController.cs | 20 +- .../Controllers/V2/Firmware/CiCdController.cs | 274 ---- .../V2/Firmware/LatestController.cs | 90 +- .../V2/Firmware/ManifestController.cs | 131 ++ .../V2/Firmware/ReleasesController.cs | 315 ++-- .../V2/Firmware/VersionsController.cs | 115 +- ...mwareReleaseStatus.cs => ReleaseStatus.cs} | 4 +- RepositoryServer/Enums/RepositoryProvider.cs | 11 + .../Firmware/CreateFirmwareVersionRequest.cs | 11 - .../Models/Firmware/FirmwareAdvisoryDto.cs | 13 + .../Models/Firmware/FirmwareArtifactUpload.cs | 7 - .../Models/Firmware/FirmwareBoardDetailDto.cs | 12 + .../Models/Firmware/FirmwareBoardDto.cs | 10 +- .../FirmwareBoardReleaseResponseDto.cs | 12 + .../Models/Firmware/FirmwareChipDto.cs | 10 +- .../Models/Firmware/FirmwareChipRefDto.cs | 11 + .../Models/Firmware/FirmwareLatestResponse.cs | 9 - .../Firmware/FirmwareManifestResponse.cs | 16 + .../Models/Firmware/FirmwareReleaseDto.cs | 15 + .../Firmware/FirmwareReleaseNoteUpload.cs | 8 - .../Models/Firmware/FirmwareSourceDto.cs | 35 + .../Models/Firmware/FirmwareUsbDeviceDto.cs | 14 + .../FirmwareUsbSerialFilterAdminDto.cs | 13 + .../Firmware/FirmwareUsbSerialFilterDto.cs | 12 + .../Firmware/FirmwareVersionResponse.cs | 12 - .../Models/Firmware/FirmwareVersionSummary.cs | 10 +- .../Models/Firmware/FixChangelogRequest.cs | 9 + .../Models/Firmware/InitReleaseRequest.cs | 21 +- .../Models/Firmware/InitReleaseResponse.cs | 1 + .../Models/Firmware/RepositoryDto.cs | 23 + .../Models/Firmware/UpsertUsbDeviceRequest.cs | 10 + .../Firmware/UpsertUsbSerialFilterRequest.cs | 10 + .../Models/Firmware/VersionListResponse.cs | 11 + RepositoryServer/Problems/FirmwareError.cs | 20 +- RepositoryServer/Program.cs | 56 +- .../RepoServerDb/FirmwareBoard.cs | 3 +- .../RepoServerDb/FirmwareBoardUsbDevice.cs | 14 + RepositoryServer/RepoServerDb/FirmwareChip.cs | 3 +- .../RepoServerDb/FirmwareChipUsbDevice.cs | 14 + .../RepoServerDb/FirmwareRelease.cs | 7 +- .../RepoServerDb/FirmwareVersion.cs | 7 +- RepositoryServer/RepoServerDb/Module.cs | 5 +- .../RepoServerDb/RepoServerContext.cs | 148 +- .../RepoServerDb/SourceRepository.cs | 21 + RepositoryServer/RepoServerDb/UsbDevice.cs | 17 + .../RepoServerDb/UsbSerialFilter.cs | 16 + RepositoryServer/RepoServerDb/Version.cs | 3 - RepositoryServer/RepositoryServer.csproj | 1 + .../Services/DiscordNotificationService.cs | 84 + .../Services/IDiscordNotificationService.cs | 14 + .../Services/StagedReleaseCleanupService.cs | 112 ++ .../Utils/CacheControlAttribute.cs | 38 + RepositoryServer/Utils/ChangelogParser.cs | 155 ++ .../Utils/FirmwareResponseMapper.cs | 68 + RepositoryServer/Utils/SourceUrlBuilder.cs | 71 + firmware-api-spec.md | 1355 +++++++++++++++++ global.json | 6 +- 80 files changed, 4081 insertions(+), 788 deletions(-) delete mode 100644 RepositoryServer.Tests/UnitTest1.cs create mode 100644 RepositoryServer.Tests/Utils/ChangelogParserTests.cs create mode 100644 RepositoryServer.Tests/Utils/SourceUrlBuilderTests.cs create mode 100644 RepositoryServer/AuthenticationHandlers/GitHubOidcAuthentication.cs delete mode 100644 RepositoryServer/Config/CiCdAuthConfig.cs create mode 100644 RepositoryServer/Config/DiscordConfig.cs create mode 100644 RepositoryServer/Config/FirmwareAdvisoryConfig.cs create mode 100644 RepositoryServer/Config/FirmwareCiCdConfig.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/Admin/ReleasesAdminController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/Admin/RepositoriesController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/Admin/UsbDevicesController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/Admin/UsbSerialFiltersController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/Admin/VersionsAdminController.cs delete mode 100644 RepositoryServer/Controllers/V2/Firmware/AdminController.cs delete mode 100644 RepositoryServer/Controllers/V2/Firmware/CiCdController.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/ManifestController.cs rename RepositoryServer/Enums/{FirmwareReleaseStatus.cs => ReleaseStatus.cs} (62%) create mode 100644 RepositoryServer/Enums/RepositoryProvider.cs delete mode 100644 RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareAdvisoryDto.cs delete mode 100644 RepositoryServer/Models/Firmware/FirmwareArtifactUpload.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareBoardDetailDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareBoardReleaseResponseDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareChipRefDto.cs delete mode 100644 RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareManifestResponse.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareReleaseDto.cs delete mode 100644 RepositoryServer/Models/Firmware/FirmwareReleaseNoteUpload.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareSourceDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareUsbDeviceDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareUsbSerialFilterAdminDto.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareUsbSerialFilterDto.cs delete mode 100644 RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs create mode 100644 RepositoryServer/Models/Firmware/FixChangelogRequest.cs create mode 100644 RepositoryServer/Models/Firmware/RepositoryDto.cs create mode 100644 RepositoryServer/Models/Firmware/UpsertUsbDeviceRequest.cs create mode 100644 RepositoryServer/Models/Firmware/UpsertUsbSerialFilterRequest.cs create mode 100644 RepositoryServer/Models/Firmware/VersionListResponse.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareBoardUsbDevice.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareChipUsbDevice.cs create mode 100644 RepositoryServer/RepoServerDb/SourceRepository.cs create mode 100644 RepositoryServer/RepoServerDb/UsbDevice.cs create mode 100644 RepositoryServer/RepoServerDb/UsbSerialFilter.cs create mode 100644 RepositoryServer/Services/DiscordNotificationService.cs create mode 100644 RepositoryServer/Services/IDiscordNotificationService.cs create mode 100644 RepositoryServer/Services/StagedReleaseCleanupService.cs create mode 100644 RepositoryServer/Utils/CacheControlAttribute.cs create mode 100644 RepositoryServer/Utils/ChangelogParser.cs create mode 100644 RepositoryServer/Utils/FirmwareResponseMapper.cs create mode 100644 RepositoryServer/Utils/SourceUrlBuilder.cs create mode 100644 firmware-api-spec.md diff --git a/CLAUDE.md b/CLAUDE.md index e45f458..fe95dc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ Unified repository server for distributing OpenShock desktop app modules and fir - 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 @@ -19,31 +20,68 @@ Unified repository server for distributing OpenShock desktop app modules and fir ## Project Structure ``` RepositoryServer/ # Main web API project - AuthenticationHandlers/ # Custom AdminToken auth handler - Config/ # ApiConfig, DbConfig, RepoConfig, MetricsConfig, FirmwareConfig + 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 endpoints - RepoController.cs # GET /v1/ — desktop module manifest - AdminController.cs # /v1/admin/... — desktop module CRUD - V2/ - Firmware/ - LatestController.cs # GET /v2/firmware/latest/{channel} - VersionsController.cs # GET /v2/firmware/versions[/{version}] - BoardsController.cs # GET /v2/firmware/boards - ChipsController.cs # GET /v2/firmware/chips - AdminController.cs # /v2/firmware/admin/... CRUD - Errors/ # Static error factories (AuthResultError, ExceptionError) + 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 (Initial + AddFirmwareTables) + Migrations/ # EF Core migrations Models/ # Desktop module DTOs - Models/Firmware/ # Firmware DTOs (response + admin request) + 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 - Utils/ # ByteArrayHexConverter, SemVersionConverter, etc. - Program.cs # App startup and middleware pipeline + 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 +RepositoryServer.Tests/ # TUnit test project (references the main project) + Utils/ + ChangelogParserTests + SourceUrlBuilderTests docker/ RepositoryServer.Dockerfile entrypoint.sh # Cert gen + `dotnet OpenShock.RepositoryServer.dll` @@ -51,24 +89,42 @@ docker/ ``` ## Namespace -`OpenShock.RepositoryServer` (renamed from `OpenShock.Desktop.RepositoryServer`) +`OpenShock.RepositoryServer` ## Key Patterns -- **Auth**: Custom `AdminTokenAuthentication` handler, `AuthSchemas.AdminToken` scheme +- **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, connection in `ApiConfig.Db.Conn` +- 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 enums: `firmware_channel`, `firmware_artifact_type`, `firmware_release_note_type`, `firmware_chip_architecture` +- 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) diff --git a/RepositoryServer.Tests/RepositoryServer.Tests.csproj b/RepositoryServer.Tests/RepositoryServer.Tests.csproj index 0c006b7..f0303a4 100644 --- a/RepositoryServer.Tests/RepositoryServer.Tests.csproj +++ b/RepositoryServer.Tests/RepositoryServer.Tests.csproj @@ -5,7 +5,7 @@ enable false true - 13 + default OpenShock.RepositoryServer.Tests OpenShock.RepositoryServer.Tests OpenShock.RepositoryServer.Tests @@ -14,4 +14,7 @@ - \ No newline at end of file + + + + diff --git a/RepositoryServer.Tests/UnitTest1.cs b/RepositoryServer.Tests/UnitTest1.cs deleted file mode 100644 index 8448f03..0000000 --- a/RepositoryServer.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace OpenShock.RepositoryServer.Tests; - -public class UnitTest1 -{ - [Test] - public async Task Test() - { - await Assert.That(true).IsTrue(); - } -} \ No newline at end of file diff --git a/RepositoryServer.Tests/Utils/ChangelogParserTests.cs b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs new file mode 100644 index 0000000..84df94e --- /dev/null +++ b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs @@ -0,0 +1,151 @@ +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).HasCount(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).HasCount(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).HasCount(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).HasCount(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).HasCount(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] + 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).HasCount(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/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 index a310fca..d1f0ad4 100644 --- a/RepositoryServer/AuthSchemas.cs +++ b/RepositoryServer/AuthSchemas.cs @@ -1,7 +1,18 @@ -namespace OpenShock.RepositoryServer; +namespace OpenShock.RepositoryServer; public static class AuthSchemas { public const string AdminToken = "AdminToken"; public const string CiCdToken = "CiCdToken"; -} \ No newline at end of file + + /// + /// 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/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/RepositoryServer/Config/ApiConfig.cs b/RepositoryServer/Config/ApiConfig.cs index 442ec25..4366758 100644 --- a/RepositoryServer/Config/ApiConfig.cs +++ b/RepositoryServer/Config/ApiConfig.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace OpenShock.RepositoryServer.Config; @@ -6,8 +6,8 @@ public class ApiConfig { [Required] public required DbConfig Db { get; init; } [Required] public required string AdminToken { get; init; } - [Required] public required CiCdAuthConfig CiCd { 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/RepositoryServer/Config/CiCdAuthConfig.cs b/RepositoryServer/Config/CiCdAuthConfig.cs deleted file mode 100644 index f704c70..0000000 --- a/RepositoryServer/Config/CiCdAuthConfig.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace OpenShock.RepositoryServer.Config; - -public sealed class CiCdAuthConfig -{ - /// - /// 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; } - - /// - /// GitHub organization or user that owns the repositories allowed to authenticate. - /// Validated against the 'repository_owner' claim in the OIDC token. - /// - [Required(AllowEmptyStrings = false)] - public required string RepositoryOwner { get; init; } - - /// - /// Optional list of specific repositories allowed to authenticate (e.g. "openshock/firmware"). - /// If null or empty, any repository under the owner is allowed. - /// - public List? AllowedRepositories { get; init; } -} 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/FirmwareAdvisoryConfig.cs b/RepositoryServer/Config/FirmwareAdvisoryConfig.cs new file mode 100644 index 0000000..1f74911 --- /dev/null +++ b/RepositoryServer/Config/FirmwareAdvisoryConfig.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Config; + +public sealed class FirmwareAdvisoryConfig +{ + [Required(AllowEmptyStrings = false)] public required string Severity { get; init; } + [Required(AllowEmptyStrings = false)] public required string Title { get; init; } + [Required(AllowEmptyStrings = false)] public required string Content { get; init; } + [Required(AllowEmptyStrings = false)] public required string AffectedVersions { get; init; } + public string? Url { get; init; } +} 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 index 6b5fdd4..9cb251d 100644 --- a/RepositoryServer/Config/FirmwareConfig.cs +++ b/RepositoryServer/Config/FirmwareConfig.cs @@ -6,4 +6,21 @@ 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); + + /// + /// Security / compatibility advisories exposed on the manifest endpoint. + /// Served from configuration, not from the database. + /// + public List Advisories { get; init; } = new(); } diff --git a/RepositoryServer/Controllers/V1/CiCdController.cs b/RepositoryServer/Controllers/V1/CiCdController.cs index e7e01b1..ab628d9 100644 --- a/RepositoryServer/Controllers/V1/CiCdController.cs +++ b/RepositoryServer/Controllers/V1/CiCdController.cs @@ -1,6 +1,7 @@ using System.IO.Compression; using System.Security.Cryptography; using Asp.Versioning; +using FlexLabs.EntityFrameworkCore.Upsert; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs new file mode 100644 index 0000000..af32b8a --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs @@ -0,0 +1,148 @@ +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; + } + + [HttpPut("{boardId}")] + public async Task UpsertBoard( + [FromRoute] string boardId, + [FromBody] CreateFirmwareBoardRequest request, + CancellationToken ct) + { + if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId, ct)) + { + return Problem(FirmwareError.FirmwareChipNotFound); + } + + var requiredArtifactTypes = Array.Empty(); + if (request.RequiredArtifactTypes is { Count: > 0 }) + { + var parsed = new List(); + foreach (var typeStr in request.RequiredArtifactTypes) + { + if (!Enum.TryParse(typeStr, true, out var artifactType)) + { + return Problem(FirmwareError.FirmwareInvalidArtifactType); + } + parsed.Add(artifactType); + } + requiredArtifactTypes = parsed.Distinct().ToArray(); + } + + var existing = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId, ct); + if (existing is not null) + { + existing.Name = request.Name; + existing.ChipId = request.ChipId; + existing.RequiredArtifactTypes = requiredArtifactTypes; + } + else + { + _db.FirmwareBoards.Add(new FirmwareBoard + { + Id = boardId, + Name = request.Name, + ChipId = request.ChipId, + Discontinued = false, + RequiredArtifactTypes = requiredArtifactTypes + }); + } + + await _db.SaveChangesAsync(ct); + return Created(); + } + + [HttpPatch("{boardId}/discontinue")] + public async Task DiscontinueBoard([FromRoute] string 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}")] + public async Task DeleteBoard([FromRoute] string 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}/usb-devices/{usbDeviceId:guid}")] + public async Task AttachUsbDevice( + [FromRoute] string 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}/usb-devices/{usbDeviceId:guid}")] + public async Task DetachUsbDevice( + [FromRoute] string boardId, + [FromRoute] Guid usbDeviceId, + CancellationToken ct) + { + await _db.FirmwareBoardUsbDevices + .Where(j => j.BoardId == boardId && j.UsbDeviceId == usbDeviceId) + .ExecuteDeleteAsync(ct); + + return NoContent(); + } +} diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs new file mode 100644 index 0000000..a9fa21c --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs @@ -0,0 +1,115 @@ +using Asp.Versioning; +using FlexLabs.EntityFrameworkCore.Upsert; +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; + } + + [HttpPut("{chipId}")] + public async Task UpsertChip( + [FromRoute] string 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 = new FirmwareChip + { + Id = chipId, + Name = request.Name, + Architecture = architecture + }; + + var executed = await _db.FirmwareChips.Upsert(chip).On(c => c.Id).RunAsync(ct); + if (executed <= 0) throw new Exception("Failed to upsert firmware chip"); + + return Created(); + } + + [HttpDelete("{chipId}")] + public async Task DeleteChip([FromRoute] string 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}/usb-devices/{usbDeviceId:guid}")] + public async Task AttachUsbDevice( + [FromRoute] string 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}/usb-devices/{usbDeviceId:guid}")] + public async Task DetachUsbDevice( + [FromRoute] string 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/AdminController.cs b/RepositoryServer/Controllers/V2/Firmware/AdminController.cs deleted file mode 100644 index 08a7c77..0000000 --- a/RepositoryServer/Controllers/V2/Firmware/AdminController.cs +++ /dev/null @@ -1,147 +0,0 @@ -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; - -[ApiVersion("2.0")] -[ApiController] -[Route("/v{version:apiVersion}/firmware/admin")] -[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)] -public class AdminController : OpenShockControllerBase -{ - private readonly RepoServerContext _db; - - public AdminController(RepoServerContext db) - { - _db = db; - } - - // ---- Version Management ---- - - [HttpDelete("versions/{firmwareVersion}")] - public async Task DeleteVersion([FromRoute] string firmwareVersion) - { - var deleted = await _db.FirmwareVersions.Where(v => v.Version == firmwareVersion).ExecuteDeleteAsync(); - if (deleted <= 0) return Problem(FirmwareError.FirmwareVersionNotFound); - return Ok(); - } - - // ---- Board Management ---- - - [HttpPut("boards/{boardId}")] - public async Task UpsertBoard([FromRoute] string boardId, [FromBody] CreateFirmwareBoardRequest request) - { - if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId)) - { - return Problem(FirmwareError.FirmwareChipNotFound); - } - - // Parse and validate required artifact types - var requiredArtifactTypes = Array.Empty(); - if (request.RequiredArtifactTypes is { Count: > 0 }) - { - var parsed = new List(); - foreach (var typeStr in request.RequiredArtifactTypes) - { - if (!Enum.TryParse(typeStr, true, out var artifactType)) - { - return Problem(FirmwareError.FirmwareInvalidArtifactType); - } - parsed.Add(artifactType); - } - requiredArtifactTypes = parsed.Distinct().ToArray(); - } - - var existing = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); - if (existing != null) - { - existing.Name = request.Name; - existing.ChipId = request.ChipId; - existing.RequiredArtifactTypes = requiredArtifactTypes; - } - else - { - _db.FirmwareBoards.Add(new FirmwareBoard - { - Id = boardId, - Name = request.Name, - ChipId = request.ChipId, - Discontinued = false, - RequiredArtifactTypes = requiredArtifactTypes - }); - } - - await _db.SaveChangesAsync(); - return Created(); - } - - [HttpPatch("boards/{boardId}/discontinue")] - public async Task DiscontinueBoard([FromRoute] string boardId) - { - var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); - if (board == null) return Problem(FirmwareError.FirmwareBoardNotFound); - - board.Discontinued = true; - await _db.SaveChangesAsync(); - return Ok(); - } - - [HttpDelete("boards/{boardId}")] - public async Task DeleteBoard([FromRoute] string boardId) - { - if (await _db.FirmwareArtifacts.AnyAsync(a => a.BoardId == boardId)) - { - return Problem(FirmwareError.FirmwareBoardInUse); - } - - var deleted = await _db.FirmwareBoards.Where(b => b.Id == boardId).ExecuteDeleteAsync(); - if (deleted <= 0) return Problem(FirmwareError.FirmwareBoardNotFound); - return Ok(); - } - - // ---- Chip Management ---- - - [HttpPut("chips/{chipId}")] - public async Task UpsertChip([FromRoute] string chipId, [FromBody] CreateFirmwareChipRequest request) - { - FirmwareChipArchitecture? architecture = null; - if (request.Architecture != null) - { - if (!Enum.TryParse(request.Architecture, true, out var parsed)) - { - return Problem(FirmwareError.FirmwareInvalidArchitecture); - } - architecture = parsed; - } - - var chip = new FirmwareChip - { - Id = chipId, - Name = request.Name, - Architecture = architecture - }; - var executed = await _db.FirmwareChips.Upsert(chip).On(c => c.Id).RunAsync(); - if (executed <= 0) throw new Exception("Failed to upsert firmware chip"); - - return Created(); - } - - [HttpDelete("chips/{chipId}")] - public async Task DeleteChip([FromRoute] string chipId) - { - if (await _db.FirmwareBoards.AnyAsync(b => b.ChipId == chipId)) - { - return Problem(FirmwareError.FirmwareChipInUse); - } - - var deleted = await _db.FirmwareChips.Where(c => c.Id == chipId).ExecuteDeleteAsync(); - if (deleted <= 0) return Problem(FirmwareError.FirmwareChipNotFound); - return Ok(); - } -} diff --git a/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs index e50d8ce..21fbd0b 100644 --- a/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs @@ -1,8 +1,9 @@ -using Asp.Versioning; +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; @@ -19,9 +20,15 @@ public BoardsController(RepoServerContext db) } [HttpGet] - public async Task ListBoards([FromQuery] string? chipId, [FromQuery] bool includeDiscontinued = false) + [CacheControl(300)] + public async Task ListBoards( + [FromQuery] string? chipId, + [FromQuery] bool includeDiscontinued = true, + CancellationToken ct = default) { - IQueryable query = _db.FirmwareBoards.Include(b => b.ChipNavigation); + IQueryable query = _db.FirmwareBoards + .Include(b => b.ChipNavigation) + .Include(b => b.UsbDevices); if (!string.IsNullOrWhiteSpace(chipId)) { @@ -33,16 +40,21 @@ public async Task ListBoards([FromQuery] string? chipId, [FromQue query = query.Where(b => !b.Discontinued); } - var boards = await query + var rows = await query.OrderBy(b => b.Id).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 + Discontinued = b.Discontinued, + UsbDevices = b.UsbDevices + .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name }) + .ToList() }) - .ToListAsync(); + .ToList(); return Ok(boards); } diff --git a/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs index 6fd2c53..932ddd8 100644 --- a/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs @@ -1,8 +1,9 @@ -using Asp.Versioning; +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; @@ -19,16 +20,25 @@ public ChipsController(RepoServerContext db) } [HttpGet] - public async Task ListChips() + [CacheControl(300)] + public async Task ListChips(CancellationToken ct) { - var chips = await _db.FirmwareChips + var rows = await _db.FirmwareChips + .Include(c => c.UsbDevices) + .OrderBy(c => c.Id) + .ToListAsync(ct); + + var chips = rows .Select(c => new FirmwareChipDto { Id = c.Id, Name = c.Name, - Architecture = c.Architecture != null ? c.Architecture.Value.ToString().ToLowerInvariant() : null + Architecture = c.Architecture?.ToString().ToLowerInvariant(), + UsbDevices = c.UsbDevices + .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name }) + .ToList() }) - .ToListAsync(); + .ToList(); return Ok(chips); } diff --git a/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs b/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs deleted file mode 100644 index b5a5295..0000000 --- a/RepositoryServer/Controllers/V2/Firmware/CiCdController.cs +++ /dev/null @@ -1,274 +0,0 @@ -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.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/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; - } - - // ---- Version Publishing ---- - - [HttpPut("versions/{firmwareVersion}")] - public async Task PublishVersion([FromRoute] string firmwareVersion, [FromBody] CreateFirmwareVersionRequest request) - { - if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) - { - return Problem(FirmwareError.FirmwareInvalidSemver); - } - - if (!Enum.TryParse(request.Channel, true, out var channel)) - { - return Problem(FirmwareError.FirmwareInvalidChannel); - } - - // Validate artifacts if provided - if (request.Artifacts is { Count: > 0 }) - { - var boardIds = request.Artifacts.Keys.ToHashSet(); - var boards = await _db.FirmwareBoards - .Where(b => boardIds.Contains(b.Id)) - .ToDictionaryAsync(b => b.Id); - - var missingBoardsCount = boardIds.Except(boards.Keys).Count(); - if (missingBoardsCount > 0) - { - return Problem(FirmwareError.FirmwareBoardNotFound); - } - - foreach (var (boardId, artifactTypes) in request.Artifacts) - { - if (artifactTypes.Keys.Any(artifactType => !Enum.TryParse(artifactType, true, out _))) - { - return Problem(FirmwareError.FirmwareInvalidArtifactType); - } - - // Validate all required artifact types are present - var board = boards[boardId]; - if (board.RequiredArtifactTypes.Length > 0) - { - var providedTypes = artifactTypes.Keys - .Select(k => Enum.Parse(k, true)) - .ToHashSet(); - var missingTypes = board.RequiredArtifactTypes - .Where(r => !providedTypes.Contains(r)) - .Select(r => r.ToString().ToLowerInvariant()) - .ToList(); - if (missingTypes.Count > 0) - { - return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); - } - } - } - } - - // Validate release note types - if (request.ReleaseNotes.Any(note => !Enum.TryParse(note.Type, true, out _))) - { - return Problem(FirmwareError.FirmwareInvalidReleaseNoteType); - } - - await using var transaction = await _db.Database.BeginTransactionAsync(); - - // Upsert the firmware version - var versionEntity = new FirmwareVersion - { - Version = firmwareVersion, - Channel = channel, - ReleaseDate = request.ReleaseDate, - CommitHash = request.CommitHash, - ReleaseUrl = request.ReleaseUrl - }; - var executed = await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(); - if (executed <= 0) throw new Exception("Failed to upsert firmware version"); - - // Replace artifacts only if provided in the request - if (request.Artifacts is { Count: > 0 }) - { - await _db.FirmwareArtifacts.Where(a => a.Version == firmwareVersion).ExecuteDeleteAsync(); - foreach (var (boardId, artifactTypes) in request.Artifacts) - { - foreach (var (artifactTypeStr, upload) in artifactTypes) - { - var artifactType = Enum.Parse(artifactTypeStr, true); - _db.FirmwareArtifacts.Add(new FirmwareArtifact - { - Version = firmwareVersion, - BoardId = boardId, - ArtifactType = artifactType, - HashSha256 = Convert.FromHexString(upload.Sha256Hash), - FileSize = upload.FileSize - }); - } - } - } - - // Replace all release notes for this version - await _db.FirmwareReleaseNotes.Where(n => n.Version == firmwareVersion).ExecuteDeleteAsync(); - for (var i = 0; i < request.ReleaseNotes.Count; i++) - { - var note = request.ReleaseNotes[i]; - _db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote - { - Version = firmwareVersion, - Index = i, - Type = Enum.Parse(note.Type, true), - Title = note.Title, - Content = note.Content - }); - } - - await _db.SaveChangesAsync(); - await transaction.CommitAsync(); - - return Created(); - } - - // ---- Board Artifact Upload ---- - - private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase) - { - ["app"] = FirmwareArtifactType.App, - ["staticfs"] = FirmwareArtifactType.StaticFs, - ["merged"] = FirmwareArtifactType.Merged, - ["bootloader"] = FirmwareArtifactType.Bootloader, - ["partitions"] = FirmwareArtifactType.Partitions, - }; - - /// - /// Uploads firmware binary artifacts for a specific board+version. - /// Accepts multipart/form-data with file fields named: app, staticfs, merged, bootloader, partitions. - /// Each file is hashed, uploaded to CDN, and recorded in the database. - /// - [HttpPut("versions/{firmwareVersion}/boards/{boardId}/upload")] - [Consumes("multipart/form-data")] - [RequestSizeLimit(64 * 1024 * 1024)] // 64 MB - public async Task UploadBoardArtifacts( - [FromRoute] string firmwareVersion, - [FromRoute] string boardId) - { - if (!SemVersion.TryParse(firmwareVersion, SemVersionStyles.Strict, out _)) - { - return Problem(FirmwareError.FirmwareInvalidSemver); - } - - if (!await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion)) - { - return Problem(FirmwareError.FirmwareVersionNotFound); - } - - var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId); - if (board == null) - { - return Problem(FirmwareError.FirmwareBoardNotFound); - } - - var files = Request.Form.Files; - if (files.Count == 0) - { - return BadRequest(new { error = "No files uploaded. Expected file fields: app, staticfs, merged, bootloader, partitions" }); - } - - // Validate all required artifact types are present in the upload - if (board.RequiredArtifactTypes.Length > 0) - { - var uploadedTypes = files - .Select(f => f.Name.ToLowerInvariant()) - .Where(ArtifactFieldNames.ContainsKey) - .Select(n => ArtifactFieldNames[n]) - .ToHashSet(); - var missingTypes = board.RequiredArtifactTypes - .Where(r => !uploadedTypes.Contains(r)) - .Select(r => r.ToString().ToLowerInvariant()) - .ToList(); - if (missingTypes.Count > 0) - { - return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); - } - } - - var uploadedArtifacts = new List(); - - foreach (var file in files) - { - var fieldName = file.Name.ToLowerInvariant(); - if (!ArtifactFieldNames.TryGetValue(fieldName, out var artifactType)) - { - return Problem(FirmwareError.FirmwareInvalidArtifactType); - } - - // Read file into memory for hashing + CDN upload - using var memoryStream = new MemoryStream(); - await file.CopyToAsync(memoryStream); - var fileBytes = memoryStream.ToArray(); - var fileSize = fileBytes.Length; - - // Compute SHA256 hash - var hashBytes = SHA256.HashData(fileBytes); - - // Upload to storage - var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifactType); - var cdnPath = $"{firmwareVersion}/{boardId}/{cdnFileName}"; - - using var uploadStream = new MemoryStream(fileBytes); - await _storage.UploadFileAsync(cdnPath, uploadStream); - - // Upsert artifact record in DB - var existing = await _db.FirmwareArtifacts.FirstOrDefaultAsync(a => - a.Version == firmwareVersion && a.BoardId == boardId && a.ArtifactType == artifactType); - - if (existing != null) - { - existing.HashSha256 = hashBytes; - existing.FileSize = fileSize; - } - else - { - _db.FirmwareArtifacts.Add(new FirmwareArtifact - { - Version = firmwareVersion, - BoardId = boardId, - ArtifactType = artifactType, - HashSha256 = hashBytes, - FileSize = fileSize, - }); - } - - var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); - uploadedArtifacts.Add(new FirmwareArtifactDto - { - Type = artifactType.ToString().ToLowerInvariant(), - Url = $"{cdnBase}/{firmwareVersion}/{boardId}/{cdnFileName}", - Sha256Hash = Convert.ToHexString(hashBytes), - FileSize = fileSize, - }); - } - - await _db.SaveChangesAsync(); - - return Ok(uploadedArtifacts); - } -} diff --git a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs index 8d7724a..efe8948 100644 --- a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs @@ -25,62 +25,86 @@ public LatestController(RepoServerContext db, ApiConfig apiConfig) } [HttpGet("{channel}")] - public async Task GetLatest([FromRoute] string channel, [FromQuery] string? board) + [CacheControl(300)] + public async Task GetLatest([FromRoute] string channel, CancellationToken ct) { if (!Enum.TryParse(channel, true, out var firmwareChannel)) { return Problem(FirmwareError.FirmwareInvalidChannel); } - var query = _db.FirmwareVersions + var latest = await _db.FirmwareVersions .Where(v => v.Channel == firmwareChannel) - .OrderByDescending(v => v.ReleaseDate); + .OrderByDescending(v => v.ReleaseDate) + .Include(v => v.RepositoryNavigation) + .Include(v => v.Artifacts) + .Include(v => v.ReleaseNotes) + .FirstOrDefaultAsync(ct); - FirmwareVersion? latestVersion; - if (!string.IsNullOrWhiteSpace(board)) + if (latest is null) { - latestVersion = await query - .Include(v => v.Artifacts.Where(a => a.BoardId == board)) - .FirstOrDefaultAsync(); + return Problem(FirmwareError.FirmwareVersionNotFound); } - else + + 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}")] + [CacheControl(300)] + public async Task GetLatestForBoard( + [FromRoute] string channel, + [FromRoute] string boardId, + [FromQuery] string? version, + CancellationToken ct) + { + if (!Enum.TryParse(channel, true, out var firmwareChannel)) { - latestVersion = await query - .Include(v => v.Artifacts) - .FirstOrDefaultAsync(); + return Problem(FirmwareError.FirmwareInvalidChannel); } - if (latestVersion == null) + 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); } - var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); - var artifacts = new Dictionary>(); - - foreach (var artifact in latestVersion.Artifacts) + // 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)) { - if (!artifacts.TryGetValue(artifact.BoardId, out var list)) - { - list = new List(); - artifacts[artifact.BoardId] = list; - } + return NoContent(); + } + + var artifacts = await _db.FirmwareArtifacts + .Where(a => a.Version == latestVersion && a.BoardId == boardId) + .ToListAsync(ct); - list.Add(new FirmwareArtifactDto - { - Type = artifact.ArtifactType.ToString().ToLowerInvariant(), - Url = $"{cdnBase}/{latestVersion.Version}/{artifact.BoardId}/{FirmwareArtifactFileNames.GetFileName(artifact.ArtifactType)}", - Sha256Hash = Convert.ToHexString(artifact.HashSha256), - FileSize = artifact.FileSize - }); + if (artifacts.Count == 0) + { + return Problem(FirmwareError.FirmwareBoardNotFound); } - return Ok(new FirmwareLatestResponse + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + return Ok(new FirmwareBoardReleaseResponseDto { - Version = latestVersion.Version, - Channel = latestVersion.Channel.ToString().ToLowerInvariant(), - ReleaseDate = latestVersion.ReleaseDate, + 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..e9c4f3b --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs @@ -0,0 +1,131 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.RepositoryServer.Config; +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; + private readonly ApiConfig _apiConfig; + + public ManifestController(RepoServerContext db, ApiConfig apiConfig) + { + _db = db; + _apiConfig = apiConfig; + } + + [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.Id) + .ToListAsync(ct); + + var chipRows = await _db.FirmwareChips + .Include(c => c.UsbDevices) + .OrderBy(c => c.Id) + .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 = c.Architecture?.ToString().ToLowerInvariant(), + UsbDevices = c.UsbDevices + .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name }) + .ToList() + }) + .ToList(); + + var advisories = _apiConfig.Firmware.Advisories + .Select(a => new FirmwareAdvisoryDto + { + Severity = a.Severity, + 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 index 9edb3b4..574842d 100644 --- a/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs @@ -1,9 +1,12 @@ 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; @@ -21,22 +24,45 @@ namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; [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; - - public ReleasesController(RepoServerContext db, IStorageService storage, 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, CancellationToken ct) + 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); @@ -52,15 +78,15 @@ public async Task InitRelease([FromBody] InitReleaseRequest reque return Problem(FirmwareError.FirmwareReleaseBoardsEmpty); } - // Check no existing staging release for this version var existingStaging = await _db.FirmwareReleases - .AnyAsync(r => r.Version == request.Version && r.Status == FirmwareReleaseStatus.Staging, ct); + .AnyAsync(r => r.Version == request.Version && + (r.Status == ReleaseStatus.Staging || r.Status == ReleaseStatus.Editing), + ct); if (existingStaging) { return Problem(FirmwareError.FirmwareReleaseAlreadyStaging); } - // Validate all declared boards exist var boardIds = request.Boards.ToHashSet(); var existingBoardCount = await _db.FirmwareBoards.CountAsync(b => boardIds.Contains(b.Id), ct); if (existingBoardCount != boardIds.Count) @@ -68,13 +94,30 @@ public async Task InitRelease([FromBody] InitReleaseRequest reque return Problem(FirmwareError.FirmwareBoardNotFound); } - // Validate release note types - foreach (var note in request.ReleaseNotes) + // Source traceability — from OIDC claims (attached by GitHubOidcAuthentication). + if (!TryReadSourceClaims(out var sourceClaims, out var missing)) { - if (!Enum.TryParse(note.Type, true, out _)) - { - return Problem(FirmwareError.FirmwareInvalidReleaseNoteType); - } + 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 @@ -82,46 +125,47 @@ public async Task InitRelease([FromBody] InitReleaseRequest reque Id = Guid.NewGuid(), Version = request.Version, Channel = channel, - CommitHash = request.CommitHash, - ReleaseUrl = request.ReleaseUrl, + RepositoryId = sourceClaims.RepositoryId, + CommitHash = sourceClaims.CommitHash, + Ref = sourceClaims.Ref, + RunId = sourceClaims.RunId, ReleaseDate = request.ReleaseDate, - Status = FirmwareReleaseStatus.Staging, + Status = status, DeclaredBoards = request.Boards.ToArray(), - CreatedAt = DateTimeOffset.UtcNow, + CreatedAt = _timeProvider.GetUtcNow(), }; _db.FirmwareReleases.Add(release); - // Stage release notes - for (var i = 0; i < request.ReleaseNotes.Count; i++) + for (var i = 0; i < notes.Count; i++) { - var note = request.ReleaseNotes[i]; + var n = notes[i]; _db.FirmwareStagedReleaseNotes.Add(new FirmwareStagedReleaseNote { ReleaseId = release.Id, Index = i, - SectionType = Enum.Parse(note.Type, true), - Title = note.Title, - Content = note.Content, + SectionType = Enum.Parse(n.Type, true), + Title = n.Title, + Content = n.Content, }); } await _db.SaveChangesAsync(ct); - return Created((string?)null, new InitReleaseResponse { Id = release.Id }); + 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 ---- - private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase) - { - ["app"] = FirmwareArtifactType.App, - ["staticfs"] = FirmwareArtifactType.StaticFs, - ["merged"] = FirmwareArtifactType.Merged, - ["bootloader"] = FirmwareArtifactType.Bootloader, - ["partitions"] = FirmwareArtifactType.Partitions, - }; - [HttpPut("{releaseId:guid}/boards/{boardId}")] [Consumes("multipart/form-data")] [RequestSizeLimit(64 * 1024 * 1024)] @@ -131,14 +175,14 @@ public async Task UploadBoardArtifacts( CancellationToken ct) { var release = await _db.FirmwareReleases.FirstOrDefaultAsync(r => r.Id == releaseId, ct); - if (release == null) + if (release is null) { return Problem(FirmwareError.FirmwareReleaseNotFound); } - if (release.Status != FirmwareReleaseStatus.Staging) + if (release.Status != ReleaseStatus.Staging && release.Status != ReleaseStatus.Editing) { - return Problem(FirmwareError.FirmwareReleaseNotStaging); + return Problem(FirmwareError.FirmwareReleaseNotEditable); } if (!release.DeclaredBoards.Contains(boardId)) @@ -147,7 +191,7 @@ public async Task UploadBoardArtifacts( } var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId, ct); - if (board == null) + if (board is null) { return Problem(FirmwareError.FirmwareBoardNotFound); } @@ -155,77 +199,129 @@ public async Task UploadBoardArtifacts( var files = Request.Form.Files; if (files.Count == 0) { - return BadRequest(new { error = "No files uploaded. Expected file fields: app, staticfs, merged, bootloader, partitions" }); + 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")); } - // Validate all required artifact types are present + 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 uploadedTypes = files - .Select(f => f.Name.ToLowerInvariant()) - .Where(ArtifactFieldNames.ContainsKey) - .Select(n => ArtifactFieldNames[n]) - .ToHashSet(); - var missingTypes = board.RequiredArtifactTypes - .Where(r => !uploadedTypes.Contains(r)) + var missingRequired = board.RequiredArtifactTypes + .Where(r => !uploadedByType.ContainsKey(r)) .Select(r => r.ToString().ToLowerInvariant()) .ToList(); - if (missingTypes.Count > 0) + if (missingRequired.Count > 0) { - return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingTypes)); + return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardId, missingRequired)); } } - // Remove any previously staged artifacts for this board in this release + // 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 uploadedArtifacts = new List(); var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + var uploadedArtifacts = new List(); + var mismatches = new List(); - foreach (var file in files) + foreach (var (artifactType, file) in uploadedByType) { - var fieldName = file.Name.ToLowerInvariant(); - if (!ArtifactFieldNames.TryGetValue(fieldName, out var artifactType)) + 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)) { - return Problem(FirmwareError.FirmwareInvalidArtifactType); + mismatches.Add($"{artifactType.ToString().ToLowerInvariant()} (expected={expected}, actual={actual})"); + continue; } - using var memoryStream = new MemoryStream(); - await file.CopyToAsync(memoryStream, ct); - var fileBytes = memoryStream.ToArray(); - - var hashBytes = SHA256.HashData(fileBytes); - - // Upload to CDN at final path var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifactType); var cdnPath = $"{release.Version}/{boardId}/{cdnFileName}"; - using var uploadStream = new MemoryStream(fileBytes); + await using var uploadStream = new MemoryStream(bytes); await _storage.UploadFileAsync(cdnPath, uploadStream, ct); - // Stage artifact record _db.FirmwareStagedArtifacts.Add(new FirmwareStagedArtifact { ReleaseId = releaseId, BoardId = boardId, ArtifactType = artifactType, - HashSha256 = hashBytes, - FileSize = fileBytes.Length, + HashSha256 = Convert.FromHexString(actual), + FileSize = bytes.Length, }); uploadedArtifacts.Add(new FirmwareArtifactDto { Type = artifactType.ToString().ToLowerInvariant(), Url = $"{cdnBase}/{release.Version}/{boardId}/{cdnFileName}", - Sha256Hash = Convert.ToHexString(hashBytes), - FileSize = fileBytes.Length, + Sha256Hash = actual, + FileSize = bytes.Length, }); } - await _db.SaveChangesAsync(ct); + if (mismatches.Count > 0) + { + return Problem(FirmwareError.FirmwareSha256Mismatch(string.Join("; ", mismatches))); + } + await _db.SaveChangesAsync(ct); return Ok(uploadedArtifacts); } @@ -239,17 +335,21 @@ public async Task PublishRelease([FromRoute] Guid releaseId, Canc .Include(r => r.StagedReleaseNotes.OrderBy(n => n.Index)) .FirstOrDefaultAsync(r => r.Id == releaseId, ct); - if (release == null) + if (release is null) { return Problem(FirmwareError.FirmwareReleaseNotFound); } - if (release.Status != FirmwareReleaseStatus.Staging) + if (release.Status == ReleaseStatus.Editing) + { + return Problem(FirmwareError.FirmwareReleaseNotesNotFinalized); + } + + if (release.Status != ReleaseStatus.Staging) { return Problem(FirmwareError.FirmwareReleaseNotStaging); } - // Check all declared boards have staged artifacts var uploadedBoardIds = release.StagedArtifacts.Select(a => a.BoardId).Distinct().ToHashSet(); var missingBoards = release.DeclaredBoards.Where(b => !uploadedBoardIds.Contains(b)).ToList(); if (missingBoards.Count > 0) @@ -257,7 +357,6 @@ public async Task PublishRelease([FromRoute] Guid releaseId, Canc return Problem(FirmwareError.FirmwareReleaseIncomplete(missingBoards)); } - // Validate required artifact types per board var boards = await _db.FirmwareBoards .Where(b => release.DeclaredBoards.Contains(b.Id)) .ToDictionaryAsync(b => b.Id, ct); @@ -283,22 +382,19 @@ public async Task PublishRelease([FromRoute] Guid releaseId, Canc await using var transaction = await _db.Database.BeginTransactionAsync(ct); - // Upsert firmware version var versionEntity = new FirmwareVersion { Version = release.Version, Channel = release.Channel, ReleaseDate = release.ReleaseDate, + RepositoryId = release.RepositoryId, CommitHash = release.CommitHash, - ReleaseUrl = release.ReleaseUrl, + Ref = release.Ref, + RunId = release.RunId, }; await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(ct); - // Replace artifacts - await _db.FirmwareArtifacts - .Where(a => a.Version == release.Version) - .ExecuteDeleteAsync(ct); - + await _db.FirmwareArtifacts.Where(a => a.Version == release.Version).ExecuteDeleteAsync(ct); foreach (var staged in release.StagedArtifacts) { _db.FirmwareArtifacts.Add(new FirmwareArtifact @@ -311,11 +407,7 @@ await _db.FirmwareArtifacts }); } - // Replace release notes - await _db.FirmwareReleaseNotes - .Where(n => n.Version == release.Version) - .ExecuteDeleteAsync(ct); - + await _db.FirmwareReleaseNotes.Where(n => n.Version == release.Version).ExecuteDeleteAsync(ct); foreach (var staged in release.StagedReleaseNotes) { _db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote @@ -328,11 +420,17 @@ await _db.FirmwareReleaseNotes }); } - release.Status = FirmwareReleaseStatus.Published; + 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", @@ -349,17 +447,16 @@ public async Task AbortRelease([FromRoute] Guid releaseId, Cancel .Include(r => r.StagedArtifacts) .FirstOrDefaultAsync(r => r.Id == releaseId, ct); - if (release == null) + if (release is null) { return Problem(FirmwareError.FirmwareReleaseNotFound); } - if (release.Status != FirmwareReleaseStatus.Staging) + if (release.Status != ReleaseStatus.Staging && release.Status != ReleaseStatus.Editing) { - return Problem(FirmwareError.FirmwareReleaseNotStaging); + return Problem(FirmwareError.FirmwareReleaseNotEditable); } - // Delete uploaded CDN files for each staged artifact foreach (var artifact in release.StagedArtifacts) { var cdnFileName = FirmwareArtifactFileNames.GetFileName(artifact.ArtifactType); @@ -367,10 +464,46 @@ public async Task AbortRelease([FromRoute] Guid releaseId, Cancel await _storage.DeleteFileAsync(cdnPath, ct); } - release.Status = FirmwareReleaseStatus.Aborted; - + release.Status = ReleaseStatus.Aborted; await _db.SaveChangesAsync(ct); - return Ok(); + 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 index 1edef1f..8543418 100644 --- a/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using OpenShock.RepositoryServer.Config; @@ -15,6 +15,9 @@ namespace OpenShock.RepositoryServer.Controllers.V2.Firmware; [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; @@ -25,9 +28,16 @@ public VersionsController(RepoServerContext db, ApiConfig apiConfig) } [HttpGet] - public async Task ListVersions([FromQuery] string? channel) + [CacheControl(3600)] + public async Task ListVersions( + [FromQuery] string? channel, + [FromQuery] int? limit, + [FromQuery] int? offset, + CancellationToken ct) { - IQueryable query = _db.FirmwareVersions; + IQueryable query = _db.FirmwareVersions + .Include(v => v.RepositoryNavigation) + .Include(v => v.ReleaseNotes); if (!string.IsNullOrWhiteSpace(channel)) { @@ -38,69 +48,94 @@ public async Task ListVersions([FromQuery] string? channel) query = query.Where(v => v.Channel == firmwareChannel); } - var versions = await query + 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 + 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() }) - .ToListAsync(); + .ToList(); - return Ok(versions); + return Ok(new VersionListResponse { Versions = summaries, Total = total }); } [HttpGet("{firmwareVersion}")] - public async Task GetVersion([FromRoute] string 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.OrderBy(n => n.Index)) - .FirstOrDefaultAsync(v => v.Version == firmwareVersion); + .Include(v => v.ReleaseNotes) + .FirstOrDefaultAsync(v => v.Version == firmwareVersion, ct); - if (version == null) + 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)); + } - var artifacts = new Dictionary>(); - foreach (var artifact in version.Artifacts) + [HttpGet("{firmwareVersion}/{boardId}")] + [CacheControl(86400, immutable: true)] + public async Task GetVersionForBoard( + [FromRoute] string firmwareVersion, + [FromRoute] string boardId, + CancellationToken ct) + { + var exists = await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion, ct); + if (!exists) { - if (!artifacts.TryGetValue(artifact.BoardId, out var list)) - { - list = new List(); - artifacts[artifact.BoardId] = list; - } - - list.Add(new FirmwareArtifactDto - { - Type = artifact.ArtifactType.ToString().ToLowerInvariant(), - Url = $"{cdnBase}/{version.Version}/{artifact.BoardId}/{FirmwareArtifactFileNames.GetFileName(artifact.ArtifactType)}", - Sha256Hash = Convert.ToHexString(artifact.HashSha256), - FileSize = artifact.FileSize - }); + return Problem(FirmwareError.FirmwareVersionNotFound); } - var releaseNotes = version.ReleaseNotes.Select(n => new FirmwareReleaseNoteDto + var artifacts = await _db.FirmwareArtifacts + .Where(a => a.Version == firmwareVersion && a.BoardId == boardId) + .ToListAsync(ct); + + if (artifacts.Count == 0) { - Type = n.SectionType.ToString().ToLowerInvariant(), - Title = n.Title, - Content = n.Content - }).ToList(); + return Problem(FirmwareError.FirmwareBoardNotFound); + } - return Ok(new FirmwareVersionResponse + var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/'); + return Ok(new FirmwareBoardReleaseResponseDto { - Version = version.Version, - Channel = version.Channel.ToString().ToLowerInvariant(), - ReleaseDate = version.ReleaseDate, - CommitHash = version.CommitHash, - ReleaseUrl = version.ReleaseUrl, - Artifacts = artifacts, - ReleaseNotes = releaseNotes + Version = firmwareVersion, + BoardId = boardId, + Artifacts = artifacts + .Select(a => FirmwareResponseMapper.ToArtifactDto(a, firmwareVersion, cdnBase)) + .ToList() }); } - } diff --git a/RepositoryServer/Enums/FirmwareReleaseStatus.cs b/RepositoryServer/Enums/ReleaseStatus.cs similarity index 62% rename from RepositoryServer/Enums/FirmwareReleaseStatus.cs rename to RepositoryServer/Enums/ReleaseStatus.cs index 7ae4674..14d9e4f 100644 --- a/RepositoryServer/Enums/FirmwareReleaseStatus.cs +++ b/RepositoryServer/Enums/ReleaseStatus.cs @@ -1,8 +1,10 @@ namespace OpenShock.RepositoryServer.Enums; -public enum FirmwareReleaseStatus +public enum ReleaseStatus { Staging, + Editing, Published, + Archived, Aborted } diff --git a/RepositoryServer/Enums/RepositoryProvider.cs b/RepositoryServer/Enums/RepositoryProvider.cs new file mode 100644 index 0000000..4b019f4 --- /dev/null +++ b/RepositoryServer/Enums/RepositoryProvider.cs @@ -0,0 +1,11 @@ +namespace OpenShock.RepositoryServer.Enums; + +/// +/// Source-code provider for the shared repositories table. Only GitHub is +/// supported today; new providers require a matching OIDC handler and URL builder. +/// Member name uses single-word casing so the Postgres enum value is github. +/// +public enum RepositoryProvider +{ + Github +} diff --git a/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs deleted file mode 100644 index 5fa7d27..0000000 --- a/RepositoryServer/Models/Firmware/CreateFirmwareVersionRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; - -public sealed class CreateFirmwareVersionRequest -{ - public required string Channel { get; init; } - public required DateTimeOffset ReleaseDate { get; init; } - public required string CommitHash { get; init; } - public string? ReleaseUrl { get; init; } - public Dictionary>? Artifacts { get; init; } - public List ReleaseNotes { get; init; } = []; -} diff --git a/RepositoryServer/Models/Firmware/FirmwareAdvisoryDto.cs b/RepositoryServer/Models/Firmware/FirmwareAdvisoryDto.cs new file mode 100644 index 0000000..fde567b --- /dev/null +++ b/RepositoryServer/Models/Firmware/FirmwareAdvisoryDto.cs @@ -0,0 +1,13 @@ +namespace OpenShock.RepositoryServer.Models.Firmware; + +/// +/// Security / compatibility advisory exposed on the manifest endpoint. Served from config. +/// +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/FirmwareArtifactUpload.cs b/RepositoryServer/Models/Firmware/FirmwareArtifactUpload.cs deleted file mode 100644 index a456194..0000000 --- a/RepositoryServer/Models/Firmware/FirmwareArtifactUpload.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; - -public sealed class FirmwareArtifactUpload -{ - 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 index 8440296..a05ed8f 100644 --- a/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs +++ b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs @@ -1,10 +1,16 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; +namespace OpenShock.RepositoryServer.Models.Firmware; -public sealed class FirmwareBoardDto +/// +/// 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 string Id { get; init; } public required string Name { get; init; } public required string 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..58561e5 --- /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 string BoardId { get; init; } + public required List Artifacts { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareChipDto.cs b/RepositoryServer/Models/Firmware/FirmwareChipDto.cs index 574b0d5..028375c 100644 --- a/RepositoryServer/Models/Firmware/FirmwareChipDto.cs +++ b/RepositoryServer/Models/Firmware/FirmwareChipDto.cs @@ -1,8 +1,14 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; +namespace OpenShock.RepositoryServer.Models.Firmware; -public sealed class FirmwareChipDto +/// +/// 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 string 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..20750e4 --- /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 string Id { get; init; } + public required string Name { get; init; } +} diff --git a/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs b/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs deleted file mode 100644 index 6e11ac5..0000000 --- a/RepositoryServer/Models/Firmware/FirmwareLatestResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; - -public sealed class FirmwareLatestResponse -{ - public required string Version { get; init; } - public required string Channel { get; init; } - public required DateTimeOffset ReleaseDate { get; init; } - public required Dictionary> Artifacts { 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..5dc7761 --- /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/FirmwareReleaseNoteUpload.cs b/RepositoryServer/Models/Firmware/FirmwareReleaseNoteUpload.cs deleted file mode 100644 index b9e60d6..0000000 --- a/RepositoryServer/Models/Firmware/FirmwareReleaseNoteUpload.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; - -public sealed class FirmwareReleaseNoteUpload -{ - 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/FirmwareVersionResponse.cs b/RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs deleted file mode 100644 index 5eaec89..0000000 --- a/RepositoryServer/Models/Firmware/FirmwareVersionResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; - -public sealed class FirmwareVersionResponse -{ - public required string Version { get; init; } - public required string Channel { get; init; } - public required DateTimeOffset ReleaseDate { get; init; } - public required string CommitHash { get; init; } - public string? ReleaseUrl { get; init; } - public required Dictionary> Artifacts { get; init; } - public required List ReleaseNotes { get; init; } -} diff --git a/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs b/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs index f9ea106..0355035 100644 --- a/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs +++ b/RepositoryServer/Models/Firmware/FirmwareVersionSummary.cs @@ -1,8 +1,14 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; +namespace OpenShock.RepositoryServer.Models.Firmware; -public sealed class FirmwareVersionSummary +/// +/// 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 index 977ff17..631f949 100644 --- a/RepositoryServer/Models/Firmware/InitReleaseRequest.cs +++ b/RepositoryServer/Models/Firmware/InitReleaseRequest.cs @@ -1,12 +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; } - public required string CommitHash { get; init; } + + [Required] public required DateTimeOffset ReleaseDate { get; init; } - public string? ReleaseUrl { get; init; } + + [Required] + [MinLength(1)] public required List Boards { get; init; } - public List ReleaseNotes { 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 index 7924f76..6b235ba 100644 --- a/RepositoryServer/Models/Firmware/InitReleaseResponse.cs +++ b/RepositoryServer/Models/Firmware/InitReleaseResponse.cs @@ -3,4 +3,5 @@ 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/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/RepositoryServer/Problems/FirmwareError.cs b/RepositoryServer/Problems/FirmwareError.cs index e8257fb..b5e227a 100644 --- a/RepositoryServer/Problems/FirmwareError.cs +++ b/RepositoryServer/Problems/FirmwareError.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; namespace OpenShock.RepositoryServer.Problems; @@ -10,7 +10,6 @@ public static class FirmwareError 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 FirmwareInvalidReleaseNoteType => new("Firmware.InvalidReleaseNoteType", "The release note 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); @@ -19,10 +18,27 @@ public static OpenShockProblem FirmwareMissingRequiredArtifacts(string boardId, 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); } diff --git a/RepositoryServer/Program.cs b/RepositoryServer/Program.cs index fb16026..4a4e234 100644 --- a/RepositoryServer/Program.cs +++ b/RepositoryServer/Program.cs @@ -1,5 +1,4 @@ -using System.Net; -using System.Security.Claims; +using System.Net; using System.Text.Json; using Asp.Versioning; using EntityFramework.Exceptions.PostgreSQL; @@ -21,7 +20,7 @@ using ValidationProblem = OpenShock.RepositoryServer.Problems.ValidationProblem; var builder = WebApplication.CreateSlimBuilder(args); - + builder.Configuration.Sources.Clear(); builder.Configuration .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) @@ -41,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); @@ -59,38 +58,7 @@ AuthSchemas.AdminToken, _ => { }) .AddJwtBearer(AuthSchemas.CiCdToken, options => { - options.Authority = "https://token.actions.githubusercontent.com"; - options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = "https://token.actions.githubusercontent.com", - ValidateAudience = true, - ValidAudience = config.CiCd.Audience, - ValidateLifetime = true, - }; - options.Events = new JwtBearerEvents - { - OnTokenValidated = context => - { - var owner = context.Principal?.FindFirstValue("repository_owner"); - if (!string.Equals(owner, config.CiCd.RepositoryOwner, StringComparison.OrdinalIgnoreCase)) - { - context.Fail("Repository owner is not allowed."); - return Task.CompletedTask; - } - - if (config.CiCd.AllowedRepositories is { Count: > 0 }) - { - var repo = context.Principal?.FindFirstValue("repository"); - if (repo == null || !config.CiCd.AllowedRepositories.Contains(repo, StringComparer.OrdinalIgnoreCase)) - { - context.Fail("Repository is not allowed."); - } - } - - return Task.CompletedTask; - } - }; + GitHubOidcAuthentication.Configure(options, config.Firmware.CiCd.Audience); }); builder.Services.AddAuthorization(); @@ -193,6 +161,12 @@ throw new InvalidOperationException($"Unknown storage type: {config.Firmware.Storage.Type}"); } +// <---- Discord notifications ----> +builder.Services.AddHttpClient(); + +// <---- Background cleanup ----> +builder.Services.AddHostedService(); + // <---- Postgres EF Core ----> builder.Services.AddDbContextPool(dbBuilder => @@ -213,10 +187,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(); @@ -250,7 +224,7 @@ // global cors policy app.UseCors(); - + app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromMinutes(1) @@ -260,11 +234,11 @@ app.UseAuthorization(); 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)); }); diff --git a/RepositoryServer/RepoServerDb/FirmwareBoard.cs b/RepositoryServer/RepoServerDb/FirmwareBoard.cs index 9a1b648..3e89291 100644 --- a/RepositoryServer/RepoServerDb/FirmwareBoard.cs +++ b/RepositoryServer/RepoServerDb/FirmwareBoard.cs @@ -1,4 +1,4 @@ -using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Enums; namespace OpenShock.RepositoryServer.RepoServerDb; @@ -12,4 +12,5 @@ public class FirmwareBoard 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..2c42eff --- /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 string BoardId { get; set; } = null!; + 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 index cd351cb..484e38e 100644 --- a/RepositoryServer/RepoServerDb/FirmwareChip.cs +++ b/RepositoryServer/RepoServerDb/FirmwareChip.cs @@ -1,4 +1,4 @@ -using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Enums; namespace OpenShock.RepositoryServer.RepoServerDb; @@ -9,4 +9,5 @@ public class FirmwareChip 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..79d07b5 --- /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 string ChipId { get; set; } = null!; + 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 index 193de79..fceb51a 100644 --- a/RepositoryServer/RepoServerDb/FirmwareRelease.cs +++ b/RepositoryServer/RepoServerDb/FirmwareRelease.cs @@ -7,13 +7,16 @@ 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? ReleaseUrl { get; set; } + public string? Ref { get; set; } + public string? RunId { get; set; } public DateTimeOffset ReleaseDate { get; set; } - public FirmwareReleaseStatus Status { get; set; } + public ReleaseStatus Status { get; set; } public string[] 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/FirmwareVersion.cs b/RepositoryServer/RepoServerDb/FirmwareVersion.cs index bf26222..71fd11a 100644 --- a/RepositoryServer/RepoServerDb/FirmwareVersion.cs +++ b/RepositoryServer/RepoServerDb/FirmwareVersion.cs @@ -1,4 +1,4 @@ -using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Enums; namespace OpenShock.RepositoryServer.RepoServerDb; @@ -7,9 +7,12 @@ 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? ReleaseUrl { get; set; } + public string? Ref { get; set; } + public string? RunId { get; set; } + public virtual SourceRepository RepositoryNavigation { get; set; } = null!; public virtual ICollection Artifacts { get; set; } = new List(); public virtual ICollection ReleaseNotes { get; set; } = new List(); } diff --git a/RepositoryServer/RepoServerDb/Module.cs b/RepositoryServer/RepoServerDb/Module.cs index 42dc48d..be43c69 100644 --- a/RepositoryServer/RepoServerDb/Module.cs +++ b/RepositoryServer/RepoServerDb/Module.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace OpenShock.RepositoryServer.RepoServerDb; public partial class Module @@ -11,7 +8,7 @@ public partial class Module public string Description { get; set; } = null!; - public Uri? SourceUrl { get; set; } = null!; + public Uri? SourceUrl { get; set; } public Uri? IconUrl { get; set; } diff --git a/RepositoryServer/RepoServerDb/RepoServerContext.cs b/RepositoryServer/RepoServerDb/RepoServerContext.cs index aa17f7d..139f6e0 100644 --- a/RepositoryServer/RepoServerDb/RepoServerContext.cs +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -1,8 +1,6 @@ -using System; -using System.Collections.Generic; using EntityFramework.Exceptions.PostgreSQL; using Microsoft.EntityFrameworkCore; -using Npgsql; +using OpenShock.RepositoryServer.Enums; namespace OpenShock.RepositoryServer.RepoServerDb; @@ -68,7 +66,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); - npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); }); optionsBuilder.UseExceptionProcessor(); @@ -84,6 +83,9 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde } } + // 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; } @@ -92,20 +94,46 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde 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; } protected override void OnModelCreating(ModelBuilder modelBuilder) { // PostgreSQL enum mappings modelBuilder - .HasPostgresEnum("firmware_channel", ["stable", "beta", "develop"]) + .HasPostgresEnum("release_channel", ["stable", "beta", "develop"]) .HasPostgresEnum("firmware_artifact_type", ["merged", "app", "bootloader", "partitions", "static_fs"]) .HasPostgresEnum("firmware_release_note_type", ["warning", "info", "breaking", "section"]) .HasPostgresEnum("firmware_chip_architecture", ["xtensa", "risc_v"]) - .HasPostgresEnum("firmware_release_status", ["staging", "published", "aborted"]); + .HasPostgresEnum("release_status", ["staging", "editing", "published", "archived", "aborted"]) + .HasPostgresEnum("repository_provider", ["github"]); + + // 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) + // Desktop module entities (unchanged — source traceability is not yet wired here) modelBuilder.Entity(entity => { entity.HasKey(e => e.Id).HasName("modules_pkey"); @@ -209,12 +237,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .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.ReleaseUrl) + entity.Property(e => e.Ref) .HasMaxLength(256) - .HasColumnName("release_url"); + .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"); @@ -285,12 +323,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .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.ReleaseUrl) + entity.Property(e => e.Ref) .HasMaxLength(256) - .HasColumnName("release_url"); + .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) @@ -300,6 +343,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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"); }); @@ -351,5 +399,83 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .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"); + + // NOTE: unique (vid, pid) with NULLS NOT DISTINCT — declared via raw SQL in the + // FirmwareV2Redesign migration (EF Core has no fluent shortcut). + }); + + // 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).HasMaxLength(32).HasColumnName("chip_id"); + joinEntity.Property(e => e.UsbDeviceId).HasColumnName("usb_device_id"); + }); + + // 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).HasMaxLength(64).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/RepositoryServer/RepoServerDb/Version.cs b/RepositoryServer/RepoServerDb/Version.cs index 74eabbf..26b3abf 100644 --- a/RepositoryServer/RepoServerDb/Version.cs +++ b/RepositoryServer/RepoServerDb/Version.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace OpenShock.RepositoryServer.RepoServerDb; public partial class Version diff --git a/RepositoryServer/RepositoryServer.csproj b/RepositoryServer/RepositoryServer.csproj index 6f4ef16..6c328d0 100644 --- a/RepositoryServer/RepositoryServer.csproj +++ b/RepositoryServer/RepositoryServer.csproj @@ -3,6 +3,7 @@ OpenShock.RepositoryServer OpenShock.RepositoryServer RepositoryServer + default 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/StagedReleaseCleanupService.cs b/RepositoryServer/Services/StagedReleaseCleanupService.cs new file mode 100644 index 0000000..289f61a --- /dev/null +++ b/RepositoryServer/Services/StagedReleaseCleanupService.cs @@ -0,0 +1,112 @@ +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. + await TickAsync(stoppingToken); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await TickAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + 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/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/RepositoryServer/Utils/FirmwareResponseMapper.cs b/RepositoryServer/Utils/FirmwareResponseMapper.cs new file mode 100644 index 0000000..f5fba89 --- /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/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/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..9a523dc 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,7 @@ { - "test": { - "runner": "Microsoft.Testing.Platform" + "sdk": { + "version": "10.0.0", + "rollForward": "latestMajor", + "allowPrerelease": false } } \ No newline at end of file From 7ccd9078aefd22d50c512994624219388ea1a165 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 17 Apr 2026 20:13:17 +0200 Subject: [PATCH 09/15] more progress --- .../Config/FirmwareAdvisoryConfig.cs | 12 -- RepositoryServer/Config/FirmwareConfig.cs | 6 - .../V2/Firmware/Admin/AdvisoriesController.cs | 110 +++++++++++++++++ .../Firmware/Admin/BoardsAdminController.cs | 113 ++++++++++++------ .../V2/Firmware/Admin/ChipsAdminController.cs | 55 ++++++--- .../V2/Firmware/BoardsController.cs | 8 +- .../V2/Firmware/ChipsController.cs | 2 +- .../V2/Firmware/LatestController.cs | 4 +- .../V2/Firmware/ManifestController.cs | 17 ++- .../V2/Firmware/ReleasesController.cs | 4 +- .../V2/Firmware/VersionsController.cs | 4 +- RepositoryServer/Enums/AdvisorySeverity.cs | 13 ++ .../Firmware/CreateFirmwareBoardRequest.cs | 11 +- .../Firmware/CreateFirmwareChipRequest.cs | 8 +- .../Firmware/FirmwareAdvisoryAdminDto.cs | 15 +++ .../Models/Firmware/FirmwareAdvisoryDto.cs | 3 +- .../Models/Firmware/FirmwareBoardDto.cs | 4 +- .../FirmwareBoardReleaseResponseDto.cs | 2 +- .../Models/Firmware/FirmwareChipDto.cs | 2 +- .../Models/Firmware/FirmwareChipRefDto.cs | 2 +- .../Models/Firmware/FirmwareReleaseDto.cs | 2 +- .../Models/Firmware/InitReleaseRequest.cs | 2 +- .../Firmware/UpsertFirmwareAdvisoryRequest.cs | 25 ++++ RepositoryServer/Problems/FirmwareError.cs | 7 +- .../RepoServerDb/FirmwareAdvisory.cs | 20 ++++ .../RepoServerDb/FirmwareArtifact.cs | 4 +- .../RepoServerDb/FirmwareBoard.cs | 9 +- .../RepoServerDb/FirmwareBoardUsbDevice.cs | 2 +- RepositoryServer/RepoServerDb/FirmwareChip.cs | 8 +- .../RepoServerDb/FirmwareChipUsbDevice.cs | 2 +- .../RepoServerDb/FirmwareRelease.cs | 2 +- .../RepoServerDb/FirmwareStagedArtifact.cs | 2 +- .../RepoServerDb/RepoServerContext.cs | 53 +++++--- .../Utils/FirmwareResponseMapper.cs | 2 +- 34 files changed, 404 insertions(+), 131 deletions(-) delete mode 100644 RepositoryServer/Config/FirmwareAdvisoryConfig.cs create mode 100644 RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs create mode 100644 RepositoryServer/Enums/AdvisorySeverity.cs create mode 100644 RepositoryServer/Models/Firmware/FirmwareAdvisoryAdminDto.cs create mode 100644 RepositoryServer/Models/Firmware/UpsertFirmwareAdvisoryRequest.cs create mode 100644 RepositoryServer/RepoServerDb/FirmwareAdvisory.cs diff --git a/RepositoryServer/Config/FirmwareAdvisoryConfig.cs b/RepositoryServer/Config/FirmwareAdvisoryConfig.cs deleted file mode 100644 index 1f74911..0000000 --- a/RepositoryServer/Config/FirmwareAdvisoryConfig.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace OpenShock.RepositoryServer.Config; - -public sealed class FirmwareAdvisoryConfig -{ - [Required(AllowEmptyStrings = false)] public required string Severity { get; init; } - [Required(AllowEmptyStrings = false)] public required string Title { get; init; } - [Required(AllowEmptyStrings = false)] public required string Content { get; init; } - [Required(AllowEmptyStrings = false)] public required string AffectedVersions { get; init; } - public string? Url { get; init; } -} diff --git a/RepositoryServer/Config/FirmwareConfig.cs b/RepositoryServer/Config/FirmwareConfig.cs index 9cb251d..edab810 100644 --- a/RepositoryServer/Config/FirmwareConfig.cs +++ b/RepositoryServer/Config/FirmwareConfig.cs @@ -17,10 +17,4 @@ public sealed class FirmwareConfig /// How long a release may remain in editing status before the cleanup job aborts it. /// public TimeSpan EditingReleaseTtl { get; init; } = TimeSpan.FromDays(7); - - /// - /// Security / compatibility advisories exposed on the manifest endpoint. - /// Served from configuration, not from the database. - /// - public List Advisories { get; init; } = new(); } diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs new file mode 100644 index 0000000..cb173aa --- /dev/null +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs @@ -0,0 +1,110 @@ +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) + { + var rows = await _db.FirmwareAdvisories + .OrderBy(a => a.Severity) + .ThenBy(a => a.Title) + .ToListAsync(ct); + + return Ok(rows.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 index af32b8a..c087365 100644 --- a/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs @@ -22,9 +22,11 @@ public BoardsAdminController(RepoServerContext db) _db = db; } - [HttpPut("{boardId}")] - public async Task UpsertBoard( - [FromRoute] string boardId, + /// + /// Create a new board. Name is the unique identifier visible to clients. + /// + [HttpPost] + public async Task CreateBoard( [FromBody] CreateFirmwareBoardRequest request, CancellationToken ct) { @@ -33,46 +35,58 @@ public async Task UpsertBoard( return Problem(FirmwareError.FirmwareChipNotFound); } - var requiredArtifactTypes = Array.Empty(); - if (request.RequiredArtifactTypes is { Count: > 0 }) + if (!TryParseRequiredArtifactTypes(request.RequiredArtifactTypes, out var requiredArtifactTypes)) { - var parsed = new List(); - foreach (var typeStr in request.RequiredArtifactTypes) - { - if (!Enum.TryParse(typeStr, true, out var artifactType)) - { - return Problem(FirmwareError.FirmwareInvalidArtifactType); - } - parsed.Add(artifactType); - } - requiredArtifactTypes = parsed.Distinct().ToArray(); + return Problem(FirmwareError.FirmwareInvalidArtifactType); } - var existing = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId, ct); - if (existing is not null) + var board = new FirmwareBoard { - existing.Name = request.Name; - existing.ChipId = request.ChipId; - existing.RequiredArtifactTypes = requiredArtifactTypes; + 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); } - else + + if (!TryParseRequiredArtifactTypes(request.RequiredArtifactTypes, out var requiredArtifactTypes)) { - _db.FirmwareBoards.Add(new FirmwareBoard - { - Id = boardId, - Name = request.Name, - ChipId = request.ChipId, - Discontinued = false, - RequiredArtifactTypes = 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 Created(); + return Ok(); } - [HttpPatch("{boardId}/discontinue")] - public async Task DiscontinueBoard([FromRoute] string boardId, CancellationToken ct) + [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) @@ -85,8 +99,8 @@ public async Task DiscontinueBoard([FromRoute] string boardId, Ca return Ok(); } - [HttpDelete("{boardId}")] - public async Task DeleteBoard([FromRoute] string boardId, CancellationToken ct) + [HttpDelete("{boardId:guid}")] + public async Task DeleteBoard([FromRoute] Guid boardId, CancellationToken ct) { if (await _db.FirmwareArtifacts.AnyAsync(a => a.BoardId == boardId, ct)) { @@ -102,9 +116,9 @@ public async Task DeleteBoard([FromRoute] string boardId, Cancell return NoContent(); } - [HttpPut("{boardId}/usb-devices/{usbDeviceId:guid}")] + [HttpPut("{boardId:guid}/usb-devices/{usbDeviceId:guid}")] public async Task AttachUsbDevice( - [FromRoute] string boardId, + [FromRoute] Guid boardId, [FromRoute] Guid usbDeviceId, CancellationToken ct) { @@ -133,9 +147,9 @@ public async Task AttachUsbDevice( return NoContent(); } - [HttpDelete("{boardId}/usb-devices/{usbDeviceId:guid}")] + [HttpDelete("{boardId:guid}/usb-devices/{usbDeviceId:guid}")] public async Task DetachUsbDevice( - [FromRoute] string boardId, + [FromRoute] Guid boardId, [FromRoute] Guid usbDeviceId, CancellationToken ct) { @@ -145,4 +159,27 @@ await _db.FirmwareBoardUsbDevices 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 index a9fa21c..6b0149a 100644 --- a/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs @@ -1,5 +1,4 @@ using Asp.Versioning; -using FlexLabs.EntityFrameworkCore.Upsert; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -23,9 +22,8 @@ public ChipsAdminController(RepoServerContext db) _db = db; } - [HttpPut("{chipId}")] - public async Task UpsertChip( - [FromRoute] string chipId, + [HttpPost] + public async Task CreateChip( [FromBody] CreateFirmwareChipRequest request, CancellationToken ct) { @@ -41,19 +39,48 @@ public async Task UpsertChip( var chip = new FirmwareChip { - Id = chipId, + Id = Guid.NewGuid(), Name = request.Name, Architecture = architecture }; - var executed = await _db.FirmwareChips.Upsert(chip).On(c => c.Id).RunAsync(ct); - if (executed <= 0) throw new Exception("Failed to upsert firmware chip"); + _db.FirmwareChips.Add(chip); + await _db.SaveChangesAsync(ct); - return Created(); + return Created((string?)null, new { id = chip.Id }); } - [HttpDelete("{chipId}")] - public async Task DeleteChip([FromRoute] string chipId, CancellationToken ct) + [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)) { @@ -69,9 +96,9 @@ public async Task DeleteChip([FromRoute] string chipId, Cancellat return NoContent(); } - [HttpPut("{chipId}/usb-devices/{usbDeviceId:guid}")] + [HttpPut("{chipId:guid}/usb-devices/{usbDeviceId:guid}")] public async Task AttachUsbDevice( - [FromRoute] string chipId, + [FromRoute] Guid chipId, [FromRoute] Guid usbDeviceId, CancellationToken ct) { @@ -100,9 +127,9 @@ public async Task AttachUsbDevice( return NoContent(); } - [HttpDelete("{chipId}/usb-devices/{usbDeviceId:guid}")] + [HttpDelete("{chipId:guid}/usb-devices/{usbDeviceId:guid}")] public async Task DetachUsbDevice( - [FromRoute] string chipId, + [FromRoute] Guid chipId, [FromRoute] Guid usbDeviceId, CancellationToken ct) { diff --git a/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs index 21fbd0b..aa6cc54 100644 --- a/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs @@ -22,7 +22,7 @@ public BoardsController(RepoServerContext db) [HttpGet] [CacheControl(300)] public async Task ListBoards( - [FromQuery] string? chipId, + [FromQuery] Guid? chipId, [FromQuery] bool includeDiscontinued = true, CancellationToken ct = default) { @@ -30,9 +30,9 @@ public async Task ListBoards( .Include(b => b.ChipNavigation) .Include(b => b.UsbDevices); - if (!string.IsNullOrWhiteSpace(chipId)) + if (chipId is { } cid) { - query = query.Where(b => b.ChipId == chipId); + query = query.Where(b => b.ChipId == cid); } if (!includeDiscontinued) @@ -40,7 +40,7 @@ public async Task ListBoards( query = query.Where(b => !b.Discontinued); } - var rows = await query.OrderBy(b => b.Id).ToListAsync(ct); + var rows = await query.OrderBy(b => b.Name).ToListAsync(ct); var boards = rows .Select(b => new FirmwareBoardDto diff --git a/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs index 932ddd8..285f939 100644 --- a/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs @@ -25,7 +25,7 @@ public async Task ListChips(CancellationToken ct) { var rows = await _db.FirmwareChips .Include(c => c.UsbDevices) - .OrderBy(c => c.Id) + .OrderBy(c => c.Name) .ToListAsync(ct); var chips = rows diff --git a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs index efe8948..3b3f165 100644 --- a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs @@ -56,11 +56,11 @@ public async Task GetLatest([FromRoute] string channel, Cancellat return Ok(FirmwareResponseMapper.ToReleaseDto(latest, boards, cdnBase)); } - [HttpGet("{channel}/{boardId}")] + [HttpGet("{channel}/{boardId:guid}")] [CacheControl(300)] public async Task GetLatestForBoard( [FromRoute] string channel, - [FromRoute] string boardId, + [FromRoute] Guid boardId, [FromQuery] string? version, CancellationToken ct) { diff --git a/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs index e9c4f3b..2481967 100644 --- a/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs @@ -1,7 +1,6 @@ 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.RepoServerDb; @@ -22,12 +21,10 @@ public sealed class ManifestController : OpenShockControllerBase ]; private readonly RepoServerContext _db; - private readonly ApiConfig _apiConfig; - public ManifestController(RepoServerContext db, ApiConfig apiConfig) + public ManifestController(RepoServerContext db) { _db = db; - _apiConfig = apiConfig; } [HttpGet] @@ -54,12 +51,12 @@ public async Task GetManifest(CancellationToken ct) var boardRows = await _db.FirmwareBoards .Include(b => b.ChipNavigation) .Include(b => b.UsbDevices) - .OrderBy(b => b.Id) + .OrderBy(b => b.Name) .ToListAsync(ct); var chipRows = await _db.FirmwareChips .Include(c => c.UsbDevices) - .OrderBy(c => c.Id) + .OrderBy(c => c.Name) .ToListAsync(ct); var filterRows = await _db.UsbSerialFilters @@ -104,16 +101,18 @@ public async Task GetManifest(CancellationToken ct) }) .ToList(); - var advisories = _apiConfig.Firmware.Advisories + var advisories = await _db.FirmwareAdvisories + .OrderBy(a => a.Severity) + .ThenBy(a => a.Title) .Select(a => new FirmwareAdvisoryDto { - Severity = a.Severity, + Severity = a.Severity.ToString().ToLowerInvariant(), Title = a.Title, Content = a.Content, AffectedVersions = a.AffectedVersions, Url = a.Url }) - .ToList(); + .ToListAsync(ct); var response = new FirmwareManifestResponse { diff --git a/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs index 574842d..8afff60 100644 --- a/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs @@ -166,12 +166,12 @@ public async Task InitRelease( // ---- Upload Board Artifacts ---- - [HttpPut("{releaseId:guid}/boards/{boardId}")] + [HttpPut("{releaseId:guid}/boards/{boardId:guid}")] [Consumes("multipart/form-data")] [RequestSizeLimit(64 * 1024 * 1024)] public async Task UploadBoardArtifacts( [FromRoute] Guid releaseId, - [FromRoute] string boardId, + [FromRoute] Guid boardId, CancellationToken ct) { var release = await _db.FirmwareReleases.FirstOrDefaultAsync(r => r.Id == releaseId, ct); diff --git a/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs index 8543418..781975e 100644 --- a/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs @@ -106,11 +106,11 @@ public async Task GetVersion([FromRoute] string firmwareVersion, return Ok(FirmwareResponseMapper.ToReleaseDto(version, boards, cdnBase)); } - [HttpGet("{firmwareVersion}/{boardId}")] + [HttpGet("{firmwareVersion}/{boardId:guid}")] [CacheControl(86400, immutable: true)] public async Task GetVersionForBoard( [FromRoute] string firmwareVersion, - [FromRoute] string boardId, + [FromRoute] Guid boardId, CancellationToken ct) { var exists = await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion, ct); 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/Models/Firmware/CreateFirmwareBoardRequest.cs b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs index c22314c..8cbb1e8 100644 --- a/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs +++ b/RepositoryServer/Models/Firmware/CreateFirmwareBoardRequest.cs @@ -1,8 +1,15 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.RepositoryServer.Models.Firmware; public sealed class CreateFirmwareBoardRequest { + [Required(AllowEmptyStrings = false)] + [MaxLength(128)] public required string Name { get; init; } - public required string ChipId { 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 index 299b08a..c62059b 100644 --- a/RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs +++ b/RepositoryServer/Models/Firmware/CreateFirmwareChipRequest.cs @@ -1,7 +1,13 @@ -namespace OpenShock.RepositoryServer.Models.Firmware; +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 index fde567b..c29ab8a 100644 --- a/RepositoryServer/Models/Firmware/FirmwareAdvisoryDto.cs +++ b/RepositoryServer/Models/Firmware/FirmwareAdvisoryDto.cs @@ -1,7 +1,8 @@ namespace OpenShock.RepositoryServer.Models.Firmware; /// -/// Security / compatibility advisory exposed on the manifest endpoint. Served from config. +/// Security / compatibility advisory exposed on the manifest endpoint. Served from the +/// firmware_advisories table; managed via /v2/firmware/admin/advisories. /// public sealed record FirmwareAdvisoryDto { diff --git a/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs index a05ed8f..9ffc4e2 100644 --- a/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs +++ b/RepositoryServer/Models/Firmware/FirmwareBoardDto.cs @@ -7,9 +7,9 @@ namespace OpenShock.RepositoryServer.Models.Firmware; /// public sealed record FirmwareBoardDto { - public required string Id { get; init; } + public required Guid Id { get; init; } public required string Name { get; init; } - public required string ChipId { 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 index 58561e5..c091b9c 100644 --- a/RepositoryServer/Models/Firmware/FirmwareBoardReleaseResponseDto.cs +++ b/RepositoryServer/Models/Firmware/FirmwareBoardReleaseResponseDto.cs @@ -7,6 +7,6 @@ namespace OpenShock.RepositoryServer.Models.Firmware; public sealed record FirmwareBoardReleaseResponseDto { public required string Version { get; init; } - public required string BoardId { 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 index 028375c..fe5110b 100644 --- a/RepositoryServer/Models/Firmware/FirmwareChipDto.cs +++ b/RepositoryServer/Models/Firmware/FirmwareChipDto.cs @@ -7,7 +7,7 @@ namespace OpenShock.RepositoryServer.Models.Firmware; /// public sealed record FirmwareChipDto { - public required string Id { get; init; } + 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 index 20750e4..9af446a 100644 --- a/RepositoryServer/Models/Firmware/FirmwareChipRefDto.cs +++ b/RepositoryServer/Models/Firmware/FirmwareChipRefDto.cs @@ -6,6 +6,6 @@ namespace OpenShock.RepositoryServer.Models.Firmware; /// public sealed record FirmwareChipRefDto { - public required string Id { get; init; } + public required Guid Id { get; init; } public required string Name { get; init; } } diff --git a/RepositoryServer/Models/Firmware/FirmwareReleaseDto.cs b/RepositoryServer/Models/Firmware/FirmwareReleaseDto.cs index 5dc7761..02513d9 100644 --- a/RepositoryServer/Models/Firmware/FirmwareReleaseDto.cs +++ b/RepositoryServer/Models/Firmware/FirmwareReleaseDto.cs @@ -11,5 +11,5 @@ public sealed record FirmwareReleaseDto public required DateTimeOffset ReleaseDate { get; init; } public required FirmwareSourceDto Source { get; init; } public required List ReleaseNotes { get; init; } - public required Dictionary Boards { get; init; } + public required Dictionary Boards { get; init; } } diff --git a/RepositoryServer/Models/Firmware/InitReleaseRequest.cs b/RepositoryServer/Models/Firmware/InitReleaseRequest.cs index 631f949..e305a08 100644 --- a/RepositoryServer/Models/Firmware/InitReleaseRequest.cs +++ b/RepositoryServer/Models/Firmware/InitReleaseRequest.cs @@ -15,7 +15,7 @@ public sealed class InitReleaseRequest [Required] [MinLength(1)] - public required List Boards { get; init; } + public required List Boards { get; init; } /// /// Markdown changelog per firmware-api-spec.md §5.3. The server parses this into 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/Problems/FirmwareError.cs b/RepositoryServer/Problems/FirmwareError.cs index b5e227a..b39af75 100644 --- a/RepositoryServer/Problems/FirmwareError.cs +++ b/RepositoryServer/Problems/FirmwareError.cs @@ -13,7 +13,7 @@ public static class FirmwareError public static OpenShockProblem FirmwareInvalidArchitecture => new("Firmware.InvalidArchitecture", "The architecture provided is not valid"); public static OpenShockProblem FirmwareBoardInUse => new("Firmware.BoardInUse", "Cannot delete board that has associated artifacts", HttpStatusCode.Conflict); public static OpenShockProblem FirmwareChipInUse => new("Firmware.ChipInUse", "Cannot delete chip that has associated boards", HttpStatusCode.Conflict); - public static OpenShockProblem FirmwareMissingRequiredArtifacts(string boardId, IEnumerable missing) => + 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); @@ -23,7 +23,7 @@ public static OpenShockProblem FirmwareMissingRequiredArtifacts(string boardId, 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) => + 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) => @@ -41,4 +41,7 @@ public static OpenShockProblem FirmwareManifestKeysMismatch(string detail) => 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/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 index 6066eb9..c918df5 100644 --- a/RepositoryServer/RepoServerDb/FirmwareArtifact.cs +++ b/RepositoryServer/RepoServerDb/FirmwareArtifact.cs @@ -1,11 +1,11 @@ -using OpenShock.RepositoryServer.Enums; +using OpenShock.RepositoryServer.Enums; namespace OpenShock.RepositoryServer.RepoServerDb; public class FirmwareArtifact { public string Version { get; set; } = null!; - public string BoardId { get; set; } = null!; + public Guid BoardId { get; set; } public FirmwareArtifactType ArtifactType { get; set; } public byte[] HashSha256 { get; set; } = null!; public long FileSize { get; set; } diff --git a/RepositoryServer/RepoServerDb/FirmwareBoard.cs b/RepositoryServer/RepoServerDb/FirmwareBoard.cs index 3e89291..f0aa3ed 100644 --- a/RepositoryServer/RepoServerDb/FirmwareBoard.cs +++ b/RepositoryServer/RepoServerDb/FirmwareBoard.cs @@ -4,9 +4,14 @@ namespace OpenShock.RepositoryServer.RepoServerDb; public class FirmwareBoard { - public string Id { get; set; } = null!; - public string ChipId { get; set; } = null!; + 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; } = []; diff --git a/RepositoryServer/RepoServerDb/FirmwareBoardUsbDevice.cs b/RepositoryServer/RepoServerDb/FirmwareBoardUsbDevice.cs index 2c42eff..19c5ed6 100644 --- a/RepositoryServer/RepoServerDb/FirmwareBoardUsbDevice.cs +++ b/RepositoryServer/RepoServerDb/FirmwareBoardUsbDevice.cs @@ -6,7 +6,7 @@ namespace OpenShock.RepositoryServer.RepoServerDb; /// public class FirmwareBoardUsbDevice { - public string BoardId { get; set; } = null!; + public Guid BoardId { get; set; } public Guid UsbDeviceId { get; set; } public virtual FirmwareBoard Board { get; set; } = null!; diff --git a/RepositoryServer/RepoServerDb/FirmwareChip.cs b/RepositoryServer/RepoServerDb/FirmwareChip.cs index 484e38e..43bfef2 100644 --- a/RepositoryServer/RepoServerDb/FirmwareChip.cs +++ b/RepositoryServer/RepoServerDb/FirmwareChip.cs @@ -4,8 +4,14 @@ namespace OpenShock.RepositoryServer.RepoServerDb; public class FirmwareChip { - public string Id { get; set; } = null!; + 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(); diff --git a/RepositoryServer/RepoServerDb/FirmwareChipUsbDevice.cs b/RepositoryServer/RepoServerDb/FirmwareChipUsbDevice.cs index 79d07b5..60e5657 100644 --- a/RepositoryServer/RepoServerDb/FirmwareChipUsbDevice.cs +++ b/RepositoryServer/RepoServerDb/FirmwareChipUsbDevice.cs @@ -6,7 +6,7 @@ namespace OpenShock.RepositoryServer.RepoServerDb; /// public class FirmwareChipUsbDevice { - public string ChipId { get; set; } = null!; + public Guid ChipId { get; set; } public Guid UsbDeviceId { get; set; } public virtual FirmwareChip Chip { get; set; } = null!; diff --git a/RepositoryServer/RepoServerDb/FirmwareRelease.cs b/RepositoryServer/RepoServerDb/FirmwareRelease.cs index fceb51a..cbd4700 100644 --- a/RepositoryServer/RepoServerDb/FirmwareRelease.cs +++ b/RepositoryServer/RepoServerDb/FirmwareRelease.cs @@ -13,7 +13,7 @@ public class FirmwareRelease public string? RunId { get; set; } public DateTimeOffset ReleaseDate { get; set; } public ReleaseStatus Status { get; set; } - public string[] DeclaredBoards { get; set; } = []; + public Guid[] DeclaredBoards { get; set; } = []; public DateTimeOffset CreatedAt { get; set; } public virtual SourceRepository RepositoryNavigation { get; set; } = null!; diff --git a/RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs b/RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs index 6232a03..6dffa70 100644 --- a/RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs +++ b/RepositoryServer/RepoServerDb/FirmwareStagedArtifact.cs @@ -5,7 +5,7 @@ namespace OpenShock.RepositoryServer.RepoServerDb; public class FirmwareStagedArtifact { public Guid ReleaseId { get; set; } - public string BoardId { get; set; } = null!; + public Guid BoardId { get; set; } public FirmwareArtifactType ArtifactType { get; set; } public byte[] HashSha256 { get; set; } = null!; public long FileSize { get; set; } diff --git a/RepositoryServer/RepoServerDb/RepoServerContext.cs b/RepositoryServer/RepoServerDb/RepoServerContext.cs index 139f6e0..9a74959 100644 --- a/RepositoryServer/RepoServerDb/RepoServerContext.cs +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -68,6 +68,7 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); }); optionsBuilder.UseExceptionProcessor(); @@ -106,6 +107,9 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde 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) { // PostgreSQL enum mappings @@ -115,7 +119,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasPostgresEnum("firmware_release_note_type", ["warning", "info", "breaking", "section"]) .HasPostgresEnum("firmware_chip_architecture", ["xtensa", "risc_v"]) .HasPostgresEnum("release_status", ["staging", "editing", "published", "archived", "aborted"]) - .HasPostgresEnum("repository_provider", ["github"]); + .HasPostgresEnum("repository_provider", ["github"]) + .HasPostgresEnum("advisory_severity", ["critical", "warning", "info"]); // Shared source-code repository registry modelBuilder.Entity(entity => @@ -189,14 +194,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.Id).HasName("firmware_chips_pkey"); entity.ToTable("firmware_chips"); - entity.Property(e => e.Id) - .HasMaxLength(32) - .HasColumnName("id"); + 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 => @@ -204,12 +211,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.Id).HasName("firmware_boards_pkey"); entity.ToTable("firmware_boards"); - entity.Property(e => e.Id) - .HasMaxLength(64) - .HasColumnName("id"); - entity.Property(e => e.ChipId) - .HasMaxLength(32) - .HasColumnName("chip_id"); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.ChipId).HasColumnName("chip_id"); entity.Property(e => e.Name) .HasMaxLength(128) .HasColumnName("name"); @@ -223,6 +226,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .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 => @@ -266,9 +273,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Version) .HasMaxLength(64) .HasColumnName("version"); - entity.Property(e => e.BoardId) - .HasMaxLength(64) - .HasColumnName("board_id"); + entity.Property(e => e.BoardId).HasColumnName("board_id"); entity.Property(e => e.ArtifactType) .HasColumnName("artifact_type"); entity.Property(e => e.HashSha256) @@ -360,9 +365,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.ReleaseId) .HasColumnName("release_id"); - entity.Property(e => e.BoardId) - .HasMaxLength(64) - .HasColumnName("board_id"); + entity.Property(e => e.BoardId).HasColumnName("board_id"); entity.Property(e => e.ArtifactType) .HasColumnName("artifact_type"); entity.Property(e => e.HashSha256) @@ -450,10 +453,24 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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).HasMaxLength(32).HasColumnName("chip_id"); + 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) @@ -474,7 +491,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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).HasMaxLength(64).HasColumnName("board_id"); + joinEntity.Property(e => e.BoardId).HasColumnName("board_id"); joinEntity.Property(e => e.UsbDeviceId).HasColumnName("usb_device_id"); }); } diff --git a/RepositoryServer/Utils/FirmwareResponseMapper.cs b/RepositoryServer/Utils/FirmwareResponseMapper.cs index f5fba89..e06d0c1 100644 --- a/RepositoryServer/Utils/FirmwareResponseMapper.cs +++ b/RepositoryServer/Utils/FirmwareResponseMapper.cs @@ -21,7 +21,7 @@ public static FirmwareReleaseDto ToReleaseDto(FirmwareVersion version, IEnumerab { var boardLookup = boardsById.ToDictionary(b => b.Id); - var boardMap = new Dictionary(); + var boardMap = new Dictionary(); foreach (var artifact in version.Artifacts) { if (!boardLookup.TryGetValue(artifact.BoardId, out var board)) From e5348df03df4553c3803cc6ea5e35333e1ca0ca4 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 17 Apr 2026 20:17:21 +0200 Subject: [PATCH 10/15] Revert incidental desktop module/version touches Restores RepoServerDb/Module.cs, RepoServerDb/Version.cs and V1/CiCdController.cs to their pre-firmware-rewrite state. The firmware work should not be modifying desktop module code. --- RepositoryServer/Controllers/V1/CiCdController.cs | 1 - RepositoryServer/RepoServerDb/Module.cs | 5 ++++- RepositoryServer/RepoServerDb/Version.cs | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/RepositoryServer/Controllers/V1/CiCdController.cs b/RepositoryServer/Controllers/V1/CiCdController.cs index ab628d9..e7e01b1 100644 --- a/RepositoryServer/Controllers/V1/CiCdController.cs +++ b/RepositoryServer/Controllers/V1/CiCdController.cs @@ -1,7 +1,6 @@ using System.IO.Compression; using System.Security.Cryptography; using Asp.Versioning; -using FlexLabs.EntityFrameworkCore.Upsert; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; diff --git a/RepositoryServer/RepoServerDb/Module.cs b/RepositoryServer/RepoServerDb/Module.cs index be43c69..42dc48d 100644 --- a/RepositoryServer/RepoServerDb/Module.cs +++ b/RepositoryServer/RepoServerDb/Module.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + namespace OpenShock.RepositoryServer.RepoServerDb; public partial class Module @@ -8,7 +11,7 @@ public partial class Module public string Description { get; set; } = null!; - public Uri? SourceUrl { get; set; } + public Uri? SourceUrl { get; set; } = null!; public Uri? IconUrl { get; set; } diff --git a/RepositoryServer/RepoServerDb/Version.cs b/RepositoryServer/RepoServerDb/Version.cs index 26b3abf..74eabbf 100644 --- a/RepositoryServer/RepoServerDb/Version.cs +++ b/RepositoryServer/RepoServerDb/Version.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + namespace OpenShock.RepositoryServer.RepoServerDb; public partial class Version From afd8f6b01de01e148a4548a46c3d1b5d3fe37388 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 17 Apr 2026 22:16:00 +0200 Subject: [PATCH 11/15] tests n stuff --- Directory.Packages.props | 2 + .../Integration/Docker/InMemoryDatabase.cs | 40 +++ .../Integration/TestAdminToken.cs | 10 + .../Tests/AdvisoriesAdminControllerTests.cs | 198 ++++++++++++++ .../Tests/BoardsAdminControllerTests.cs | 224 ++++++++++++++++ .../Tests/ChipsAdminControllerTests.cs | 159 +++++++++++ .../Tests/LatestControllerTests.cs | 173 ++++++++++++ .../Tests/ManifestControllerTests.cs | 167 ++++++++++++ .../PublicBoardsAndChipsControllerTests.cs | 105 ++++++++ .../Tests/RepositoriesAdminControllerTests.cs | 133 +++++++++ .../Tests/UsbDevicesAdminControllerTests.cs | 126 +++++++++ .../UsbSerialFiltersAdminControllerTests.cs | 128 +++++++++ .../Tests/VersionsControllerTests.cs | 253 ++++++++++++++++++ .../Integration/WebApplicationFactory.cs | 145 ++++++++++ .../RepositoryServer.Tests.csproj | 2 + .../Utils/ChangelogParserTests.cs | 83 ++++++ .../Utils/FirmwareArtifactFileNamesTests.cs | 30 +++ .../V2/Firmware/ChipsController.cs | 2 +- .../V2/Firmware/ManifestController.cs | 2 +- RepositoryServer/Program.cs | 4 + .../Services/StagedReleaseCleanupService.cs | 36 ++- RepositoryServer/Utils/EnumNaming.cs | 19 ++ 22 files changed, 2025 insertions(+), 16 deletions(-) create mode 100644 RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs create mode 100644 RepositoryServer.Tests/Integration/TestAdminToken.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/BoardsAdminControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/ChipsAdminControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/UsbSerialFiltersAdminControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs create mode 100644 RepositoryServer.Tests/Integration/WebApplicationFactory.cs create mode 100644 RepositoryServer.Tests/Utils/FirmwareArtifactFileNamesTests.cs create mode 100644 RepositoryServer/Utils/EnumNaming.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 989bf0c..3753f65 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,5 +36,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..f05277e --- /dev/null +++ b/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs @@ -0,0 +1,40 @@ +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() + .WithImage("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..cdcdf03 --- /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!).HasCount(3); + + // Ordered by the PG enum ordinal: 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).HasCount(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..3ae5db1 --- /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).HasCount(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).HasCount(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).HasCount(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).HasCount(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).HasCount(1); + await Assert.That(Guid.Parse(devices[0].GetProperty("id").GetString()!)) + .IsEqualTo(usbDeviceId); + + var advisories = manifest.GetProperty("advisories").EnumerateArray().ToList(); + await Assert.That(advisories).HasCount(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..053236e --- /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!).HasCount(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!).HasCount(2); + await Assert.That(withoutDiscontinued!).HasCount(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!).HasCount(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..1d99a25 --- /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!).HasCount(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!).HasCount(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..6568486 --- /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!).HasCount(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..f0e7483 --- /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!).HasCount(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..2d96ca1 --- /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).HasCount(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..fe93262 --- /dev/null +++ b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs @@ -0,0 +1,145 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +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 +/// * calls to build the schema from the +/// current EF model — no real migrations are run because the project doesn't ship +/// firmware migrations yet +/// +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 db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + } + + /// + /// 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"); + + builder.ConfigureAppConfiguration((_, cfg) => + { + cfg.AddInMemoryCollection(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", + }); + }); + + 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 index f0303a4..7b4047a 100644 --- a/RepositoryServer.Tests/RepositoryServer.Tests.csproj +++ b/RepositoryServer.Tests/RepositoryServer.Tests.csproj @@ -13,6 +13,8 @@ + + diff --git a/RepositoryServer.Tests/Utils/ChangelogParserTests.cs b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs index 84df94e..a85895a 100644 --- a/RepositoryServer.Tests/Utils/ChangelogParserTests.cs +++ b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs @@ -106,6 +106,89 @@ public async Task Parse_PlainItem_NoTitleExtracted() 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).HasCount(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).HasCount(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).HasCount(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).HasCount(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).HasCount(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).HasCount(1); + await Assert.That(notes[0].Title).IsNull(); + await Assert.That(notes[0].Content).IsEqualTo("**** — still content"); + } + [Test] public async Task Parse_SpecExample_MatchesGoldenStructure() { 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/Controllers/V2/Firmware/ChipsController.cs b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs index 285f939..e0f271b 100644 --- a/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs @@ -33,7 +33,7 @@ public async Task ListChips(CancellationToken ct) { Id = c.Id, Name = c.Name, - Architecture = c.Architecture?.ToString().ToLowerInvariant(), + 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() diff --git a/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs index 2481967..86e997a 100644 --- a/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs @@ -94,7 +94,7 @@ public async Task GetManifest(CancellationToken ct) { Id = c.Id, Name = c.Name, - Architecture = c.Architecture?.ToString().ToLowerInvariant(), + 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() diff --git a/RepositoryServer/Program.cs b/RepositoryServer/Program.cs index 4a4e234..403c49d 100644 --- a/RepositoryServer/Program.cs +++ b/RepositoryServer/Program.cs @@ -249,3 +249,7 @@ app.MapScalarApiReference(); 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/RepositoryServer/Services/StagedReleaseCleanupService.cs b/RepositoryServer/Services/StagedReleaseCleanupService.cs index 289f61a..3126056 100644 --- a/RepositoryServer/Services/StagedReleaseCleanupService.cs +++ b/RepositoryServer/Services/StagedReleaseCleanupService.cs @@ -42,23 +42,31 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(TickInterval, _timeProvider); - // Fire once immediately, then on every tick. - await TickAsync(stoppingToken); + // 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)) { - try - { - await TickAsync(stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - return; - } - catch (Exception ex) - { - _logger.LogError(ex, "Staged-release cleanup tick failed"); - } + 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"); } } 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()); + } +} From 5a510cf46fdc57de4664fe4654db20f4a96b03fa Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 4 Aug 2026 13:14:05 +0200 Subject: [PATCH 12/15] Fix integration test harness and duplicate Npgsql enum registrations - Test fixture now injects config via UseSetting so values reach Program.cs before ApiConfig validation (ConfigureAppConfiguration overlays only apply at Build() in minimal hosting) - Make RepoServerContext.MapEnums the single source of enum mappings with pinned DB names; drop the duplicate HasPostgresEnum block and map enums in the migration-tool path too - Register only the pooled DbContext factory and derive scoped contexts from it; the AddDbContextPool + AddPooledDbContextFactory combo applied MapEnums twice and broke type mapping on first model use - Order advisories by CLR severity rank in memory since MapEnum creates PG enum labels alphabetically - Opt into the Microsoft.Testing.Platform dotnet test runner via global.json and update the documented test command --- CLAUDE.md | 4 +- .../Tests/AdvisoriesAdminControllerTests.cs | 2 +- .../Integration/WebApplicationFactory.cs | 44 +++++++++++-------- .../V2/Firmware/Admin/AdvisoriesController.cs | 10 +++-- .../V2/Firmware/ManifestController.cs | 7 ++- RepositoryServer/Program.cs | 11 ++--- .../RepoServerDb/RepoServerContext.cs | 42 +++++++++--------- global.json | 5 ++- 8 files changed, 70 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe95dc7..c9aea70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,8 @@ docker/ ## Commands - Build: `dotnet build RepositoryServer/RepositoryServer.csproj` -- Test: `dotnet test RepositoryServer.Tests/RepositoryServer.Tests.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/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs index cdcdf03..c736ea1 100644 --- a/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs +++ b/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs @@ -88,7 +88,7 @@ public async Task Get_ReturnsAllAdvisoriesOrderedBySeverity() await Assert.That(body).IsNotNull(); await Assert.That(body!).HasCount(3); - // Ordered by the PG enum ordinal: Critical (0), Warning (1), Info (2). + // 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"); diff --git a/RepositoryServer.Tests/Integration/WebApplicationFactory.cs b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs index fe93262..af70b13 100644 --- a/RepositoryServer.Tests/Integration/WebApplicationFactory.cs +++ b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs @@ -88,26 +88,32 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); - builder.ConfigureAppConfiguration((_, cfg) => + // 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 { - cfg.AddInMemoryCollection(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", - }); - }); + ["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 => { diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs index cb173aa..08d3c99 100644 --- a/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs @@ -48,12 +48,14 @@ public async Task CreateAdvisory([FromBody] UpsertFirmwareAdvisor [HttpGet] public async Task ListAdvisories(CancellationToken ct) { - var rows = await _db.FirmwareAdvisories + // 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) - .ToListAsync(ct); - - return Ok(rows.Select(ToDto)); + .Select(ToDto)); } [HttpPut("{id:guid}")] diff --git a/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs index 86e997a..7428243 100644 --- a/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs +++ b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs @@ -101,7 +101,10 @@ public async Task GetManifest(CancellationToken ct) }) .ToList(); - var advisories = await _db.FirmwareAdvisories + // 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 @@ -112,7 +115,7 @@ public async Task GetManifest(CancellationToken ct) AffectedVersions = a.AffectedVersions, Url = a.Url }) - .ToListAsync(ct); + .ToList(); var response = new FirmwareManifestResponse { diff --git a/RepositoryServer/Program.cs b/RepositoryServer/Program.cs index 403c49d..a89ed79 100644 --- a/RepositoryServer/Program.cs +++ b/RepositoryServer/Program.cs @@ -169,15 +169,16 @@ // <---- Postgres EF Core ----> -builder.Services.AddDbContextPool(dbBuilder => -{ - RepoServerContext.ConfigureOptionsBuilder(dbBuilder, config.Db.Conn, config.Db.Debug); -}); - +// 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 => { RepoServerContext.ConfigureOptionsBuilder(dbBuilder, config.Db.Conn, config.Db.Debug); }); +builder.Services.AddScoped(sp => + sp.GetRequiredService>().CreateDbContext()); diff --git a/RepositoryServer/RepoServerDb/RepoServerContext.cs b/RepositoryServer/RepoServerDb/RepoServerContext.cs index 9a74959..4686e49 100644 --- a/RepositoryServer/RepoServerDb/RepoServerContext.cs +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -30,8 +30,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (_migrationTool) { - // Migration tooling: plain connection string, no MapEnum (to avoid duplicate enum schemas) - optionsBuilder.UseNpgsql("Host=localhost;Database=repo-server;Username=openshock;Password=openshock"); + optionsBuilder.UseNpgsql("Host=localhost;Database=repo-server;Username=openshock;Password=openshock", MapEnums); optionsBuilder.UseExceptionProcessor(); optionsBuilder.EnableSensitiveDataLogging(); optionsBuilder.EnableDetailedErrors(); @@ -57,19 +56,28 @@ public RepoServerContext(DbContextOptions 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, npgsqlBuilder => - { - npgsqlBuilder.MapEnum(); - npgsqlBuilder.MapEnum(); - npgsqlBuilder.MapEnum(); - npgsqlBuilder.MapEnum(); - npgsqlBuilder.MapEnum(); - npgsqlBuilder.MapEnum(); - npgsqlBuilder.MapEnum(); - }); + optionsBuilder.UseNpgsql(connectionString, MapEnums); optionsBuilder.UseExceptionProcessor(); @@ -112,16 +120,6 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde protected override void OnModelCreating(ModelBuilder modelBuilder) { - // PostgreSQL enum mappings - modelBuilder - .HasPostgresEnum("release_channel", ["stable", "beta", "develop"]) - .HasPostgresEnum("firmware_artifact_type", ["merged", "app", "bootloader", "partitions", "static_fs"]) - .HasPostgresEnum("firmware_release_note_type", ["warning", "info", "breaking", "section"]) - .HasPostgresEnum("firmware_chip_architecture", ["xtensa", "risc_v"]) - .HasPostgresEnum("release_status", ["staging", "editing", "published", "archived", "aborted"]) - .HasPostgresEnum("repository_provider", ["github"]) - .HasPostgresEnum("advisory_severity", ["critical", "warning", "info"]); - // Shared source-code repository registry modelBuilder.Entity(entity => { diff --git a/global.json b/global.json index 9a523dc..909df9a 100644 --- a/global.json +++ b/global.json @@ -3,5 +3,8 @@ "version": "10.0.0", "rollForward": "latestMajor", "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} From 6c1dcf302559a347767c2e4c3f64a71d6e0f2212 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 4 Aug 2026 13:28:04 +0200 Subject: [PATCH 13/15] Add UnifiedRepositoryServer migration, validate it in tests, pin vulnerable transitives - Generate the firmware/USB/repositories schema migration (enums, 14 new tables, unique constraints); desktop tables intentionally untouched - Declare the usb_serial_filters unique (vid, pid) NULLS NOT DISTINCT index fluently via AreNullsDistinct(false) instead of the never-written raw-SQL migration the model comment referenced - Test fixture now applies real migrations through MigrationOpenShockContext instead of EnsureCreated, so the suite validates the migration chain - Enable CPM transitive pinning and pin Microsoft.OpenApi 2.7.5 (GHSA-v5pm-xwqc-g5wc) and OpenTelemetry.Api 1.15.3 (GHSA-g94r-2vxg-569j) --- Directory.Packages.props | 5 + .../Integration/WebApplicationFactory.cs | 12 +- ...112228_UnifiedRepositoryServer.Designer.cs | 770 ++++++++++++++++++ .../20260804112228_UnifiedRepositoryServer.cs | 436 ++++++++++ .../MigrationOpenShockContextModelSnapshot.cs | 661 ++++++++++++++- .../RepoServerDb/RepoServerContext.cs | 8 +- 6 files changed, 1879 insertions(+), 13 deletions(-) create mode 100644 RepositoryServer/Migrations/20260804112228_UnifiedRepositoryServer.Designer.cs create mode 100644 RepositoryServer/Migrations/20260804112228_UnifiedRepositoryServer.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 3753f65..f6056f1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,6 +2,8 @@ true + + true net10.0 enable enable @@ -23,6 +25,9 @@ + + + diff --git a/RepositoryServer.Tests/Integration/WebApplicationFactory.cs b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs index af70b13..9f4df6e 100644 --- a/RepositoryServer.Tests/Integration/WebApplicationFactory.cs +++ b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs @@ -4,6 +4,7 @@ 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; @@ -18,9 +19,8 @@ namespace OpenShock.RepositoryServer.Tests.Integration; /// (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 -/// * calls to build the schema from the -/// current EF model — no real migrations are run because the project doesn't ship -/// firmware migrations yet +/// * 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 @@ -36,8 +36,10 @@ public async Task InitializeAsync() _ = Server; // force the host to build await using var scope = Services.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.EnsureCreatedAsync(); + var loggerFactory = scope.ServiceProvider.GetRequiredService(); + await using var migrationContext = new MigrationOpenShockContext( + PostgreSql.Container.GetConnectionString(), debug: false, loggerFactory); + await migrationContext.Database.MigrateAsync(); } /// 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 index ac9f8f1..27553eb 100644 --- a/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs +++ b/RepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs @@ -1,8 +1,10 @@ // +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 @@ -16,12 +18,397 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.3") + .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.Desktop.RepositoryServer.RepoServerDb.Module", b => + 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) @@ -55,7 +442,103 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("modules", (string)null); }); - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b => + 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) @@ -96,9 +579,144 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("versions", (string)null); }); - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b => + modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.FirmwareArtifact", b => { - b.HasOne("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", "ModuleNavigation") + 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) @@ -108,10 +726,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ModuleNavigation"); }); - modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", b => + 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/RepoServerDb/RepoServerContext.cs b/RepositoryServer/RepoServerDb/RepoServerContext.cs index 4686e49..d1f0ddd 100644 --- a/RepositoryServer/RepoServerDb/RepoServerContext.cs +++ b/RepositoryServer/RepoServerDb/RepoServerContext.cs @@ -427,8 +427,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Pid).HasColumnName("pid"); entity.Property(e => e.Description).HasMaxLength(256).HasColumnName("description"); - // NOTE: unique (vid, pid) with NULLS NOT DISTINCT — declared via raw SQL in the - // FirmwareV2Redesign migration (EF Core has no fluent shortcut). + // 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 From 290a6f2346de4576db258a084dc1476713b0d298 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 4 Aug 2026 13:59:07 +0200 Subject: [PATCH 14/15] Fix obsolete-API warnings in test project - Replace deprecated HasCount(n) assertions with Count().IsEqualTo(n) - Pass the Postgres image via the PostgreSqlBuilder constructor instead of the obsolete parameterless constructor + WithImage --- .../Integration/Docker/InMemoryDatabase.cs | 3 +-- .../Tests/AdvisoriesAdminControllerTests.cs | 4 ++-- .../Tests/ManifestControllerTests.cs | 12 +++++----- .../PublicBoardsAndChipsControllerTests.cs | 8 +++---- .../Tests/RepositoriesAdminControllerTests.cs | 4 ++-- .../Tests/UsbDevicesAdminControllerTests.cs | 2 +- .../UsbSerialFiltersAdminControllerTests.cs | 2 +- .../Tests/VersionsControllerTests.cs | 2 +- .../Utils/ChangelogParserTests.cs | 24 +++++++++---------- 9 files changed, 30 insertions(+), 31 deletions(-) diff --git a/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs b/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs index f05277e..e696f6e 100644 --- a/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs +++ b/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs @@ -16,8 +16,7 @@ public PostgreSqlContainer Container { get { - _container ??= new PostgreSqlBuilder() - .WithImage("postgres:17-alpine") + _container ??= new PostgreSqlBuilder("postgres:17-alpine") .WithName($"repo-server-tests-pg-{Guid.NewGuid():N}") .WithDatabase("repo_server_tests") .WithUsername("repo_server") diff --git a/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs index c736ea1..1937c43 100644 --- a/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs +++ b/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs @@ -86,7 +86,7 @@ public async Task Get_ReturnsAllAdvisoriesOrderedBySeverity() var body = await response.Content.ReadFromJsonAsync>(); await Assert.That(body).IsNotNull(); - await Assert.That(body!).HasCount(3); + 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"); @@ -175,7 +175,7 @@ public async Task Post_Advisory_AppearsOnPublicManifest() var manifest = await publicClient.GetFromJsonAsync("/v2/firmware/manifest"); var advisories = manifest.GetProperty("advisories").EnumerateArray().ToList(); - await Assert.That(advisories).HasCount(1); + 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(); diff --git a/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs index 3ae5db1..16f27af 100644 --- a/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs +++ b/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs @@ -117,7 +117,7 @@ public async Task GetManifest_WithSeededData_ReturnsFullShape() var manifest = await response.Content.ReadFromJsonAsync(); var boards = manifest.GetProperty("boards").EnumerateArray().ToList(); - await Assert.That(boards).HasCount(1); + 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"); @@ -125,30 +125,30 @@ await Assert.That(Guid.Parse(boards[0].GetProperty("id").GetString()!)) await Assert.That(boards[0].GetProperty("discontinued").GetBoolean()).IsFalse(); var boardUsb = boards[0].GetProperty("usbDevices").EnumerateArray().ToList(); - await Assert.That(boardUsb).HasCount(1); + 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).HasCount(1); + 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).HasCount(1); + 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).HasCount(1); + 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).HasCount(1); + 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"); } diff --git a/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs index 053236e..ecbb092 100644 --- a/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs +++ b/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs @@ -44,7 +44,7 @@ public async Task ListBoards_FilterByChipId_ReturnsOnlyMatching() var boardsForChipA = await client.GetFromJsonAsync>( $"/v2/firmware/boards?chipId={chipAId}"); await Assert.That(boardsForChipA).IsNotNull(); - await Assert.That(boardsForChipA!).HasCount(1); + await Assert.That(boardsForChipA!).Count().IsEqualTo(1); await Assert.That(boardsForChipA[0].ChipId).IsEqualTo(chipAId); } @@ -75,8 +75,8 @@ public async Task ListBoards_ExcludeDiscontinued_HidesEOLBoards() var withoutDiscontinued = await client.GetFromJsonAsync>( "/v2/firmware/boards?includeDiscontinued=false"); - await Assert.That(withDiscontinued!).HasCount(2); - await Assert.That(withoutDiscontinued!).HasCount(1); + await Assert.That(withDiscontinued!).Count().IsEqualTo(2); + await Assert.That(withoutDiscontinued!).Count().IsEqualTo(1); await Assert.That(withoutDiscontinued![0].Name).IsEqualTo("Active"); } @@ -98,7 +98,7 @@ public async Task ListChips_ReturnsArchitectureLowercase() using var client = Factory.CreateClient(); var chips = await client.GetFromJsonAsync>("/v2/firmware/chips"); await Assert.That(chips).IsNotNull(); - await Assert.That(chips!).HasCount(1); + 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 index 1d99a25..4a159e4 100644 --- a/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs +++ b/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs @@ -24,7 +24,7 @@ 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!).HasCount(0); + await Assert.That(repos!).Count().IsEqualTo(0); } [Test] @@ -49,7 +49,7 @@ public async Task Get_WithSeededData_ReturnsRepositories() using var client = Factory.CreateAdminClient(); var repos = await client.GetFromJsonAsync>(BasePath); await Assert.That(repos).IsNotNull(); - await Assert.That(repos!).HasCount(1); + 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"); diff --git a/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs index 6568486..4fca5cd 100644 --- a/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs +++ b/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs @@ -118,7 +118,7 @@ public async Task Get_ListsDevicesOrderedByVidPid() var devices = await client.GetFromJsonAsync>(BasePath); await Assert.That(devices).IsNotNull(); - await Assert.That(devices!).HasCount(3); + 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 index f0e7483..f9cb83d 100644 --- a/RepositoryServer.Tests/Integration/Tests/UsbSerialFiltersAdminControllerTests.cs +++ b/RepositoryServer.Tests/Integration/Tests/UsbSerialFiltersAdminControllerTests.cs @@ -84,7 +84,7 @@ public async Task Get_ReturnsAllFilters() var body = await client.GetFromJsonAsync>(BasePath); await Assert.That(body).IsNotNull(); - await Assert.That(body!).HasCount(3); + await Assert.That(body!).Count().IsEqualTo(3); } [Test] diff --git a/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs index 2d96ca1..89d03d1 100644 --- a/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs +++ b/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs @@ -59,7 +59,7 @@ await SeedVersionsAsync( 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).HasCount(1); + await Assert.That(versions).Count().IsEqualTo(1); await Assert.That(versions[0]).IsEqualTo("1.5.0"); } diff --git a/RepositoryServer.Tests/Utils/ChangelogParserTests.cs b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs index a85895a..5ec8832 100644 --- a/RepositoryServer.Tests/Utils/ChangelogParserTests.cs +++ b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs @@ -44,7 +44,7 @@ public async Task Parse_MapsBreakingWarningInfo_ToLowercaseEnumNames() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(3); + 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"); @@ -58,7 +58,7 @@ public async Task Parse_CustomHeading_BecomesSectionWithTitle() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(2); + 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"); @@ -74,7 +74,7 @@ public async Task Parse_ExtractsBoldTitlePrefix_WithEmDashSeparator() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(1); + 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"); @@ -88,7 +88,7 @@ public async Task Parse_MultiLineItemWithoutBullets_IsCombined() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(1); + await Assert.That(notes).Count().IsEqualTo(1); await Assert.That(notes[0].Content).IsEqualTo("Line one\nLine two"); } @@ -100,7 +100,7 @@ public async Task Parse_PlainItem_NoTitleExtracted() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(1); + 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"); @@ -116,7 +116,7 @@ public async Task Parse_SupportsMultipleTitleSeparators(string input, string exp var result = ChangelogParser.Parse(input); await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(1); + await Assert.That(notes).Count().IsEqualTo(1); await Assert.That(notes[0].Title).IsEqualTo(expectedTitle); await Assert.That(notes[0].Content).IsEqualTo(expectedContent); } @@ -129,7 +129,7 @@ public async Task Parse_IgnoresLinesBeforeFirstHeading() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(1); + await Assert.That(notes).Count().IsEqualTo(1); await Assert.That(notes[0].Content).IsEqualTo("Actual content"); } @@ -141,7 +141,7 @@ public async Task Parse_HandlesCrlfLineEndings() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(2); + 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"); @@ -156,7 +156,7 @@ public async Task Parse_HeadingsAreCaseInsensitive() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(3); + 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"); @@ -171,7 +171,7 @@ public async Task Parse_BulletsIgnoreNonBulletBodyInSameSection() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(2); + 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"); } @@ -184,7 +184,7 @@ public async Task Parse_EmptyBoldTitle_FallsBackToLiteralContent() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(1); + await Assert.That(notes).Count().IsEqualTo(1); await Assert.That(notes[0].Title).IsNull(); await Assert.That(notes[0].Content).IsEqualTo("**** — still content"); } @@ -211,7 +211,7 @@ public async Task Parse_SpecExample_MatchesGoldenStructure() await Assert.That(result.IsT0).IsTrue(); var notes = result.AsT0; - await Assert.That(notes).HasCount(7); + await Assert.That(notes).Count().IsEqualTo(7); await Assert.That(notes[0].Type).IsEqualTo("breaking"); await Assert.That(notes[0].Title).IsEqualTo("Config format"); From a1c038ec1ac0943dd427e38a1599a38d523e7fe0 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 4 Aug 2026 14:08:52 +0200 Subject: [PATCH 15/15] Add models for the release-tool release.json manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C# mirrors of the ReleaseData/ChangeEntry/NoticeEntry/ReleaseNoteEntry/ Repository structs emitted by the OpenShock release-tool (schema_version 1). Not consumed yet — ingestion wiring lands separately once the integration point is decided. --- .../Models/Release/ChangeEntry.cs | 34 +++++++++++++ .../Models/Release/NoticeEntry.cs | 16 ++++++ .../Models/Release/ReleaseData.cs | 51 +++++++++++++++++++ .../Models/Release/ReleaseNoteEntry.cs | 16 ++++++ RepositoryServer/Models/Release/Repository.cs | 19 +++++++ 5 files changed, 136 insertions(+) create mode 100644 RepositoryServer/Models/Release/ChangeEntry.cs create mode 100644 RepositoryServer/Models/Release/NoticeEntry.cs create mode 100644 RepositoryServer/Models/Release/ReleaseData.cs create mode 100644 RepositoryServer/Models/Release/ReleaseNoteEntry.cs create mode 100644 RepositoryServer/Models/Release/Repository.cs 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; } +}