openvibers/openvibe.mediahub README.md

README.md

main 45.5 KB · 591 lines · markdown

1# OpenVibe.MediaHub
2
3> Your files, shared on your terms.
4
5**Live site:** https://openvibe.download · **API docs:** https://openvibe.services · **For AI:** https://openvibe.download/llms.txt
6**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** alpha. One process serves **openvibe.download** (a private drive with share links and folder backup through
9sync keys), **openvibe.video** (uploads and browser recordings with AI captions, chapters and the AI cut, watch pages and
10playlists, and OpenVibe.Live's stream recordings and clips) and **openvibe.pics** (picture hosting with resize, convert
11and alt text, 2026-10-15), chosen by Host header.
12**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.
13
14## Purpose
15
16OpenVibe.MediaHub is the network's file service: it serves **openvibe.download** as a private drive where a
17signed-in person can upload, organise, share and delete their own files. The bytes live in OpenVibe.Media; MediaHub
18keeps the metadata, the per-person limits, the share links and the report queue. It also answers OpenVibe.Network's
19account export and deletion events (ADR-033). The same process serves **openvibe.video**, the public watch pages for
20OpenVibe.Live's stream recordings and clips (OpenVibe.Video v1, step A), and **openvibe.pics**, OpenVibe.Pics (below).
21
22## Owns
23
24The drive's own tables, and it is the authority for them (`migrations/0002_mediahub.sql`; `migrations/0001_initial.sql`
25creates none):
26
27- `mh_files` — one row per file the person uploaded, naming its `med_…` object in Media (a soft delete keeps the row
28 until the bytes are gone).
29- `mh_folders` — the person's folders and their nesting.
30- `mh_shares` — the share links: slug, expiry, allow-list, download count, report high-water mark, revocation and
31 suspension.
32- `mh_reports` — one report per person per share.
33- `mh_usage` — the per-person upload count for the UTC day.
34- `mh_uploads` — an in-flight chunked upload session (Media's multipart id, the parts expected, the expiry).
35- `account_data_events` — the once-per-event record of applied account export and deletion deliveries
36 (openvibe-sdk/account-data, ADR-033).
37- `video_playlists` and `video_playlist_items` — the person's playlists and their ordered Live items
38 (`migrations/0003_video_playlists.sql`); the items cascade from their playlist.
39- `pics_images`, `pics_albums`, `pics_album_images`, `pics_reports`, `pics_blocklist`, `pics_usage`
40 (`migrations/0006_pics.sql`) — OpenVibe.Pics: each picture (its Media object and thumbnail, size, hashes, state and
41 report count; a signed-out upload's delete key only as its sha256), albums and their order, one report per reporter
42 per picture, the takedown blocklist (sha256 and difference hashes) and uploads per uploader per day.
43- `telemetry_samples` (`migrations/0005_telemetry.sql`) — requests aggregated by route template, method and status class,
44 never by raw URL; `/shared`, static assets, health, readiness, `/metrics` not counted; one `http.request` sample per key each
45 minute plus active-requests, p95 latency and event-loop-lag gauges (openvibe-sdk 0.46, `platform.telemetry-sample@1`); 14-day
46 rows pruned daily. `server/app.js` builds it (`createHttpTelemetry`); `server/index.js` flushes it every minute and on stop.
47
48## Does not own
49
50- The bytes are **OpenVibe.Media**'s: MediaHub stores an object id and asks Media to create, read and delete the
51 object; Media's own retention decides when the bytes actually go.
52- Identity, usernames and canonical account subjects are **OpenVibe.Network**'s, resolved through Network's
53 `identity.subject.resolve`.
54- Event delivery, and the account export/deletion topics, are **OpenVibe.Events**': MediaHub only creates its
55 subscriptions and consumes the two deliveries.
56- **CSAM hash-matching against known material** (PhotoDNA, NCMEC hash lists): that needs the owner's NCMEC / Cloudflare
57 CSAM Scanning Tool account. Pics matches only its own takedown blocklist, and child-safety reports hide at once.
58
59## Depends on
60
61- **OpenVibe.Network** — SSO (OAuth 2 with PKCE S256, client id `media-hub`) and its JWKS: `OV_NETWORK_URL`,
62 `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`,
63 `MEDIAHUB_AUDIENCE`. It calls `identity.subject.resolve` on the internal URL with a service token of its own.
64- **OpenVibe.Media** — where the bytes live: `MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`
65 (and `MEDIAHUB_MEDIA_TIMEOUT_MS`). Media calls use the service's Network service token. Grant
66 `[media-hub, media.object.upload, openvibe.media]` and `[media-hub, media.object.read, openvibe.media]`
67 on namespace `media-hub`.
68- **OpenVibe.Live** — where the recordings and clips come from. openvibe.video reads Live's public JSON API
69 server-side, signed out (`MEDIAHUB_LIVE_URL`, `MEDIAHUB_LIVE_PUBLIC_URL`).
70- **OpenVibe.Events** — `MEDIAHUB_EVENTS_URL` (the two subscriptions are created at boot) and
71 `MEDIAHUB_EVENTS_SECRET` (the v2 signature on `POST /internal/events`).
72- **PostgreSQL** and **Valkey** — `DATABASE_URL` / `DATABASE_DIRECT_URL`, `VALKEY_URL` / `VALKEY_PREFIX`, read
73 through openvibe-sdk/db and openvibe-sdk/valkey.
74- **Packages** — openvibe-contracts, openvibe-sdk and openvibe-shared (versions below), with express, helmet,
75 cookie-parser and express-rate-limit.
76
77## Capabilities
78
79The service manifest (openvibe-contracts, `manifests/services/media-hub.json`) lists ten capabilities it owns —
80`video.vod.read`, `video.playlist.read`, `video.playlist.manage`, `pics.image.read`, `pics.image.upload`,
81`pics.album.read`, `pics.album.manage`, `download.file.read`, `download.file.upload`, `download.share.create`.
82The two playlist capabilities are registered in `server/http/principal.js` and guarded on the video host's API
83(see **Playlist API**): `video.playlist.read` lets an app, agent or service list and read the playlists it owns and
84any public or unlisted playlist by id, and `video.playlist.manage` lets it create, edit, reorder and delete its own.
85`download.file.read` is for agent tokens only, and reads one file only when its owner created an active grant for that
86agent. A capability never lists the drive or replaces the per-file grant. Every other drive write still requires a
87signed-in person (or that person's scoped sync key).
88
89The signed-in `/files` root and search also show that person's Pics images and ready Video uploads as read-only
90entries. Their bytes and lifecycle remain with Pics and Video; opening an entry goes to its owning service. Those
91source entries are not yet eligible for Download agent grants, which apply to files uploaded into the drive.
92
93On other services it calls:
94
95- OpenVibe.Network `identity.subject.resolve` — to turn the usernames a share names into canonical account subjects.
96- OpenVibe.Events `events.subscription.manage` — to create its account export/deletion subscriptions at boot.
97- OpenVibe.Events `events.event.publish` — its own events, in the namespace `mediahub.*` (an event type has no hyphen):
98 `mediahub.video.published` (an upload's first time public and ready: upload, owner, title, url; public; once, by
99 `video_uploads.published_at`) and `mediahub.picture.uploaded` (a signed-in person's picture: picture, owner; internal,
100 without the link or title; signed-out pictures emit nothing). Written to `event_outbox` (migration 0016) in the
101 change's own transaction by [server/events-outbox.js](server/events-outbox.js) and relayed by the SDK; off without
102 `MEDIAHUB_EVENTS_URL`/`EVENTS_URL` and the client secret. OpenVibe.Quest credits "Publish a video" and "Share a picture".
103- OpenVibe.Search `search.document.write` — OpenVibe.Video's public, ready uploads as Search documents (owner
104 `media-hub`, type `video`, `/u/<id>`) through the owner API ([server/video/search-index.js](server/video/search-index.js),
105 `MEDIAHUB_SEARCH_URL`; a minute after boot, hourly, and 30 s after an edit; a private, hidden or removed upload is
106 tombstoned; `video_search_state`, migration 0013).
107
108## Why v1 is narrow
109
110Public file hosting is the riskiest surface on the network: OpenVibe.Media has no malware or CSAM scanning yet. So
111OpenVibe.Download v1 is a **private drive**, not a public host:
112
113- Every upload and every file page needs a signed-in OpenVibe person, or a **sync key** that person made for their own
114 backups (below). Apps, agents and anonymous callers cannot upload.
115- Files are private to their owner: another person's file id answers 404.
116- A share link (`/s/<slug>`, 24 random url-safe characters) expires in 1 hour to 30 days (7 by default), can be revoked,
117 counts downloads, and is one of three kinds: for anyone **signed in** with the link (the default), for **named**
118 OpenVibe accounts, or **public** (`public: true`, migration `0007_public_shares.sql`; the owner's Q10, MediaFire-style):
119 its page and its downloads open for anyone with the link, no account needed. Named accounts are resolved through
120 OpenVibe.Network to account subjects, and only subjects are matched, because a username can change hands; a name no
121 account has is refused, and a public link cannot also name accounts.
122- Every share page has a Report form (malware, illegal content, copyright, something else). Three distinct reports,
123 or any report of illegal content, suspend the share at once. Staff (Network role `admin` or `global_mod`) then
124 restore it, or remove the file, which also deletes the object in Media.
125- Downloads are always attachments, with `X-Content-Type-Options: nosniff`,
126 `Content-Security-Policy: default-src 'none'; sandbox` and a real `Content-Type` only for a short safe list (images
127 except SVG, audio, video, PDF, plain text, zip). HTML, SVG and anything executable come back as
128 `application/octet-stream`. The name survives in any script (`filename*=UTF-8''…`) and can never break the header.
129- **Previews** (`/files/:id/preview?w=96|640`, and `/s/:slug/preview` for a single-file share):
130 - An image (JPEG, PNG, WebP, GIF or AVIF, never SVG) shows a picture of itself in the list (96 px) and on its page
131 and share page (640 px).
132 - OpenVibe.Media makes it as a signed WebP variant of the private object, and MediaHub passes it through: `nosniff`,
133 a sandbox CSP, `private, max-age=300`, never an attachment and never counted as a download.
134 - Only the owner gets one, or whoever may open the share. Other files show their extension.
135- Per-person limits, all from the environment: 25 GB stored and 2 GB per file (the owner's Q10 for free accounts), 50
136 uploads a day, 20 live share links.
137
138## How it works
139
140Bytes live in **OpenVibe.Media** (`MEDIAHUB_MEDIA_URL`, app `media-hub`). MediaHub uses its Network service token and
141calls Media server-side only, naming the person with `X-OV-Subject`, and never hands a browser a Media URL:
142
143- **Small files:** the plain form (`POST /files/upload`, no JavaScript needed) streams the file to a temp file with
144 its sha256, then into Media. The ceiling is `MEDIAHUB_SINGLE_UPLOAD_BYTES`, 256 MB.
145- **Big files:** the page's small script uploads in parts (`/api/v1/uploads/:id/parts/:n`, 8 MB each) that can
146 resume after a dropped connection.
147- **Downloads:** MediaHub checks access, then streams from Media with its own headers. Range requests pass through,
148 so downloads resume.
149- **The bin:** `DELETE /api/v1/files/:id` (or the page's Delete) sets `binned_at` and revokes the file's share links;
150 the bytes stay in Media and still count against storage for 30 days. `POST /api/v1/files/:id/restore` brings it
151 back; `DELETE /api/v1/files/:id?forever=1` deletes it now. A due job (`server/drive/bin.js`) purges files binned
152 more than 30 days ago, 200 at a time. `PATCH /api/v1/files/:id` renames/moves, `PATCH`/`DELETE /api/v1/folders/:id`
153 rename/delete an empty folder, and `GET /api/v1/files?q=` searches every folder. All of it is documented in
154 `/openapi.json` and migration `0023_files_bin.sql` adds the column and its index.
155
156MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).
157
158## OpenVibe.Pics (openvibe.pics)
159
160An open, ad-free picture host: drop a picture, get a link to share or embed. Built in [server/pics/](server/pics/).
161
162- **Upload:** a signed-in person uploads JPEG, PNG, GIF, WebP or AVIF (20 MB, 100 megapixels): the plain form at `/`
163 works without JavaScript; [public/js/pics.js](public/js/pics.js) adds drag-and-drop, paste and several files at once
164 through the API. Limits: 200 a day, 5 GB a person (`MEDIAHUB_PICS_*`).
165- **Every picture is re-encoded** ([server/pics/images.js](server/pics/images.js), sharp/libvips): it must decode as
166 one of those formats (a renamed script, an SVG or a truncated file is refused), the EXIF orientation is applied and
167 every metadata block is dropped (GPS position, camera serial, XMP, IPTC), animations keep their frames, colour goes
168 to sRGB, and a 640-pixel WebP thumbnail is made. Nothing is ever served as it was uploaded.
169- **Storage and serving:** the picture and its thumbnail are **private** OpenVibe.Media objects (`MEDIAHUB_MEDIA_APP`).
170 `/i/<slug>.<ext>` (the direct link) and `/i/<slug>/thumb.webp` read them from Media and serve them with `nosniff`, a
171 sandboxing CSP, `Access-Control-Allow-Origin: *` and a 10-minute public cache, so the Media id never reaches a
172 browser and a hidden picture leaves shared caches within minutes.
173- **Resize and convert** ([server/pics/variants.js](server/pics/variants.js)): `/i/<slug>/w/<width>.<webp|avif|jpg|png>`
174 at 320, 640, 1280, 1920 or 2560 px (never wider than the picture), made here with sharp from the stored copy
175 (Media's own variants stop at 640 px), kept in a 64 MB LRU and served with the direct link's 10-minute cache; an
176 animated picture keeps its frames as WebP. The picture page lists them; `pics.image.variant` budgets the misses.
177- **Alt text** ([server/pics/describe.js](server/pics/describe.js)): what a screen reader says for a picture, used in
178 its page, the thumbnails and every embed snippet (the title when there is none). The uploader writes it, or presses
179 "Write alt text with AI": only then does the 640-pixel thumbnail go to OpenVibe.AI's `pics.describe_image` (which
180 may use an outside model provider and keeps only the image's hash), and the draft (alt text, a caption labelled
181 "Described by AI", tags) is kept until the uploader edits it. At most `MEDIAHUB_PICS_AI_PER_DAY` (300) pictures a
182 day for the site, `pics.image.describe` per person; off with `MEDIAHUB_PICS_AI=off` or without `OV_AI_INTERNAL_URL`.
183 Network grants `media-hub` the `pics.describe_image` namespace on `ai.run.create`.
184- **Unlisted:** `/i/<slug>` (8 random characters) is reachable by its link only, noindex, never listed; robots.txt
185 disallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCode
186 and HTML.
187- **Albums** (`/albums`, `/a/<slug>`): a person's own, shown to anyone with the link (hidden pictures left out).
188- **Reports and takedown:** anyone may report a picture once (signed out: counted by a keyed hash of the address).
189 A child-safety report hides it at once; any other reason hides it at 3 distinct reports. Hidden is 404 for everyone
190 but its uploader and staff. Staff (`/staff`, Network `admin`/`global_mod`) restore, hide, or take down: a takedown
191 deletes the bytes in Media and puts the picture's sha256 (uploaded and served) and its 64-bit difference hash on
192 `pics_blocklist`, so the same picture, or a resized, re-compressed or retouched copy (within 10 bits: measured on
193 real photos, such copies land 0–10 bits away and different photos 23–42), cannot be uploaded again.
194- **Signed out** (`MEDIAHUB_PICS_ANONYMOUS=1`, **off**): uploads up to 10 MB, 20 a day per address, each with a delete
195 key shown once and stored only as its sha256; a picture nobody has viewed for 30 days is deleted by an hourly sweep.
196 It stays off until the owner registers the DMCA designated agent and the CSAM hash-matching account (NCMEC /
197 Cloudflare CSAM Scanning Tool): plan T10's gate.
198- **Legal:** `/terms`, `/privacy` and `/dmca` name openvibe.pics (openvibe-shared/legal, the `ugc` profile).
199
200API on the pics host (`/api/v1`, a person; app, agent and service tokens are refused):
201
202| Route | |
203|---|---|
204| `POST /images` | upload: the picture is the body (`Content-Type: image/*`; optional `X-Title`, `X-Filename`, URI-encoded); 201 with the links |
205| `GET /images/:slug` | metadata and links (hidden: uploader and staff only; 410 when taken down) |
206| `PATCH /images/:slug` | the uploader: `{ title, description, alt }` |
207| `POST /images/:slug/describe` | the uploader: OpenVibe.AI drafts the alt text, a caption and tags (the picture goes to OpenVibe.AI only on this call) |
208| `DELETE /images/:slug` | the uploader (signed out: `X-Delete-Key`) |
209| `POST /images/:slug/reports` | anyone: `{ reason, note }`; `reason` one of `child_safety`, `illegal`, `copyright`, `sexual`, `violence`, `harassment`, `private_info`, `spam`, `other` |
210| `GET /me/images` | your pictures, newest first, with your usage |
211| `GET /albums`, `POST /albums` | your albums; create one `{ title, description }` |
212| `GET /albums/:slug` | an album and its pictures |
213| `POST /albums/:slug/images`, `DELETE /albums/:slug/images/:image`, `DELETE /albums/:slug` | the owner adds or takes out a picture, or deletes the album (the pictures stay) |
214
215## OpenVibe.Video (openvibe.video)
216
217OpenVibe.Video serves watch pages for videos people upload here, and for the recordings and clips people already
218made public on OpenVibe.Live, plus playlists. Live's media files stay on OpenVibe.Media (`playback_url`), and the
219pages read Live's public JSON API server-side, signed out (Live already applies visibility: a private item answers
220404, an unlisted one is reachable by id but noindex and never listed).
221
222**Uploads** (since 2026-10-15; `server/video/uploads.js`, `upload-routes.js`, migration `0008_video_uploads.sql`).
223- **Who and what:** a signed-in person uploads at `/upload`. MP4, WebM and MKV are accepted; MOV and M4V are the same
224 container and go to Media as MP4.
225- **Limits:** `VIDEO_UPLOAD_MAX_BYTES` (2 GB) a file, `VIDEO_UPLOAD_QUOTA_BYTES` (20 GB) a person,
226 `VIDEO_UPLOADS_PER_DAY` (10).
227- **How the bytes travel:**
228 - `public/js/video-upload.js` sends the file in 8 MB parts, in order: `POST /api/v1/videos`, then
229 `PUT /api/v1/videos/:id/parts/:n` (nginx allows 9 MB on that route only), then `POST …/complete`. Every call is
230 same-origin and signed in.
231 - Each part is appended to one **OpenVibe.Media VOD** in this tenant (`/api/v1/media-hub/vods`, the
232 `media.object.upload` grant). The video therefore gets what a Live recording gets: probe, thumbnail, seekable file,
233 HLS timeline and renditions.
234 - Completion runs in the background: the upload is *processing* until Media is done. The watch page says so and
235 reloads itself (`Refresh: 15`), and a restart is recovered by asking Media again when the page or "Your videos" is
236 read.
237- **Where it shows:** the watch page is `/u/<id>`. A public, ready video is on the person's channel (`/@username`,
238 as **Videos**), on the home page's **Latest uploads** and in the sitemap. Unlisted videos open by link only;
239 private ones only for the owner and staff.
240- **Owner tools:** the owner edits the title, description and visibility (Media's title and visibility follow) and
241 deletes (Media's VOD is deleted). `/me/videos` lists their uploads with their state.
242- **Reports:** anyone who can see a video may report it, once per person or per keyed address. A child-safety report
243 hides it at once, and three reports hide it until staff decide; the owner still sees it, labelled.
244- **Account deletion:** erases the rows and deletes the VODs in Media.
245- **Recording** (`/record`, `public/js/video-record.js`):
246 - Loads the SRI-pinned Cam kit from `openvibe.cam` on the Video page. The person records a camera or screen clip,
247 reviews, trims and chooses Use this. Nothing uploads until that choice.
248 - The returned `File` goes through the ordinary multipart upload form, with progress and retries. A recording is
249 private by default. The older `/api/v1/videos/recordings` chunk API remains available to its existing callers.
250- **Edit in Studio** (`server/video/studio.js`): the finished-recording watch page and each ready row on
251 `/me/videos` link to `https://openvibe.studio/edit?media=<vup id>&from=video` (`MEDIAHUB_STUDIO_URL`), and the
252 record completion response carries the same `edit_url`. `MEDIAHUB_STUDIO_VIDEO_EDIT=off` (the switch is **on** by default) hides those three
253 actions while leaving the hand-off API below available.
254 - Studio is another origin and has no Video cookie, so it calls these routes server-to-server with the person's
255 own Network token as a Bearer: `GET /api/v1/video/studio/media/:id` (metadata and where it came from),
256 `GET …/media/:id/bytes` (Range reads for the editor preview), `POST …/studio/publish` and
257 `GET …/studio/cuts/:id` (how the trim is going).
258 - `:id` may be the upload id (`vup_…`), the `/watch?v=` id or Media's VOD id. The read rule is the watch page's
259 (owner, staff, or a ready public/unlisted item); a private item of someone else answers 404 exactly like a
260 missing id. Only the owner may publish, and a cookie from this site cannot call these routes.
261 - Publishing sends a `start`/`end` range, an optional `cover` second, title and visibility. `videoCuts.request`
262 queues a **trim** through the existing cuts worker (`server/video/cut.js`, migration `0029_video_trim.sql`):
263 ffmpeg reads the original through Media's signed URL and re-encodes exactly that range as a new upload of the
264 owner's. The copy names its original in `video_uploads.edited_from`; the original is unchanged. After Media
265 finishes the copy, MediaHub takes the chosen frame with ffmpeg and stores it through Media's own custom-thumbnail
266 route, then records the cover URL on the copy. A range under one second or outside the video is refused with a
267 plain sentence.
268 - The worker is one cut at a time; the Studio page polls the cut until it is done. A copy that fails loses its
269 partial upload and reports the plain reason.
270- **AI edit** (`server/video/ai-edit.js`, migration `0009_video_ai.sql`):
271 - Every few minutes, OpenVibe.AI's `media.analyze` runs on each finished upload or recording. It reads the video
272 through Media's signed URL (private ones too), runs locally (whisper.cpp, the local model) and never uses a paid
273 model (`allow_paid: false`).
274 - The transcript becomes captions (`/u/:id/captions.vtt`, a track) and a transcript panel; the highlights become
275 chapters (`/u/:id/chapters.vtt` and a list); the overview becomes an "About this video" summary, labelled as AI,
276 which the owner can make the description in one click.
277 - A run still going is joined again by its key; a failure retries with backoff, 4 attempts at most.
278 - Needs `OV_AI_INTERNAL_URL` and the Network grant `[media-hub, ai.run.create, openvibe.ai, ['media.analyze']]`;
279 `VIDEO_AI_ANALYSIS=off` turns it off.
280- **AI cut** (`server/video/cut.js`, migration `0015_video_cuts.sql`): the owner's "AI edit" panel on a finished upload
281 makes an edited copy and never touches the original.
282 - **Cut the pauses** (pauses of 1.5 s or more become half a second, lead-in and tail silence go), **cut tight** (0.7 s
283 or more become 0.3 s), or a **highlights reel** (the best six moments the AI edit found, at most 3 minutes; older
284 AI edits fall back to the chapter starts).
285 - One cut at a time on the host, under `nice 19` with two threads: ffmpeg reads the original through Media's signed
286 URL on `MEDIAHUB_MEDIA_URL`, finds the pauses (`silencedetect` at -40 dB, sound only), re-encodes the kept ranges
287 (trim and concat, H.264 veryfast and AAC, at most 1080 lines; a browser recording may have one keyframe in minutes,
288 so it is not cut on keyframes) as fragmented MP4 on stdout, and sends it to Media in 8 MB pieces as it is made: no
289 file on MediaHub's disk.
290 - The copy is a new private upload of the same person (`video_uploads.edited_from`) through the recording path, so
291 it gets Media's processing, its own AI captions and chapters, and counts toward the daily uploads and the quota.
292 - One waiting or running cut per person, `VIDEO_CUTS_PER_DAY` (5) a day, originals up to
293 `VIDEO_CUTS_MAX_SOURCE_SECONDS` (3 h); a cut a restart interrupted loses its partial copy and is retried once.
294 `POST /u/:id/cut`, `POST /api/v1/videos/:id/cuts` `{ mode }`, `GET …/cuts`; `VIDEO_CUTS=off` turns it off.
295
296The player plays Media's segment timeline (`hls_url`, HLS) when the recording has one: natively where the browser
297plays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded
298only then). The file stays the fallback: a recording without a timeline plays it, and a playlist that fails falls
299back to it once at the same position. When playback starts, the page sends one beacon from the viewer's browser to
300Live's `POST /api/views/:kind/:id`, so Live counts the view for the viewer's own address (the server-side reads all
301come from the loopback address). The CSP allows Media's hosts (openvibe.media, its shield edge.openvibe.media and
302the presigned R2/B2 hosts it may redirect a file to) and Live's origin for the beacon.
303
304**Structured data.** A public recording's page carries a schema.org `VideoObject` (name, description, thumbnail, upload
305time, ISO 8601 duration, the file, the view count, the channel as author) with its chapters as `Clip` parts (Google's
306key moments, each a `?t=` link); a clip's page has its own `VideoObject`, `isPartOf` its recording; a public
307playlist's page an `ItemList`. The thumbnail is the page's share image (`og:image`, `og:type` `video.other`). AI clips
308stay out of search as on Live (noindex, no structured data), and so do a recording in progress and an owner's private
309view.
310
311**Owner tools.** A recording's channel, and a clip's maker or channel, see a "Manage" panel on the watch page: title,
312description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends to
313OpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), so
314Live's rules decide who may do what and its refusal is shown as it is; the cached reads of the item and of the lists
315are dropped after a change. A private item is shown to its owner only (noindex, never cached).
316
317**Make a clip.** Any signed-in viewer of a finished recording cuts a clip of up to 60 seconds from its page (start and
318end as m:ss or h:mm:ss; "Start here" / "End here" mark the player's position). openvibe.video sends it to Live's own
319`POST /api/vods/clips` as the person, with their address in `X-Forwarded-For` (Live limits clip-making per person and
320per address), so Live's rules, limits and channel settings apply; the new clip's page says it is being cut, and
321checks again by itself, until Media has cut it.
322
323The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link to
324the OpenVibe.Community thread. The video host signs in through the same Network SSO as the drive, with its own
325callback URI (`https://openvibe.video/auth/callback`); each host's session cookie is host-only, so signing in on one
326does not sign in on the other.
327
328| Path | Who | What |
329|---|---|---|
330| `/` | anyone | what OpenVibe.Video is, then the latest recordings and clips |
331| `/v/:id` | anyone | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips, add-to-playlist |
332| `/c/:id` | anyone | a clip watch page: player, attribution, the full-recording link, add-to-playlist |
333| `POST /v/:id/clip` | any signed-in viewer (Live decides) | cut a clip `{ start, end, title? }` of up to 60 seconds |
334| `POST /v/:id/manage`, `POST /v/:id/delete` | the recording's channel (Live decides) | edit title, description and visibility; delete (a ticked box) |
335| `POST /c/:id/manage`, `POST /c/:id/delete` | the clip's maker or channel (Live decides) | edit title and visibility; delete |
336| `/@:username` | anyone | a channel's recordings (and clips), newest first, 24 per page |
337| `/playlists` | the owner | their playlists: create, rename, change visibility, delete |
338| `/playlists/:id` | anyone with the link | one playlist: public and unlisted are open by link (unlisted noindex), private only to the owner |
339| `/v/:id/captions.vtt` | anyone | the AI transcript as WebVTT captions |
340| `/v/:id/chapters.vtt` | anyone | the AI moments as WebVTT chapters |
341| `/v/:id/download/:file` | whoever may see the recording (a private one: its owner) | the Download menu's files: `video` (OpenVibe.Media's file as an attachment, through Live's download link), `thumbnail`, `transcript.{txt,srt,vtt,json}`, `chat.{txt,csv,json}` (every message, in the recording's own time), `chapters.{txt,vtt,json}` (TXT is the lines a YouTube description takes), `info.json`, `all.zip` (everything but the video); named `<title> - <channel> - <date>.<what>.<ext>` ([server/video/downloads.js](server/video/downloads.js)) |
342| `/c/:id/download/:file` | whoever may see the clip | `video`, `thumbnail`, `transcript.{txt,srt,vtt,json}`, `info.json`, `all.zip` |
343| `/sitemap.xml`, `/robots.txt`, `/llms.txt` | anyone | crawl artifacts for the video host |
344| `/<INDEXNOW_KEY>.txt` | anyone | IndexNow's key file, only with a key: every 10 minutes a sweep of Live's public feed sends new watch pages, their channel pages and the sitemap to search engines ([server/video/indexnow.js](server/video/indexnow.js)) |
345
346Configuration: `MEDIAHUB_VIDEO_URL` (default `https://openvibe.video`; its hostname is the video host),
347`MEDIAHUB_LIVE_URL` (default `https://openvibe.live`; production points it at the loopback address),
348`MEDIAHUB_LIVE_PUBLIC_URL` (default `https://openvibe.live`; links people click) and `MEDIAHUB_CHAT_URL`
349(default `https://openvibe.live`, which proxies `/api/chat/` to OpenVibe.Chat; production points it at the loopback
350address). The video host is chosen by `Host` header in `server/app.js`, so the drive's pages, API and legal pages
351never answer there. Only https URLs from Live (player source, thumbnails, the thread link) reach a page; anything
352else is dropped.
353
354Playlists: 100 per owner and 500 items per playlist, from `MEDIAHUB_VIDEO_PLAYLISTS_MAX` and
355`MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX`. A playlist holds a kind and a number: a Live id (`vod` or `clip`) or an upload's number (`upload`, the `seq` of
356`video_uploads`, migration 0010); the
357watch pages fetch the cards through the cached Live client, and an item Live no longer answers is "No longer
358available" to the owner and hidden for everyone else. "Play all" starts the first item with `?list=<id>`, and each
359watch page opened with `?list=` shows "Up next" and advances when the video ends. A playlist may also be owned by
360an app, agent or service through the API below; its public page attributes it "Curated by an app".
361
362A channel page (`/@user`) lists that person's public playlists (newest twelve). An upload's watch page has the same
363"Save to playlist" form (a ready video that is not private) and the same "Up next" panel; an upload that is made
364private, hidden or removed drops out of everyone's playlists (its owner's list shows "No longer available").
365
366## Pages
367
368| Path | Who | What |
369|---|---|---|
370| `/` | anyone | openvibe.download's home: what it is, the safety rules, sign in. (openvibe.video is served by this same app; openvibe.pics is parked in nginx) |
371| `/files`, `/files/:id` | the owner | your drive: folders, files, the usage bar, upload, share, delete |
372| `/s/:slug` | a signed-in person | a share page: the name, the size, who shared it (their username), the expiry, Download, Report |
373| `/staff` | staff | the reports queue: restore or remove |
374| `/safety` | anyone | what is not allowed, how reports work, [email protected] |
375| `/updates` | anyone | what shipped |
376
377## Uploads without an account (drops, off)
378
379The owner's Q10 (MediaFire-style), built and **off** until the DMCA designated agent and the CSAM hash-matching account
380exist (plan T10, the same gate as Pics' signed-out uploads): `MEDIAHUB_DOWNLOAD_ANONYMOUS=1` turns it on.
381
382- The home page (signed out) gets an upload box ([public/js/drop.js](public/js/drop.js)): `POST /api/v1/drops`
383 answers a session and a token, the parts are `PUT /api/v1/drops/:id/parts/:n` with `X-Drop-Token`, and
384 `POST /api/v1/drops/:id/complete` answers the public page `/d/<slug>` and a delete link shown once (the key's sha256 is
385 kept). The token, not the address, holds the session, so a phone that changes network keeps uploading.
386- Up to 2 GB a file, 10 files and 10 GB a day per address (`MEDIAHUB_DROP_*`), an ownerless OpenVibe.Media object,
387 kept 30 days after the last download (an hourly sweep deletes the rest). `mh_drops`, migration 0012.
388- `/d/<slug>`: name, size, downloads, Download (an attachment with the drive's headers), and a Report form. Any report
389 of illegal content, or three distinct ones, suspends it; staff restore it or take it down from `/staff`, and a
390 taken-down drop's sha256 goes on `mh_drop_blocklist`, so the same bytes are refused (451 `drop.blocked`).
391
392## Backups: sync keys and `openvibe-download`
393
394A person keeps a folder on their computer backed up in their drive with the `openvibe-download` command
395(openvibe-sdk 0.49.0, `openvibe-sdk/download`): `openvibe-download sync ~/Documents --to Backups/laptop` sends what is
396new or changed (same size and sha256 is skipped), keeps the folder tree, and with `--delete` removes drive files whose
397local copy is gone. It authenticates with a **sync key** made on `/sync` (`OPENVIBE_DOWNLOAD_KEY`):
398
399- `ovdl_` and 43 url-safe characters, shown once; only its sha256 is stored (`mh_sync_keys`, migration 0011), with
400 the first 12 characters to tell keys apart. 10 live keys per person (`MEDIAHUB_SYNC_KEYS_MAX`).
401- It acts for its owner on the drive's `/api/v1` only (the router checks it before the shared principal): files,
402 folders, uploads and usage. Share links answer 403 `sync_key.not_allowed`, and Pics and Video never see it.
403- Its uploads have their own daily cap (`MEDIAHUB_SYNC_UPLOADS_PER_DAY`, 1,000, against the browser's 50) and their
404 own request budgets (`media-hub.sync.upload.start|complete`, 120 a minute, 1,000 an hour); storage and the per-file
405 size are capped the same. A revoked or unknown key is 401 `sync_key.invalid`.
406- The account export lists keys without their hashes; deleting the account deletes them.
407
408## API (`/api/v1`, a signed-in person, or their sync key)
409
410| Route | |
411|---|---|
412| `GET /files`, `POST /files`, `GET /files/:id`, `DELETE /files/:id` | your files |
413| `GET /folders`, `POST /folders` | your folders |
414| `POST /shares`, `DELETE /shares/:slug` | make (`{ file_id \| folder_id, hours, usernames? }`) and revoke share links |
415| `GET /agent-grants`, `POST /agent-grants`, `DELETE /agent-grants/:id` | list, make and revoke a read-only grant for one agent and one file; grants expire after 1–720 hours |
416| `GET /agent/files/:id/content` | agent-only `download.file.read`; streams the granted file with byte-range support |
417| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |
418| `GET /usage` | your usage against the limits |
419
420On the video host (openvibe.video), the playlist API accepts a person Bearer token, an app/agent/service token
421with the right capability, or (for public reads) no token — see **Playlist API** below. On the drive API, the
422agent file-content route requires both an agent token with `download.file.read` and that agent's active per-file grant.
423Other drive routes remain person-only, except for the owner's scoped sync key.
424
425## Playlist API
426
427The video host's playlist API is `/api/v1/video/*`. The caller is the existing principal
428(`server/http/principal.js`): a person's own Network token as `Authorization: Bearer` works exactly like the
429session, an app/agent/service token needs the capability the route names, and public reads are open to anyone.
430
431| Route | Who | What |
432|---|---|---|
433| `GET /api/v1/video/playlists` | person, or app with `video.playlist.read` | the caller's own playlists, newest first |
434| `POST /api/v1/video/playlists` | person, or app with `video.playlist.manage` | create one from `{ title, description?, visibility? }` |
435| `GET /api/v1/video/playlists/:id` | owner (private too); anyone for public/unlisted; app with `video.playlist.read` for others' public/unlisted | one playlist and its items |
436| `PATCH /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | rename / describe / change visibility |
437| `DELETE /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | delete it (its items go with it) |
438| `POST /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | add one `{ kind: 'vod'|'clip'|'upload', id }` |
439| `PATCH /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | reorder: the full ordered list `{ items: [{ kind, id }, …] }` |
440| `DELETE /api/v1/video/playlists/:id/items/:kind/:itemId` | owner, or app with `video.playlist.manage` | remove one |
441| `GET /api/v1/video/users/:username/playlists` | anyone | that person's public playlists, newest first, `limit` (default 20, max 50) and an opaque `cursor` |
442
443An app/agent/service owns a playlist under its requester (`app:app_…`, `agent:agt_…`, `service:<slug>`) and the
444playlist records its `project_id`. Its Network token is minted for the media hub's audience, `openvibe.download`, as
445for the drive (one service, one audience). A person's session-cookie writes must be same-origin; a person Bearer token needs
446no capability. A private playlist answers `404 playlist.not_found` to anyone but its owner (never 403). Errors are
447RFC 9457 problem+json with a stable code (`token.required`, `capability.denied`, `playlist.not_found`, and the
448playlist validation codes).
449
450Create with an app token:
451
452```bash
453curl -sS https://openvibe.video/api/v1/video/playlists \
454 -H 'Authorization: Bearer <app token>' \
455 -H 'Content-Type: application/json' \
456 -d '{"title":"Clipped moments","visibility":"unlisted"}'
457```
458
459A public read (no token) returns the playlist and its items:
460
461```bash
462curl -sS https://openvibe.video/api/v1/video/playlists/<id>
463```
464
465```json
466{
467 "playlist": {
468 "id": "vpl_…", "title": "…", "description": "…", "visibility": "unlisted",
469 "owner": { "kind": "user", "username": "kim" },
470 "item_count": 2, "created_at": "…", "updated_at": "…"
471 },
472 "items": [{ "kind": "vod", "id": 101, "position": 0, "added_at": "…" }]
473}
474```
475
476`owner` is `{ kind: 'user', username }` for a person, or `{ kind: 'app' | 'agent' | 'service' }` otherwise. Items
477are Live ids (`vod`, `clip`) or upload numbers (`upload`); the watch pages resolve their display fields lazily, through
478the cached Live client for Live's and from `video_uploads` for uploads.
479
480## Account export and deletion (ADR-033)
481
482`POST /internal/events` (v2 signature under `MEDIAHUB_EVENTS_SECRET`, loopback only; nginx answers `/internal/` with 404)
483takes `network.account.export_requested` and `network.account.deleted`, and the subscriptions are created at boot
484(`MEDIAHUB_EVENTS_URL`).
485
486- **Export:** the person's files, folders, shares, the reports they made and their usage; their playlists; and their
487 OpenVibe.Pics pictures, albums (with their pictures' order), the reports they filed and their upload counts.
488- **Deletion:** all of it is erased, and each object (a file, a picture and its thumbnail) is deleted in Media too.
489 The takedown blocklist is staff's record, not the person's, and stays.
490
491## Configuration
492
493Every name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internal
494URL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`),
495Live (`MEDIAHUB_LIVE_URL`, `MEDIAHUB_LIVE_PUBLIC_URL`), Video (`MEDIAHUB_VIDEO_URL`), Pics (`MEDIAHUB_PICS_URL` and the
496`MEDIAHUB_PICS_*` limits, `MEDIAHUB_PICS_ANONYMOUS`),
497Events, and the limits (`MEDIAHUB_QUOTA_BYTES`, `MEDIAHUB_MAX_FILE_BYTES`,
498`MEDIAHUB_UPLOADS_PER_DAY`, `MEDIAHUB_MAX_SHARES`, `MEDIAHUB_SINGLE_UPLOAD_BYTES`, `MEDIAHUB_PART_BYTES`).
499
500## Development
501
502```bash
503npm install
504cp .env.example .env
505npm run dev # http://127.0.0.1:4990, PGlite under data/
506npm test # every test/*.test.js on PGlite, with stand-ins for Network and Media
507```
508
509## Acceptance
510
511`npm test` ([test/run.js](test/run.js)) runs every `test/*.test.js` in its own process on a temporary PGlite
512database, with in-process stand-ins for OpenVibe.Network and OpenVibe.Media. The main files:
513
514- `auth-ops.test.js`, `auth-jwks.test.js` — sign-in redirects with PKCE S256 and cookies, truthful readiness, and
515 JWKS fetch/verify through an outage.
516- `drive-upload.test.js` — the plain form and the chunked path, resume after a dropped part, and the 512 MB / 2 GB /
517 50-a-day caps.
518- `drive-download.test.js` — every download is an attachment with nosniff and a sandbox CSP, the real type only for
519 the safe list, and Range resumes.
520- `drive-shares.test.js` — a share is 24 random characters, expires in 1 hour to 7 days, is revocable, counted and
521 sign-in only, and matches its allow-list on subjects.
522- `drive-reports.test.js` — one report per person per share, three distinct reports or one illegal report suspend
523 it, and staff restore or remove.
524- `drive-pages.test.js` — every page renders without JavaScript and everything typed is escaped everywhere shown.
525- `form-upload.test.js` — the multipart reader streams to disk and refuses an over-long field, a second file and a
526 cut-off body.
527- `account-data.test.js` — signed export and deletion on the loopback route, with the Media objects deleted.
528- `caller-limits.test.js` — one caller's 429 arrives before the route runs, another caller still passes, and the
529 window reopens on the clock.
530- `security-secrets.test.js`, `security-session.test.js`, `no-internal-key.test.js`, `open-redirect.test.js` —
531 secrets never leave in a body, header or log, the session cookie holds a person's Network session, no
532 `X-Internal-Key` path exists, and the sign-in `next` never leaves the site.
533- `nginx-auth-limit.test.js`, `nginx-parked-domains.test.js` — the nginx rate zones and the parked-domain redirects.
534- `discovery.test.js`, `layout.test.js`, `asset-cache.test.js`, `perf-budget.test.js`, `service-kit.test.js` — crawl
535 artifacts, page layout, asset cache headers, home-page size budgets and the graceful stop.
536
537## Deploy
538
539`sudo ovhost deploy media-hub` on the host (unit [deploy/systemd/openvibe-media-hub.service](deploy/systemd/openvibe-media-hub.service),
540env `/etc/openvibe/media-hub.env`). The vhost [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf)
541serves openvibe.download and openvibe.video (both proxy to the app on 127.0.0.1:4990) and parks openvibe.pics with a
542302 to `https://openvibe.network/` (each name with its own certificate); it is installed with `ov-vhost-install`.
543Uploads stream through nginx unbuffered; downloads are not buffered either.
544
545## Security (threat notes)
546
547Reporting a vulnerability: [SECURITY.md](SECURITY.md).
548
549- Uploads are account-only and files are private. A share link needs a signed-in downloader unless its owner made it
550 public; a public link's page takes reports from anyone (a signed-out reporter is a keyed hash of the address, never
551 the address), and three reports, or one of illegal content, suspend it. No download renders as a page.
552- The form reader streams files to disk. A text field over 64 KB, a second file, or a body cut off before its last
553 boundary is refused.
554- Session tokens are httpOnly cookies. Secrets live only in the env file, and no secret or request body is logged.
555- Next, before Pics or Download take signed-out uploads: scanning (hash lists, malware), a DMCA agent, and abuse intake
556 shared with OpenVibe.Media's holds. Signed-in uploads are open on all three sites.
557
558---
559
560Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
561
562<!-- versions:start -->
563- openvibe-contracts: v0.419.0
564- openvibe-sdk: v0.161.0
565- openvibe-shared: v4.241.0
566<!-- versions:end -->
567
568### Public Pics gallery
569
570`GET /api/v1/gallery?sort=popular|new|rising&q=&tag=&cursor=&limit=30` lists only active,
571explicitly published pictures whose sharing audience is Everyone. A new upload is unlisted: anyone with
572the link can open it and nobody else, and no picture is opted in by migration 0028. The owner publishes using
573`POST /api/v1/images/:slug/publication` with `{ "listed": true, "tags": ["landscape"] }`.
574Setting `listed` false removes discovery without changing access; narrowing Share also clears publication.
575`GET /api/v1/pictures/:id` respects the current audience, and authenticated readers can retrieve raster
576bytes at `GET /api/v1/images/:slug/bytes`. Public likes are idempotent PUT/DELETE `/images/:slug/like`.
577Views count one keyed address per day on public picture pages; private pictures expose no counts.
578
579Resumable uploads: POST `/api/v1/uploads` with `{size, content_type, name, title}`, PATCH `/uploads/:id`
580with raw chunks up to 1 MiB and `Upload-Offset`, GET the same resource to resume, POST
581`/uploads/:id/complete` to re-encode and save privately, or DELETE to cancel. Sessions expire after a day;
582four unfinished uploads per person, bounded by the ordinary image size, quota and daily limits. Complete
583is idempotent. Expired chunks are reclaimed when new sessions are created. All writes require a person;
584session-cookie writes must be same-origin. `pics.picture@1` and `pics.gallery@1` describe the responses.
585
586Cross-site rollout: deploy the Community Pics discussion policy before enabling discussions; it checks
587current public visibility on every read. Studio's image-tool adapter requires `STUDIO_IMAGE_WORKER_URL`
588and returns an explicit unavailable state until a worker is configured. No image worker was invoked in
589this change. Cam already posts captured pictures to the existing upload endpoint. Shared `ov-upload`
590is not in the pinned Shared release yet; Pics retains its existing drop/paste/choose uploader.
591