5 MB
Work file cap
16 KB
Python script cap
50 KB
Save code cap
20
InnoDB tables
128 MB
Python container RAM
0
Cookies / IPs in app layer

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 sessionStorage and 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 reads REMOTE_ADDR. The save_codes table 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 from content-sandbox.com with connect-src 'none' and forced Set-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

LayerFilesRole
Edge / proxy tinyfiles.tv.conf.nginx
content-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

POST/api/save/{creator}/{slug}create · encrypted or plaintext
PUT/api/save/{creator}/{slug}/{code}/{bucket}write one bucket
GET/api/save/{creator}/{slug}/{code}read all buckets
GET/api/save/{creator}/{slug}/{code}/{bucket}read one bucket

Community

POST/api/community/postsigned post · payloadB64 + sigB64
POST/api/community/guestunsigned guest post
GET/api/community/postsapproved feed
POST/api/guestbook/{creator}/{slug}submit entry · queues for mod
GET/api/guestbook/{creator}/{slug}approved entries
POST/api/replies/{post_id}submit reply · queues for mod
GET/api/replies/{post_id}approved replies

Works

POST/api/worksupload · requires member session
GET/api/workslist published · ?creator= ?category= ?limit=
GET/api/works/{creator}/{slug}single work + files
POST/api/python/{creator}/{slug}invoke python backend · 64 KB input cap

Rater

POST/api/rate/{creator}/{slug}cast vote · {key, score}
GET/api/rate/{creator}/{slug}aggregate · votes + average
POST/api/rater/provisionself-serve · requires published work

Admin (Bearer mod token + X-Mod-Name)

GET/api/admin/queueworks · posts · guestbook · failed
POST/api/admin/approvetype: work | post | guestbook | job
GET/api/admin/rater/listkey hashes + vote counts
POST/api/admin/rater/createkey shown once
POST/api/admin/rater/revokevote history preserved

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.js after first real publish
  • Individual work pages — work.html is 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/*.js with empty arrays until first publish fires the regen

Launch checklist

01

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.

02

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.

03

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.

04

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.

05

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.

06

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.

07

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.

08

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:

Rate limiting: the old save_write / save_read split was unreachable — the two-segment slug never matched the write zone. Consolidated into a single save zone at 180 r/m. Same for works.

Save API slug: save.php regexes now accept /{creator}/{slug}/{code} instead of one-segment paths. The hash key is sha256(creator/slug + ':' + code) — the composite is what the frontend sends and what the schema was designed for.

Provenance: works.reviewed_by, works.rejected_by, posts.approved_by, posts.rejected_by, guestbook_entries.approved_by. Every moderation action now has an actor attached to the row, not just to the events stream.

Trigger filters: triggers.filter_json is now consumed by triggers.py. Existing triggers with filter_json = NULL behave identically to before. New triggers can filter on any path in the event payload.

Seccomp: flipped from SCMP_ACT_ALLOW (denylist) to SCMP_ACT_ERRNO (allowlist) with an explicit syscall set. This matches what the Python page and Security page advertise.

Python upload: works.php accepts a python field. jobs.py reads it, enforces the 16 KB cap, rejects if the ZIP contains a reserved backend.py, and stores role='python'. The move is outside the html/else branch — a legitimate Python work with a ZIP payload isn't silently dropped anymore.

What's next

Not urgent, but worth keeping on the list:

  • — Wire index.html's hero + card grid to window.TF_WORKS once 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 that jobs.py generates. The intake pipeline already produces the file at /{creator}/{slug}/index.html from work-template.html; the same template can become the reference page mod sees when clicking "Preview" in the admin dashboard.
  • — Move www-data off raw Docker access. docker-socket-proxy or 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.
Read the security model →