openvibers/openvibe.mediahub Commits
Video: uploads (Media VODs), watch pages at /u/:id, on channels and the home page (#67)
Changes
@@@ -175,10 +175,34 @@ API on the pics host (`/api/v1`, a person; app, agent and service tokens are ref## OpenVibe.Video (openvibe.video)--OpenVibe.Video is public and read-only: it serves watch pages for the recordings and clips people already made--public on OpenVibe.Live, plus playlists. There are no uploads and no new storage — the media files stay on--OpenVibe.Media (`playback_url`), and the pages read Live's public JSON API server-side, signed out (Live already--applies visibility: a private item answers 404, an unlisted one is reachable by id but noindex and never listed).++OpenVibe.Video serves watch pages for videos people upload here, and for the recordings and clips people already++made public on OpenVibe.Live, plus playlists. Live's media files stay on OpenVibe.Media (`playback_url`), and the++pages read Live's public JSON API server-side, signed out (Live already applies visibility: a private item answers++404, an unlisted one is reachable by id but noindex and never listed).++++**Uploads** (since 2026-10-15; `server/video/uploads.js`, `upload-routes.js`, migration `0008_video_uploads.sql`).++- **Who and what:** a signed-in person uploads at `/upload`. MP4, WebM and MKV are accepted; MOV and M4V are the same++ container and go to Media as MP4.++- **Limits:** `VIDEO_UPLOAD_MAX_BYTES` (2 GB) a file, `VIDEO_UPLOAD_QUOTA_BYTES` (20 GB) a person,++ `VIDEO_UPLOADS_PER_DAY` (10).++- **How the bytes travel:**++ - `public/js/video-upload.js` sends the file in 8 MB parts, in order: `POST /api/v1/videos`, then++ `PUT /api/v1/videos/:id/parts/:n` (nginx allows 9 MB on that route only), then `POST …/complete`. Every call is++ same-origin and signed in.++ - Each part is appended to one **OpenVibe.Media VOD** in this tenant (`/api/v1/media-hub/vods`, the++ `media.object.upload` grant). The video therefore gets what a Live recording gets: probe, thumbnail, seekable file,++ HLS timeline and renditions.++ - Completion runs in the background: the upload is *processing* until Media is done. The watch page says so and++ reloads itself (`Refresh: 15`), and a restart is recovered by asking Media again when the page or "Your videos" is++ read.++- **Where it shows:** the watch page is `/u/<id>`. A public, ready video is on the person's channel (`/@username`,++ as **Videos**), on the home page's **Latest uploads** and in the sitemap. Unlisted videos open by link only;++ private ones only for the owner and staff.++- **Owner tools:** the owner edits the title, description and visibility (Media's title and visibility follow) and++ deletes (Media's VOD is deleted). `/me/videos` lists their uploads with their state.++- **Reports:** anyone who can see a video may report it, once per person or per keyed address. A child-safety report++ hides it at once, and three reports hide it until staff decide; the owner still sees it, labelled.++- **Account deletion:** erases the rows and deletes the VODs in Media.The player plays Media's segment timeline (`hls_url`, HLS) when the recording has one: natively where the browserplays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded@@@ -319,6 +319,14 @@ server {location ~* ^/internal(/|$) { return 404; }location ~* ^/metrics(/|$) { return 404; }++ # Uploads (server/video/upload-routes.js): one 8 MB part per request, in order, unbuffered to MediaHub.++ location ~ ^/api/v1/videos/[^/]+/parts/[0-9]+$ {++ client_max_body_size 9m;++ proxy_request_buffering off;++ proxy_read_timeout 180s;++ proxy_pass http://127.0.0.1:4990;++ }++location / {proxy_pass http://127.0.0.1:4990;}nnew file mode 100644@@@ -0,0 +1,43 @@++-- phase: expand++-- OpenVibe.Video uploads (server/video/uploads.js): videos people upload to openvibe.video. The bytes are an++-- OpenVibe.Media VOD in MediaHub's tenant (media-hub): Media stores them, probes them, makes the thumbnail and the HLS++-- timeline. MediaHub keeps who uploaded what, its title and visibility, how far the upload got, and the reports.++CREATE TABLE video_uploads (++ id text COLLATE "C" PRIMARY KEY, -- vup_<ULID>, the /u/<id> address++ owner text COLLATE "C" NOT NULL, -- user:usr_…++ owner_username text COLLATE "C", -- the channel name at upload (/@<username> lists it)++ title text NOT NULL, -- <= 120 characters (enforced in code)++ description text NOT NULL DEFAULT '', -- <= 5,000 characters (enforced in code)++ visibility text COLLATE "C" NOT NULL DEFAULT 'public',++ media_vod_id bigint, -- Media's VOD id in the media-hub tenant++ content_type text COLLATE "C" NOT NULL, -- what the parts are sent to Media as (video/mp4, video/webm, video/x-matroska)++ size_bytes bigint NOT NULL,++ part_size integer NOT NULL,++ parts_expected integer NOT NULL,++ parts_received integer NOT NULL DEFAULT 0,++ status text COLLATE "C" NOT NULL DEFAULT 'uploading',++ error text,++ duration_seconds double precision,++ thumbnail_url text,++ state text COLLATE "C" NOT NULL DEFAULT 'active',++ report_count integer NOT NULL DEFAULT 0,++ views bigint NOT NULL DEFAULT 0,++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ ready_at text COLLATE "C",++ CHECK (visibility IN ('public', 'unlisted', 'private')),++ CHECK (status IN ('uploading', 'processing', 'ready', 'failed')),++ CHECK (state IN ('active', 'hidden', 'removed'))++);++CREATE INDEX video_uploads_owner ON video_uploads (owner, id DESC);++CREATE INDEX video_uploads_public ON video_uploads (id DESC) WHERE visibility = 'public' AND status = 'ready' AND state = 'active';++CREATE INDEX video_uploads_channel ON video_uploads (lower(owner_username), id DESC);++++CREATE TABLE video_upload_reports (++ upload_id text COLLATE "C" NOT NULL REFERENCES video_uploads(id) ON DELETE CASCADE,++ reporter text COLLATE "C" NOT NULL, -- user:usr_… or ip:<keyed hash> (one report each)++ reason text COLLATE "C" NOT NULL,++ note text NOT NULL DEFAULT '',++ created_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (upload_id, reporter)++);@@@ -205,3 +205,13 @@ a.ovv-channel-name:hover { text-decoration: underline; }.ovv-cutting { aspect-ratio: 16 / 9; display: grid; place-items: center; padding: 24px; text-align: center; background: var(--ovv-card); color: var(--ovv-dim); }.ovv-cutting p { margin: 0; max-width: 40ch; }.ovv-cutting i { color: var(--accent-light, var(--ovv-accent)); margin-right: 6px; }++/* Uploads (/upload, /u/:id, /me/videos) */++.ovv-upload { display: grid; gap: 12px; max-width: 640px; }++.ovv-upload progress { width: 100%; height: 10px; }++.ovv-upload-status { margin: 0; min-height: 1.4em; }++.ovv-processing { display: grid; place-items: center; min-height: 220px; padding: 20px; text-align: center; }++.ovv-description p { white-space: pre-line; margin: 0; }++.ovv-report fieldset { border: 0; padding: 0; margin: 8px 0; display: grid; gap: 6px; }++.ovv-radio { display: flex; gap: 8px; align-items: baseline; }++.ovv-table { width: 100%; border-collapse: collapse; }++.ovv-table th, .ovv-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--ovv-line); }nnew file mode 100644@@@ -0,0 +1,72 @@++/* OpenVibe.Video — /upload: sends the chosen video in 8 MB parts (POST /api/v1/videos, one PUT per part in order, then++ * complete), so a big file uploads in one go and the person watches the progress. A failed part is tried again three++ * times before the upload stops. Every request is same-origin: the session cookie and Sec-Fetch-Site are the browser's.++ * Nothing about the file but its size, type and name (to tell MOV from MP4) is sent before its bytes. */++(function () {++ 'use strict';++ var form = document.querySelector('form[data-video-upload]');++ if (!form || !window.fetch) return;++ var file = form.querySelector('input[type=file]');++ var bar = form.querySelector('progress');++ var status = form.querySelector('[role=status]');++ var button = form.querySelector('button[type=submit]');++++ function say(t) { if (status) status.textContent = t; }++ function busy(on) { button.disabled = on; Array.prototype.forEach.call(form.elements, function (el) { if (el !== button) el.disabled = on; }); }++ function request(url, options) {++ return fetch(url, options).then(function (res) {++ if (res.ok) return res.json().catch(function () { return null; });++ return res.json().then(function (p) { throw new Error((p && p.detail) || ('The upload was refused (' + res.status + ').')); },++ function () { throw new Error('The upload was refused (' + res.status + ').'); });++ });++ }++ function withRetry(fn, tries) {++ return fn().catch(function (err) {++ if (tries <= 1 || /refused \((4\d\d)\)|Send part|already finished/.test(err.message)) throw err;++ return new Promise(function (r) { setTimeout(r, 1500); }).then(function () { return withRetry(fn, tries - 1); });++ });++ }++++ form.addEventListener('submit', function (event) {++ event.preventDefault();++ var f = file.files && file.files[0];++ if (!f) { say('Choose a video first.'); return; }++ var title = form.elements.title.value;++ var meta = { title: title, description: form.elements.description.value, visibility: form.elements.visibility.value, size: f.size, content_type: f.type || '', name: f.name };++ busy(true);++ bar.hidden = false; bar.max = f.size; bar.value = 0;++ say('Starting…');++ var sent = 0;++ var id = null;++ request('/api/v1/videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta) }).then(function (out) {++ var up = out.upload;++ id = up.id;++ var chain = Promise.resolve();++ for (var n = 1; n <= up.parts_expected; n++) {++ chain = chain.then(function (part) {++ return function () {++ var start = (part - 1) * up.part_size;++ var slice = f.slice(start, Math.min(start + up.part_size, f.size));++ return withRetry(function () {++ return request('/api/v1/videos/' + up.id + '/parts/' + part, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: slice });++ }, 3).then(function () {++ sent += slice.size;++ bar.value = sent;++ say('Uploading… ' + Math.round((sent / f.size) * 100) + '%');++ });++ };++ }(n));++ }++ return chain;++ }).then(function () {++ say('Finishing…');++ return request('/api/v1/videos/' + id + '/complete', { method: 'POST' });++ }).then(function (done) {++ say('Uploaded. OpenVibe.Media is processing it; opening its page…');++ window.location.assign(done.video.url);++ }).catch(function (err) {++ busy(false);++ say((err && err.message) || 'The upload did not go through.');++ });++ });++})();@@@ -43,17 +43,22 @@ const TABLES = [{ table: 'pics_albums', subject: 'owner', value: AS_REQUESTER, file: 'pics_albums.json' },{ table: 'pics_images', subject: 'owner', value: AS_REQUESTER, file: 'pics_images.json' },{ table: 'pics_usage', subject: 'uploader', value: AS_REQUESTER, file: 'pics_usage.json' },++ // OpenVibe.Video uploads (migrations/0008_video_uploads.sql): the reports a person filed, then their videos (other++ // people's reports on them cascade). The bytes are Media VODs, deleted with extraErase.++ { table: 'video_upload_reports', subject: 'reporter', value: AS_REQUESTER, file: 'video_upload_reports.json' },++ { table: 'video_uploads', subject: 'owner', value: AS_REQUESTER, file: 'videos.json' },];function createAccount({ db, config, media, log = console }) {/** The object ids of a deletion, read before the rows go (extraErase runs after them). */let pendingObjects = [];++ let pendingVods = [];const accountData = createAccountData({db,service: 'media-hub',tables: TABLES,-- note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists, and your OpenVibe.Pics pictures and albums. The bytes of your files and pictures live in OpenVibe.Media.',++ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists, your OpenVibe.Pics pictures and albums, and the videos you uploaded to OpenVibe.Video. The bytes of your files, pictures and videos live in OpenVibe.Media.',extraExport: async (db, subject) => {const rows = await db.many(`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at@@@ -74,6 +79,12 @@ function createAccount({ db, config, media, log = console }) {];},extraErase: async () => {++ const vods = pendingVods;++ pendingVods = [];++ for (const id of vods) {++ const out = await media.vodDelete(id);++ if (!out.ok && out.status !== 404 && out.status !== 410) log.warn(`[MediaHub] account deletion: Media refused to delete VOD ${id}: ${out.status} ${out.code}`);++ }const ids = pendingObjects;pendingObjects = [];for (const id of ids) {@@@ -101,6 +112,7 @@ function createAccount({ db, config, media, log = console }) {...(await db.many('SELECT object_id FROM mh_files WHERE owner = ANY($1::text[])', [subjects])).map((r) => r.object_id),...(await db.many('SELECT object_id, thumb_object_id FROM pics_images WHERE owner = ANY($1::text[])', [subjects])).flatMap((r) => [r.object_id, r.thumb_object_id]),].filter(Boolean);++ pendingVods = (await db.many('SELECT media_vod_id FROM video_uploads WHERE owner = ANY($1::text[]) AND media_vod_id IS NOT NULL', [subjects])).map((r) => Number(r.media_vod_id));}return await accountData.apply(ev, { send });}@@@ -70,7 +70,9 @@ async function createApp(opts = {}) {const account = createAccount({ db: s.db, config, media, log });const playlists = createPlaylists({ s, config, log });const pics = createPics({ s, media, config, log });-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, fetchImpl, log };++ // OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.++ const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, fetchImpl, log };// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.ctx.indexnow = opts.indexnow || (() => {const warn = (...a) => (log.warn || console.warn)(...a);@@@ -76,6 +76,12 @@ function load(env = process.env) {// this service (/i/<slug>.<ext>). Signed-out uploads stay off until the DMCA agent and the CSAM// hash-matching account exist (plan T10 gate, owner items O25 and the CSAM tool account): `anonymous` is// MEDIAHUB_PICS_ANONYMOUS=1 then.++ // Uploads on openvibe.video (server/video/uploads.js): the bytes are OpenVibe.Media VODs in this tenant.++ videoUploads: {++ maxBytes: size(env.VIDEO_UPLOAD_MAX_BYTES, 2 * GB),++ quotaBytes: size(env.VIDEO_UPLOAD_QUOTA_BYTES, 20 * GB),++ perDay: Math.max(1, size(env.VIDEO_UPLOADS_PER_DAY, 10)),++ },pics: {url: trim(env.MEDIAHUB_PICS_URL || 'https://openvibe.pics'),maxImageBytes: size(env.MEDIAHUB_PICS_MAX_IMAGE_BYTES, 20 * MB),@@@ -17,6 +17,14 @@* bytes(objectId, { range }) GET <signed URL on the Media host>* remove(objectId) DELETE …/objects/:id (a soft delete in Media)*++ * OpenVibe.Video uploads use Media's VOD API in the same tenant (/api/v1/media-hub/vods, the media.object.upload grant),++ * which gives an uploaded video what Live's recordings get: probe, thumbnail, seekable file and the HLS timeline.++ *++ * vodCreate({ title, visibility, ownerSubject }) POST /api/v1/media-hub/vods++ * vodChunk(vodId, buffer, contentType) POST …/vods/:id/chunks (appended in order)++ * vodComplete(vodId) POST …/vods/:id/complete (probe, thumbnail; minutes for a big file)++ * vodGet(vodId) / vodUpdate(vodId, { title, visibility }) / vodDelete(vodId)++ ** Two details worth knowing. Media answers a private object's download with a signed URL on its *public* origin;* this client rewrites that URL onto the configured Media host before fetching it, so the bytes are read from the* one address MEDIAHUB_MEDIA_URL names and never from a URL a caller supplied. And a Media call never throws for@@@ -52,7 +60,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console}/** One JSON call. Never throws for a Media refusal; throws only when Media cannot be reached at all. */-- async function call(path, { method = 'GET', body, json, headers = {}, raw = false } = {}) {++ async function call(path, { method = 'GET', body, json, headers = {}, raw = false, root = null, timeoutMs = null } = {}) {if (!enabled) return { ok: false, status: 503, code: 'media.not_configured', detail: 'OV_OAUTH_CLIENT_SECRET is not set' };let token;try {@@@ -66,7 +74,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = consoleif (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }let res;try {-- res = await fetchImpl(`${base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(media.timeoutMs) });++ res = await fetchImpl(`${root || base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(timeoutMs || media.timeoutMs) });} catch (err) {log.warn('[MediaHub] Media did not answer:', (err && err.name) || '', (err && err.message) || '');return { ok: false, status: 502, code: 'media.unreachable', detail: 'OpenVibe.Media did not answer' };@@@ -80,9 +88,30 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = consolereturn { ok: true, status: res.status, data };}++ const vods = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/vods`;++ const VOD_EXT = { 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/x-matroska': 'mkv' };++return {enabled,++ /** A VOD row for an upload (Media's user_id is the uploader's usr_… subject; MediaHub owns the meaning). */++ vodCreate: ({ title, visibility = 'public', ownerSubject }) => call('', {++ root: vods(), method: 'POST',++ json: { title: String(title || 'Video').slice(0, 300), visibility: ['public', 'unlisted', 'private'].includes(visibility) ? visibility : 'private', user_id: subjectHeader(ownerSubject) },++ }),++ /** One part of the file, appended to the VOD's file in the order sent (segment 1: one continuous file). */++ vodChunk(vodId, buffer, contentType) {++ const form = new FormData();++ form.append('segmentId', '1');++ form.append('chunk', new Blob([buffer], { type: contentType }), `part.${VOD_EXT[contentType] || 'mp4'}`);++ return call(`/${Number(vodId)}/chunks`, { root: vods(), method: 'POST', body: form, timeoutMs: 120_000 });++ },++ /** Close the file: Media remuxes, probes, makes the thumbnail and announces it. A big file takes minutes. */++ vodComplete: (vodId) => call(`/${Number(vodId)}/complete`, { root: vods(), method: 'POST', json: {}, timeoutMs: 30 * 60_000 }),++ vodGet: (vodId) => call(`/${Number(vodId)}`, { root: vods() }),++ vodUpdate: (vodId, patch) => call(`/${Number(vodId)}`, { root: vods(), method: 'PUT', json: patch }),++ vodDelete: (vodId) => call(`/${Number(vodId)}`, { root: vods(), method: 'DELETE' }),++/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false }) {// A drive file always belongs to a person. A picture uploaded signed out (Pics, `ownerless`) is the@@@ -91,7 +91,7 @@ ${render(o.body)}}// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).--const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Playlists', href: '/playlists' }];++const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Upload', href: '/upload' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];/** Send a video page. Signed-out pages are public; a session cookie makes a page private, no-store. */function send(res, status, o) {@@@ -29,6 +29,7 @@ const { send, asset, assetVersion, SITE_NAME } = require('./layout');const { createLiveClient, LiveRefusedError } = require('./live-client');const { createChatClient } = require('./chat-client');const { createVideoApiRoutes } = require('./api');++const { createUploadApi, mountUploadPages } = require('./upload-routes');const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');@@@ -525,10 +526,15 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar// ── Playlist API (openvibe.video only; the drive's /api/v1 is untouched) ──r.use('/api/v1/video', createVideoApiRoutes(ctx));++ // Uploads' part API (server/video/upload-routes.js): raw parts and its own JSON, before the forms' parser.++ r.use('/api/v1/videos', createUploadApi({ ctx, config }));// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON// parser, so the two never read each other's bodies.r.use(express.urlencoded({ extended: false, limit: '64kb' }));++ // ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──++ const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card });++// ── Playlist page pieces ───────────────────────────────async function playlistForm(req, kind, id) {if (!signedIn(req)) {@@@ -632,13 +638,15 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar]);const vodItems = (vods && Array.isArray(vods.items)) ? vods.items : [];const clipItems = (clips && Array.isArray(clips.items)) ? clips.items : [];++ const uploaded = await ctx.videoUploads.latest(HOME_LIMIT);return page(req, res, {index: true,-- description: 'Stream recordings and clips from OpenVibe.Live, with AI chapters and captions.',++ description: 'Videos people upload, and stream recordings and clips from OpenVibe.Live with AI chapters and captions.',body: html`<header class="ovv-hero"><p class="ovv-eyebrow"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> OpenVibe.Video</p>--<h1>Watch what OpenVibe streamed.</h1>--<p class="ovv-lede">Stream recordings and clips from OpenVibe.Live, with AI chapters and captions. Uploads are not open here — watch what people made public on OpenVibe.Live.</p>--<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="${livePublic}/">Live now on OpenVibe.Live</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser">Go live from your browser</a></p></header>++<h1>Watch, upload, stream.</h1>++<p class="ovv-lede">Videos people upload here, and the recordings and clips of streams on OpenVibe.Live with AI chapters and captions. Sign in with your OpenVibe account to upload: it plays everywhere, on your channel at openvibe.video/@you.</p>++<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a> <a class="ovv-btn" href="${livePublic}/">Live now on OpenVibe.Live</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser">Go live from your browser</a></p></header>++${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Latest uploads</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Latest recordings</h2>${grid(vodItems.map(feedCard), 'No recordings yet.')}</section><section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Latest clips</h2>${grid(clipItems.map(feedCard), 'No clips yet.')}</section>`,});@@@ -971,12 +979,14 @@ ${manageable ? ownerPanel('clip', id, clip) : ''}const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;const nextLink = hasMore ? `?page=${pageNum + 1}` : null;++ const uploaded = pageNum === 1 ? await ctx.videoUploads.channel(username) : [];return page(req, res, {index: true,title: `@${username}`,-- description: `Recordings and clips from @${username} on OpenVibe.Video.`,++ description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>++${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Videos</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,@@@ -1136,7 +1146,7 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down}r.get('/sitemap.xml', async (req, res) => {-- const [vods, clips] = await Promise.all([feedItems('vods'), feedItems('clips')]);++ const [vods, clips, uploaded] = await Promise.all([feedItems('vods'), feedItems('clips'), ctx.videoUploads.latest(500)]);const day = (iso) => { const d = new Date(String(iso || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };const newest = (list) => list.map(day).filter(Boolean).sort().pop() || null;const items = [...vods, ...clips].filter((it) => it && Number.isSafeInteger(Number(it.id)));@@@ -1147,10 +1157,17 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and downconst d = day(it.created_at);if (!channels.has(u) || (d && d > channels.get(u))) channels.set(u, d);}++ for (const x of uploaded) {++ const u = x.owner_username;++ if (!u || !/^[A-Za-z0-9_]{1,32}$/.test(u)) continue;++ const d = day(x.ready_at || x.created_at);++ if (!channels.has(u) || (d && d > channels.get(u))) channels.set(u, d);++ }const urls = [{ loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },{ loc: `${base}/updates` },...items.map((it) => ({ loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at) })),++ ...uploaded.map((x) => ({ loc: `${base}/u/${x.id}`, lastmod: day(x.updated_at) })),...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),].map((u) => (u.lastmod ? u : { loc: u.loc }));return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));nnew file mode 100644@@@ -0,0 +1,246 @@++'use strict';++++/**++ * Uploads on openvibe.video (server/video/uploads.js): the pages and the part API.++ *++ * GET /upload the form (title, description, visibility, the file); public/js/video-upload.js sends it++ * POST /api/v1/videos start: { title, description, visibility, size, content_type, name } → { upload }++ * PUT /api/v1/videos/:id/parts/:n one 8 MB part, in order (application/octet-stream)++ * POST /api/v1/videos/:id/complete close the file; Media finishes it in the background → { video: { id, url } }++ * GET /u/:id the watch page (processing: it says so and reloads itself)++ * POST /u/:id/manage | /u/:id/delete | /u/:id/report++ * GET /me/videos your uploads, with their state++ *++ * Every write is a signed-in person's (a session on openvibe.video) from openvibe.video itself (Sec-Fetch-Site or++ * Origin). Reports are open to anyone who can see the video, one per person (or per keyed address) per video.++ */++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { sameOrigin } = require('../http/principal');++const { html, notice } = require('../render/html');++const { UploadError } = require('./uploads');++++const REASON_TEXT = {++ child_safety: 'It sexualises or endangers a child', violence: 'Violence or a threat', harassment: 'Harassment or someone\'s private details',++ sexual: 'Sexual content', copyright: 'It is my work, used without permission', spam: 'Spam or a scam', other: 'Something else',++};++++function problem(res, err) {++ const e = err instanceof UploadError ? err : null;++ if (!e) throw err;++ return res.status(e.status).type('application/problem+json').json({ type: 'about:blank', status: e.status, code: e.code, detail: e.detail });++}++++/** The JSON part API (mounted before the site's urlencoded parser: parts are raw bytes). */++function createUploadApi({ ctx, config }) {++ const up = ctx.videoUploads;++ const r = express.Router();++ r.use((req, res, next) => { res.set('Cache-Control', cache.htmlHeaders({ private: true })); next(); });++ const who = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.subject++ ? { requester: `user:${req.viewer.subject}`, username: req.viewer.username || null } : null);++ const guard = (req, res) => {++ if (!who(req)) { problem(res, new UploadError(401, 'auth.required', 'Sign in to upload a video.')); return false; }++ if (!sameOrigin(req, config.video.url)) { problem(res, new UploadError(403, 'upload.cross_site', 'Upload from openvibe.video itself.')); return false; }++ return true;++ };++ const own = async (req, res) => {++ const row = await up.byId(req.params.id);++ if (!row || row.owner !== who(req).requester || row.state === 'removed') { problem(res, new UploadError(404, 'upload.not_found', 'No such upload.')); return null; }++ return row;++ };++++ r.post('/', express.json({ limit: '16kb' }), async (req, res) => {++ if (!guard(req, res)) return undefined;++ try {++ const b = req.body || {};++ const row = await up.start(who(req), { title: b.title, description: b.description, visibility: b.visibility, size: b.size, contentType: b.content_type, name: b.name });++ return res.status(201).json({ upload: { id: row.id, part_size: row.part_size, parts_expected: row.parts_expected } });++ } catch (err) { return problem(res, err); }++ });++++ r.put('/:id/parts/:n', express.raw({ type: () => true, limit: `${Math.ceil(up.PART_SIZE / 1024) + 64}kb` }), async (req, res) => {++ if (!guard(req, res)) return undefined;++ const row = await own(req, res);++ if (!row) return undefined;++ try {++ const out = await up.part(row, Number(req.params.n), Buffer.isBuffer(req.body) ? req.body : null);++ return res.json({ parts_received: out.parts_received, parts_expected: out.parts_expected });++ } catch (err) { return problem(res, err); }++ });++++ r.post('/:id/complete', async (req, res) => {++ if (!guard(req, res)) return undefined;++ const row = await own(req, res);++ if (!row) return undefined;++ try {++ const out = await up.complete(row);++ return res.status(202).json({ video: { id: out.id, status: out.status, url: `/u/${out.id}` } });++ } catch (err) { return problem(res, err); }++ });++++ return r;++}++++/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */++function mountUploadPages(r, h) {++ const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card } = h;++ const up = ctx.videoUploads;++ const cfg = ctx.config.videoUploads;++ const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });++ const GB = 1024 ** 3;++++ const statusText = (row) => (row.state === 'hidden' ? 'Hidden after reports, waiting for staff'++ : row.status === 'uploading' ? `Upload stopped at ${Math.round((row.parts_received / row.parts_expected) * 100)}%`++ : row.status === 'processing' ? 'Processing' : row.status === 'failed' ? 'Failed' : visibilityLabel(row.visibility));++++ r.get('/upload', mustSignIn('/upload', 'Uploads belong to your OpenVibe account: sign in to upload a video.'), async (req, res) => page(req, res, {++ title: 'Upload a video',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Upload' }],++ scripts: ['js/video-upload.js'],++ body: html`<h1>Upload a video</h1>++<form class="ovv-upload ovv-panel" data-video-upload method="post" action="/upload">++<label class="ovv-field"><span>Video file</span><input name="file" type="file" accept="video/mp4,video/quicktime,video/x-m4v,video/webm,video/x-matroska,.mp4,.mov,.m4v,.webm,.mkv" required></label>++<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" required></label>++<label class="ovv-field"><span>Description</span><textarea name="description" rows="4" maxlength="5000"></textarea></label>++<label class="ovv-field"><span>Who can see it</span><select name="visibility">${visibilityOptions('public')}</select></label>++<p class="ovv-fine">Public is listed on your channel and searchable; unlisted opens only by its link; private only for you. MP4, MOV, WebM or MKV, up to ${Math.round(cfg.maxBytes / GB)} GB a video and ${Math.round(cfg.quotaBytes / GB)} GB in all, ${cfg.perDay} uploads a day. Upload only what you made or may share: reported videos are hidden while staff look.</p>++<progress hidden value="0" max="1"></progress>++<p class="ovv-upload-status" role="status" aria-live="polite"></p>++<button class="ovv-btn ovv-btn--primary" type="submit">Upload</button>++<noscript><p class="ovv-fine">Uploading needs JavaScript: a video goes up in parts so a big file and a dropped connection both work.</p></noscript>++</form>++<p><a href="/me/videos">Your videos</a></p>`,++ }));++++ // Without JavaScript the form posts here: a video goes up in parts, which needs the page's script.++ r.post('/upload', (req, res) => page(req, res, {++ title: 'Upload a video',++ body: html`<h1>Uploading needs JavaScript</h1><p>A video goes up in 8 MB parts, so a big file and a dropped connection both work. Turn JavaScript on for openvibe.video and <a href="/upload">try again</a>.</p>`,++ }, 400));++++ r.get('/me/videos', mustSignIn('/me/videos', 'Sign in to see the videos you uploaded.'), async (req, res) => {++ const rows = await up.mine(ownerOf(req));++ await Promise.all(rows.filter((x) => x.status === 'processing').map((x) => up.refresh(x).catch(() => null)));++ const fresh = await up.mine(ownerOf(req));++ return page(req, res, {++ title: 'Your videos',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your videos' }],++ body: html`<h1>Your videos</h1>++<p><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></p>++${fresh.length ? html`<table class="ovv-table"><thead><tr><th scope="col">Video</th><th scope="col">State</th><th scope="col">Length</th><th scope="col">Views</th><th scope="col">Uploaded</th></tr></thead><tbody>++${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${statusText(x)}</td><td>${x.duration_seconds ? durationLabel(x.duration_seconds) : ''}</td><td>${Number(x.views) || 0}</td><td>${dateLabel(x.created_at) || ''}</td></tr>`)}++</tbody></table>` : html`<p class="muted">Nothing uploaded yet.</p>`}`,++ });++ });++++ r.get('/u/:id', async (req, res) => {++ let row = await up.byId(req.params.id);++ const who = whoOf(req);++ if (!up.canSee(row, who)) return notFound(req, res);++ if (row.status === 'processing') row = await up.refresh(row);++ const own = who.requester && row.owner === who.requester;++ const manage = own || who.staff;++ const ready = row.status === 'ready';++ const vod = ready ? await up.playback(row) : null;++ if (ready && !own) await up.view(row);++ const listed = ready && row.state === 'active' && row.visibility === 'public';++ const channel = row.owner_username;++ const metaParts = [row.ready_at ? html`<time datetime="${row.ready_at}">${dateLabel(row.ready_at)}</time>` : null, row.duration_seconds ? durationLabel(row.duration_seconds) : null, `${Number(row.views) || 0} views`].filter(Boolean);++ const thumb = httpsUrl(row.thumbnail_url);++ if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done++ return page(req, res, {++ index: listed, noindex: !listed,++ ...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),++ title: row.title,++ description: row.description ? row.description.slice(0, 300) : `A video by @${channel || 'someone'} on OpenVibe.Video.`,++ ...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),++ scripts: ready ? ['js/video.js'] : [],++ body: html`<div class="ovv-watch">++<div class="ovv-main">++${req.query.saved === '1' ? notice('Saved.', 'ok') : ''}${req.query.reported === '1' ? notice('Thanks: the report reached OpenVibe staff.', 'ok') : ''}++${row.state === 'hidden' ? notice('Hidden after reports: only you and OpenVibe staff can see it until staff decide.', 'warn') : ''}++${row.visibility === 'private' && ready ? notice('Private: only you (and OpenVibe staff) can see this video.', 'info') : ''}++${ready && vod ? html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline${thumb ? html` poster="${`${thumb}?w=1280&fmt=webp`}"` : ''} src="${fileOf(vod) || ''}" data-ovv-video${hlsAttrs(vod)}></video></div>`++ : html`<div class="ovv-player-wrap ovv-processing" role="status">${row.status === 'processing' ? html`<p><i class="fa-solid fa-gear fa-spin" aria-hidden="true"></i> Processing: OpenVibe.Media is making it playable. This page reloads by itself.</p>`++ : row.status === 'failed' ? html`<p>This upload could not be processed. ${row.error || ''}</p>`++ : html`<p>The upload stopped before the end (${row.parts_received} of ${row.parts_expected} parts). Upload the file again from <a href="/upload">Upload</a>.</p>`}</div>`}++<h1 class="ovv-title">${row.title}</h1>++<div class="ovv-byline">++${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}</a>` : ''}++<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}++${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>++</div>++${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}++</div>++<aside class="ovv-side">++${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><h2 id="h-owner">Manage this video</h2>++<form class="ovv-owner-form" method="post" action="/u/${row.id}/manage">++<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" value="${row.title}" required></label>++<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="5000">${row.description}</textarea></label>++<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(row.visibility)}</select></label>++<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button></form>++<details class="ovv-danger"><summary>Delete this video</summary>++<form class="ovv-owner-delete" method="post" action="/u/${row.id}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>++</details></section>` : ''}++${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report this video</summary>++<form method="post" action="/u/${row.id}/report">++<fieldset><legend>What is wrong?</legend>${Object.entries(REASON_TEXT).map(([k, t]) => html`<label class="ovv-radio"><input type="radio" name="reason" value="${k}" required> ${t}</label>`)}</fieldset>++<label class="ovv-field"><span>Anything staff should know (optional)</span><textarea name="note" rows="2" maxlength="1000"></textarea></label>++<button class="ovv-btn" type="submit">Send the report</button>++<p class="ovv-fine">A child-safety report hides the video at once; three reports hide it until staff decide.</p></form></details>` : ''}++</aside>++</div>`,++ });++ });++++ r.post('/u/:id/manage', mustSignIn('here'), async (req, res) => {++ const row = await up.byId(req.params.id);++ const who = whoOf(req);++ if (!row || row.state === 'removed' || !(row.owner === who.requester || who.staff)) return notFound(req, res);++ if (!sameSite(req, res, `/u/${row.id}`)) return undefined;++ try {++ const b = req.body || {};++ await up.update(row, { title: b.title, description: b.description, visibility: b.visibility });++ } catch (err) {++ if (!(err instanceof UploadError)) throw err;++ return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>${err.detail}</p><p><a href="/u/${row.id}">Back</a></p>` }, err.status);++ }++ return res.redirect(303, `/u/${row.id}?saved=1`);++ });++++ r.post('/u/:id/delete', mustSignIn('here'), async (req, res) => {++ const row = await up.byId(req.params.id);++ const who = whoOf(req);++ if (!row || row.state === 'removed' || !(row.owner === who.requester || who.staff)) return notFound(req, res);++ if (!sameSite(req, res, `/u/${row.id}`)) return undefined;++ if ((req.body || {}).confirm !== '1') return res.redirect(303, `/u/${row.id}`);++ await up.remove(row);++ return res.redirect(303, '/me/videos');++ });++++ r.post('/u/:id/report', async (req, res) => {++ const row = await up.byId(req.params.id);++ if (!up.canSee(row, whoOf(req))) return notFound(req, res);++ if (!sameSite(req, res, `/u/${row.id}`)) return undefined;++ try {++ const b = req.body || {};++ await up.report(row, { reporter: signedIn(req) ? ownerOf(req) : up.addressId(req.ip), reason: String(b.reason || ''), note: b.note });++ } catch (err) {++ if (!(err instanceof UploadError)) throw err;++ return page(req, res, { title: 'Not sent', body: html`<h1>Not sent</h1><p>${err.detail}</p><p><a href="/u/${row.id}">Back</a></p>` }, err.status);++ }++ return res.redirect(303, `/u/${row.id}?reported=1`);++ });++++ /** A card for an upload (home, channel). */++ return function uploadCard(x) {++ return card({++ href: `/u/${x.id}`, thumb: httpsUrl(x.thumbnail_url), title: x.title,++ channel: x.owner_username || null, channelHref: x.owner_username ? `/@${x.owner_username}` : null,++ duration: x.duration_seconds ? durationLabel(x.duration_seconds) : null,++ date: dateLabel(x.ready_at || x.created_at) || null, iso: x.ready_at || x.created_at || null,++ });++ };++}++++module.exports = { createUploadApi, mountUploadPages, REASON_TEXT };nnew file mode 100644@@@ -0,0 +1,176 @@++'use strict';++++/**++ * Uploads on openvibe.video: a signed-in person uploads a video; it becomes an OpenVibe.Media VOD in MediaHub's tenant,++ * so it gets what a Live recording gets (probe, thumbnail, seekable file, HLS timeline), and a watch page at /u/<id>.++ *++ * start(who, { title, description, visibility, size, contentType }) → the row, and Media's VOD created for it++ * part(row, n, buffer) the n-th part, in order (Media appends it to one continuous file)++ * complete(row) closes the file; Media finalizes in the background, the row goes processing → ready++ * refresh(row) reads Media again for a row still processing (after a restart it is how it finishes)++ * update(row, patch) / remove(row) / report(row, …)++ *++ * Rules: MP4 (QuickTime and M4V are the same container and go to Media as MP4), WebM and Matroska; at most++ * VIDEO_UPLOAD_MAX_BYTES (2 GB) a file, VIDEO_UPLOAD_QUOTA_BYTES (20 GB) a person, VIDEO_UPLOADS_PER_DAY (10) a day.++ * Reports: a child-safety report hides the video at once, three reports from different people hide it; the owner and++ * staff still see a hidden video, labelled. A removed upload's VOD is deleted in Media.++ */++const crypto = require('crypto');++++const KINDS = { 'video/mp4': 'video/mp4', 'video/quicktime': 'video/mp4', 'video/x-m4v': 'video/mp4', 'video/webm': 'video/webm', 'video/x-matroska': 'video/x-matroska' };++const EXT_KINDS = { mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska' };++const VISIBILITY = ['public', 'unlisted', 'private'];++const REASONS = ['child_safety', 'violence', 'harassment', 'sexual', 'copyright', 'spam', 'other'];++const PART_SIZE = 8 * 1024 * 1024;++const HIDE_AFTER = 3;++++const cleanLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);++const cleanText = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);++++class UploadError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; this.detail = detail; }++}++++/** The type the parts are sent to Media as, from the browser's type or else the file name; null when not a video we take. */++function kindOf(contentType, name) {++ const t = String(contentType || '').split(';')[0].trim().toLowerCase();++ if (KINDS[t]) return KINDS[t];++ const ext = String(name || '').toLowerCase().match(/\.([a-z0-9]{2,5})$/);++ return ext && EXT_KINDS[ext[1]] ? EXT_KINDS[ext[1]] : null;++}++++function createUploads({ s, media, config, log = console }) {++ const cfg = config.videoUploads;++ const addressKey = crypto.createHash('sha256').update(`video-address:${config.oauth.clientSecret || 'dev'}`).digest();++ const addressId = (ip) => `ip:${crypto.createHmac('sha256', addressKey).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;++ const running = new Map(); // upload id → the completion in flight in this process++++ const byId = (id) => (/^vup_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id)) ? s.db.maybe('SELECT * FROM video_uploads WHERE id = $1', [String(id)]) : null);++ const patchRow = async (id, patch) => {++ const cols = Object.keys(patch);++ await s.db.query(`UPDATE video_uploads SET ${cols.map((c, i) => `${c} = $${i + 3}`).join(', ')}, updated_at = $2 WHERE id = $1`, [id, s.iso(), ...cols.map((c) => patch[c])]);++ return await byId(id);++ };++++ /** May `who` ({ requester, staff }) see this upload at all? */++ function canSee(row, who) {++ if (!row || row.state === 'removed') return false;++ const own = Boolean(who && who.requester && row.owner === who.requester);++ if (own || (who && who.staff)) return true;++ return row.state === 'active' && row.status === 'ready' && row.visibility !== 'private';++ }++++ async function start(who, input) {++ if (!who || !who.requester) throw new UploadError(401, 'auth.required', 'Sign in to upload a video.');++ const title = cleanLine(input.title, 120);++ if (!title) throw new UploadError(422, 'upload.title_required', 'Give the video a title.');++ const description = cleanText(input.description, 5000);++ const visibility = VISIBILITY.includes(input.visibility) ? input.visibility : 'public';++ const size = Number(input.size);++ const kind = kindOf(input.contentType, input.name);++ if (!kind) throw new UploadError(415, 'upload.type', 'Upload an MP4, MOV, WebM or MKV video.');++ if (!Number.isInteger(size) || size < 1) throw new UploadError(422, 'upload.size', 'The file is empty.');++ if (size > cfg.maxBytes) throw new UploadError(413, 'upload.too_big', `A video can be at most ${Math.round(cfg.maxBytes / 1024 ** 3)} GB.`);++ const today = s.iso().slice(0, 10);++ const usage = await s.db.maybe(`SELECT count(*) FILTER (WHERE created_at >= $2)::int AS today, coalesce(sum(size_bytes) FILTER (WHERE state <> 'removed'), 0)::bigint AS bytes++ FROM video_uploads WHERE owner = $1`, [who.requester, today]);++ if (usage && usage.today >= cfg.perDay) throw new UploadError(429, 'upload.daily_limit', `That is ${cfg.perDay} uploads today. Try again tomorrow.`);++ if (usage && Number(usage.bytes) + size > cfg.quotaBytes) throw new UploadError(413, 'upload.quota', `Your videos would pass ${Math.round(cfg.quotaBytes / 1024 ** 3)} GB. Delete one first.`);++ const vod = await media.vodCreate({ title, visibility, ownerSubject: who.requester });++ if (!vod.ok || !vod.data || vod.data.id == null) throw new UploadError(502, 'upload.media', `OpenVibe.Media could not start the upload (${vod.code || vod.status}).`);++ const id = s.newId('vup');++ const now = s.iso();++ await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)`,++ [id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, size, PART_SIZE, Math.ceil(size / PART_SIZE), now]);++ return await byId(id);++ }++++ async function part(row, n, buffer) {++ if (row.status !== 'uploading') throw new UploadError(409, 'upload.closed', 'This upload is already finished.');++ if (n !== row.parts_received + 1) throw new UploadError(409, 'upload.out_of_order', `Send part ${row.parts_received + 1} next.`);++ const last = n === row.parts_expected;++ const want = last ? row.size_bytes - (n - 1) * row.part_size : row.part_size;++ if (!buffer || buffer.length !== want) throw new UploadError(422, 'upload.part_size', `Part ${n} should be ${want} bytes.`);++ const out = await media.vodChunk(row.media_vod_id, buffer, row.content_type);++ if (!out.ok) throw new UploadError(502, 'upload.media', `OpenVibe.Media did not take part ${n} (${out.code || out.status}). Try again.`);++ return await patchRow(row.id, { parts_received: n });++ }++++ /** What Media says about the VOD → the row's status, duration and thumbnail. */++ async function absorb(row, vod) {++ const v = vod && (vod.vod || vod);++ if (!v || v.is_recording) return row;++ const duration = Number(v.duration_seconds);++ const ready = Boolean(v.file_url || v.playback_url) && Number.isFinite(duration) && duration > 0;++ if (!ready) return row;++ return await patchRow(row.id, { status: 'ready', duration_seconds: duration, thumbnail_url: v.thumbnail_url || null, ready_at: row.ready_at || s.iso(), error: null });++ }++++ async function complete(row) {++ if (row.status === 'ready' || row.status === 'processing') return row;++ if (row.parts_received !== row.parts_expected) throw new UploadError(409, 'upload.incomplete', `${row.parts_expected - row.parts_received} parts are still missing.`);++ const now = await patchRow(row.id, { status: 'processing' });++ // Media can take minutes on a big file: answer now, finish in the background (refresh() finishes it after a restart).++ const job = media.vodComplete(row.media_vod_id).then(async (out) => {++ if (out.ok) return await absorb(await byId(row.id), out.data);++ if (out.status >= 500 || out.code === 'media.unreachable') return null; // refresh() asks again later++ return await patchRow(row.id, { status: 'failed', error: `Media could not process the video (${out.code || out.status}).` });++ }).catch((err) => { log.warn('[MediaHub] video completion:', err.message); return null; })++ .finally(() => running.delete(row.id));++ running.set(row.id, job);++ return now;++ }++++ /** A row still processing: ask Media how it is going (no more than once every 15 seconds per row). */++ const lastAsked = new Map();++ async function refresh(row) {++ if (!row || row.status !== 'processing' || running.has(row.id)) return row;++ const t = Date.now();++ if (t - (lastAsked.get(row.id) || 0) < 15_000) return row;++ lastAsked.set(row.id, t);++ const out = await media.vodGet(row.media_vod_id);++ if (out.ok) return await absorb(row, out.data);++ if (out.status === 404) return await patchRow(row.id, { status: 'failed', error: 'Media no longer has this video.' });++ return row;++ }++++ async function update(row, input) {++ const patch = {};++ if (input.title !== undefined) { const t = cleanLine(input.title, 120); if (!t) throw new UploadError(422, 'upload.title_required', 'Give the video a title.'); patch.title = t; }++ if (input.description !== undefined) patch.description = cleanText(input.description, 5000);++ if (input.visibility !== undefined) { if (!VISIBILITY.includes(input.visibility)) throw new UploadError(422, 'upload.visibility', 'Public, unlisted or private.'); patch.visibility = input.visibility; }++ if (!Object.keys(patch).length) return row;++ if (patch.title || patch.visibility) await media.vodUpdate(row.media_vod_id, { ...(patch.title ? { title: patch.title } : {}), ...(patch.visibility ? { visibility: patch.visibility } : {}) });++ return await patchRow(row.id, patch);++ }++++ async function remove(row) {++ const out = await media.vodDelete(row.media_vod_id);++ if (!out.ok && out.status !== 404) log.warn(`[MediaHub] video ${row.id}: Media refused the delete: ${out.status} ${out.code}`);++ return await patchRow(row.id, { state: 'removed' });++ }++++ async function report(row, { reporter, reason, note }) {++ if (!REASONS.includes(reason)) throw new UploadError(422, 'report.reason', 'Choose what is wrong with the video.');++ const added = Boolean(await s.db.maybe(`INSERT INTO video_upload_reports (upload_id, reporter, reason, note, created_at) VALUES ($1, $2, $3, $4, $5)++ ON CONFLICT (upload_id, reporter) DO NOTHING RETURNING upload_id`, [row.id, reporter, reason, cleanText(note, 1000), s.iso()]));++ if (!added) return { recorded: false, hidden: row.state === 'hidden' };++ const n = Number(await s.db.value('SELECT count(*) FROM video_upload_reports WHERE upload_id = $1', [row.id]));++ const hide = row.state === 'active' && (reason === 'child_safety' || n >= HIDE_AFTER);++ await patchRow(row.id, { report_count: n, ...(hide ? { state: 'hidden' } : {}) });++ return { recorded: true, hidden: hide || row.state === 'hidden' };++ }++++ return {++ PART_SIZE, REASONS, UploadError, kindOf, addressId, canSee,++ byId, start, part, complete, refresh, update, remove, report,++ mine: (owner, limit = 60) => s.db.many("SELECT * FROM video_uploads WHERE owner = $1 AND state <> 'removed' ORDER BY id DESC LIMIT $2", [owner, limit]),++ latest: (limit = 12) => s.db.many("SELECT * FROM video_uploads WHERE visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $1", [limit]),++ channel: (username, limit = 48) => s.db.many("SELECT * FROM video_uploads WHERE lower(owner_username) = lower($1) AND visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $2", [String(username), limit]),++ view: async (row) => { await s.db.query('UPDATE video_uploads SET views = views + 1 WHERE id = $1', [row.id]); },++ playback: async (row) => { const out = await media.vodGet(row.media_vod_id); return out.ok ? (out.data.vod || out.data) : null; },++ };++}++++module.exports = { createUploads, kindOf, UploadError, KINDS, REASONS, VISIBILITY };@@@ -37,6 +37,10 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {const sessions = new Map(); // id → { id, object_id, part_size, total_size, parts_expected, status, parts: Map }const requests = [];const deleted = [];++ const vods = new Map(); // OpenVibe.Video uploads: id → { id, title, visibility, user_id, bytes, types, status }++ const deletedVods = [];++ const vodState = { duration: 42.5, completeFails: false };++ let vodSeq = 0;const state = { down: false, tokenRejects: false };let seq = 0;@@@ -87,7 +91,53 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {const entry = { method: req.method, path, query: url.search, authorization: req.headers.authorization || null, subject: req.headers['x-ov-subject'] || null, bytes: raw.length };requests.push(entry);if (state.down) return problem(res, 503, 'media.down', 'Media is restarting');-- if (!path.startsWith('/api/v2/') && !path.startsWith('/o/')) return problem(res, 404, 'route.not_found', 'no such route');++ if (!path.startsWith('/api/v2/') && !path.startsWith('/o/') && !path.startsWith('/api/v1/')) return problem(res, 404, 'route.not_found', 'no such route');++++ // ── The v1 VOD API OpenVibe.Video uploads use (/api/v1/:app/vods): create, chunks (multipart, appended in++ // order), complete (a probe stand-in: duration from vodState.duration, a thumbnail and the HLS URL), get, put,++ // delete. Needs the service token like the v2 API.++ if (path.startsWith('/api/v1/')) {++ if (state.tokenRejects || req.headers.authorization !== `Bearer ${SERVICE_TOKEN}`) return problem(res, 401, 'auth.required', 'Authentication required');++ const parts = path.split('/').filter(Boolean); // api v1 :app vods [:id] [chunks|complete]++ if (parts[3] !== 'vods') return problem(res, 404, 'route.not_found', 'no such route');++ const vodPublicOf = (v) => ({++ id: v.id, title: v.title, visibility: v.visibility, user_id: v.user_id, is_recording: v.status !== 'ready',++ duration_seconds: v.status === 'ready' ? vodState.duration : 0, file_size: v.bytes.length,++ file_url: v.status === 'ready' ? `https://openvibe.media/v/vod-${v.id}.mp4` : null,++ hls_url: v.status === 'ready' ? `https://openvibe.media/o/med_vod${v.id}/master.m3u8` : null,++ thumbnail_url: v.status === 'ready' ? `https://openvibe.media/t/vod-${v.id}.jpg` : null,++ });++ if (parts.length === 4 && req.method === 'POST') {++ const body = JSON.parse(raw.toString('utf8') || '{}');++ const id = ++vodSeq;++ vods.set(id, { id, title: body.title, visibility: body.visibility, user_id: body.user_id, bytes: Buffer.alloc(0), types: [], status: 'recording' });++ return json(res, 201, { id });++ }++ const v = vods.get(Number(parts[4]));++ if (!v || v.deleted) return problem(res, 404, 'vod.not_found', 'VOD not found');++ if (parts[5] === 'chunks' && req.method === 'POST') {++ // The multipart body's `chunk` file part: its bytes and its Content-Type (what Media names the file by).++ const boundary = /boundary=([^;]+)/.exec(String(req.headers['content-type'] || ''));++ if (!boundary) return problem(res, 400, 'vod.chunk', 'not multipart');++ const text = raw.toString('latin1');++ const piece = text.split(`--${boundary[1]}`).find((x) => /name="chunk"/.test(x));++ if (!piece) return problem(res, 400, 'vod.chunk', 'No chunk data');++ const head = piece.slice(0, piece.indexOf('\r\n\r\n'));++ const body = Buffer.from(piece.slice(piece.indexOf('\r\n\r\n') + 4, piece.length - 2), 'latin1');++ v.types.push((/content-type:\s*([^\r\n]+)/i.exec(head) || [])[1] || null);++ v.bytes = Buffer.concat([v.bytes, body]);++ return json(res, 200, { vodId: v.id, chunkIndex: v.types.length - 1, status: 'appended' });++ }++ if (parts[5] === 'complete' && req.method === 'POST') {++ if (vodState.completeFails) return problem(res, 500, 'vod.finalize', 'Failed to finalize VOD');++ v.status = 'ready';++ return json(res, 200, { vod: vodPublicOf(v) });++ }++ if (parts.length === 5 && req.method === 'GET') return json(res, 200, vodPublicOf(v));++ if (parts.length === 5 && req.method === 'PUT') { Object.assign(v, JSON.parse(raw.toString('utf8') || '{}')); return json(res, 200, vodPublicOf(v)); }++ if (parts.length === 5 && req.method === 'DELETE') { v.deleted = true; deletedVods.push(v.id); return json(res, 200, { ok: true }); }++ return problem(res, 404, 'route.not_found', 'no such route');++ }// ── The public bytes of an object (/o/:id), signature-checked like Media checks it ──if (path.startsWith('/o/')) {@@@ -262,7 +312,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));const url = `http://127.0.0.1:${server.address().port}`;return {-- url, objects, sessions, requests, deleted, state,++ url, objects, sessions, requests, deleted, state, vods, deletedVods, vodState,bytesOf: (id) => (objects.get(id) && objects.get(id).bytes) || null,objectOf: (id) => objects.get(id) || null,setDown: (v) => { state.down = v; },@@@ -330,9 +330,9 @@ function countOccurrences(haystack, needle) {assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> part\n');});-- await check('the navbar names the site\'s places: Videos and Playlists, the current one active', async () => {++ await check('the navbar names the site\'s places: Videos, Upload, Your videos and Playlists, the current one active', async () => {const home = (await getVideo('/')).text;-- assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);++ assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);assert.ok(home.includes('href="/playlists"'), 'the no-JS nav links Playlists too');});nnew file mode 100644@@@ -0,0 +1,149 @@++'use strict';++/**++ * OpenVibe.Video uploads (server/video/uploads.js, upload-routes.js): a signed-in person uploads a video in 8 MB parts++ * from openvibe.video; it becomes a Media VOD in MediaHub's tenant (the bytes appended in order, QuickTime sent as++ * MP4), completes in the background, and gets a watch page, a place on the channel and the home page, and the sitemap.++ * - only a signed-in, same-origin person may upload; types, sizes and the order of parts are checked;++ * - a processing upload says so and reloads itself, then plays from Media's file and HLS;++ * - unlisted and private ones stay off lists, private ones off the page for anyone but the owner;++ * - the owner edits (Media's title follows) and deletes (Media's VOD is deleted);++ * - reports: one per person, a child-safety report hides it at once; the owner still sees it, labelled.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++const MB = 1024 * 1024;++++function startLive() {++ const server = http.createServer((req, res) => {++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify({ vods: [], clips: [], items: [], hasMore: false }));++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));++}++++function vreq(base, path, { method = 'GET', cookie, form, json, body, same = true, type } = {}) {++ return new Promise((resolve, reject) => {++ const u = new URL(base + path);++ const h = { Host: 'openvibe.video' };++ if (cookie) h.cookie = cookie;++ if (same && method !== 'GET') h['sec-fetch-site'] = 'same-origin';++ let data;++ if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; }++ if (json) { data = Buffer.from(JSON.stringify(json)); h['content-type'] = 'application/json'; }++ if (body) { data = body; h['content-type'] = type || 'application/octet-stream'; }++ if (data) h['content-length'] = data.length;++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, headers: res.headers, text, json: () => JSON.parse(text) }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++++(async () => {++ const live = await startLive();++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url, VIDEO_UPLOADS_PER_DAY: '4' } });++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++ const kimCookie = t.signIn(kim);++ const samCookie = t.signIn(sam);++ const file = Buffer.alloc(8 * MB + 1234, 7);++ file.write('ftypqt ', 4);++ let id = null;++++ await check('signed out, cross-site, wrong type and too big are refused before Media hears of it', async () => {++ const meta = { title: 'Trip', size: file.length, content_type: 'video/quicktime', name: 'trip.mov', visibility: 'public' };++ assert.strictEqual((await vreq(t.base, '/api/v1/videos', { method: 'POST', json: meta })).status, 401);++ assert.strictEqual((await vreq(t.base, '/api/v1/videos', { method: 'POST', json: meta, cookie: kimCookie, same: false })).status, 403);++ const avi = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { ...meta, content_type: 'video/x-msvideo', name: 'trip.avi' }, cookie: kimCookie });++ assert.strictEqual(avi.status, 415);++ assert.match(avi.json().detail, /MP4, MOV, WebM or MKV/);++ assert.strictEqual((await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { ...meta, size: 3 * 1024 * MB }, cookie: kimCookie })).status, 413);++ assert.strictEqual(t.media.vods.size, 0, 'no VOD was created');++ });++++ await check('a MOV goes up in 8 MB parts, in order, to one Media VOD as MP4, and completes in the background', async () => {++ const start = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { title: 'Trip to the coast', description: 'Line one\nLine two', size: file.length, content_type: 'video/quicktime', name: 'trip.mov', visibility: 'public' }, cookie: kimCookie });++ assert.strictEqual(start.status, 201, start.text);++ const up = start.json().upload;++ id = up.id;++ assert.match(id, /^vup_[0-9A-Z]{26}$/);++ assert.deepStrictEqual([up.part_size, up.parts_expected], [8 * MB, 2]);++ const out = await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie });++ assert.strictEqual(out.status, 409, 'part 1 first');++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 100), cookie: kimCookie })).status, 422, 'a part is its full size');++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: samCookie })).status, 404, 'not sam\'s upload');++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: kimCookie })).status, 200);++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie })).status, 200);++ const vod = [...t.media.vods.values()][0];++ assert.ok(vod.bytes.equals(file), 'Media has the file, byte for byte');++ assert.deepStrictEqual(vod.types, ['video/mp4', 'video/mp4'], 'QuickTime is sent as MP4');++ assert.strictEqual(vod.user_id, kim.subject);++ const done1 = await vreq(t.base, `/api/v1/videos/${id}/complete`, { method: 'POST', cookie: kimCookie });++ assert.strictEqual(done1.status, 202);++ assert.strictEqual(done1.json().video.url, `/u/${id}`);++ });++++ await check('the watch page plays it from Media, and it is on the channel, the home page and the sitemap', async () => {++ let page = null;++ for (let i = 0; i < 20; i++) {++ page = await vreq(t.base, `/u/${id}`);++ if (page.status === 200 && page.text.includes('data-ovv-video')) break;++ await new Promise((r) => setTimeout(r, 50));++ }++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));++ assert.ok(page.text.includes('<h1 class="ovv-title">Trip to the coast</h1>'));++ assert.ok(page.text.includes('src="https://openvibe.media/v/vod-1.mp4"') && page.text.includes('data-ovv-hls="https://openvibe.media/o/med_vod1/master.m3u8"'), 'the file, and HLS on the same origin');++ assert.ok(page.text.includes('Line one\nLine two'));++ assert.ok(page.text.includes('<a class="ovv-channel-name" href="/@kim">@kim</a>'));++ assert.ok(page.text.includes('Report this video'), 'others may report it');++ assert.ok(!page.text.includes('Manage this video'));++ assert.ok((await vreq(t.base, '/')).text.includes(`href="/u/${id}"`), 'the home page');++ assert.ok((await vreq(t.base, '/@kim')).text.includes(`href="/u/${id}"`), 'the channel');++ assert.ok((await vreq(t.base, '/sitemap.xml')).text.includes(`https://openvibe.video/u/${id}`), 'the sitemap');++ const mine = await vreq(t.base, '/me/videos', { cookie: kimCookie });++ assert.ok(mine.text.includes('Trip to the coast') && mine.text.includes('Public'));++ });++++ await check('the owner edits (Media follows) and makes it private: gone for everyone else, still the owner\'s', async () => {++ const r = await vreq(t.base, `/u/${id}/manage`, { method: 'POST', cookie: kimCookie, form: { title: 'Coast trip', description: 'x', visibility: 'private' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual([...t.media.vods.values()][0].title, 'Coast trip');++ assert.strictEqual((await vreq(t.base, `/u/${id}`)).status, 404);++ assert.strictEqual((await vreq(t.base, `/u/${id}`, { cookie: samCookie })).status, 404);++ const own = await vreq(t.base, `/u/${id}`, { cookie: kimCookie });++ assert.ok(own.text.includes('Private: only you') && own.text.includes('noindex'));++ assert.ok(!(await vreq(t.base, '/')).text.includes(`/u/${id}`), 'off the home page');++ assert.strictEqual((await vreq(t.base, `/u/${id}/manage`, { method: 'POST', cookie: samCookie, form: { title: 'Mine now' } })).status, 404);++ await vreq(t.base, `/u/${id}/manage`, { method: 'POST', cookie: kimCookie, form: { title: 'Coast trip', visibility: 'public' } });++ });++++ await check('a child-safety report hides it at once; one report per person; the owner sees why', async () => {++ const r = await vreq(t.base, `/u/${id}/report`, { method: 'POST', cookie: samCookie, form: { reason: 'child_safety', note: 'look at 0:12' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual((await vreq(t.base, `/u/${id}`)).status, 404, 'hidden from everyone');++ const own = await vreq(t.base, `/u/${id}`, { cookie: kimCookie });++ assert.ok(own.text.includes('Hidden after reports'));++ assert.strictEqual((await vreq(t.base, `/u/${id}/report`, { method: 'POST', cookie: samCookie, form: { reason: 'nonsense' } })).status, 404, 'hidden: not reportable by others');++ });++++ await check('delete removes the VOD in Media; the daily limit holds', async () => {++ const r = await vreq(t.base, `/u/${id}/delete`, { method: 'POST', cookie: kimCookie, form: { confirm: '1' } });++ assert.strictEqual(r.status, 303);++ assert.deepStrictEqual(t.media.deletedVods, [1]);++ assert.strictEqual((await vreq(t.base, `/u/${id}`, { cookie: kimCookie })).status, 404);++ const meta = { title: 'More', size: 10, content_type: 'video/mp4', name: 'a.mp4' };++ for (let i = 0; i < 3; i++) assert.strictEqual((await vreq(t.base, '/api/v1/videos', { method: 'POST', json: meta, cookie: kimCookie })).status, 201);++ const fifth = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: meta, cookie: kimCookie });++ assert.strictEqual(fifth.status, 429);++ });++++ await t.close();++ await live.close();++ done();++})();
@@ -175,10 +175,34 @@ API on the pics host (`/api/v1`, a person; app, agent and service tokens are ref
@@ -175,10 +175,34 @@ API on the pics host (`/api/v1`, a person; app, agent and service tokens are ref
## OpenVibe.Video (openvibe.video)
## OpenVibe.Video (openvibe.video)
-OpenVibe.Video is public and read-only: it serves watch pages for the recordings and clips people already made
-public on OpenVibe.Live, plus playlists. There are no uploads and no new storage — the media files stay on
-OpenVibe.Media (`playback_url`), and the pages read Live's public JSON API server-side, signed out (Live already
-applies visibility: a private item answers 404, an unlisted one is reachable by id but noindex and never listed).
+OpenVibe.Video serves watch pages for videos people upload here, and for the recordings and clips people already
+made public on OpenVibe.Live, plus playlists. Live's media files stay on OpenVibe.Media (`playback_url`), and the
+pages read Live's public JSON API server-side, signed out (Live already applies visibility: a private item answers
+404, an unlisted one is reachable by id but noindex and never listed).
+
+**Uploads** (since 2026-10-15; `server/video/uploads.js`, `upload-routes.js`, migration `0008_video_uploads.sql`).
+- **Who and what:** a signed-in person uploads at `/upload`. MP4, WebM and MKV are accepted; MOV and M4V are the same
+ container and go to Media as MP4.
+- **Limits:** `VIDEO_UPLOAD_MAX_BYTES` (2 GB) a file, `VIDEO_UPLOAD_QUOTA_BYTES` (20 GB) a person,
+ `VIDEO_UPLOADS_PER_DAY` (10).
+- **How the bytes travel:**
+ - `public/js/video-upload.js` sends the file in 8 MB parts, in order: `POST /api/v1/videos`, then
+ `PUT /api/v1/videos/:id/parts/:n` (nginx allows 9 MB on that route only), then `POST …/complete`. Every call is
+ same-origin and signed in.
+ - Each part is appended to one **OpenVibe.Media VOD** in this tenant (`/api/v1/media-hub/vods`, the
+ `media.object.upload` grant). The video therefore gets what a Live recording gets: probe, thumbnail, seekable file,
+ HLS timeline and renditions.
+ - Completion runs in the background: the upload is *processing* until Media is done. The watch page says so and
+ reloads itself (`Refresh: 15`), and a restart is recovered by asking Media again when the page or "Your videos" is
+ read.
+- **Where it shows:** the watch page is `/u/<id>`. A public, ready video is on the person's channel (`/@username`,
+ as **Videos**), on the home page's **Latest uploads** and in the sitemap. Unlisted videos open by link only;
+ private ones only for the owner and staff.
+- **Owner tools:** the owner edits the title, description and visibility (Media's title and visibility follow) and
+ deletes (Media's VOD is deleted). `/me/videos` lists their uploads with their state.
+- **Reports:** anyone who can see a video may report it, once per person or per keyed address. A child-safety report
+ hides it at once, and three reports hide it until staff decide; the owner still sees it, labelled.
+- **Account deletion:** erases the rows and deletes the VODs in Media.
The player plays Media's segment timeline (`hls_url`, HLS) when the recording has one: natively where the browser
The player plays Media's segment timeline (`hls_url`, HLS) when the recording has one: natively where the browser
plays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded
plays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded
@@ -319,6 +319,14 @@ server {
@@ -319,6 +319,14 @@ server {
location ~* ^/internal(/|$) { return 404; }
location ~* ^/internal(/|$) { return 404; }
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/metrics(/|$) { return 404; }
+ # Uploads (server/video/upload-routes.js): one 8 MB part per request, in order, unbuffered to MediaHub.
+ location ~ ^/api/v1/videos/[^/]+/parts/[0-9]+$ {
+ client_max_body_size 9m;
+ proxy_request_buffering off;
+ proxy_read_timeout 180s;
+ proxy_pass http://127.0.0.1:4990;
+ }
+
location / {
location / {
proxy_pass http://127.0.0.1:4990;
proxy_pass http://127.0.0.1:4990;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+-- phase: expand
+-- OpenVibe.Video uploads (server/video/uploads.js): videos people upload to openvibe.video. The bytes are an
+-- OpenVibe.Media VOD in MediaHub's tenant (media-hub): Media stores them, probes them, makes the thumbnail and the HLS
+-- timeline. MediaHub keeps who uploaded what, its title and visibility, how far the upload got, and the reports.
+CREATE TABLE video_uploads (
+ id text COLLATE "C" PRIMARY KEY, -- vup_<ULID>, the /u/<id> address
+ owner text COLLATE "C" NOT NULL, -- user:usr_…
+ owner_username text COLLATE "C", -- the channel name at upload (/@<username> lists it)
+ title text NOT NULL, -- <= 120 characters (enforced in code)
+ description text NOT NULL DEFAULT '', -- <= 5,000 characters (enforced in code)
+ visibility text COLLATE "C" NOT NULL DEFAULT 'public',
+ media_vod_id bigint, -- Media's VOD id in the media-hub tenant
+ content_type text COLLATE "C" NOT NULL, -- what the parts are sent to Media as (video/mp4, video/webm, video/x-matroska)
+ size_bytes bigint NOT NULL,
+ part_size integer NOT NULL,
+ parts_expected integer NOT NULL,
+ parts_received integer NOT NULL DEFAULT 0,
+ status text COLLATE "C" NOT NULL DEFAULT 'uploading',
+ error text,
+ duration_seconds double precision,
+ thumbnail_url text,
+ state text COLLATE "C" NOT NULL DEFAULT 'active',
+ report_count integer NOT NULL DEFAULT 0,
+ views bigint NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ ready_at text COLLATE "C",
+ CHECK (visibility IN ('public', 'unlisted', 'private')),
+ CHECK (status IN ('uploading', 'processing', 'ready', 'failed')),
+ CHECK (state IN ('active', 'hidden', 'removed'))
+);
+CREATE INDEX video_uploads_owner ON video_uploads (owner, id DESC);
+CREATE INDEX video_uploads_public ON video_uploads (id DESC) WHERE visibility = 'public' AND status = 'ready' AND state = 'active';
+CREATE INDEX video_uploads_channel ON video_uploads (lower(owner_username), id DESC);
+
+CREATE TABLE video_upload_reports (
+ upload_id text COLLATE "C" NOT NULL REFERENCES video_uploads(id) ON DELETE CASCADE,
+ reporter text COLLATE "C" NOT NULL, -- user:usr_… or ip:<keyed hash> (one report each)
+ reason text COLLATE "C" NOT NULL,
+ note text NOT NULL DEFAULT '',
+ created_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (upload_id, reporter)
+);
@@ -205,3 +205,13 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -205,3 +205,13 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-cutting { aspect-ratio: 16 / 9; display: grid; place-items: center; padding: 24px; text-align: center; background: var(--ovv-card); color: var(--ovv-dim); }
.ovv-cutting { aspect-ratio: 16 / 9; display: grid; place-items: center; padding: 24px; text-align: center; background: var(--ovv-card); color: var(--ovv-dim); }
.ovv-cutting p { margin: 0; max-width: 40ch; }
.ovv-cutting p { margin: 0; max-width: 40ch; }
.ovv-cutting i { color: var(--accent-light, var(--ovv-accent)); margin-right: 6px; }
.ovv-cutting i { color: var(--accent-light, var(--ovv-accent)); margin-right: 6px; }
+/* Uploads (/upload, /u/:id, /me/videos) */
+.ovv-upload { display: grid; gap: 12px; max-width: 640px; }
+.ovv-upload progress { width: 100%; height: 10px; }
+.ovv-upload-status { margin: 0; min-height: 1.4em; }
+.ovv-processing { display: grid; place-items: center; min-height: 220px; padding: 20px; text-align: center; }
+.ovv-description p { white-space: pre-line; margin: 0; }
+.ovv-report fieldset { border: 0; padding: 0; margin: 8px 0; display: grid; gap: 6px; }
+.ovv-radio { display: flex; gap: 8px; align-items: baseline; }
+.ovv-table { width: 100%; border-collapse: collapse; }
+.ovv-table th, .ovv-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--ovv-line); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+/* OpenVibe.Video — /upload: sends the chosen video in 8 MB parts (POST /api/v1/videos, one PUT per part in order, then
+ * complete), so a big file uploads in one go and the person watches the progress. A failed part is tried again three
+ * times before the upload stops. Every request is same-origin: the session cookie and Sec-Fetch-Site are the browser's.
+ * Nothing about the file but its size, type and name (to tell MOV from MP4) is sent before its bytes. */
+(function () {
+ 'use strict';
+ var form = document.querySelector('form[data-video-upload]');
+ if (!form || !window.fetch) return;
+ var file = form.querySelector('input[type=file]');
+ var bar = form.querySelector('progress');
+ var status = form.querySelector('[role=status]');
+ var button = form.querySelector('button[type=submit]');
+
+ function say(t) { if (status) status.textContent = t; }
+ function busy(on) { button.disabled = on; Array.prototype.forEach.call(form.elements, function (el) { if (el !== button) el.disabled = on; }); }
+ function request(url, options) {
+ return fetch(url, options).then(function (res) {
+ if (res.ok) return res.json().catch(function () { return null; });
+ return res.json().then(function (p) { throw new Error((p && p.detail) || ('The upload was refused (' + res.status + ').')); },
+ function () { throw new Error('The upload was refused (' + res.status + ').'); });
+ });
+ }
+ function withRetry(fn, tries) {
+ return fn().catch(function (err) {
+ if (tries <= 1 || /refused \((4\d\d)\)|Send part|already finished/.test(err.message)) throw err;
+ return new Promise(function (r) { setTimeout(r, 1500); }).then(function () { return withRetry(fn, tries - 1); });
+ });
+ }
+
+ form.addEventListener('submit', function (event) {
+ event.preventDefault();
+ var f = file.files && file.files[0];
+ if (!f) { say('Choose a video first.'); return; }
+ var title = form.elements.title.value;
+ var meta = { title: title, description: form.elements.description.value, visibility: form.elements.visibility.value, size: f.size, content_type: f.type || '', name: f.name };
+ busy(true);
+ bar.hidden = false; bar.max = f.size; bar.value = 0;
+ say('Starting…');
+ var sent = 0;
+ var id = null;
+ request('/api/v1/videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta) }).then(function (out) {
+ var up = out.upload;
+ id = up.id;
+ var chain = Promise.resolve();
+ for (var n = 1; n <= up.parts_expected; n++) {
+ chain = chain.then(function (part) {
+ return function () {
+ var start = (part - 1) * up.part_size;
+ var slice = f.slice(start, Math.min(start + up.part_size, f.size));
+ return withRetry(function () {
+ return request('/api/v1/videos/' + up.id + '/parts/' + part, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: slice });
+ }, 3).then(function () {
+ sent += slice.size;
+ bar.value = sent;
+ say('Uploading… ' + Math.round((sent / f.size) * 100) + '%');
+ });
+ };
+ }(n));
+ }
+ return chain;
+ }).then(function () {
+ say('Finishing…');
+ return request('/api/v1/videos/' + id + '/complete', { method: 'POST' });
+ }).then(function (done) {
+ say('Uploaded. OpenVibe.Media is processing it; opening its page…');
+ window.location.assign(done.video.url);
+ }).catch(function (err) {
+ busy(false);
+ say((err && err.message) || 'The upload did not go through.');
+ });
+ });
+})();
@@ -43,17 +43,22 @@ const TABLES = [
@@ -43,17 +43,22 @@ const TABLES = [
{ table: 'pics_albums', subject: 'owner', value: AS_REQUESTER, file: 'pics_albums.json' },
{ table: 'pics_albums', subject: 'owner', value: AS_REQUESTER, file: 'pics_albums.json' },
{ table: 'pics_images', subject: 'owner', value: AS_REQUESTER, file: 'pics_images.json' },
{ table: 'pics_images', subject: 'owner', value: AS_REQUESTER, file: 'pics_images.json' },
{ table: 'pics_usage', subject: 'uploader', value: AS_REQUESTER, file: 'pics_usage.json' },
{ table: 'pics_usage', subject: 'uploader', value: AS_REQUESTER, file: 'pics_usage.json' },
+ // OpenVibe.Video uploads (migrations/0008_video_uploads.sql): the reports a person filed, then their videos (other
+ // people's reports on them cascade). The bytes are Media VODs, deleted with extraErase.
+ { table: 'video_upload_reports', subject: 'reporter', value: AS_REQUESTER, file: 'video_upload_reports.json' },
+ { table: 'video_uploads', subject: 'owner', value: AS_REQUESTER, file: 'videos.json' },
];
];
function createAccount({ db, config, media, log = console }) {
function createAccount({ db, config, media, log = console }) {
/** The object ids of a deletion, read before the rows go (extraErase runs after them). */
/** The object ids of a deletion, read before the rows go (extraErase runs after them). */
let pendingObjects = [];
let pendingObjects = [];
+ let pendingVods = [];
const accountData = createAccountData({
const accountData = createAccountData({
db,
db,
service: 'media-hub',
service: 'media-hub',
tables: TABLES,
tables: TABLES,
- note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists, and your OpenVibe.Pics pictures and albums. The bytes of your files and pictures live in OpenVibe.Media.',
+ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists, your OpenVibe.Pics pictures and albums, and the videos you uploaded to OpenVibe.Video. The bytes of your files, pictures and videos live in OpenVibe.Media.',
extraExport: async (db, subject) => {
extraExport: async (db, subject) => {
const rows = await db.many(
const rows = await db.many(
`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at
`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at
@@ -74,6 +79,12 @@ function createAccount({ db, config, media, log = console }) {
@@ -74,6 +79,12 @@ function createAccount({ db, config, media, log = console }) {
];
];
},
},
extraErase: async () => {
extraErase: async () => {
+ const vods = pendingVods;
+ pendingVods = [];
+ for (const id of vods) {
+ const out = await media.vodDelete(id);
+ if (!out.ok && out.status !== 404 && out.status !== 410) log.warn(`[MediaHub] account deletion: Media refused to delete VOD ${id}: ${out.status} ${out.code}`);
+ }
const ids = pendingObjects;
const ids = pendingObjects;
pendingObjects = [];
pendingObjects = [];
for (const id of ids) {
for (const id of ids) {
@@ -101,6 +112,7 @@ function createAccount({ db, config, media, log = console }) {
@@ -101,6 +112,7 @@ function createAccount({ db, config, media, log = console }) {
...(await db.many('SELECT object_id FROM mh_files WHERE owner = ANY($1::text[])', [subjects])).map((r) => r.object_id),
...(await db.many('SELECT object_id FROM mh_files WHERE owner = ANY($1::text[])', [subjects])).map((r) => r.object_id),
...(await db.many('SELECT object_id, thumb_object_id FROM pics_images WHERE owner = ANY($1::text[])', [subjects])).flatMap((r) => [r.object_id, r.thumb_object_id]),
...(await db.many('SELECT object_id, thumb_object_id FROM pics_images WHERE owner = ANY($1::text[])', [subjects])).flatMap((r) => [r.object_id, r.thumb_object_id]),
].filter(Boolean);
].filter(Boolean);
+ pendingVods = (await db.many('SELECT media_vod_id FROM video_uploads WHERE owner = ANY($1::text[]) AND media_vod_id IS NOT NULL', [subjects])).map((r) => Number(r.media_vod_id));
}
}
return await accountData.apply(ev, { send });
return await accountData.apply(ev, { send });
}
}
@@ -70,7 +70,9 @@ async function createApp(opts = {}) {
@@ -70,7 +70,9 @@ async function createApp(opts = {}) {
const account = createAccount({ db: s.db, config, media, log });
const account = createAccount({ db: s.db, config, media, log });
const playlists = createPlaylists({ s, config, log });
const playlists = createPlaylists({ s, config, log });
const pics = createPics({ s, media, config, log });
const pics = createPics({ s, media, config, log });
- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, fetchImpl, log };
+ // OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
+ const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });
+ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, fetchImpl, log };
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
ctx.indexnow = opts.indexnow || (() => {
ctx.indexnow = opts.indexnow || (() => {
const warn = (...a) => (log.warn || console.warn)(...a);
const warn = (...a) => (log.warn || console.warn)(...a);
@@ -76,6 +76,12 @@ function load(env = process.env) {
@@ -76,6 +76,12 @@ function load(env = process.env) {
// this service (/i/<slug>.<ext>). Signed-out uploads stay off until the DMCA agent and the CSAM
// this service (/i/<slug>.<ext>). Signed-out uploads stay off until the DMCA agent and the CSAM
// hash-matching account exist (plan T10 gate, owner items O25 and the CSAM tool account): `anonymous` is
// hash-matching account exist (plan T10 gate, owner items O25 and the CSAM tool account): `anonymous` is
// MEDIAHUB_PICS_ANONYMOUS=1 then.
// MEDIAHUB_PICS_ANONYMOUS=1 then.
+ // Uploads on openvibe.video (server/video/uploads.js): the bytes are OpenVibe.Media VODs in this tenant.
+ videoUploads: {
+ maxBytes: size(env.VIDEO_UPLOAD_MAX_BYTES, 2 * GB),
+ quotaBytes: size(env.VIDEO_UPLOAD_QUOTA_BYTES, 20 * GB),
+ perDay: Math.max(1, size(env.VIDEO_UPLOADS_PER_DAY, 10)),
+ },
pics: {
pics: {
url: trim(env.MEDIAHUB_PICS_URL || 'https://openvibe.pics'),
url: trim(env.MEDIAHUB_PICS_URL || 'https://openvibe.pics'),
maxImageBytes: size(env.MEDIAHUB_PICS_MAX_IMAGE_BYTES, 20 * MB),
maxImageBytes: size(env.MEDIAHUB_PICS_MAX_IMAGE_BYTES, 20 * MB),
@@ -17,6 +17,14 @@
@@ -17,6 +17,14 @@
* bytes(objectId, { range }) GET <signed URL on the Media host>
* bytes(objectId, { range }) GET <signed URL on the Media host>
* remove(objectId) DELETE …/objects/:id (a soft delete in Media)
* remove(objectId) DELETE …/objects/:id (a soft delete in Media)
*
*
+ * OpenVibe.Video uploads use Media's VOD API in the same tenant (/api/v1/media-hub/vods, the media.object.upload grant),
+ * which gives an uploaded video what Live's recordings get: probe, thumbnail, seekable file and the HLS timeline.
+ *
+ * vodCreate({ title, visibility, ownerSubject }) POST /api/v1/media-hub/vods
+ * vodChunk(vodId, buffer, contentType) POST …/vods/:id/chunks (appended in order)
+ * vodComplete(vodId) POST …/vods/:id/complete (probe, thumbnail; minutes for a big file)
+ * vodGet(vodId) / vodUpdate(vodId, { title, visibility }) / vodDelete(vodId)
+ *
* Two details worth knowing. Media answers a private object's download with a signed URL on its *public* origin;
* Two details worth knowing. Media answers a private object's download with a signed URL on its *public* origin;
* this client rewrites that URL onto the configured Media host before fetching it, so the bytes are read from the
* this client rewrites that URL onto the configured Media host before fetching it, so the bytes are read from the
* one address MEDIAHUB_MEDIA_URL names and never from a URL a caller supplied. And a Media call never throws for
* one address MEDIAHUB_MEDIA_URL names and never from a URL a caller supplied. And a Media call never throws for
@@ -52,7 +60,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -52,7 +60,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
}
}
/** One JSON call. Never throws for a Media refusal; throws only when Media cannot be reached at all. */
/** One JSON call. Never throws for a Media refusal; throws only when Media cannot be reached at all. */
- async function call(path, { method = 'GET', body, json, headers = {}, raw = false } = {}) {
+ async function call(path, { method = 'GET', body, json, headers = {}, raw = false, root = null, timeoutMs = null } = {}) {
if (!enabled) return { ok: false, status: 503, code: 'media.not_configured', detail: 'OV_OAUTH_CLIENT_SECRET is not set' };
if (!enabled) return { ok: false, status: 503, code: 'media.not_configured', detail: 'OV_OAUTH_CLIENT_SECRET is not set' };
let token;
let token;
try {
try {
@@ -66,7 +74,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -66,7 +74,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }
if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }
let res;
let res;
try {
try {
- res = await fetchImpl(`${base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(media.timeoutMs) });
+ res = await fetchImpl(`${root || base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(timeoutMs || media.timeoutMs) });
} catch (err) {
} catch (err) {
log.warn('[MediaHub] Media did not answer:', (err && err.name) || '', (err && err.message) || '');
log.warn('[MediaHub] Media did not answer:', (err && err.name) || '', (err && err.message) || '');
return { ok: false, status: 502, code: 'media.unreachable', detail: 'OpenVibe.Media did not answer' };
return { ok: false, status: 502, code: 'media.unreachable', detail: 'OpenVibe.Media did not answer' };
@@ -80,9 +88,30 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -80,9 +88,30 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
return { ok: true, status: res.status, data };
return { ok: true, status: res.status, data };
}
}
+ const vods = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/vods`;
+ const VOD_EXT = { 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/x-matroska': 'mkv' };
+
return {
return {
enabled,
enabled,
+ /** A VOD row for an upload (Media's user_id is the uploader's usr_… subject; MediaHub owns the meaning). */
+ vodCreate: ({ title, visibility = 'public', ownerSubject }) => call('', {
+ root: vods(), method: 'POST',
+ json: { title: String(title || 'Video').slice(0, 300), visibility: ['public', 'unlisted', 'private'].includes(visibility) ? visibility : 'private', user_id: subjectHeader(ownerSubject) },
+ }),
+ /** One part of the file, appended to the VOD's file in the order sent (segment 1: one continuous file). */
+ vodChunk(vodId, buffer, contentType) {
+ const form = new FormData();
+ form.append('segmentId', '1');
+ form.append('chunk', new Blob([buffer], { type: contentType }), `part.${VOD_EXT[contentType] || 'mp4'}`);
+ return call(`/${Number(vodId)}/chunks`, { root: vods(), method: 'POST', body: form, timeoutMs: 120_000 });
+ },
+ /** Close the file: Media remuxes, probes, makes the thumbnail and announces it. A big file takes minutes. */
+ vodComplete: (vodId) => call(`/${Number(vodId)}/complete`, { root: vods(), method: 'POST', json: {}, timeoutMs: 30 * 60_000 }),
+ vodGet: (vodId) => call(`/${Number(vodId)}`, { root: vods() }),
+ vodUpdate: (vodId, patch) => call(`/${Number(vodId)}`, { root: vods(), method: 'PUT', json: patch }),
+ vodDelete: (vodId) => call(`/${Number(vodId)}`, { root: vods(), method: 'DELETE' }),
+
/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */
/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */
async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false }) {
async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false }) {
// A drive file always belongs to a person. A picture uploaded signed out (Pics, `ownerless`) is the
// A drive file always belongs to a person. A picture uploaded signed out (Pics, `ownerless`) is the
@@ -91,7 +91,7 @@ ${render(o.body)}
@@ -91,7 +91,7 @@ ${render(o.body)}
}
}
// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).
// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).
-const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Playlists', href: '/playlists' }];
+const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Upload', href: '/upload' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];
/** Send a video page. Signed-out pages are public; a session cookie makes a page private, no-store. */
/** Send a video page. Signed-out pages are public; a session cookie makes a page private, no-store. */
function send(res, status, o) {
function send(res, status, o) {
@@ -29,6 +29,7 @@ const { send, asset, assetVersion, SITE_NAME } = require('./layout');
@@ -29,6 +29,7 @@ const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createChatClient } = require('./chat-client');
const { createChatClient } = require('./chat-client');
const { createVideoApiRoutes } = require('./api');
const { createVideoApiRoutes } = require('./api');
+const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -525,10 +526,15 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -525,10 +526,15 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
// ── Playlist API (openvibe.video only; the drive's /api/v1 is untouched) ──
// ── Playlist API (openvibe.video only; the drive's /api/v1 is untouched) ──
r.use('/api/v1/video', createVideoApiRoutes(ctx));
r.use('/api/v1/video', createVideoApiRoutes(ctx));
+ // Uploads' part API (server/video/upload-routes.js): raw parts and its own JSON, before the forms' parser.
+ r.use('/api/v1/videos', createUploadApi({ ctx, config }));
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// parser, so the two never read each other's bodies.
// parser, so the two never read each other's bodies.
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
+ // ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
+ const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card });
+
// ── Playlist page pieces ───────────────────────────────
// ── Playlist page pieces ───────────────────────────────
async function playlistForm(req, kind, id) {
async function playlistForm(req, kind, id) {
if (!signedIn(req)) {
if (!signedIn(req)) {
@@ -632,13 +638,15 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -632,13 +638,15 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
]);
]);
const vodItems = (vods && Array.isArray(vods.items)) ? vods.items : [];
const vodItems = (vods && Array.isArray(vods.items)) ? vods.items : [];
const clipItems = (clips && Array.isArray(clips.items)) ? clips.items : [];
const clipItems = (clips && Array.isArray(clips.items)) ? clips.items : [];
+ const uploaded = await ctx.videoUploads.latest(HOME_LIMIT);
return page(req, res, {
return page(req, res, {
index: true,
index: true,
- description: 'Stream recordings and clips from OpenVibe.Live, with AI chapters and captions.',
+ description: 'Videos people upload, and stream recordings and clips from OpenVibe.Live with AI chapters and captions.',
body: html`<header class="ovv-hero"><p class="ovv-eyebrow"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> OpenVibe.Video</p>
body: html`<header class="ovv-hero"><p class="ovv-eyebrow"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> OpenVibe.Video</p>
-<h1>Watch what OpenVibe streamed.</h1>
-<p class="ovv-lede">Stream recordings and clips from OpenVibe.Live, with AI chapters and captions. Uploads are not open here — watch what people made public on OpenVibe.Live.</p>
-<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="${livePublic}/">Live now on OpenVibe.Live</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser">Go live from your browser</a></p></header>
+<h1>Watch, upload, stream.</h1>
+<p class="ovv-lede">Videos people upload here, and the recordings and clips of streams on OpenVibe.Live with AI chapters and captions. Sign in with your OpenVibe account to upload: it plays everywhere, on your channel at openvibe.video/@you.</p>
+<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a> <a class="ovv-btn" href="${livePublic}/">Live now on OpenVibe.Live</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser">Go live from your browser</a></p></header>
+${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Latest uploads</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Latest recordings</h2>${grid(vodItems.map(feedCard), 'No recordings yet.')}</section>
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Latest recordings</h2>${grid(vodItems.map(feedCard), 'No recordings yet.')}</section>
<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Latest clips</h2>${grid(clipItems.map(feedCard), 'No clips yet.')}</section>`,
<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Latest clips</h2>${grid(clipItems.map(feedCard), 'No clips yet.')}</section>`,
});
});
@@ -971,12 +979,14 @@ ${manageable ? ownerPanel('clip', id, clip) : ''}
@@ -971,12 +979,14 @@ ${manageable ? ownerPanel('clip', id, clip) : ''}
const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;
const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;
const nextLink = hasMore ? `?page=${pageNum + 1}` : null;
const nextLink = hasMore ? `?page=${pageNum + 1}` : null;
+ const uploaded = pageNum === 1 ? await ctx.videoUploads.channel(username) : [];
return page(req, res, {
return page(req, res, {
index: true,
index: true,
title: `@${username}`,
title: `@${username}`,
- description: `Recordings and clips from @${username} on OpenVibe.Video.`,
+ description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,
body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
+${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Videos</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}
${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}
${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,
${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,
@@ -1136,7 +1146,7 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
@@ -1136,7 +1146,7 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
}
}
r.get('/sitemap.xml', async (req, res) => {
r.get('/sitemap.xml', async (req, res) => {
- const [vods, clips] = await Promise.all([feedItems('vods'), feedItems('clips')]);
+ const [vods, clips, uploaded] = await Promise.all([feedItems('vods'), feedItems('clips'), ctx.videoUploads.latest(500)]);
const day = (iso) => { const d = new Date(String(iso || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };
const day = (iso) => { const d = new Date(String(iso || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };
const newest = (list) => list.map(day).filter(Boolean).sort().pop() || null;
const newest = (list) => list.map(day).filter(Boolean).sort().pop() || null;
const items = [...vods, ...clips].filter((it) => it && Number.isSafeInteger(Number(it.id)));
const items = [...vods, ...clips].filter((it) => it && Number.isSafeInteger(Number(it.id)));
@@ -1147,10 +1157,17 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
@@ -1147,10 +1157,17 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
const d = day(it.created_at);
const d = day(it.created_at);
if (!channels.has(u) || (d && d > channels.get(u))) channels.set(u, d);
if (!channels.has(u) || (d && d > channels.get(u))) channels.set(u, d);
}
}
+ for (const x of uploaded) {
+ const u = x.owner_username;
+ if (!u || !/^[A-Za-z0-9_]{1,32}$/.test(u)) continue;
+ const d = day(x.ready_at || x.created_at);
+ if (!channels.has(u) || (d && d > channels.get(u))) channels.set(u, d);
+ }
const urls = [
const urls = [
{ loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },
{ loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },
{ loc: `${base}/updates` },
{ loc: `${base}/updates` },
...items.map((it) => ({ loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at) })),
...items.map((it) => ({ loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at) })),
+ ...uploaded.map((x) => ({ loc: `${base}/u/${x.id}`, lastmod: day(x.updated_at) })),
...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
].map((u) => (u.lastmod ? u : { loc: u.loc }));
].map((u) => (u.lastmod ? u : { loc: u.loc }));
return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));
return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));
new file mode 100644
new file mode 100644
@@ -0,0 +1,246 @@
@@ -0,0 +1,246 @@
+'use strict';
+
+/**
+ * Uploads on openvibe.video (server/video/uploads.js): the pages and the part API.
+ *
+ * GET /upload the form (title, description, visibility, the file); public/js/video-upload.js sends it
+ * POST /api/v1/videos start: { title, description, visibility, size, content_type, name } → { upload }
+ * PUT /api/v1/videos/:id/parts/:n one 8 MB part, in order (application/octet-stream)
+ * POST /api/v1/videos/:id/complete close the file; Media finishes it in the background → { video: { id, url } }
+ * GET /u/:id the watch page (processing: it says so and reloads itself)
+ * POST /u/:id/manage | /u/:id/delete | /u/:id/report
+ * GET /me/videos your uploads, with their state
+ *
+ * Every write is a signed-in person's (a session on openvibe.video) from openvibe.video itself (Sec-Fetch-Site or
+ * Origin). Reports are open to anyone who can see the video, one per person (or per keyed address) per video.
+ */
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { sameOrigin } = require('../http/principal');
+const { html, notice } = require('../render/html');
+const { UploadError } = require('./uploads');
+
+const REASON_TEXT = {
+ child_safety: 'It sexualises or endangers a child', violence: 'Violence or a threat', harassment: 'Harassment or someone\'s private details',
+ sexual: 'Sexual content', copyright: 'It is my work, used without permission', spam: 'Spam or a scam', other: 'Something else',
+};
+
+function problem(res, err) {
+ const e = err instanceof UploadError ? err : null;
+ if (!e) throw err;
+ return res.status(e.status).type('application/problem+json').json({ type: 'about:blank', status: e.status, code: e.code, detail: e.detail });
+}
+
+/** The JSON part API (mounted before the site's urlencoded parser: parts are raw bytes). */
+function createUploadApi({ ctx, config }) {
+ const up = ctx.videoUploads;
+ const r = express.Router();
+ r.use((req, res, next) => { res.set('Cache-Control', cache.htmlHeaders({ private: true })); next(); });
+ const who = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.subject
+ ? { requester: `user:${req.viewer.subject}`, username: req.viewer.username || null } : null);
+ const guard = (req, res) => {
+ if (!who(req)) { problem(res, new UploadError(401, 'auth.required', 'Sign in to upload a video.')); return false; }
+ if (!sameOrigin(req, config.video.url)) { problem(res, new UploadError(403, 'upload.cross_site', 'Upload from openvibe.video itself.')); return false; }
+ return true;
+ };
+ const own = async (req, res) => {
+ const row = await up.byId(req.params.id);
+ if (!row || row.owner !== who(req).requester || row.state === 'removed') { problem(res, new UploadError(404, 'upload.not_found', 'No such upload.')); return null; }
+ return row;
+ };
+
+ r.post('/', express.json({ limit: '16kb' }), async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ try {
+ const b = req.body || {};
+ const row = await up.start(who(req), { title: b.title, description: b.description, visibility: b.visibility, size: b.size, contentType: b.content_type, name: b.name });
+ return res.status(201).json({ upload: { id: row.id, part_size: row.part_size, parts_expected: row.parts_expected } });
+ } catch (err) { return problem(res, err); }
+ });
+
+ r.put('/:id/parts/:n', express.raw({ type: () => true, limit: `${Math.ceil(up.PART_SIZE / 1024) + 64}kb` }), async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const row = await own(req, res);
+ if (!row) return undefined;
+ try {
+ const out = await up.part(row, Number(req.params.n), Buffer.isBuffer(req.body) ? req.body : null);
+ return res.json({ parts_received: out.parts_received, parts_expected: out.parts_expected });
+ } catch (err) { return problem(res, err); }
+ });
+
+ r.post('/:id/complete', async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const row = await own(req, res);
+ if (!row) return undefined;
+ try {
+ const out = await up.complete(row);
+ return res.status(202).json({ video: { id: out.id, status: out.status, url: `/u/${out.id}` } });
+ } catch (err) { return problem(res, err); }
+ });
+
+ return r;
+}
+
+/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
+function mountUploadPages(r, h) {
+ const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card } = h;
+ const up = ctx.videoUploads;
+ const cfg = ctx.config.videoUploads;
+ const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
+ const GB = 1024 ** 3;
+
+ const statusText = (row) => (row.state === 'hidden' ? 'Hidden after reports, waiting for staff'
+ : row.status === 'uploading' ? `Upload stopped at ${Math.round((row.parts_received / row.parts_expected) * 100)}%`
+ : row.status === 'processing' ? 'Processing' : row.status === 'failed' ? 'Failed' : visibilityLabel(row.visibility));
+
+ r.get('/upload', mustSignIn('/upload', 'Uploads belong to your OpenVibe account: sign in to upload a video.'), async (req, res) => page(req, res, {
+ title: 'Upload a video',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Upload' }],
+ scripts: ['js/video-upload.js'],
+ body: html`<h1>Upload a video</h1>
+<form class="ovv-upload ovv-panel" data-video-upload method="post" action="/upload">
+<label class="ovv-field"><span>Video file</span><input name="file" type="file" accept="video/mp4,video/quicktime,video/x-m4v,video/webm,video/x-matroska,.mp4,.mov,.m4v,.webm,.mkv" required></label>
+<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" required></label>
+<label class="ovv-field"><span>Description</span><textarea name="description" rows="4" maxlength="5000"></textarea></label>
+<label class="ovv-field"><span>Who can see it</span><select name="visibility">${visibilityOptions('public')}</select></label>
+<p class="ovv-fine">Public is listed on your channel and searchable; unlisted opens only by its link; private only for you. MP4, MOV, WebM or MKV, up to ${Math.round(cfg.maxBytes / GB)} GB a video and ${Math.round(cfg.quotaBytes / GB)} GB in all, ${cfg.perDay} uploads a day. Upload only what you made or may share: reported videos are hidden while staff look.</p>
+<progress hidden value="0" max="1"></progress>
+<p class="ovv-upload-status" role="status" aria-live="polite"></p>
+<button class="ovv-btn ovv-btn--primary" type="submit">Upload</button>
+<noscript><p class="ovv-fine">Uploading needs JavaScript: a video goes up in parts so a big file and a dropped connection both work.</p></noscript>
+</form>
+<p><a href="/me/videos">Your videos</a></p>`,
+ }));
+
+ // Without JavaScript the form posts here: a video goes up in parts, which needs the page's script.
+ r.post('/upload', (req, res) => page(req, res, {
+ title: 'Upload a video',
+ body: html`<h1>Uploading needs JavaScript</h1><p>A video goes up in 8 MB parts, so a big file and a dropped connection both work. Turn JavaScript on for openvibe.video and <a href="/upload">try again</a>.</p>`,
+ }, 400));
+
+ r.get('/me/videos', mustSignIn('/me/videos', 'Sign in to see the videos you uploaded.'), async (req, res) => {
+ const rows = await up.mine(ownerOf(req));
+ await Promise.all(rows.filter((x) => x.status === 'processing').map((x) => up.refresh(x).catch(() => null)));
+ const fresh = await up.mine(ownerOf(req));
+ return page(req, res, {
+ title: 'Your videos',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your videos' }],
+ body: html`<h1>Your videos</h1>
+<p><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></p>
+${fresh.length ? html`<table class="ovv-table"><thead><tr><th scope="col">Video</th><th scope="col">State</th><th scope="col">Length</th><th scope="col">Views</th><th scope="col">Uploaded</th></tr></thead><tbody>
+${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${statusText(x)}</td><td>${x.duration_seconds ? durationLabel(x.duration_seconds) : ''}</td><td>${Number(x.views) || 0}</td><td>${dateLabel(x.created_at) || ''}</td></tr>`)}
+</tbody></table>` : html`<p class="muted">Nothing uploaded yet.</p>`}`,
+ });
+ });
+
+ r.get('/u/:id', async (req, res) => {
+ let row = await up.byId(req.params.id);
+ const who = whoOf(req);
+ if (!up.canSee(row, who)) return notFound(req, res);
+ if (row.status === 'processing') row = await up.refresh(row);
+ const own = who.requester && row.owner === who.requester;
+ const manage = own || who.staff;
+ const ready = row.status === 'ready';
+ const vod = ready ? await up.playback(row) : null;
+ if (ready && !own) await up.view(row);
+ const listed = ready && row.state === 'active' && row.visibility === 'public';
+ const channel = row.owner_username;
+ const metaParts = [row.ready_at ? html`<time datetime="${row.ready_at}">${dateLabel(row.ready_at)}</time>` : null, row.duration_seconds ? durationLabel(row.duration_seconds) : null, `${Number(row.views) || 0} views`].filter(Boolean);
+ const thumb = httpsUrl(row.thumbnail_url);
+ if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done
+ return page(req, res, {
+ index: listed, noindex: !listed,
+ ...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
+ title: row.title,
+ description: row.description ? row.description.slice(0, 300) : `A video by @${channel || 'someone'} on OpenVibe.Video.`,
+ ...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
+ scripts: ready ? ['js/video.js'] : [],
+ body: html`<div class="ovv-watch">
+<div class="ovv-main">
+${req.query.saved === '1' ? notice('Saved.', 'ok') : ''}${req.query.reported === '1' ? notice('Thanks: the report reached OpenVibe staff.', 'ok') : ''}
+${row.state === 'hidden' ? notice('Hidden after reports: only you and OpenVibe staff can see it until staff decide.', 'warn') : ''}
+${row.visibility === 'private' && ready ? notice('Private: only you (and OpenVibe staff) can see this video.', 'info') : ''}
+${ready && vod ? html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline${thumb ? html` poster="${`${thumb}?w=1280&fmt=webp`}"` : ''} src="${fileOf(vod) || ''}" data-ovv-video${hlsAttrs(vod)}></video></div>`
+ : html`<div class="ovv-player-wrap ovv-processing" role="status">${row.status === 'processing' ? html`<p><i class="fa-solid fa-gear fa-spin" aria-hidden="true"></i> Processing: OpenVibe.Media is making it playable. This page reloads by itself.</p>`
+ : row.status === 'failed' ? html`<p>This upload could not be processed. ${row.error || ''}</p>`
+ : html`<p>The upload stopped before the end (${row.parts_received} of ${row.parts_expected} parts). Upload the file again from <a href="/upload">Upload</a>.</p>`}</div>`}
+<h1 class="ovv-title">${row.title}</h1>
+<div class="ovv-byline">
+${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}</a>` : ''}
+<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
+${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
+</div>
+${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
+</div>
+<aside class="ovv-side">
+${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><h2 id="h-owner">Manage this video</h2>
+<form class="ovv-owner-form" method="post" action="/u/${row.id}/manage">
+<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" value="${row.title}" required></label>
+<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="5000">${row.description}</textarea></label>
+<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(row.visibility)}</select></label>
+<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button></form>
+<details class="ovv-danger"><summary>Delete this video</summary>
+<form class="ovv-owner-delete" method="post" action="/u/${row.id}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>
+</details></section>` : ''}
+${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report this video</summary>
+<form method="post" action="/u/${row.id}/report">
+<fieldset><legend>What is wrong?</legend>${Object.entries(REASON_TEXT).map(([k, t]) => html`<label class="ovv-radio"><input type="radio" name="reason" value="${k}" required> ${t}</label>`)}</fieldset>
+<label class="ovv-field"><span>Anything staff should know (optional)</span><textarea name="note" rows="2" maxlength="1000"></textarea></label>
+<button class="ovv-btn" type="submit">Send the report</button>
+<p class="ovv-fine">A child-safety report hides the video at once; three reports hide it until staff decide.</p></form></details>` : ''}
+</aside>
+</div>`,
+ });
+ });
+
+ r.post('/u/:id/manage', mustSignIn('here'), async (req, res) => {
+ const row = await up.byId(req.params.id);
+ const who = whoOf(req);
+ if (!row || row.state === 'removed' || !(row.owner === who.requester || who.staff)) return notFound(req, res);
+ if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
+ try {
+ const b = req.body || {};
+ await up.update(row, { title: b.title, description: b.description, visibility: b.visibility });
+ } catch (err) {
+ if (!(err instanceof UploadError)) throw err;
+ return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>${err.detail}</p><p><a href="/u/${row.id}">Back</a></p>` }, err.status);
+ }
+ return res.redirect(303, `/u/${row.id}?saved=1`);
+ });
+
+ r.post('/u/:id/delete', mustSignIn('here'), async (req, res) => {
+ const row = await up.byId(req.params.id);
+ const who = whoOf(req);
+ if (!row || row.state === 'removed' || !(row.owner === who.requester || who.staff)) return notFound(req, res);
+ if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
+ if ((req.body || {}).confirm !== '1') return res.redirect(303, `/u/${row.id}`);
+ await up.remove(row);
+ return res.redirect(303, '/me/videos');
+ });
+
+ r.post('/u/:id/report', async (req, res) => {
+ const row = await up.byId(req.params.id);
+ if (!up.canSee(row, whoOf(req))) return notFound(req, res);
+ if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
+ try {
+ const b = req.body || {};
+ await up.report(row, { reporter: signedIn(req) ? ownerOf(req) : up.addressId(req.ip), reason: String(b.reason || ''), note: b.note });
+ } catch (err) {
+ if (!(err instanceof UploadError)) throw err;
+ return page(req, res, { title: 'Not sent', body: html`<h1>Not sent</h1><p>${err.detail}</p><p><a href="/u/${row.id}">Back</a></p>` }, err.status);
+ }
+ return res.redirect(303, `/u/${row.id}?reported=1`);
+ });
+
+ /** A card for an upload (home, channel). */
+ return function uploadCard(x) {
+ return card({
+ href: `/u/${x.id}`, thumb: httpsUrl(x.thumbnail_url), title: x.title,
+ channel: x.owner_username || null, channelHref: x.owner_username ? `/@${x.owner_username}` : null,
+ duration: x.duration_seconds ? durationLabel(x.duration_seconds) : null,
+ date: dateLabel(x.ready_at || x.created_at) || null, iso: x.ready_at || x.created_at || null,
+ });
+ };
+}
+
+module.exports = { createUploadApi, mountUploadPages, REASON_TEXT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,176 @@
@@ -0,0 +1,176 @@
+'use strict';
+
+/**
+ * Uploads on openvibe.video: a signed-in person uploads a video; it becomes an OpenVibe.Media VOD in MediaHub's tenant,
+ * so it gets what a Live recording gets (probe, thumbnail, seekable file, HLS timeline), and a watch page at /u/<id>.
+ *
+ * start(who, { title, description, visibility, size, contentType }) → the row, and Media's VOD created for it
+ * part(row, n, buffer) the n-th part, in order (Media appends it to one continuous file)
+ * complete(row) closes the file; Media finalizes in the background, the row goes processing → ready
+ * refresh(row) reads Media again for a row still processing (after a restart it is how it finishes)
+ * update(row, patch) / remove(row) / report(row, …)
+ *
+ * Rules: MP4 (QuickTime and M4V are the same container and go to Media as MP4), WebM and Matroska; at most
+ * VIDEO_UPLOAD_MAX_BYTES (2 GB) a file, VIDEO_UPLOAD_QUOTA_BYTES (20 GB) a person, VIDEO_UPLOADS_PER_DAY (10) a day.
+ * Reports: a child-safety report hides the video at once, three reports from different people hide it; the owner and
+ * staff still see a hidden video, labelled. A removed upload's VOD is deleted in Media.
+ */
+const crypto = require('crypto');
+
+const KINDS = { 'video/mp4': 'video/mp4', 'video/quicktime': 'video/mp4', 'video/x-m4v': 'video/mp4', 'video/webm': 'video/webm', 'video/x-matroska': 'video/x-matroska' };
+const EXT_KINDS = { mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska' };
+const VISIBILITY = ['public', 'unlisted', 'private'];
+const REASONS = ['child_safety', 'violence', 'harassment', 'sexual', 'copyright', 'spam', 'other'];
+const PART_SIZE = 8 * 1024 * 1024;
+const HIDE_AFTER = 3;
+
+const cleanLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
+const cleanText = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);
+
+class UploadError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; this.detail = detail; }
+}
+
+/** The type the parts are sent to Media as, from the browser's type or else the file name; null when not a video we take. */
+function kindOf(contentType, name) {
+ const t = String(contentType || '').split(';')[0].trim().toLowerCase();
+ if (KINDS[t]) return KINDS[t];
+ const ext = String(name || '').toLowerCase().match(/\.([a-z0-9]{2,5})$/);
+ return ext && EXT_KINDS[ext[1]] ? EXT_KINDS[ext[1]] : null;
+}
+
+function createUploads({ s, media, config, log = console }) {
+ const cfg = config.videoUploads;
+ const addressKey = crypto.createHash('sha256').update(`video-address:${config.oauth.clientSecret || 'dev'}`).digest();
+ const addressId = (ip) => `ip:${crypto.createHmac('sha256', addressKey).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;
+ const running = new Map(); // upload id → the completion in flight in this process
+
+ const byId = (id) => (/^vup_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id)) ? s.db.maybe('SELECT * FROM video_uploads WHERE id = $1', [String(id)]) : null);
+ const patchRow = async (id, patch) => {
+ const cols = Object.keys(patch);
+ await s.db.query(`UPDATE video_uploads SET ${cols.map((c, i) => `${c} = $${i + 3}`).join(', ')}, updated_at = $2 WHERE id = $1`, [id, s.iso(), ...cols.map((c) => patch[c])]);
+ return await byId(id);
+ };
+
+ /** May `who` ({ requester, staff }) see this upload at all? */
+ function canSee(row, who) {
+ if (!row || row.state === 'removed') return false;
+ const own = Boolean(who && who.requester && row.owner === who.requester);
+ if (own || (who && who.staff)) return true;
+ return row.state === 'active' && row.status === 'ready' && row.visibility !== 'private';
+ }
+
+ async function start(who, input) {
+ if (!who || !who.requester) throw new UploadError(401, 'auth.required', 'Sign in to upload a video.');
+ const title = cleanLine(input.title, 120);
+ if (!title) throw new UploadError(422, 'upload.title_required', 'Give the video a title.');
+ const description = cleanText(input.description, 5000);
+ const visibility = VISIBILITY.includes(input.visibility) ? input.visibility : 'public';
+ const size = Number(input.size);
+ const kind = kindOf(input.contentType, input.name);
+ if (!kind) throw new UploadError(415, 'upload.type', 'Upload an MP4, MOV, WebM or MKV video.');
+ if (!Number.isInteger(size) || size < 1) throw new UploadError(422, 'upload.size', 'The file is empty.');
+ if (size > cfg.maxBytes) throw new UploadError(413, 'upload.too_big', `A video can be at most ${Math.round(cfg.maxBytes / 1024 ** 3)} GB.`);
+ const today = s.iso().slice(0, 10);
+ const usage = await s.db.maybe(`SELECT count(*) FILTER (WHERE created_at >= $2)::int AS today, coalesce(sum(size_bytes) FILTER (WHERE state <> 'removed'), 0)::bigint AS bytes
+ FROM video_uploads WHERE owner = $1`, [who.requester, today]);
+ if (usage && usage.today >= cfg.perDay) throw new UploadError(429, 'upload.daily_limit', `That is ${cfg.perDay} uploads today. Try again tomorrow.`);
+ if (usage && Number(usage.bytes) + size > cfg.quotaBytes) throw new UploadError(413, 'upload.quota', `Your videos would pass ${Math.round(cfg.quotaBytes / 1024 ** 3)} GB. Delete one first.`);
+ const vod = await media.vodCreate({ title, visibility, ownerSubject: who.requester });
+ if (!vod.ok || !vod.data || vod.data.id == null) throw new UploadError(502, 'upload.media', `OpenVibe.Media could not start the upload (${vod.code || vod.status}).`);
+ const id = s.newId('vup');
+ const now = s.iso();
+ await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)`,
+ [id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, size, PART_SIZE, Math.ceil(size / PART_SIZE), now]);
+ return await byId(id);
+ }
+
+ async function part(row, n, buffer) {
+ if (row.status !== 'uploading') throw new UploadError(409, 'upload.closed', 'This upload is already finished.');
+ if (n !== row.parts_received + 1) throw new UploadError(409, 'upload.out_of_order', `Send part ${row.parts_received + 1} next.`);
+ const last = n === row.parts_expected;
+ const want = last ? row.size_bytes - (n - 1) * row.part_size : row.part_size;
+ if (!buffer || buffer.length !== want) throw new UploadError(422, 'upload.part_size', `Part ${n} should be ${want} bytes.`);
+ const out = await media.vodChunk(row.media_vod_id, buffer, row.content_type);
+ if (!out.ok) throw new UploadError(502, 'upload.media', `OpenVibe.Media did not take part ${n} (${out.code || out.status}). Try again.`);
+ return await patchRow(row.id, { parts_received: n });
+ }
+
+ /** What Media says about the VOD → the row's status, duration and thumbnail. */
+ async function absorb(row, vod) {
+ const v = vod && (vod.vod || vod);
+ if (!v || v.is_recording) return row;
+ const duration = Number(v.duration_seconds);
+ const ready = Boolean(v.file_url || v.playback_url) && Number.isFinite(duration) && duration > 0;
+ if (!ready) return row;
+ return await patchRow(row.id, { status: 'ready', duration_seconds: duration, thumbnail_url: v.thumbnail_url || null, ready_at: row.ready_at || s.iso(), error: null });
+ }
+
+ async function complete(row) {
+ if (row.status === 'ready' || row.status === 'processing') return row;
+ if (row.parts_received !== row.parts_expected) throw new UploadError(409, 'upload.incomplete', `${row.parts_expected - row.parts_received} parts are still missing.`);
+ const now = await patchRow(row.id, { status: 'processing' });
+ // Media can take minutes on a big file: answer now, finish in the background (refresh() finishes it after a restart).
+ const job = media.vodComplete(row.media_vod_id).then(async (out) => {
+ if (out.ok) return await absorb(await byId(row.id), out.data);
+ if (out.status >= 500 || out.code === 'media.unreachable') return null; // refresh() asks again later
+ return await patchRow(row.id, { status: 'failed', error: `Media could not process the video (${out.code || out.status}).` });
+ }).catch((err) => { log.warn('[MediaHub] video completion:', err.message); return null; })
+ .finally(() => running.delete(row.id));
+ running.set(row.id, job);
+ return now;
+ }
+
+ /** A row still processing: ask Media how it is going (no more than once every 15 seconds per row). */
+ const lastAsked = new Map();
+ async function refresh(row) {
+ if (!row || row.status !== 'processing' || running.has(row.id)) return row;
+ const t = Date.now();
+ if (t - (lastAsked.get(row.id) || 0) < 15_000) return row;
+ lastAsked.set(row.id, t);
+ const out = await media.vodGet(row.media_vod_id);
+ if (out.ok) return await absorb(row, out.data);
+ if (out.status === 404) return await patchRow(row.id, { status: 'failed', error: 'Media no longer has this video.' });
+ return row;
+ }
+
+ async function update(row, input) {
+ const patch = {};
+ if (input.title !== undefined) { const t = cleanLine(input.title, 120); if (!t) throw new UploadError(422, 'upload.title_required', 'Give the video a title.'); patch.title = t; }
+ if (input.description !== undefined) patch.description = cleanText(input.description, 5000);
+ if (input.visibility !== undefined) { if (!VISIBILITY.includes(input.visibility)) throw new UploadError(422, 'upload.visibility', 'Public, unlisted or private.'); patch.visibility = input.visibility; }
+ if (!Object.keys(patch).length) return row;
+ if (patch.title || patch.visibility) await media.vodUpdate(row.media_vod_id, { ...(patch.title ? { title: patch.title } : {}), ...(patch.visibility ? { visibility: patch.visibility } : {}) });
+ return await patchRow(row.id, patch);
+ }
+
+ async function remove(row) {
+ const out = await media.vodDelete(row.media_vod_id);
+ if (!out.ok && out.status !== 404) log.warn(`[MediaHub] video ${row.id}: Media refused the delete: ${out.status} ${out.code}`);
+ return await patchRow(row.id, { state: 'removed' });
+ }
+
+ async function report(row, { reporter, reason, note }) {
+ if (!REASONS.includes(reason)) throw new UploadError(422, 'report.reason', 'Choose what is wrong with the video.');
+ const added = Boolean(await s.db.maybe(`INSERT INTO video_upload_reports (upload_id, reporter, reason, note, created_at) VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (upload_id, reporter) DO NOTHING RETURNING upload_id`, [row.id, reporter, reason, cleanText(note, 1000), s.iso()]));
+ if (!added) return { recorded: false, hidden: row.state === 'hidden' };
+ const n = Number(await s.db.value('SELECT count(*) FROM video_upload_reports WHERE upload_id = $1', [row.id]));
+ const hide = row.state === 'active' && (reason === 'child_safety' || n >= HIDE_AFTER);
+ await patchRow(row.id, { report_count: n, ...(hide ? { state: 'hidden' } : {}) });
+ return { recorded: true, hidden: hide || row.state === 'hidden' };
+ }
+
+ return {
+ PART_SIZE, REASONS, UploadError, kindOf, addressId, canSee,
+ byId, start, part, complete, refresh, update, remove, report,
+ mine: (owner, limit = 60) => s.db.many("SELECT * FROM video_uploads WHERE owner = $1 AND state <> 'removed' ORDER BY id DESC LIMIT $2", [owner, limit]),
+ latest: (limit = 12) => s.db.many("SELECT * FROM video_uploads WHERE visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $1", [limit]),
+ channel: (username, limit = 48) => s.db.many("SELECT * FROM video_uploads WHERE lower(owner_username) = lower($1) AND visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $2", [String(username), limit]),
+ view: async (row) => { await s.db.query('UPDATE video_uploads SET views = views + 1 WHERE id = $1', [row.id]); },
+ playback: async (row) => { const out = await media.vodGet(row.media_vod_id); return out.ok ? (out.data.vod || out.data) : null; },
+ };
+}
+
+module.exports = { createUploads, kindOf, UploadError, KINDS, REASONS, VISIBILITY };
@@ -37,6 +37,10 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -37,6 +37,10 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const sessions = new Map(); // id → { id, object_id, part_size, total_size, parts_expected, status, parts: Map }
const sessions = new Map(); // id → { id, object_id, part_size, total_size, parts_expected, status, parts: Map }
const requests = [];
const requests = [];
const deleted = [];
const deleted = [];
+ const vods = new Map(); // OpenVibe.Video uploads: id → { id, title, visibility, user_id, bytes, types, status }
+ const deletedVods = [];
+ const vodState = { duration: 42.5, completeFails: false };
+ let vodSeq = 0;
const state = { down: false, tokenRejects: false };
const state = { down: false, tokenRejects: false };
let seq = 0;
let seq = 0;
@@ -87,7 +91,53 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -87,7 +91,53 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const entry = { method: req.method, path, query: url.search, authorization: req.headers.authorization || null, subject: req.headers['x-ov-subject'] || null, bytes: raw.length };
const entry = { method: req.method, path, query: url.search, authorization: req.headers.authorization || null, subject: req.headers['x-ov-subject'] || null, bytes: raw.length };
requests.push(entry);
requests.push(entry);
if (state.down) return problem(res, 503, 'media.down', 'Media is restarting');
if (state.down) return problem(res, 503, 'media.down', 'Media is restarting');
- if (!path.startsWith('/api/v2/') && !path.startsWith('/o/')) return problem(res, 404, 'route.not_found', 'no such route');
+ if (!path.startsWith('/api/v2/') && !path.startsWith('/o/') && !path.startsWith('/api/v1/')) return problem(res, 404, 'route.not_found', 'no such route');
+
+ // ── The v1 VOD API OpenVibe.Video uploads use (/api/v1/:app/vods): create, chunks (multipart, appended in
+ // order), complete (a probe stand-in: duration from vodState.duration, a thumbnail and the HLS URL), get, put,
+ // delete. Needs the service token like the v2 API.
+ if (path.startsWith('/api/v1/')) {
+ if (state.tokenRejects || req.headers.authorization !== `Bearer ${SERVICE_TOKEN}`) return problem(res, 401, 'auth.required', 'Authentication required');
+ const parts = path.split('/').filter(Boolean); // api v1 :app vods [:id] [chunks|complete]
+ if (parts[3] !== 'vods') return problem(res, 404, 'route.not_found', 'no such route');
+ const vodPublicOf = (v) => ({
+ id: v.id, title: v.title, visibility: v.visibility, user_id: v.user_id, is_recording: v.status !== 'ready',
+ duration_seconds: v.status === 'ready' ? vodState.duration : 0, file_size: v.bytes.length,
+ file_url: v.status === 'ready' ? `https://openvibe.media/v/vod-${v.id}.mp4` : null,
+ hls_url: v.status === 'ready' ? `https://openvibe.media/o/med_vod${v.id}/master.m3u8` : null,
+ thumbnail_url: v.status === 'ready' ? `https://openvibe.media/t/vod-${v.id}.jpg` : null,
+ });
+ if (parts.length === 4 && req.method === 'POST') {
+ const body = JSON.parse(raw.toString('utf8') || '{}');
+ const id = ++vodSeq;
+ vods.set(id, { id, title: body.title, visibility: body.visibility, user_id: body.user_id, bytes: Buffer.alloc(0), types: [], status: 'recording' });
+ return json(res, 201, { id });
+ }
+ const v = vods.get(Number(parts[4]));
+ if (!v || v.deleted) return problem(res, 404, 'vod.not_found', 'VOD not found');
+ if (parts[5] === 'chunks' && req.method === 'POST') {
+ // The multipart body's `chunk` file part: its bytes and its Content-Type (what Media names the file by).
+ const boundary = /boundary=([^;]+)/.exec(String(req.headers['content-type'] || ''));
+ if (!boundary) return problem(res, 400, 'vod.chunk', 'not multipart');
+ const text = raw.toString('latin1');
+ const piece = text.split(`--${boundary[1]}`).find((x) => /name="chunk"/.test(x));
+ if (!piece) return problem(res, 400, 'vod.chunk', 'No chunk data');
+ const head = piece.slice(0, piece.indexOf('\r\n\r\n'));
+ const body = Buffer.from(piece.slice(piece.indexOf('\r\n\r\n') + 4, piece.length - 2), 'latin1');
+ v.types.push((/content-type:\s*([^\r\n]+)/i.exec(head) || [])[1] || null);
+ v.bytes = Buffer.concat([v.bytes, body]);
+ return json(res, 200, { vodId: v.id, chunkIndex: v.types.length - 1, status: 'appended' });
+ }
+ if (parts[5] === 'complete' && req.method === 'POST') {
+ if (vodState.completeFails) return problem(res, 500, 'vod.finalize', 'Failed to finalize VOD');
+ v.status = 'ready';
+ return json(res, 200, { vod: vodPublicOf(v) });
+ }
+ if (parts.length === 5 && req.method === 'GET') return json(res, 200, vodPublicOf(v));
+ if (parts.length === 5 && req.method === 'PUT') { Object.assign(v, JSON.parse(raw.toString('utf8') || '{}')); return json(res, 200, vodPublicOf(v)); }
+ if (parts.length === 5 && req.method === 'DELETE') { v.deleted = true; deletedVods.push(v.id); return json(res, 200, { ok: true }); }
+ return problem(res, 404, 'route.not_found', 'no such route');
+ }
// ── The public bytes of an object (/o/:id), signature-checked like Media checks it ──
// ── The public bytes of an object (/o/:id), signature-checked like Media checks it ──
if (path.startsWith('/o/')) {
if (path.startsWith('/o/')) {
@@ -262,7 +312,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -262,7 +312,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}`;
const url = `http://127.0.0.1:${server.address().port}`;
return {
return {
- url, objects, sessions, requests, deleted, state,
+ url, objects, sessions, requests, deleted, state, vods, deletedVods, vodState,
bytesOf: (id) => (objects.get(id) && objects.get(id).bytes) || null,
bytesOf: (id) => (objects.get(id) && objects.get(id).bytes) || null,
objectOf: (id) => objects.get(id) || null,
objectOf: (id) => objects.get(id) || null,
setDown: (v) => { state.down = v; },
setDown: (v) => { state.down = v; },
@@ -330,9 +330,9 @@ function countOccurrences(haystack, needle) {
@@ -330,9 +330,9 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> part\n');
assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> part\n');
});
});
- await check('the navbar names the site\'s places: Videos and Playlists, the current one active', async () => {
+ await check('the navbar names the site\'s places: Videos, Upload, Your videos and Playlists, the current one active', async () => {
const home = (await getVideo('/')).text;
const home = (await getVideo('/')).text;
- assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);
+ assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);
assert.ok(home.includes('href="/playlists"'), 'the no-JS nav links Playlists too');
assert.ok(home.includes('href="/playlists"'), 'the no-JS nav links Playlists too');
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+'use strict';
+/**
+ * OpenVibe.Video uploads (server/video/uploads.js, upload-routes.js): a signed-in person uploads a video in 8 MB parts
+ * from openvibe.video; it becomes a Media VOD in MediaHub's tenant (the bytes appended in order, QuickTime sent as
+ * MP4), completes in the background, and gets a watch page, a place on the channel and the home page, and the sitemap.
+ * - only a signed-in, same-origin person may upload; types, sizes and the order of parts are checked;
+ * - a processing upload says so and reloads itself, then plays from Media's file and HLS;
+ * - unlisted and private ones stay off lists, private ones off the page for anyone but the owner;
+ * - the owner edits (Media's title follows) and deletes (Media's VOD is deleted);
+ * - reports: one per person, a child-safety report hides it at once; the owner still sees it, labelled.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+const MB = 1024 * 1024;
+
+function startLive() {
+ const server = http.createServer((req, res) => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ vods: [], clips: [], items: [], hasMore: false }));
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
+}
+
+function vreq(base, path, { method = 'GET', cookie, form, json, body, same = true, type } = {}) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(base + path);
+ const h = { Host: 'openvibe.video' };
+ if (cookie) h.cookie = cookie;
+ if (same && method !== 'GET') h['sec-fetch-site'] = 'same-origin';
+ let data;
+ if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; }
+ if (json) { data = Buffer.from(JSON.stringify(json)); h['content-type'] = 'application/json'; }
+ if (body) { data = body; h['content-type'] = type || 'application/octet-stream'; }
+ if (data) h['content-length'] = data.length;
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, headers: res.headers, text, json: () => JSON.parse(text) }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+
+(async () => {
+ const live = await startLive();
+ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url, VIDEO_UPLOADS_PER_DAY: '4' } });
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+ const kimCookie = t.signIn(kim);
+ const samCookie = t.signIn(sam);
+ const file = Buffer.alloc(8 * MB + 1234, 7);
+ file.write('ftypqt ', 4);
+ let id = null;
+
+ await check('signed out, cross-site, wrong type and too big are refused before Media hears of it', async () => {
+ const meta = { title: 'Trip', size: file.length, content_type: 'video/quicktime', name: 'trip.mov', visibility: 'public' };
+ assert.strictEqual((await vreq(t.base, '/api/v1/videos', { method: 'POST', json: meta })).status, 401);
+ assert.strictEqual((await vreq(t.base, '/api/v1/videos', { method: 'POST', json: meta, cookie: kimCookie, same: false })).status, 403);
+ const avi = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { ...meta, content_type: 'video/x-msvideo', name: 'trip.avi' }, cookie: kimCookie });
+ assert.strictEqual(avi.status, 415);
+ assert.match(avi.json().detail, /MP4, MOV, WebM or MKV/);
+ assert.strictEqual((await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { ...meta, size: 3 * 1024 * MB }, cookie: kimCookie })).status, 413);
+ assert.strictEqual(t.media.vods.size, 0, 'no VOD was created');
+ });
+
+ await check('a MOV goes up in 8 MB parts, in order, to one Media VOD as MP4, and completes in the background', async () => {
+ const start = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { title: 'Trip to the coast', description: 'Line one\nLine two', size: file.length, content_type: 'video/quicktime', name: 'trip.mov', visibility: 'public' }, cookie: kimCookie });
+ assert.strictEqual(start.status, 201, start.text);
+ const up = start.json().upload;
+ id = up.id;
+ assert.match(id, /^vup_[0-9A-Z]{26}$/);
+ assert.deepStrictEqual([up.part_size, up.parts_expected], [8 * MB, 2]);
+ const out = await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie });
+ assert.strictEqual(out.status, 409, 'part 1 first');
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 100), cookie: kimCookie })).status, 422, 'a part is its full size');
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: samCookie })).status, 404, 'not sam\'s upload');
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: kimCookie })).status, 200);
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie })).status, 200);
+ const vod = [...t.media.vods.values()][0];
+ assert.ok(vod.bytes.equals(file), 'Media has the file, byte for byte');
+ assert.deepStrictEqual(vod.types, ['video/mp4', 'video/mp4'], 'QuickTime is sent as MP4');
+ assert.strictEqual(vod.user_id, kim.subject);
+ const done1 = await vreq(t.base, `/api/v1/videos/${id}/complete`, { method: 'POST', cookie: kimCookie });
+ assert.strictEqual(done1.status, 202);
+ assert.strictEqual(done1.json().video.url, `/u/${id}`);
+ });
+
+ await check('the watch page plays it from Media, and it is on the channel, the home page and the sitemap', async () => {
+ let page = null;
+ for (let i = 0; i < 20; i++) {
+ page = await vreq(t.base, `/u/${id}`);
+ if (page.status === 200 && page.text.includes('data-ovv-video')) break;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
+ assert.ok(page.text.includes('<h1 class="ovv-title">Trip to the coast</h1>'));
+ assert.ok(page.text.includes('src="https://openvibe.media/v/vod-1.mp4"') && page.text.includes('data-ovv-hls="https://openvibe.media/o/med_vod1/master.m3u8"'), 'the file, and HLS on the same origin');
+ assert.ok(page.text.includes('Line one\nLine two'));
+ assert.ok(page.text.includes('<a class="ovv-channel-name" href="/@kim">@kim</a>'));
+ assert.ok(page.text.includes('Report this video'), 'others may report it');
+ assert.ok(!page.text.includes('Manage this video'));
+ assert.ok((await vreq(t.base, '/')).text.includes(`href="/u/${id}"`), 'the home page');
+ assert.ok((await vreq(t.base, '/@kim')).text.includes(`href="/u/${id}"`), 'the channel');
+ assert.ok((await vreq(t.base, '/sitemap.xml')).text.includes(`https://openvibe.video/u/${id}`), 'the sitemap');
+ const mine = await vreq(t.base, '/me/videos', { cookie: kimCookie });
+ assert.ok(mine.text.includes('Trip to the coast') && mine.text.includes('Public'));
+ });
+
+ await check('the owner edits (Media follows) and makes it private: gone for everyone else, still the owner\'s', async () => {
+ const r = await vreq(t.base, `/u/${id}/manage`, { method: 'POST', cookie: kimCookie, form: { title: 'Coast trip', description: 'x', visibility: 'private' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual([...t.media.vods.values()][0].title, 'Coast trip');
+ assert.strictEqual((await vreq(t.base, `/u/${id}`)).status, 404);
+ assert.strictEqual((await vreq(t.base, `/u/${id}`, { cookie: samCookie })).status, 404);
+ const own = await vreq(t.base, `/u/${id}`, { cookie: kimCookie });
+ assert.ok(own.text.includes('Private: only you') && own.text.includes('noindex'));
+ assert.ok(!(await vreq(t.base, '/')).text.includes(`/u/${id}`), 'off the home page');
+ assert.strictEqual((await vreq(t.base, `/u/${id}/manage`, { method: 'POST', cookie: samCookie, form: { title: 'Mine now' } })).status, 404);
+ await vreq(t.base, `/u/${id}/manage`, { method: 'POST', cookie: kimCookie, form: { title: 'Coast trip', visibility: 'public' } });
+ });
+
+ await check('a child-safety report hides it at once; one report per person; the owner sees why', async () => {
+ const r = await vreq(t.base, `/u/${id}/report`, { method: 'POST', cookie: samCookie, form: { reason: 'child_safety', note: 'look at 0:12' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual((await vreq(t.base, `/u/${id}`)).status, 404, 'hidden from everyone');
+ const own = await vreq(t.base, `/u/${id}`, { cookie: kimCookie });
+ assert.ok(own.text.includes('Hidden after reports'));
+ assert.strictEqual((await vreq(t.base, `/u/${id}/report`, { method: 'POST', cookie: samCookie, form: { reason: 'nonsense' } })).status, 404, 'hidden: not reportable by others');
+ });
+
+ await check('delete removes the VOD in Media; the daily limit holds', async () => {
+ const r = await vreq(t.base, `/u/${id}/delete`, { method: 'POST', cookie: kimCookie, form: { confirm: '1' } });
+ assert.strictEqual(r.status, 303);
+ assert.deepStrictEqual(t.media.deletedVods, [1]);
+ assert.strictEqual((await vreq(t.base, `/u/${id}`, { cookie: kimCookie })).status, 404);
+ const meta = { title: 'More', size: 10, content_type: 'video/mp4', name: 'a.mp4' };
+ for (let i = 0; i < 3; i++) assert.strictEqual((await vreq(t.base, '/api/v1/videos', { method: 'POST', json: meta, cookie: kimCookie })).status, 201);
+ const fifth = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: meta, cookie: kimCookie });
+ assert.strictEqual(fifth.status, 429);
+ });
+
+ await t.close();
+ await live.close();
+ done();
+})();