- Pending/uploading rows get a cancel button; every cancel is confirmed in a dialog and the source file is always kept — only a confirmed upload deletes originals. The in-flight HTTP transfer aborts immediately (async OkHttp call instead of blocking execute()). - Enqueue refuses content whose SHA-256 is already pending/uploading, so sharing or picking the same file twice no longer uploads it twice in parallel (content URIs are opaque; the hash is the identity). - Cancel/finish races resolved with conditional DB updates: a cancel landing mid-flight keeps the source; a cancel after completion is refused with a toast instead of flipping a finished row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
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 withstream 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
HEADbefore 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. The queue dedupes the same way: content whose hash is already pending or uploading is refused at enqueue time ("already in the queue"), so adding the same file twice — even via two different content URIs — never uploads it twice in parallel. - Cancel keeps the original. A pending or in-flight upload can be cancelled from its queue row; every cancel asks for confirmation so it can't happen by accident. Cancelling aborts the transfer and discards the working copy, but never deletes the source file — only a confirmed upload does that. A cancel that races a finishing upload loses gracefully: if it lands first, the source is kept even though the bytes reached the server; if the upload finished first, the app says so and nothing is flipped.
- 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, failed and cancelled 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/dataat/downloads. Fronted by the cluster's Cilium ingress athttps://puploader.ad.itaz.eu(cert-manager LE cert) and exposed to the tailnet as its own Tailscale devicepuploader(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.
- QR pairing — open
https://puploader.ad.itaz.euin 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. - In-app SSO (optional path) — the app opens
https://puploader.ad.itaz.eu/pair/appin a Custom Tab; after the same authentik login the server redirects tointernaluploader://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 tomysources.co.uk/michal/internaluploader-server:<date>-<sha>). - APK:
cd android && JAVA_HOME=~/.jdks/jdk17 ./gradlew assembleRelease(release signing reads~/.internaluploader/signing.properties; seeandroid/README.md). Publish to the private F-Droid repo withthelab-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).