A self-hosted, single-Worker app to browse, upload, edit, and manage all your Cloudflare media — Images, Stream videos, and audio in R2 — from one gallery. SSR UI on TanStack Start, API on Hono, all on a single Cloudflare Worker, gated by Cloudflare Access. Your Cloudflare API token is stored encrypted at rest and never reaches the browser.
The unified library (grid view) — images, videos, and R2 audio with inline players, populated from your connected Cloudflare account.
Connect your Cloudflare account once (a scoped API token, entered in-app and stored AES-GCM-encrypted in D1) and manage everything from a unified library:
- Unified library — Images, Stream videos, and R2 audio in one place, with Grid / Table / Cinema views, type filter, and live sort.
- Upload — drag-and-drop images, videos (resumable TUS), and audio. Bytes go browser → Cloudflare via one-time, pre-authorized URLs; large files never transit the Worker.
- Manage — rename, edit metadata, toggle signed-URL protection, delete (single + bulk).
- Image transforms — an in-drawer builder for Cloudflare flexible variants (resize / fit / crop / rotate / quality / format / blur / sharpen / …) with a live preview, copy-URL, and download. HEIC uploads supported.
- Image variants — full CRUD on account-level named variants (in Settings).
- Video clipping — trim a Stream video into a new clip with a range slider.
- Downloads — generate and download MP4 / audio-only M4A for a video.
- Captions — list, AI-generate, upload
.vtt, and delete subtitles. - Thumbnail & playback — set a video's poster-thumbnail timestamp (live preview) and allowed embedding origins.
- Audio — first-class audio type stored in a dedicated R2 bucket, with an animated spectrum player.
- Private by default — the whole app sits behind Cloudflare Access; the stored API token is used only server-side.
| Layer | Choice |
|---|---|
| Runtime | Cloudflare Workers (workerd), single origin |
| UI / SSR | TanStack Start (file-based routes, edge SSR) + React 19 + Vite |
| Components | Mantine 9 (+ @gfazioli/mantine-audio, Tabler icons, Nunito) |
| Client state | TanStack Query (server cache) + Zustand (UI state) |
| Tables | TanStack Table + Virtual |
| API | Hono, mounted as a splat server route on the same Worker |
| Auth gate | Cloudflare Access JWT verification (jose) |
| Storage | Cloudflare Images, Stream, R2 (audio), D1 (connection + audio metadata) + Drizzle |
| Secrets | Stored CF token encrypted with AES-GCM (TOKEN_ENC_KEY) |
| Tooling | oxlint + Prettier, Vitest, Wrangler 4 |
One Cloudflare Worker serves the SSR UI and the API:
- TanStack Start renders the UI at the edge; the Hono API is mounted as a catch-all server route (
src/routes/api/$.ts→app.fetch(request, env)), so there's one origin, onewrangler deploy, no CORS. accessGuardmiddleware verifies the Cloudflare Access JWT on every/api/*request.- Your Cloudflare API token is entered in the in-app Settings page and stored AES-GCM-encrypted in D1 (
cf_connection). The browser only ever receives one-time upload URLs and signed delivery URLs — never the token. - Audio bytes live in a dedicated R2 bucket (
AUDIO_BUCKET); audio metadata lives in D1 (audio_files).
- Node 22+ and a Cloudflare account (Workers Paid is recommended for Stream/Images).
- Wrangler (
npm i -g wrangleror usenpx wrangler), logged in (wrangler login). - A scoped Cloudflare API token with Images Read+Edit and Stream Read+Edit (you'll paste this into the app's Settings, not into config).
- A Cloudflare Access (Zero Trust) self-hosted application protecting the Worker's hostname.
git clone https://github.com/harborline/cloudflare-media-manager.git
cd cloudflare-media-manager
npm installwrangler d1 create media-gallery # copy the returned database_id
wrangler r2 bucket create media-gallery-audioOpen wrangler.jsonc and fill in the placeholders:
d1_databases[0].database_id→ the id fromwrangler d1 create.vars.TEAM_DOMAIN→https://<your-team>.cloudflareaccess.com.vars.POLICY_AUD→ your Access application's AUD tag (see step 3).- (Optional) add a
routesentry to serve on your own domain.
Apply migrations (local + remote):
wrangler d1 migrations apply media-gallery --local
wrangler d1 migrations apply media-gallery --remoteThe stored Cloudflare token is encrypted at rest with an AES-GCM key:
wrangler secret put TOKEN_ENC_KEY # paste the output of: openssl rand -base64 32In Zero Trust → Access → Applications, add a self-hosted application for the Worker's hostname (e.g. media.example.com or <worker>.<subdomain>.workers.dev) plus a policy for who may sign in. Then copy:
- your team domain →
TEAM_DOMAIN, - the application AUD tag →
POLICY_AUD.
npm run deploy # vite build && wrangler deployVisit the deployed URL, sign in through Access, open Settings, and paste your scoped Cloudflare API token + account ID to connect.
.github/workflows/deploy-app.yml deploys on push to main when these repository secrets are set (the deploy step is skipped otherwise, so CI stays green without them):
CLOUDFLARE_API_TOKENCLOUDFLARE_ACCOUNT_ID
cp .dev.vars.example .dev.vars # sets TOKEN_ENC_KEY + DEV_BYPASS_ACCESS=1
npm run dev # http://localhost:5173DEV_BYPASS_ACCESS=1 lets you run locally without Cloudflare Access. The local D1 starts empty — connect your account via Settings to populate the gallery.
| Command | What it does |
|---|---|
npm run dev |
Vite dev server (UI + Worker via the Cloudflare plugin) |
npm run build |
Build the client + SSR server bundles |
npm run deploy |
Build and wrangler deploy |
npm run typecheck |
tsc --noEmit for the app and the worker |
npm run test |
Vitest (worker + pure-logic unit tests) |
npm run check |
oxlint --fix + Prettier |
src/ # TanStack Start UI
routes/ # file-based routes (incl. api/$.ts → Hono)
features/Gallery.tsx # the unified media library
components/ # cards, table, cinema, detail drawer + edit panels,
# upload modal, transform/clip/download/caption/settings panels
lib/ # cf-api fetchers, media model, zustand store, helpers
worker/ # Cloudflare Worker (server)
src/routes/ # Hono routes: images, stream, audio, settings, me, …
src/middleware/access.ts# Cloudflare Access JWT verification
src/lib/ # cf REST helpers, URL/sign helpers, connection + audio stores
migrations/ # D1 migrations
docs/ # standalone docs site (React + Mantine + MDX)
- Every
/api/*route is gated by Cloudflare Access (JWT verified server-side withjose). - The Cloudflare API token is stored encrypted (AES-GCM) in D1 and used only inside the Worker.
- Uploads use one-time, pre-authorized Cloudflare URLs; private delivery URLs are HMAC-signed server-side.
- No account credentials are ever sent to the browser.
Issues and PRs welcome. Run npm run check && npm run typecheck && npm run test before opening a PR.
GNU AGPL-3.0-or-later. Source files carry /* AGPL-3.0-or-later */ headers.
