internaluploader: server + docs

One-way upload server: authentik SSO pairing (QR + app-initiated deep
link), hashed device tokens, streamed sha256-verified uploads into the
metube directory as <sha256>.<ext>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 23:17:25 +01:00
committed by Michal
commit d8a606ce46
47 changed files with 4196 additions and 0 deletions

71
README.md Normal file
View File

@@ -0,0 +1,71 @@
# internaluploader
One-way file drop: an Android app that uploads files into the metube download
directory on nas001 (`/volume1/ss/data`). There is no way to browse, list or
download anything through it — uploads only.
Every file is stored as `<sha256-of-content>.<original extension>`, so names
carry nothing, re-uploads are idempotent, and a retried upload can be verified
server-side before it is accepted.
## Parts
- `server/` — Node 22 / TypeScript. Runs as a docker container **on nas001**
(Pulumi docker-over-ssh, same pattern as seaweedfs), bind-mounting
`/volume1/ss/data` at `/downloads`. Fronted by the cluster's Cilium ingress
at `https://puploader.ad.itaz.eu` (cert-manager LE cert) and exposed to the
tailnet as its own Tailscale device `puploader` (userspace proxy, SNI
passthrough) — reachable from anywhere on the tailnet, nothing public.
- `android/` — Kotlin app "InternalUploader". No settings screen, no server
address anywhere in the UI. The server URL is baked into the build and can
be replaced by whatever a pairing QR carries.
## Auth model
Browser SSO (authentik at `sso.ad.itaz.eu`) is the only identity check; the
app itself never sees a password.
1. **QR pairing** — open `https://puploader.ad.itaz.eu` in a browser, sign in
through authentik, and the page shows a QR code. Scan it with the app and
the device is paired. The QR carries a single-use pairing code (5 min TTL)
plus the server URL.
2. **In-app SSO (optional path)** — the app opens
`https://puploader.ad.itaz.eu/pair/app` in a Custom Tab; after the same
authentik login the server redirects to `internaluploader://pair?c=<code>`
and the app finishes pairing with the same exchange.
Pairing exchanges the one-time code for a long-lived **device token** (256-bit
random, stored hashed on the server, `Authorization: Bearer` on every upload).
## API (server ⇄ app contract)
QR / deep-link payload (JSON): `{"v":1,"u":"https://puploader.ad.itaz.eu","c":"<code>"}`
| Endpoint | Auth | Behaviour |
|---|---|---|
| `GET /healthz` | none | 200 `ok` |
| `GET /` | browser SSO | pairing page with QR |
| `GET /pair/app` | browser SSO | 302 → `internaluploader://pair?c=<code>` |
| `POST /api/pair` `{"code":"…"}` | pairing code | 200 `{"token":"…","name":"<user>"}`; 410 expired/used |
| `HEAD /api/object/<sha256>.<ext>` | bearer | 204 exists / 404 not — lets a retry skip the bytes |
| `PUT /api/upload?name=<sha256>.<ext>` | bearer | body = raw bytes, streamed to a temp file **on the same filesystem**, hash verified, then renamed into `/downloads`. 201 created, 200 already existed, 409 hash mismatch, 413 too large |
One-way by construction: the only read endpoint takes a full content hash the
caller must already possess.
## Building
- Server image: `server/build.sh` (podman build + push to
`mysources.co.uk/michal/internaluploader-server:<date>-<sha>`).
- APK: `cd android && JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew assembleRelease`
(release signing reads `~/.internaluploader/signing.properties`; see
`android/README.md`). Publish to the private F-Droid repo with
`thelab-kubernetes-pulumi/scripts/fdroid/publish.py add <apk>`.
## Deployment
Declared in `thelab-kubernetes-pulumi` — `deployments/puploader/` plus an
authentik OIDC app (`deployments/authentik/apps/puploader.ts`), a
`k8s-deployments:puploader` config block, and a `puploader` entry in the
worker0 tailscale exposures (two-step: first apply registers the device, then
set `deviceIp` for the Cloudflare A record).