Project Overview.
The current state of tinyfiles.tv — subsystems, endpoints, schema, and what still needs to happen before the site can accept its first real work. Written for the person maintaining it.
Core invariants
These hold across the whole stack. If any of them ever stops holding, the site has broken one of its promises.
- No cookies, no accounts. Player-facing pages set no cookies of their own. Theme, identity, and save tokens default to
sessionStorageand vanish on tab close. Persistence is opt-in. - No IP or User-Agent in the app. Rate limiting happens in nginx at the proxy layer keyed on
$binary_remote_addr, with ephemeral shared-memory zones. PHP never readsREMOTE_ADDR. Thesave_codestable has no IP column. - Cryptographic identity. Members sign payloads and 24-hour stateless sessions with client-side ECDSA P-256 keys. Server bridges WebCrypto P1363 to OpenSSL DER, enforces 10-minute staleness, and uses a 1-hour nonce replay table.
- Origin isolation. User works never execute on
tinyfiles.tv. Works run in sandboxed iframes served fromcontent-sandbox.comwithconnect-src 'none'and forcedSet-Cookie: "". - Static-first catalog. The frontend reads pre-generated
data/works.js,tags.js,ranked.js,graveyard.js. Regeneration is an async job, never synchronous with a page request. - If the Python container fails one test, Python closes. Public adversarial suite is the gate. Binary outcome.
Subsystems
Upload & intake
Member uploads a ZIP (or HTML), a sidecar JSON, up to 3 covers, and optionally a 16 KB Python file. Staged outside the served tree, then an intake job extracts safely (rejects .. and symlinks), SHA-256-hashes every file, renders the wrapper page, and inserts the work as pending_review.
works.php jobs.py upload.html
Admin dashboard
Single HTML page. Mod enters token + mod name. Four tabs: pending works, pending posts, pending guestbook, failed jobs. Plus a rater management tab. Every action writes reviewed_by / approved_by / rejected_by to the row and emits an event.
admin.html admin/queue.php admin/approve.php
Retro Save System
Three positions on one API: offline (client-held armored block), encrypted online (16-char code → ciphertext keyed by sha256(slug:code)), plaintext online (16-char code + optional 4-letter word, server can read). 100 buckets × 4 KB each, 50 KB total.
save.php save.html
Python runtime
HTML + Python pairs only. Every .py passes bandit before a human sees it. At runtime: ephemeral Docker container, --net=none, read-only rootfs, UID 65534, --cap-drop=ALL, seccomp allowlist, 128 MB RAM, 0.5 CPU, 32 PIDs, 3 s timeout. Orphan containers swept every minute by a systemd timer.
python.php run.sh python-seccomp.json
Rater jury
Creators with ≥1 published work self-provision a rater key. External raters get one from the admin dashboard. Votes are pseudonymous (key hash, not name), 0–100. UNIQUE(work_id, rater_key_hash) prevents double voting. At ≥3 votes and average <50, the work flips to in_graveyard and drops out of browse feeds.
rate.php rater.php admin/rater.php
Community & guestbook
Signed posts (ECDSA over full payload) and unverified guest posts both queue for moderation. Guestbook entries per work. Threaded replies per post. Every row has an approved flag; nothing shows publicly until a moderator publishes it.
community.php guestbook.php replies.php
Trigger engine
Append-only events table is the spine. Worker polls every 2 s from a file cursor, matches event_pattern (exact or prefix*), then optionally evaluates a recursive JSON filter (and / or / path / equals / greater_than / changed / from-to). Dispatch actions: enqueue job, emit event.
triggers.py events triggers
Catalog regeneration
On work.published or work.graveyard_changed, a trigger enqueues catalog_regen. The job rebuilds six artifacts atomically: works.js, tags.js, ranked.js, graveyard.js, sitemap.xml, rss.xml, and checksums.txt. Nothing is hand-edited.
catalog.py jobs.py
The stack
| Layer | Files | Role |
|---|---|---|
| Edge / proxy | tinyfiles.tv.conf.nginxcontent-sandbox.com.conf.nginx |
TLS, HSTS, CSP, rate-limit zones, FastCGI routing, cookie stripping on the content domain. |
| PHP 8.2 API | bootstrap.php, works.php, save.php, python.php, community.php, guestbook.php, replies.php, rate.php, rater.php |
ECDSA verification, upload staging, save invariants, container bridge, pseudonymous voting. |
| Admin | admin/queue.php, admin/approve.php, admin/rater.php |
Bearer-gated queue inspection, work/post/guestbook approval, failed job retry, rater key lifecycle. |
| Workers | common.py, jobs.py, catalog.py, triggers.py |
Intake extraction, wrapper render, atomic artifact rebuild, event dispatch. |
| Sandbox / ops | run.sh, python-seccomp.json, container-sweep.sh, nonce-sweep.sh, *.service, *.timer |
Docker isolation, seccomp allowlist, systemd hardening, orphan container cleanup, nonce pruning. |
| Database | schema.sql (MySQL / InnoDB) |
20 tables: identity, catalog, saves, community, rater, python, pipeline, substrate. |
API surface
Save
Community
Works
Rater
Admin (Bearer mod token + X-Mod-Name)
What's live vs. preview
Live
- Save API — all three privacy positions wired end-to-end
- Community boards — signed + guest posts, moderated feed
- Admin dashboard — queue, approve, retry, rater keys
- Rater system — self-provision, vote, aggregate, graveyard flip
- Upload pipeline — ZIP intake, hashing, wrapper render
- Python execution — HTML + Python pairs, containerized
- Trigger engine — patterns + JSON filters, event-driven
- Catalog regen — 6 static artifacts rebuilt on publish
- All static pages — site, theme engine, identity generator
Preview / demo only
- Homepage catalog — still 12 hardcoded demo cards; wire to
data/works.jsafter first real publish - Individual work pages —
work.htmlis the template; real works will live at/{creator}/{slug}/ - Playlist player — simulated playback, no audio routing
- Stats page — layout built, counters not running
- Feed data files — seed
data/*.jswith empty arrays until first publish fires the regen
Launch checklist
Populate /etc/tinyfiles/config.env
Strong TF_DB_PASS and TF_MOD_TOKEN (32-byte hex from openssl rand -hex 32). Set chmod 640, own root:www-data. Every worker and every PHP request depends on this file.
Load schema.sql and seed initial names
Fresh install: mysql < schema.sql creates 20 tables and seeds the two pipeline triggers. Then INSERT your own member name + ECDSA public key into names so you own the handle before the first post.
Seed data/*.js with empty arrays
public/data/{works,tags,ranked,graveyard}.js — one line each: window.TF_WORKS = []; etc. Lets tags.html, ranked.html, graveyard.html render their empty states without 404ing on the fetch.
Enable the daemons and timers
systemctl enable --now tinyfiles-jobs.service tinyfiles-triggers.service tinyfiles-container-sweep.timer tinyfiles-nonce-sweep.timer. Without the jobs worker, no upload ever completes intake. Without triggers, no catalog ever regenerates.
Grant www-data docker access (v1)
Pragmatic for a single-user VPS: usermod -aG docker www-data. For a hardened box, replace with a docker-socket-proxy that restricts the API surface. The container is the trust boundary either way; this only decides the daemon blast radius.
Verify the seccomp profile loads
docker run --rm --security-opt=seccomp=/etc/tinyfiles/python-seccomp.json tinyfiles/python-runner:latest python3 -c "print(1)". Should print 1. If it fails with EPERM, the allowlist is missing a syscall — journalctl -u tinyfiles-jobs will show which.
First upload as a smoke test
Open /upload.html. Password gate → identity block → ZIP + sidecar. Job lands in pending_review within a few seconds. Open /admin.html, approve it. catalog_regen fires. Work lives at /{you}/{slug}/. If any step fails, the error is in journalctl -u tinyfiles-jobs.
Backup priorities
Snapshot names above all — losing it orphans every claimed handle. Then works, work_files, save_buckets, and the filesystem under /opt/tinyfiles/public/works/. A nightly mysqldump + rsync is enough for v1.
What changed recently
A short history of the last round of fixes, for the next time you look at this file:
What's next
Not urgent, but worth keeping on the list:
-
—
Wire
index.html's hero + card grid towindow.TF_WORKSonce three or four real works are published. The demo cards are honest for now; they'll look dishonest once a real work is live and the homepage doesn't show it. -
—
Replace
work.html's template with the actual wrapper thatjobs.pygenerates. The intake pipeline already produces the file at/{creator}/{slug}/index.htmlfromwork-template.html; the same template can become the reference page mod sees when clicking "Preview" in the admin dashboard. -
—
Move
www-dataoff raw Docker access.docker-socket-proxyor rootless podman. The container is the boundary; this only shrinks the daemon's blast radius. -
—
The five substrate tables (
sequences,sequence_steps,sequence_runs,buckets,bucket_objects) are scaffolded but unused. If they stay unused through the first real traffic, drop them. If they're useful, wire them. Either way, don't leave them ambiguous.