Private by construction, not by setting
There is no privacy toggle in Partmov because there is nothing public to turn off. Rooms are unlisted, links expire, media URLs live for two minutes, and the analytics measure the player rather than the people.
Six layers of access control
| Layer | Mechanism | Detail |
|---|---|---|
| Account | Magic link + signed session cookie | No password to phish or reuse. Tokens are single-use, 15-minute, and bound to the requesting email. Sessions are HttpOnly, Secure, SameSite=Lax, revocable server-side. |
| Room | Unlisted by construction | No listing endpoint exists, room ids are UUIDv4, and enumeration returns 404 for anything the caller may not know about. |
| Invitation | Hashed token + expiry + use limit | The plaintext token is shown to the host once. The database stores only SHA-256. Default expiry 24 hours, default max_uses 1, optional argon2id passphrase, and revocation takes effect on live sockets. |
| Socket | Short-lived ticket | The WebSocket is authenticated by a 60-second JWT scoped to one room and one role, so a leaked URL is not a leaked session. |
| Media | HMAC-signed, 120-second URLs | Signature covers the object key, room id, session id, expiry, and client IP prefix. Object storage is never publicly reachable. |
| Storage | Per-owner prefixes + SSE | Keys are u/<user_id>/… so cross-tenant reads require a forged signature, not just a guessed id. Server-side encryption is on. |
How a segment request is authorised
Every media byte passes a gate that knows which room and which session asked for it. Storage credentials never leave the server side.
# minting (API, on GET /api/rooms/:id/playback-urls)
exp = now + 120s
msg = f"{object_key}|{room_id}|{session_id}|{exp}|{ip_prefix}"
sig = base64url(hmac_sha256(MEDIA_SIGNING_KEY, msg))
url = f"https://partmov.example/media/{object_key}?e={exp}&s={session_id}&r={room_id}&sig={sig}"
# verifying (media gate, per segment request)
1. exp has not passed → else 403 expired
2. sig matches recomputed HMAC → else 403 bad_signature
3. session is an active participant of room → else 403 not_in_room
4. room.status = 'active' and asset not taken down → else 403 unavailable
5. ip_prefix matches /24 (v4) or /48 (v6) → else 403 moved
6. proxy the byte range from MinIO; never redirect to a storage URL
# rotation: two signing keys are live at once (current + previous) so key
# rotation never invalidates a film that is already playing.- Why not presigned S3 URLs? A presigned MinIO URL is valid for its whole TTL to anyone who holds it and cannot be revoked. A gate can check room membership, takedown status, and revocation on every single request.
- Stopping casual downloads. Segment URLs are short-lived and IP-prefix bound, and the master playlist is signed separately. Someone determined can still reassemble segments they are authorised to watch — that is true of every non-DRM player on the web, and the design says so rather than pretending otherwise.
- Where DRM would fit.If licensed catalogue content ever requires it, the path is Widevine or PlayReady with an open-source licence server such as Shaka Packager plus an EME integration. It costs a per-title packaging step, browser-specific failure modes, and — decisively — the loss of fine-grained playback control on some platforms, which is the product’s core promise. So DRM stays out until a contract requires it, and never applies to user uploads.
Content stays with its owner, and leaves when told
- Structural isolation. Object keys embed the owner id, every asset query is scoped by
owner_id, and the media gate re-derives ownership from the room rather than trusting the request. - Deletion is a pipeline, not a flag.
DELETE /api/assets/:idmarks the assetdeleting, closes dependent rooms withreason: asset_removed, revokes their invitations, then a purge worker deletes the originals prefix, the renditions prefix, subtitle objects, poster, and sprites — verifying with a list call that zero objects remain before writingasset.purgedto the audit log. - Backups respect deletion. Media backups are prefix-synchronised with
mc mirror --remove, so a purge propagates. Database backups older than the purge are the only place a title reference survives, and they age out on a 30-day schedule. - Account deletion. Cascades to assets, rooms, invitations, and sessions; enqueues purge jobs for every prefix; and leaves behind only anonymised audit rows with a null actor.
The verification step matters. A purge that does not confirm an empty prefix is a promise, not a deletion — so the job fails loudly and retries rather than reporting success.
Rejecting bad input before it becomes a problem
| Vector | Control |
|---|---|
| Upload validation | ffprobe must find a decodable video stream; container must be in an allowlist (mkv, mp4, mov, webm, avi, ts); declared size and SHA-256 must match the received bytes; anything else is deleted, not quarantined. |
| Dangerous input | FFmpeg runs in a container with no network, a read-only root filesystem, a dropped capability set, a memory ceiling, and a wall-clock timeout. Protocol whitelisting is enabled so a crafted file cannot make FFmpeg fetch a remote URL. |
| Archive and script tricks | Content type is decided by probing, never by extension or client-supplied MIME. Filenames are regenerated as UUIDs, so path traversal and double-extension tricks have no surface. |
| Storage abuse | Per-account quota and per-file size cap, enforced before the tus endpoint is issued and re-checked on completion. |
| Link brute force | Tokens are 132 bits of entropy. Join attempts are limited to 5 per IP per minute and 20 per room per hour, and a room locks joins for 15 minutes after 10 failures. |
| Traffic abuse | Per-session segment request ceiling based on the film's real bitrate: a client that requests far more than realtime is scraping, and gets throttled then blocked. |
| Chat abuse | Length caps, per-participant rate limits, and no link unfurling. In a two-person room the honest remedy is that either person can close the room instantly. |
Measure the playback, not the person
Every metric below describes the system's behaviour. None of them describe what someone watched, when they watched it, or with whom.
| Metric | Type | Target |
|---|---|---|
| partmov_startup_ms | histogram | p95 under 2.5 s from room open to first frame |
| partmov_rebuffer_ratio | gauge | under 0.5 percent of watch time |
| partmov_sync_drift_ms | histogram | p95 under 120 ms, p99 under 400 ms |
| partmov_room_join_success | counter pair | over 99 percent of valid invites join on first attempt |
| partmov_rate_nudge_seconds | counter | how long clients spend correcting — a proxy for network health |
| partmov_hard_seek_total | counter | should stay near zero; every one is a visible artefact |
| partmov_transcode_duration_ms | histogram | per rung, to size worker capacity |
| partmov_ws_reconnects_total | counter | by reason, to catch proxy timeout regressions |
- No third-party scripts, no advertising or analytics SDKs, no fingerprinting, no cross-site cookies.
- Access logs store an IP prefix rather than a full address, and are dropped after 14 days.
- Metric labels are bounded: room and user ids never become label values, so cardinality stays flat and dashboards cannot become a viewing history.
- Client telemetry is aggregate-first: the browser reports buffer and drift numbers, never a list of titles.
Threat model, stated plainly. Partmov defends against link leakage, room enumeration, cross-tenant reads, direct storage access, casual scraping, and operator over-collection. It does not defend against a participant recording their own screen, and it does not claim to.