- Delete the source file once an upload is confirmed (best-effort: SAF picks get a persisted read+write grant and are removed; read-only shared streams are left in place). Cache copy always removed. A removed source shows as 'removed from phone'. - Distinguish duplicates: a HEAD (or the server's 200 existed) marks the item 'Duplicate · already on server' — content is never re-sent or overwritten, since the name IS the sha256. Different content = different hash = a new file, not a duplicate. - 'Clear history' app-bar button clears finished/duplicate/failed rows. - Register as an 'Open with' target (ACTION_VIEW, content/file) in addition to the existing share-sheet target. - Room schema v2 (destructive fallback; queue is ephemeral). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
4.7 KiB
Markdown
94 lines
4.7 KiB
Markdown
# 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).
|