openvibers/openvibe.mediahub README.md
README.md
| 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 |
| 9 | sync keys), **openvibe.video** (uploads and browser recordings with AI captions, chapters and the AI cut, watch pages and |
| 10 | playlists, and OpenVibe.Live's stream recordings and clips) and **openvibe.pics** (picture hosting with resize, convert |
| 11 | and 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 | |
| 16 | OpenVibe.MediaHub is the network's file service: it serves **openvibe.download** as a private drive where a |
| 17 | signed-in person can upload, organise, share and delete their own files. The bytes live in OpenVibe.Media; MediaHub |
| 18 | keeps the metadata, the per-person limits, the share links and the report queue. It also answers OpenVibe.Network's |
| 19 | account export and deletion events (ADR-033). The same process serves **openvibe.video**, the public watch pages for |
| 20 | OpenVibe.Live's stream recordings and clips (OpenVibe.Video v1, step A), and **openvibe.pics**, OpenVibe.Pics (below). |
| 21 | |
| 22 | ## Owns |
| 23 | |
| 24 | The drive's own tables, and it is the authority for them (`migrations/0002_mediahub.sql`; `migrations/0001_initial.sql` |
| 25 | creates 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 | |
| 79 | The 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`. |
| 82 | The 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 |
| 84 | any 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 |
| 86 | agent. A capability never lists the drive or replaces the per-file grant. Every other drive write still requires a |
| 87 | signed-in person (or that person's scoped sync key). |
| 88 | |
| 89 | The signed-in `/files` root and search also show that person's Pics images and ready Video uploads as read-only |
| 90 | entries. Their bytes and lifecycle remain with Pics and Video; opening an entry goes to its owning service. Those |
| 91 | source entries are not yet eligible for Download agent grants, which apply to files uploaded into the drive. |
| 92 | |
| 93 | On 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 | |
| 110 | Public file hosting is the riskiest surface on the network: OpenVibe.Media has no malware or CSAM scanning yet. So |
| 111 | OpenVibe.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 | |
| 140 | Bytes live in **OpenVibe.Media** (`MEDIAHUB_MEDIA_URL`, app `media-hub`). MediaHub uses its Network service token and |
| 141 | calls 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 | |
| 156 | MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql). |
| 157 | |
| 158 | ## OpenVibe.Pics (openvibe.pics) |
| 159 | |
| 160 | An 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 | |
| 200 | API 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 | |
| 217 | OpenVibe.Video serves watch pages for videos people upload here, and for the recordings and clips people already |
| 218 | made public on OpenVibe.Live, plus playlists. Live's media files stay on OpenVibe.Media (`playback_url`), and the |
| 219 | pages read Live's public JSON API server-side, signed out (Live already applies visibility: a private item answers |
| 220 | 404, 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 | |
| 296 | The player plays Media's segment timeline (`hls_url`, HLS) when the recording has one: natively where the browser |
| 297 | plays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded |
| 298 | only then). The file stays the fallback: a recording without a timeline plays it, and a playlist that fails falls |
| 299 | back to it once at the same position. When playback starts, the page sends one beacon from the viewer's browser to |
| 300 | Live's `POST /api/views/:kind/:id`, so Live counts the view for the viewer's own address (the server-side reads all |
| 301 | come from the loopback address). The CSP allows Media's hosts (openvibe.media, its shield edge.openvibe.media and |
| 302 | the 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 |
| 305 | time, ISO 8601 duration, the file, the view count, the channel as author) with its chapters as `Clip` parts (Google's |
| 306 | key moments, each a `?t=` link); a clip's page has its own `VideoObject`, `isPartOf` its recording; a public |
| 307 | playlist's page an `ItemList`. The thumbnail is the page's share image (`og:image`, `og:type` `video.other`). AI clips |
| 308 | stay out of search as on Live (noindex, no structured data), and so do a recording in progress and an owner's private |
| 309 | view. |
| 310 | |
| 311 | **Owner tools.** A recording's channel, and a clip's maker or channel, see a "Manage" panel on the watch page: title, |
| 312 | description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends to |
| 313 | OpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), so |
| 314 | Live'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 |
| 315 | are 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 |
| 318 | end 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 |
| 320 | per address), so Live's rules, limits and channel settings apply; the new clip's page says it is being cut, and |
| 321 | checks again by itself, until Media has cut it. |
| 322 | |
| 323 | The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link to |
| 324 | the OpenVibe.Community thread. The video host signs in through the same Network SSO as the drive, with its own |
| 325 | callback URI (`https://openvibe.video/auth/callback`); each host's session cookie is host-only, so signing in on one |
| 326 | does 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 | |
| 346 | Configuration: `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 |
| 350 | address). The video host is chosen by `Host` header in `server/app.js`, so the drive's pages, API and legal pages |
| 351 | never answer there. Only https URLs from Live (player source, thumbnails, the thread link) reach a page; anything |
| 352 | else is dropped. |
| 353 | |
| 354 | Playlists: 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 |
| 357 | watch pages fetch the cards through the cached Live client, and an item Live no longer answers is "No longer |
| 358 | available" to the owner and hidden for everyone else. "Play all" starts the first item with `?list=<id>`, and each |
| 359 | watch page opened with `?list=` shows "Up next" and advances when the video ends. A playlist may also be owned by |
| 360 | an app, agent or service through the API below; its public page attributes it "Curated by an app". |
| 361 | |
| 362 | A 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 |
| 364 | private, 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 | |
| 379 | The owner's Q10 (MediaFire-style), built and **off** until the DMCA designated agent and the CSAM hash-matching account |
| 380 | exist (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 | |
| 394 | A 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 |
| 396 | new or changed (same size and sha256 is skipped), keeps the folder tree, and with `--delete` removes drive files whose |
| 397 | local 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 | |
| 420 | On the video host (openvibe.video), the playlist API accepts a person Bearer token, an app/agent/service token |
| 421 | with the right capability, or (for public reads) no token — see **Playlist API** below. On the drive API, the |
| 422 | agent file-content route requires both an agent token with `download.file.read` and that agent's active per-file grant. |
| 423 | Other drive routes remain person-only, except for the owner's scoped sync key. |
| 424 | |
| 425 | ## Playlist API |
| 426 | |
| 427 | The 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 |
| 429 | session, 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 | |
| 443 | An app/agent/service owns a playlist under its requester (`app:app_…`, `agent:agt_…`, `service:<slug>`) and the |
| 444 | playlist records its `project_id`. Its Network token is minted for the media hub's audience, `openvibe.download`, as |
| 445 | for the drive (one service, one audience). A person's session-cookie writes must be same-origin; a person Bearer token needs |
| 446 | no capability. A private playlist answers `404 playlist.not_found` to anyone but its owner (never 403). Errors are |
| 447 | RFC 9457 problem+json with a stable code (`token.required`, `capability.denied`, `playlist.not_found`, and the |
| 448 | playlist validation codes). |
| 449 | |
| 450 | Create with an app token: |
| 451 | |
| 452 | ```bash |
| 453 | curl -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 | |
| 459 | A public read (no token) returns the playlist and its items: |
| 460 | |
| 461 | ```bash |
| 462 | curl -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 |
| 477 | are Live ids (`vod`, `clip`) or upload numbers (`upload`); the watch pages resolve their display fields lazily, through |
| 478 | the 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) |
| 483 | takes `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 | |
| 493 | Every name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internal |
| 494 | URL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`), |
| 495 | Live (`MEDIAHUB_LIVE_URL`, `MEDIAHUB_LIVE_PUBLIC_URL`), Video (`MEDIAHUB_VIDEO_URL`), Pics (`MEDIAHUB_PICS_URL` and the |
| 496 | `MEDIAHUB_PICS_*` limits, `MEDIAHUB_PICS_ANONYMOUS`), |
| 497 | Events, 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 |
| 503 | npm install |
| 504 | cp .env.example .env |
| 505 | npm run dev # http://127.0.0.1:4990, PGlite under data/ |
| 506 | npm 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 |
| 512 | database, 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), |
| 540 | env `/etc/openvibe/media-hub.env`). The vhost [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf) |
| 541 | serves openvibe.download and openvibe.video (both proxy to the app on 127.0.0.1:4990) and parks openvibe.pics with a |
| 542 | 302 to `https://openvibe.network/` (each name with its own certificate); it is installed with `ov-vhost-install`. |
| 543 | Uploads stream through nginx unbuffered; downloads are not buffered either. |
| 544 | |
| 545 | ## Security (threat notes) |
| 546 | |
| 547 | Reporting 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 | |
| 560 | Part 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, |
| 571 | explicitly published pictures whose sharing audience is Everyone. A new upload is unlisted: anyone with |
| 572 | the 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"] }`. |
| 574 | Setting `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 |
| 576 | bytes at `GET /api/v1/images/:slug/bytes`. Public likes are idempotent PUT/DELETE `/images/:slug/like`. |
| 577 | Views count one keyed address per day on public picture pages; private pictures expose no counts. |
| 578 | |
| 579 | Resumable uploads: POST `/api/v1/uploads` with `{size, content_type, name, title}`, PATCH `/uploads/:id` |
| 580 | with 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; |
| 582 | four unfinished uploads per person, bounded by the ordinary image size, quota and daily limits. Complete |
| 583 | is idempotent. Expired chunks are reclaimed when new sessions are created. All writes require a person; |
| 584 | session-cookie writes must be same-origin. `pics.picture@1` and `pics.gallery@1` describe the responses. |
| 585 | |
| 586 | Cross-site rollout: deploy the Community Pics discussion policy before enabling discussions; it checks |
| 587 | current public visibility on every read. Studio's image-tool adapter requires `STUDIO_IMAGE_WORKER_URL` |
| 588 | and returns an explicit unavailable state until a worker is configured. No image worker was invoked in |
| 589 | this change. Cam already posts captured pictures to the existing upload endpoint. Shared `ov-upload` |
| 590 | is not in the pinned Shared release yet; Pics retains its existing drop/paste/choose uploader. |
| 591 |