# 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 `.`, so names carry nothing, re-uploads are idempotent, and a retried upload can be verified server-side before it is accepted. ## Behaviour - **Integrity is structural.** The remote name *is* the SHA-256 of the content. The server recomputes the hash of the bytes it receives and stores the file only if it matches (mismatch → 409, nothing written). So a success response is proof the server holds byte-for-byte-intact content — which is what makes deleting the local copy safe. - **Delete after confirmed upload.** Once an upload is confirmed, the app removes its working copy and, best-effort, the original file. Files picked in-app (SAF) are removed via the persisted read+write grant; a file that arrives as a read-only share/`open with` stream cannot be deleted and is left in place (not a failure). A removed source shows as "removed from phone". - **Duplicates are never rewritten.** Identical content (same hash) already on the server is detected by a `HEAD` before any bytes are sent; the item is marked *Duplicate · already on server* and nothing is re-uploaded or overwritten. Different content is a different hash, i.e. a different file — not a duplicate. - **Entry points.** Share sheet (`ACTION_SEND`), "Open with" on a downloaded file (`ACTION_VIEW`), and an in-app file picker. "Clear history" (app-bar button) drops finished, duplicate and failed rows; in-flight uploads keep running. ## 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=` 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":""}` | Endpoint | Auth | Behaviour | |---|---|---| | `GET /healthz` | none | 200 `ok` | | `GET /` | browser SSO | pairing page with QR | | `GET /pair/app` | browser SSO | 302 → `internaluploader://pair?c=` | | `POST /api/pair` `{"code":"…"}` | pairing code | 200 `{"token":"…","name":""}`; 410 expired/used | | `HEAD /api/object/.` | bearer | 204 exists / 404 not — lets a retry skip the bytes | | `PUT /api/upload?name=.` | 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:-`). - APK: `cd android && JAVA_HOME=~/.jdks/jdk17 ./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 `. ## 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).