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.)
- 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.