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

Releases & registry

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:

  1. Merge feature PRs to main as usual. release-please keeps an open "release" PR whose title is the next version and whose body is the accumulated changelog.
  2. Merge that release PR to cut the release: it tags vX.Y.Z, updates CHANGELOG.md, and creates the GitHub Release.
  3. .github/workflows/release.yml then builds the root Dockerfile and pushes the image to ghcr.io/imariel2d/keepr, tagged X.Y.Z, X.Y, and latest.

Pull the image with:

docker pull ghcr.io/imariel2d/keepr:latest

Notes:

  • 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 gets contents/issues/pull-requests: write, the image job only packages: 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 .dockerignore and never ships, the runtime is pinned to ASPNETCORE_ENVIRONMENT=Production in .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. The X.Y.Z / X.Y / latest tags are convenient but mutable (a later release reuses latest and X.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 an image: source with a registry credential.

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

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages