michal 81d5f33acc app v1.1.1: fix top bar drawing under the status bar (edge-to-edge insets)
Android 15 / targetSdk 35 forces edge-to-edge, so the app bar and its
buttons drew behind the status bar (under the clock/battery).
fitsSystemWindows on the root CoordinatorLayout insets the app bar below
the status bar, and a window-insets listener pads the queue list above
the gesture nav bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 00:13:01 +01:00
2026-09-28 23:18:15 +01:00
2026-09-28 23:18:15 +01:00

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 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=<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=~/.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 <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).

Description
No description provided
Readme 169 KiB
Languages
Kotlin 62%
TypeScript 35.9%
Dockerfile 1.5%
Shell 0.6%