Skip to content

Upload a backup file #353

Description

@DutchJaFO

Background

A backup that exists only inside the container is a restore point that cannot survive the container.
#349 adds download, which gets a copy out; this is the other half — bringing one back, after a volume
is recreated, a host is replaced, or every stored backup has been removed to clear the quota.

It is also the only path that recovers an installation whose stored backups are all gone or all
unreadable. Everything else in this cluster assumes a usable file is already in {dataDir}/backups/.

What needs to be done

  1. POST /api/v1/admin/backups/upload, multipart, in the Backup OpenAPI category behind the admin
    API key and the admin rate-limit policy. An explicit action route rather than POST /backups,
    which would otherwise have to mean both "accept these bytes" and "create one from the live
    database" — see Admin endpoints to list, delete and report status for database backups #349's create endpoint. UploadBackup / "Upload a backup" per the naming convention,
    with the WithName value held in a private const string shared with its logging tag.

  2. The bytes are validated as a SQLite database before the file is accepted — the SQLite format 3
    header magic and an actual open plus a read that proves it is usable, not the extension and not the
    magic alone. Storing a file that only reveals itself as unusable later, at restore time, is the exact
    failure mode this requirement exists to design out.

  3. An explicit maximum upload size, configurable, refused with a stated reason — never a truncated
    write, and never the framework's bare 413 with no detail about what the limit is.

  4. The quota is checked before anything is written. An upload consumes the same budget Reset returns an unhandled 500 when no backup can be taken, and the five backup failure causes are indistinguishable #348
    governs, so a full folder refuses in the same vocabulary (BudgetExceeded) with the same remedies,
    rather than pushing the folder past a limit every other path respects.

  5. The stored file name is sanitised server-side — never taken from the client as-is. No separators,
    no traversal segments, and the resolved path verified to sit inside BackupsPath.

  6. The stored name must be unique, and a collision is refused rather than resolved. An upload whose
    sanitised name already exists returns a 409 naming the existing file, and stores nothing. Silently
    auto-renaming to a free name was considered and rejected: it makes "unique" true by fiat and hands
    back a file under a name the operator did not choose, which is worse than being told.

  7. Uniqueness is decided case-insensitively. Backup.db and backup.db are one file on Windows and
    two on Linux; without this, whether an upload destroys an existing restore point would depend on the
    host filesystem. Consistent with CLAUDE.md's case-insensitive-by-default rule, applied here to a
    file name rather than a column.

  8. An existing backup is replaced only on explicit permission, via a parameter that is never a
    default. Overwriting destroys a restore point, so the caller states that intent per call and the
    audit entry records which file was replaced, not merely that an upload happened — the same
    "why is there no backup from that date" property Reset returns an unhandled 500 when no backup can be taken, and the five backup failure causes are indistinguishable #348 established for a skipped backup.

    This is deliberately not the pattern the endpoint side-effect policy forbids, and the distinction
    is stated here so it is not later "corrected" away: an opt-in flag is forbidden when it bolts a
    second, independent action onto the endpoint (the rejected restore=true below). This flag adds no
    second action — it authorises how this endpoint's own single job resolves a collision, exactly as
    allowNoBackup does for a Reset that cannot be backed up.

  9. The endpoint stores the file and does nothing else (developer decision, 2026-08-29). An
    optional restore flag was considered and rejected: a parameter that changes what data survives the
    call is the shape CLAUDE.md's endpoint side-effect policy names explicitly, and the recovery path
    does not need it — upload, then call restore (Restore a stored backup, refusing one taken ahead of this build #352). Two explicit actions, neither doing the other's
    job.

  10. Failed validation stores nothing. A rejected upload leaves the backups folder exactly as it was.

  11. An upload writes an audit entry.

  12. The endpoint answers while the database is degraded — recovering an installation with no usable
    backup is a primary use, and that installation is degraded by definition. Nothing here reads
    database content.

  13. docs/api-endpoints.md and the [Description] attributes are updated in the same commit.

Expected tests

Test class Test method Starts
AdminBackupUploadEndpointTests Upload_StoresTheFile_AndItAppearsInTheList ❌
AdminBackupUploadEndpointTests Upload_NotASqliteDatabase_IsRejectedAndStoresNothing ❌
AdminBackupUploadEndpointTests Upload_ValidHeaderButUnreadableContent_IsRejectedAndStoresNothing ❌
AdminBackupUploadEndpointTests Upload_AboveTheSizeLimit_IsRefusedWithAStatedLimit ❌
AdminBackupUploadEndpointTests Upload_WhenTheQuotaIsFull_RefusesWithBudgetExceeded ❌
AdminBackupUploadEndpointTests Upload_FileNameWithASeparator_IsSanitisedAndStaysInsideTheBackupsFolder ❌
AdminBackupUploadEndpointTests Upload_NameCollidingWithAnExistingBackup_IsRefusedAndStoresNothing ❌
AdminBackupUploadEndpointTests Upload_NameCollidingOnlyByCase_IsTreatedAsACollision ❌
AdminBackupUploadEndpointTests Upload_OverwriteIsNotTheDefault ❌
AdminBackupUploadEndpointTests Upload_WithOverwritePermitted_ReplacesTheExistingBackup ❌
AdminBackupUploadEndpointTests Upload_WithOverwritePermitted_AuditEntryNamesTheReplacedFile ❌
AdminBackupUploadEndpointTests Upload_DoesNotTouchTheLiveDatabase ❌
AdminBackupUploadEndpointTests Upload_WritesAnAuditEntry ❌
AdminBackupUploadEndpointTests Upload_WithoutApiKey_Returns401 ❌
AdminBackupUploadEndpointTests Upload_RemainsReachableWhileDegraded ❌

Upload_WithOverwritePermitted_ReplacesTheExistingBackup is the positive control for requirements 6–8:
a suite that only ever asserts refusals would stay green against a build that refused every upload.

Definition of done

  • All expected tests listed above start red before implementation
  • All requirements implemented
  • All expected tests pass (green)
  • No regression in related tests
  • Findings summarised in a closing comment

Scope boundary

This endpoint does not restore. Restoring an uploaded backup is a second, explicit call to the
restore endpoint (#352). The two have no build-order dependency in either direction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions