Media upload service — ASP.NET Core + Angular monolith, deployed on DigitalOcean App Platform via Docker, with files in Cloudflare R2 (S3-compatible, zero egress).
Design rationale lives in docs/ai-design-decisions.md; the human decisions that govern it are in docs/my-decisions.md.
- Own cookie-session auth (register / login / logout), revocable and sliding
- Any media type, gated by a per-user 5 GB quota with a live "space remaining" figure
- Presigned S3 multipart uploads: bytes go browser → R2 directly, resumable
- Private storage; downloads via short-TTL presigned GET
- Folder hierarchy — nested folders, move, rename; folders are metadata only, so a move costs zero object-storage calls
- Trash — delete is a soft delete, recoverable for 10 days, then purged by a background sweeper. Trashed bytes still count against quota until purged.
- Name collisions auto-suffix (
report.pdf→report (2).pdf) instead of failing - No scanning or post-processing yet (both deferred — see the docs)
Folders and trash are backend-only so far; the Angular UI for them is the next task. Contract + implementation prompt: docs/api-changes-frontend.md.
src/Api/ ASP.NET Core API (also serves the Angular SPA from wwwroot)
Domain/ User, MediaFile, Folder, MediaStatus
Data/ AppDbContext + Migrations
Storage/ IObjectStorage + R2/S3 implementation
Features/ Auth, Uploads, Media, Folders, Trash, Me controllers
Services/ Quota, Folder, Trash, NameAllocator, cleanup/purge sweepers
OpenApi/ Swagger document generation
src/ClientApp/ Angular SPA (to be generated — see its README)
tests/Api.Tests/ xUnit — name allocation and validation
Dockerfile Multi-stage: Angular -> .NET -> runtime
docker-compose.yml Local Postgres + MinIO
.do/app.yaml App Platform spec
# 1. Start Postgres + MinIO (S3 stand-in) and create the bucket
docker compose up -d
# 2. Run the API (applies EF migrations on startup)
dotnet run --project src/Api
# Dev config (appsettings.Development.json) points storage at MinIO on :9000.
# health check
curl http://localhost:5xxx/health # port shown in console outputMinIO console: http://localhost:9001 (minioadmin / minioadmin).
With the API running in Development, browse http://localhost:5080/swagger — the raw OpenAPI
document is at /openapi/v1.json. Both are Development-only.
To call a secured endpoint, just POST /api/auth/login once. The session is an HttpOnly cookie, so
the browser attaches it to every later call by itself — there is no token to copy or paste.
The document is generated by the first-party Microsoft.AspNetCore.OpenApi package and picks up
XML doc comments straight from the controllers — so a <summary> on an action is the API
documentation. Keep them accurate when changing behaviour.
Full contract, including request/response shapes: docs/api-changes-frontend.md.
| Method | Route | Purpose |
|---|---|---|
| POST | /api/auth/register · /api/auth/login |
start a session (sets the cookie) |
| POST | /api/auth/logout |
revoke the session and clear the cookie |
| GET | /api/auth/session |
who am I? 401 when signed out |
| GET | /api/me/usage |
quota / used / remaining / trashed bytes |
| POST | /api/uploads/init |
reserve quota + open multipart upload (takes folderId) |
| GET | /api/uploads/{id}/part-url?partNumber=N |
presigned PUT for a part |
| POST | /api/uploads/{id}/complete |
assemble parts, reconcile quota |
| POST | /api/uploads/{id}/abort |
cancel, release quota |
| GET | /api/folders/contents?folderId= |
browse a folder: breadcrumbs, subfolders, files |
| POST | /api/folders |
create a folder |
| PATCH | /api/folders/{id} · /api/media/{id} |
rename |
| POST | /api/folders/{id}/move · /api/media/{id}/move |
move (null = root) |
| GET | /api/media |
list your files (?scoped=true&folderId= to scope) |
| GET | /api/media/{id}/download-url |
presigned GET |
| DELETE | /api/media/{id} · /api/folders/{id} |
move to trash (recursive for folders) |
| GET | /api/trash |
what's in the trash, with purgesAt |
| POST | /api/trash/{id}/restore |
restore |
| DELETE | /api/trash/{id} · /api/trash |
purge one · empty trash |
Names may come back changed. A collision inside a folder is auto-suffixed rather than rejected (
report.pdf→report (2).pdf), on create, rename, move, and upload. Always use the name from the response.
- Push to a GitHub repo; set
github.repoin .do/app.yaml. - Set secrets (invite code, R2 credentials, DB connection) as App Platform SECRET env vars. Sessions need no secret — each one is an independent random token, stored hashed.
doctl apps create --spec .do/app.yaml(or connect the repo in the UI).- Set R2 bucket CORS using docs/r2-cors.json (replace
YOUR-APP-DOMAIN). It allowsPUT/GETfrom your origin and — critically — exposes theETagheader the multipart client must read. Without this, uploads fail. (Dev/MinIO CORS is already wired indocker-compose.yml.)
Releases are cut with release-please from the
Conventional-Commit history (feat: → minor, fix: → patch), and each release publishes a
container image to the GitHub Container Registry. The chain:
- Merge feature PRs to
mainas usual. release-please keeps an open "release" PR whose title is the next version and whose body is the accumulated changelog. - Merge that release PR to cut the release: it tags
vX.Y.Z, updatesCHANGELOG.md, and creates the GitHub Release. .github/workflows/release.ymlthen builds the rootDockerfileand pushes the image toghcr.io/imariel2d/keepr, taggedX.Y.Z,X.Y, andlatest.
Pull the image with:
docker pull ghcr.io/imariel2d/keepr:latestNotes:
- Auth uses the built-in
GITHUB_TOKEN— no PAT to manage. CI (ci.yml) stays read-only; the release workflow keeps a read-only floor and grants writes per job — the release-please job getscontents/issues/pull-requests: write, the image job onlypackages: write. - GHCR packages are private by default. To let an unauthenticated host pull, make the package public once (repo → Packages → package settings), or pull with a token.
- The published image is hardened for public distribution:
appsettings.Development.json(the dev admin / MinIO defaults) is excluded via.dockerignoreand never ships, the runtime is pinned toASPNETCORE_ENVIRONMENT=Productionin.do/app.yaml, and the build attaches SLSA provenance + an SBOM so consumers can verify provenance and pin by digest. - App Platform still builds from source on push (
.do/app.yaml), so GHCR is a parallel, versioned artifact — handy for the VPS/Droplet deploy path. TheX.Y.Z/X.Y/latesttags are convenient but mutable (a later release reuseslatestandX.Y); when a deployment must use exact image bytes, pin by digest (ghcr.io/imariel2d/keepr@sha256:<digest>). To deploy the released image instead of building from source, point the App Platform service at animage:source with a registry credential.
- Frontend for folders + trash — the backend is done and verified; see docs/api-changes-frontend.md
- Sweeper leasing: both background sweepers are single-instance-safe only. Two instances would
double-release quota — needs
pg_try_advisory_lockbefore scaling out. - Resumable-across-reloads: the client re-uploads from scratch on failure (the API already
supports resuming, since
uploadId+ part ETags persist) - Future: file sharing → then malware scan + content moderation become required (Q5)
The Angular SPA lives in src/ClientApp (Angular 21, standalone, signals). See its README for the dev proxy setup and the client-side multipart flow.