Skip to content

Repository files navigation

Keepr

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.

Feature summary

  • 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.pdfreport (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.

Layout

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

Run locally

# 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 output

MinIO console: http://localhost:9001 (minioadmin / minioadmin).

API docs (Swagger)

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.

API quick reference

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.pdfreport (2).pdf), on create, rename, move, and upload. Always use the name from the response.

Deploy (DigitalOcean App Platform)

  1. Push to a GitHub repo; set github.repo in .do/app.yaml.
  2. 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.
  3. doctl apps create --spec .do/app.yaml (or connect the repo in the UI).
  4. Set R2 bucket CORS using docs/r2-cors.json (replace YOUR-APP-DOMAIN). It allows PUT/GET from your origin and — critically — exposes the ETag header the multipart client must read. Without this, uploads fail. (Dev/MinIO CORS is already wired in docker-compose.yml.)

Not built yet (tracked in the docs)

  • 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_lock before 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)

Frontend

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.

About

Self-hostable personal media store: folders, trash, previews, and presigned direct-to-S3 uploads. ASP.NET Core + Angular.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages