openvibers/openvibe.live Commits
Private VODs and clips are missing to everyone but their owners and staff: GET /api/vods/:id answered anonymous callers with a stub carrying a private VOD's title, username and avatar; now the same 404 and body as an unknown id. One rule in server/media-proxy/access.js (private = visibility 'private', or a legacy row with no visibility and is_public 0; owners = uploader/clipper, clipped channel, stream's streamer; staff = admin/global_mod), fetched with the app's authority, applied to VOD detail, live-info, memories, context, the live-recording lookup, publish/edit/delete, clipping a VOD, clip detail and clip writes, thumbnail regeneration and comments (403s that confirmed a private id become the missing-id 404). Also closed: /api/chat-ai/vod-transcripts returned any VOD's transcript and overview, the server-rendered /vod/:id and /clip/:id pages put a private item's title, overview and transcript into shared HTML, /api/streams/recent carried a private legacy VOD's id and thumbnail (frozen vods table, read-only query change), and the stream transcript's legacy fallback could name a private VOD. test/media-privacy.test.js compares every refused answer with the missing-id answer
Changes
@@@ -53,6 +53,10 @@ From the dashboard:- **Bulk delete** old media by age (e.g., delete VODs older than 30 days)- **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load++## Who sees a private VOD or clip++++A private item (visibility `private`, or a legacy row with no visibility and `is_public = 0`) is visible only to its owners — the uploader or clipper, the clipped channel's streamer, the streamer whose stream it came from — and to staff (admin, global mod). Everyone else gets exactly the answer an unknown id gets: the same 404 and body on `/api/vods/:id` and its `live-info`/`memories`/`context`, `/api/clips/:id`, comments, thumbnail regeneration and the write routes (a 403 would confirm the id exists). It is also left out of `/api/chat-ai/vod-transcripts`, the server-rendered `/vod/:id` and `/clip/:id` pages, and `/api/streams/recent`. The rule lives in `server/media-proxy/access.js`; Media applies the same rule to `openvibe.media/v/:id` and `/c/:id`. Unlisted items stay reachable by direct link.++## Chat ReplayVODs include synchronized chat replay. Messages are stored in the database with timestamps relative to the stream start. Deleted messages are automatically excluded from replay (soft-delete with `is_deleted` flag).@@@ -264,14 +264,21 @@ router.get('/live-captions/:username', (req, res) => {* openvibe.media/live/:sel/transcript.json served null for every post-cutover VOD.* This is the endpoint Media reads instead of its own dead column.*/--router.get('/vod-transcripts', (req, res) => {++router.get('/vod-transcripts', async (req, res) => {try {const ids = String(req.query.ids || '').split(',').map(x => parseInt(x, 10)).filter(Number.isFinite).slice(0, 50);if (!ids.length) return res.json({ transcripts: {} });++ // Public, unauthenticated endpoint: a private VOD's transcript and overview are left out,++ // exactly like an id with no VOD. Media is asked for each VOD's visibility; an id it cannot++ // vouch for (missing, or Media unreachable) is left out too.++ const media = require('../media-client');++ const { isPrivate } = require('../media-proxy/access');++ const shown = await Promise.all(ids.map((id) => media.getVod(id).then((v) => (v && !isPrivate(v) ? id : null), () => null)));++const out = {};-- for (const vodId of ids) {++ for (const vodId of shown.filter((id) => id != null)) {let segments = [];let events = [];// Prefer the timeline: it covers the whole stream and carries sound events.@@@ -334,8 +341,11 @@ router.get('/transcript/:streamId', async (req, res) => {} catch { /* */ }if (!vodId) {try {-- const v = db.get('SELECT id FROM vods WHERE stream_id = ? AND COALESCE(is_recording, 0) = 0 ORDER BY COALESCE(is_public,1) DESC, id DESC LIMIT 1', [sid]);-- vodId = v ? v.id : null; // legacy pre-migration rows++ // Legacy pre-migration rows; never a private one (Media's list above is public-only too).++ const v = db.get(`SELECT id FROM vods WHERE stream_id = ? AND COALESCE(is_recording, 0) = 0++ AND COALESCE(visibility, CASE WHEN is_public = 1 THEN 'public' ELSE 'private' END) != 'private'++ ORDER BY COALESCE(is_public,1) DESC, id DESC LIMIT 1`, [sid]);++ vodId = v ? v.id : null;} catch { /* */ }}// Sound events ride alongside the speech segments so the transcript view can show@@@ -2838,6 +2838,8 @@ function getRecentStreams(limit = 20) {) latest ON latest.user_id = s.user_id AND latest.latest_ended_at = s.ended_atJOIN users u ON s.user_id = u.idLEFT JOIN vods v ON v.stream_id = s.id AND COALESCE(v.is_recording, 0) = 0++ -- a public list: only a public VOD's id and thumbnail ride along (never a private or unlisted one)++ AND COALESCE(v.visibility, CASE WHEN v.is_public = 1 THEN 'public' ELSE 'private' END) = 'public'WHERE s.is_live = 0 AND s.ended_at IS NOT NULLORDER BY s.ended_at DESCLIMIT ?nnew file mode 100644@@@ -0,0 +1,45 @@++/**++ * OpenVibe.Live — who may see a VOD or clip (Media rows and the frozen legacy rows alike).++ *++ * A private item is invisible to everyone but its owners and staff, and "invisible" means the++ * caller gets exactly what a missing id gets: the same 404 and body, never a stub with the++ * title and username, never a 403 that confirms the id is real.++ *++ * private visibility 'private', or no visibility and a falsy is_public (legacy rows)++ * owners the uploader/clipper (user_id), the clipped channel (channel_user_id) and the++ * streamer whose stream it came from (stream_id → streams.user_id)++ * staff admin or global_mod (permissions.isStaff)++ *++ * Unlisted items stay reachable by direct link — that is what unlisted means.++ */++'use strict';++const db = require('../db/database');++const permissions = require('../auth/permissions');++++function isPrivate(row) {++ if (!row) return false;++ if (row.visibility) return row.visibility === 'private';++ return !row.is_public || row.is_public === '0';++}++++function ownerIds(row) {++ const ids = [row.user_id, row.channel_user_id];++ if (row.stream_id) {++ try { const s = db.getStreamById(row.stream_id); if (s) ids.push(s.user_id); } catch { /* no stream */ }++ }++ return ids.filter((x) => x != null).map(Number);++}++++/** Owner or staff — may see the item whatever its visibility. */++function canSeePrivate(user, row) {++ if (!user || !row) return false;++ if (permissions.isStaff(user)) return true;++ return ownerIds(row).includes(Number(user.id));++}++++/** May this caller (req.user, or null) see this row at all? */++function canView(user, row) {++ return !!row && (!isPrivate(row) || canSeePrivate(user, row));++}++++module.exports = { isPrivate, canSeePrivate, canView, ownerIds };@@@ -10,9 +10,24 @@ const db = require('../db/database');const media = require('../media-client');const { requireAuth, optionalAuth } = require('../auth/auth');const permissions = require('../auth/permissions');++const access = require('./access');const router = express.Router();++/** The answer for a missing clip — also what anyone who may not see a private one gets. */++function clipNotFound(res) {++ return res.status(404).json({ error: 'Clip not found' });++}++++/**++ * A refusal on a clip the caller may not act on: 403 when they can see it anyway, the++ * missing-clip 404 when it is private to them (a 403 would confirm that it exists).++ */++function refuse(req, res, clip, message) {++ if (!access.canView(req.user, clip)) return clipNotFound(res);++ return res.status(403).json({ error: message });++}++function mediaErr(res, err, fallback) {if (err && err.name === 'MediaApiError' && err.status) {return res.status(err.status).json(err.body || { error: err.message });@@@ -177,18 +192,19 @@ router.get('/', async (req, res) => {// ── Clip detail ──────────────────────────────────────────────router.get('/:id', optionalAuth, async (req, res) => {try {-- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Clip not found' });++ if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);++ // Fetched with the app's authority: Live applies the visibility rule (./access), so staff and++ // the clipped channel's streamer can open a private clip and everyone else gets the++ // missing-clip answer.let clip;-- try { clip = await media.getClip(req.params.id, { actingUser: media.actingUserFrom(req) }); }-- catch (err) { return mediaErr(res, err, 'Failed to get clip'); }-- if (!clip) return res.status(404).json({ error: 'Clip not found' });-- withUserFields(clip);---- if (clip.visibility === 'private') {-- let allowed = req.user && (req.user.id === clip.user_id || req.user.role === 'admin');-- if (!allowed && req.user && clip.stream_id) { const s = db.getStreamById(clip.stream_id); if (s && s.user_id === req.user.id) allowed = true; }-- if (!allowed) return res.status(404).json({ error: 'Clip not found' });++ try { clip = await media.getClip(req.params.id); }++ catch (err) {++ if (err && err.name === 'MediaApiError' && err.status === 404) return clipNotFound(res);++ return mediaErr(res, err, 'Failed to get clip');}++ if (!clip || !access.canView(req.user, clip)) return clipNotFound(res);++ if (access.isPrivate(clip)) res.set('Cache-Control', 'private, no-store');++ withUserFields(clip);// Unique-view tracking (content_views stays in live.db).try {@@@ -237,7 +253,7 @@ router.get('/:id', optionalAuth, async (req, res) => {try {const v = await media.getVod(clip.vod_id);const recording = v && (v.status === 'recording' || v.is_recording);-- if (v && v.visibility !== 'private' && !recording) clip.vod_available = true;++ if (v && access.canView(req.user, v) && !recording) clip.vod_available = true;} catch { /* */ }}@@@ -253,7 +269,7 @@ router.put('/:id/title', requireAuth, async (req, res) => {let clip;try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }if (!clip) return res.status(404).json({ error: 'Clip not found' });-- if (!(await canActorModerateClip(req.user, clip))) return res.status(403).json({ error: 'Not authorized to edit this clip' });++ if (!(await canActorModerateClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to edit this clip');const title = (req.body.title || '').trim();if (!title || title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });await media.updateClip(clip.id, { title });@@@ -276,7 +292,7 @@ router.put('/:id/visibility', requireAuth, async (req, res) => {const canByChannel = ownerId && (req.user.id === ownerId || (() => { const ch = db.getChannelByUserId(ownerId); return !!(ch && db.isChannelModerator(req.user.id, ch.id)); })());const canByStaff = !canByChannel && req.user.id !== clip.user_id && await canActorModerateClip(req.user, clip);const ownClipNoChannel = !ownerId && req.user.id === clip.user_id;-- if (!(canByChannel || canByStaff || ownClipNoChannel)) return res.status(403).json({ error: 'Only the streamer can change clip visibility' });++ if (!(canByChannel || canByStaff || ownClipNoChannel)) return refuse(req, res, clip, 'Only the streamer can change clip visibility');if (req.body.visibility !== undefined) {await media.updateClip(clip.id, { visibility: req.body.visibility });return res.json({ message: `Clip is now ${req.body.visibility}`, visibility: req.body.visibility, is_public: req.body.visibility === 'public' ? 1 : 0 });@@@ -319,7 +335,7 @@ router.delete('/:id', requireAuth, async (req, res) => {let clip;try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }if (!clip) return res.status(404).json({ error: 'Clip not found' });-- if (!(await canActorDeleteClip(req.user, clip))) return res.status(403).json({ error: 'Not authorized to delete this clip' });++ if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to delete this clip');await media.deleteClip(clip.id);res.json({ message: 'Clip deleted' });} catch (err) {@@@ -337,7 +353,7 @@ router.post('/:id/recut', requireAuth, async (req, res) => {let clip;try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }if (!clip) return res.status(404).json({ error: 'Clip not found' });-- if (!(await canActorDeleteClip(req.user, clip))) return res.status(403).json({ error: 'Not authorized to re-cut this clip' });++ if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to re-cut this clip');const out = await media.recutClip(clip.id);res.json({ message: 'Re-cutting clip', status: out?.status || 'processing' });} catch (err) {@@@ -11,6 +11,7 @@ const db = require('../db/database');const media = require('../media-client');const { requireAuth, optionalAuth } = require('../auth/auth');const { pushNotification, actorInfo: notificationActor } = require('../utils/notify');++const access = require('./access');const router = express.Router();@@@ -25,25 +26,35 @@ async function getCommentTarget(contentType, contentId) {if (contentType === 'vod') {const vod = await media.getVod(contentId);if (!vod) return null;-- return { user_id: vod.user_id, title: vod.title || 'your VOD', label: 'VOD', url: `https://openvibe.live/vod/${contentId}` };++ return { row: vod, user_id: vod.user_id, title: vod.title || 'your VOD', label: 'VOD', url: `https://openvibe.live/vod/${contentId}` };}if (contentType === 'clip') {const clip = await media.getClip(contentId);if (!clip) return null;-- return { user_id: clip.user_id, title: clip.title || 'your clip', label: 'clip', url: `https://openvibe.live/clip/${contentId}`, stream_id: clip.stream_id || null };++ return { row: clip, user_id: clip.user_id, title: clip.title || 'your clip', label: 'clip', url: `https://openvibe.live/clip/${contentId}`, stream_id: clip.stream_id || null };}} catch { /* Media unreachable → treat as not found */ }return null;}++/**++ * The commented-on VOD/clip, if this caller may see it. A private one is missing to everyone but++ * its owners and staff (./access), so its comments are too — they quote and discuss it.++ */++async function visibleTarget(user, contentType, contentId) {++ const target = await getCommentTarget(contentType, contentId);++ return target && access.canView(user, target.row) ? target : null;++}++// ── List Comments ────────────────────────────────────────────--router.get('/:type/:id', optionalAuth, (req, res) => {++router.get('/:type/:id', optionalAuth, async (req, res) => {try {const contentType = req.params.type;const contentId = parseInt(req.params.id);if (!['vod', 'clip'].includes(contentType) || !contentId) {return res.status(400).json({ error: 'Invalid content type or ID' });}++ if (!(await visibleTarget(req.user, contentType, contentId))) return res.status(404).json({ error: 'Content not found' });const limit = Math.min(parseInt(req.query.limit || '50'), 100);const offset = parseInt(req.query.offset || '0');const comments = db.getComments(contentType, contentId, limit, offset);@@@ -73,7 +84,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {return res.status(400).json({ error: 'Comment must be 1-2000 characters' });}const parentId = req.body.parent_id ? parseInt(req.body.parent_id) : null;-- const target = await getCommentTarget(contentType, contentId);++ const target = await visibleTarget(req.user, contentType, contentId);if (!target) return res.status(404).json({ error: 'Content not found' });let parent = null;@@@ -146,9 +157,13 @@ router.post('/:type/:id', requireAuth, async (req, res) => {});// ── Get Replies ──────────────────────────────────────────────--router.get('/:commentId/replies', optionalAuth, (req, res) => {++router.get('/:commentId/replies', optionalAuth, async (req, res) => {try {-- const replies = db.getCommentReplies(parseInt(req.params.commentId));++ const parent = db.getCommentById(parseInt(req.params.commentId));++ if (!parent || !(await visibleTarget(req.user, parent.content_type, parent.content_id))) {++ return res.status(404).json({ error: 'Comment not found' });++ }++ const replies = db.getCommentReplies(parent.id);res.json({ replies });} catch {res.status(500).json({ error: 'Failed to get replies' });@@@ -13,6 +13,7 @@ const fs = require('fs');const multer = require('multer');const db = require('../db/database');const media = require('../media-client');++const access = require('./access');const liveThumbs = require('./live-thumbs');const { requireAuth, optionalAuth } = require('../auth/auth');@@@ -50,7 +51,9 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), (req, reasync function generateFor(kind, id, req, res) {try {const meta = kind === 'vod' ? await media.getVod(id) : await media.getClip(id);-- if (!meta) return res.status(404).json({ error: `${kind === 'vod' ? 'VOD' : 'Clip'} not found` });++ const notFound = () => res.status(404).json({ error: `${kind === 'vod' ? 'VOD' : 'Clip'} not found` });++ // A private item is missing to anyone who may not see it (a 403 would confirm it exists).++ if (!meta || !access.canView(req.user, meta)) return notFound();const canManage = !!req.user && (meta.user_id === req.user.id || req.user.role === 'admin');const isPublic = meta.visibility ? meta.visibility === 'public' : !!meta.is_public;if (!canManage && !isPublic) return res.status(403).json({ error: `Not your ${kind}` });@@@ -4,8 +4,8 @@* Thin proxy that preserves the public API paths the SPA already calls and* forwards to OpenVibe.Media (Media API v1). Metadata is proxied as JSON; big* media file payloads 302-redirect to MEDIA_PUBLIC_URL. Auth/ownership checks-- * stay here (Live is the authority for its own users); Media additionally-- * applies user ACLs when the caller's Network JWT is forwarded.++ * stay here (Live is the authority for its own users): reads use the app's++ * authority and ./access decides who may see a private VOD.*/'use strict';const express = require('express');@@@ -15,6 +15,7 @@ const media = require('../media-client');const recorder = require('../streaming/recorder');const { requireAuth, optionalAuth } = require('../auth/auth');const permissions = require('../auth/permissions');++const access = require('./access');const router = express.Router();@@@ -72,6 +73,27 @@ function defaultClipTitle(sanitized, sourceTitle) {return (src ? `Clip: ${src}` : 'Untitled Clip').slice(0, 200);}++/** The answer for a missing VOD — also what anyone who may not see a private one gets. */++function vodNotFound(res) {++ return res.status(404).json({ error: 'VOD not found' });++}++++/**++ * Load a VOD with the app's authority and apply Live's visibility rule (./access). Resolves to++ * the row, or null after answering: 404 for missing or hidden, 502 when Media is unreachable.++ */++async function loadVisibleVod(req, res, id) {++ if (!/^\d+$/.test(String(id))) { vodNotFound(res); return null; }++ let vod;++ try { vod = await media.getVod(id); } catch (err) {++ if (err && err.name === 'MediaApiError' && err.status === 404) vodNotFound(res);++ else mediaErr(res, err, 'Failed to get VOD');++ return null;++ }++ if (!vod || !access.canView(req.user, vod)) { vodNotFound(res); return null; }++ return vod;++}++function mediaErr(res, err, fallback) {if (err && err.name === 'MediaApiError' && err.status) {return res.status(err.status).json(err.body || { error: err.message });@@@ -143,7 +165,12 @@ async function ownVodOrModerator(req, res) {let owns = vod.user_id === req.user.id;if (!owns && vod.stream_id) { const s = db.getStreamById(vod.stream_id); if (s && s.user_id === req.user.id) owns = true; }if (!owns) owns = permissions.canModerateContentOwner(req.user, vod.user_id ? db.getUserById(vod.user_id) : null);-- if (!owns) { res.status(403).json({ error: 'Not authorized' }); return null; }++ if (!owns) {++ // Someone who may not even see a private VOD is not told it exists.++ if (!access.canView(req.user, vod)) vodNotFound(res);++ else res.status(403).json({ error: 'Not authorized' });++ return null;++ }return vod;}@@@ -232,6 +259,11 @@ router.get('/stream/:streamId/live', optionalAuth, async (req, res) => {if (!rec || !rec.vodId || rec.clipsOnly) return res.status(404).json({ error: 'No active VOD recording' });let vod = null;try { vod = await media.getVod(rec.vodId); } catch { /* Media hiccup — synthesize */ }++ // A private recording of a public live stream is still private: without Media's row, the++ // stream's own VOD visibility decides.++ const stream = db.getStreamById(streamId);++ const probe = vod || { user_id: stream?.user_id, stream_id: streamId, visibility: stream ? db.resolveStreamVodVisibility(stream) : 'private' };++ if (!access.canView(req.user, probe)) return res.status(404).json({ error: 'No active VOD recording' });res.json({vod: vod || {id: rec.vodId,@@@ -247,9 +279,8 @@ router.get('/stream/:streamId/live', optionalAuth, async (req, res) => {router.get('/:id/live-info', optionalAuth, async (req, res) => {try {-- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });-- const vod = await media.getVod(req.params.id);-- if (!vod) return res.status(404).json({ error: 'VOD not found' });++ const vod = await loadVisibleVod(req, res, req.params.id);++ if (!vod) return;const recording = vod.status === 'recording' || !!vod.is_recording;res.json({id: vod.id,@@@ -343,9 +374,9 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {// AI "memory" timeline for a VOD (stream_memories stays in live.db).router.get('/:id/memories', optionalAuth, async (req, res) => {try {-- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });-- let streamId = null;-- try { const vod = await media.getVod(req.params.id); streamId = vod?.stream_id || null; } catch { /* */ }++ const vod = await loadVisibleVod(req, res, req.params.id);++ if (!vod) return;++ const streamId = vod.stream_id || null;if (!streamId) return res.json({ memories: [] });const memories = (db.getStreamMemories(streamId) || []).map(m => ({offset_seconds: m.offset_seconds,@@@ -375,11 +406,9 @@ router.get('/:id/context', optionalAuth, async (req, res) => {// is served from the cache and never reaches the visibility check below.let vod = null;try { vod = await media.getVod(id); } catch { vod = null; }-- if (!vod) return res.status(404).json({ error: 'VOD not found' });-- const isPrivate = vod.visibility === 'private';-- if (isPrivate && !(req.user && (req.user.id === vod.user_id || req.user.role === 'admin'))) {-- return res.status(404).json({ error: 'VOD not found' });-- }++ if (!vod) return vodNotFound(res);++ const isPrivate = access.isPrivate(vod);++ if (!access.canView(req.user, vod)) return vodNotFound(res);// A private VOD's context must not sit in a shared cache anywhere on the way back either.const cacheHeader = isPrivate ? 'private, no-store' : 'public, max-age=60';@@@ -428,11 +457,12 @@ router.get('/:id/context', optionalAuth, async (req, res) => {router.get('/:id', optionalAuth, async (req, res) => {try {-- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });-- let vod;-- try { vod = await media.getVod(req.params.id, { actingUser: media.actingUserFrom(req) }); }-- catch (err) { return mediaErr(res, err, 'Failed to get VOD'); }-- if (!vod) return res.status(404).json({ error: 'VOD not found' });++ // Fetched with the app's authority (no acting user): Live decides who sees what, so staff++ // can open a private VOD, and everyone else gets the missing-VOD answer — never a stub++ // carrying the title and username, which is what anonymous callers used to get.++ const vod = await loadVisibleVod(req, res, req.params.id);++ if (!vod) return;++ if (access.isPrivate(vod)) res.set('Cache-Control', 'private, no-store');withUserFields(vod);// Enrich with local stream details for chat replay.@@@ -461,24 +491,12 @@ router.get('/:id', optionalAuth, async (req, res) => {}} catch { /* */ }-- const isOwnerOrAdmin = req.user && (req.user.id === vod.user_id || req.user.role === 'admin');-- const isPrivate = (vod.visibility ? vod.visibility === 'private' : !vod.is_public);let clips = [];try {const q = vod.stream_id ? { stream_id: vod.stream_id } : { vod_id: vod.id };const co = await media.listClips(q);clips = co?.clips || (Array.isArray(co) ? co : []);} catch { /* */ }-- if (isPrivate && !isOwnerOrAdmin) {-- return res.json({-- vod: {-- id: vod.id, title: vod.title, username: vod.username, display_name: vod.display_name,-- avatar_url: vod.avatar_url, is_public: 0, is_private: true, stream_id: vod.stream_id,-- created_at: vod.created_at, user_id: vod.user_id,-- },-- clips,-- });-- }// Unique-view tracking stays local (content_views is a Live table).try {@@@ -557,7 +575,7 @@ router.post('/:id/publish', requireAuth, async (req, res) => {try {let vod;try { vod = await media.getVod(req.params.id); } catch (err) { return mediaErr(res, err, 'VOD not found'); }-- if (!vod) return res.status(404).json({ error: 'VOD not found' });++ if (!vod || !access.canView(req.user, vod)) return vodNotFound(res);if (vod.user_id !== req.user.id) return res.status(403).json({ error: 'Not your VOD' });await media.updateVod(req.params.id, { visibility: 'public', is_public: 1 });res.json({ message: 'VOD published', is_public: true });@@@ -715,9 +733,8 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =let vod;try { vod = await media.getVod(parsedVodId); } catch (err) { return mediaErr(res, err, 'VOD not found'); }if (!vod) return res.status(404).json({ error: 'VOD not found or file missing' });-- if ((vod.visibility === 'private' || (!vod.is_public && !vod.visibility)) && vod.user_id !== req.user.id && req.user.role !== 'admin') {-- return res.status(403).json({ error: 'Cannot create clips from private videos' });-- }++ // Clipping someone else's private VOD: the same answer as a missing one.++ if (!access.canView(req.user, vod)) return vodNotFound(res);const effStreamId = parsedStreamId || vod.stream_id || null;const effStream = effStreamId ? db.getStreamById(effStreamId) : null;if (effStream && !db.isStreamClipRecordingEnabled(effStream)) {@@@ -31,6 +31,7 @@ async function _cached(key, fn) {const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public' }))?.pastes || []);++const { isPrivate } = require('../media-proxy/access');const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));const _pasteGet = (slug) => _cached(`p:${slug}`, () => require('../pastes-client').getPaste(slug));@@@ -289,7 +290,9 @@ function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name,async function _vodMeta(id) {let v; try { v = await _vodGet(id); } catch { v = null; }-- if (!v) return null;++ // Private: fall through exactly like an unknown id. This HTML is cached per id and shared by++ // every visitor, so it never carries a private VOD's title, overview or transcript.++ if (!v || isPrivate(v)) return null;if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;_overlayAiState(v, 'vod');const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');@@@ -322,7 +325,7 @@ async function _vodMeta(id) {async function _clipMeta(id) {let c; try { c = await _clipGet(id); } catch { c = null; }-- if (!c) return null;++ if (!c || isPrivate(c)) return null; // private: same as unknown (see _vodMeta)if (c.duration_seconds == null && c.duration != null) c.duration_seconds = c.duration;_overlayAiState(c, 'clip');const indexable = Number(c.is_public) === 1 && (!c.visibility || c.visibility === 'public');nnew file mode 100644@@@ -0,0 +1,258 @@++/**++ * Private VODs and clips are missing to everyone but their owners and staff.++ *++ * GET /api/vods/:id used to answer an anonymous caller with a stub carrying a private VOD's title,++ * username and avatar; its companions (live-info, memories, context), the clip-from-VOD, publish,++ * edit and thumbnail routes answered 403, which confirms the id; comments on private items were++ * readable; the batch transcript endpoint returned any VOD's transcript; the server-rendered++ * /vod/:id and /clip/:id pages put a private item's title and overview into shared HTML; and the++ * recently-ended list carried a private legacy VOD's id and thumbnail. Every one of those now gives++ * the exact answer an unknown id gets.++ *++ * The real routers run on a temp database with Media stubbed in-process and sign-in stubbed by an++ * `x-test-user` header (as in authorization.test.js).++ *++ * node test/media-privacy.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = path.join(os.tmpdir(), `ov-media-privacy-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++// ── Sign-in stub (before any router captures the middleware) ──++const auth = require('../server/auth/auth');++const signIn = (req) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? db.getUserById(id) : null;++ if (u) { req.user = u; req.authSource = 'network'; }++ return u;++};++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };++++const addUser = (id, username, role) => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)++ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);++addUser(1, 'admin', 'admin');++addUser(3, 'alice', 'streamer'); // owns the private VOD; her channel was clipped++addUser(5, 'carol', 'user'); // clipped alice's stream (the private clip's creator)++addUser(6, 'mod', 'global_mod'); // staff++addUser(7, 'mallory', 'user'); // nobody special++addUser(8, 'eve', 'user'); // nobody special, second account (clip cooldowns are per user)++db.ensureChannel(3);++const chanA = db.getChannelByUserId(3);++const streamA = Number(db.createStream({ user_id: 3, channel_id: chanA.id, title: 'A', protocol: 'webrtc' }).lastInsertRowid);++++// ── Media stub ──++const media = require('../server/media-client');++const VODS = {++ 100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },++ 101: { id: 101, user_id: 3, stream_id: streamA, title: 'Secret VOD', visibility: 'private', is_public: 0, status: 'ready', description: 'secret description' },++ 102: { id: 102, user_id: 3, title: 'Legacy hidden', is_public: 0, status: 'ready' }, // no visibility: legacy private++ 103: { id: 103, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },++};++const CLIPS = {++ 200: { id: 200, user_id: 5, vod_id: 100, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' },++ 201: { id: 201, user_id: 5, channel_user_id: 3, stream_id: streamA, vod_id: 101, title: 'Secret clip', visibility: 'private', is_public: 0, status: 'ready' },++};++const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });++media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };++media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };++media.listClips = async () => ({ clips: [] });++media.listVods = async () => ({ vods: [] });++media.generateThumbnail = async () => ({ url: '/t/x.jpg' });++media.createClip = async () => ({ id: 999, status: 'processing' });++media.updateVod = async () => ({});++media.updateClip = async () => ({});++media.deleteClip = async () => ({});++const recorder = require('../server/streaming/recorder');++recorder.getActiveRecording = (sid) => (Number(sid) === streamA ? { vodId: 101, startedAt: Date.now() - 60000 } : null);++++raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview_short, ai_transcript_json) VALUES (?, ?, ?)')++ .run(101, 'secret overview', JSON.stringify([{ start: 0, end: 1, text: 'secret words' }]));++raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview_short, ai_transcript_json) VALUES (?, ?, ?)')++ .run(100, 'public overview', JSON.stringify([{ start: 0, end: 1, text: 'public words' }]));++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/vods', require('../server/media-proxy/vods'));++app.use('/api/clips', require('../server/media-proxy/clips'));++app.use('/api/comments', require('../server/media-proxy/comments'));++app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));++app.use('/api/chat-ai', require('../server/ai/chat-ai-routes'));++const server = http.createServer(app).listen(0);++++let ipSeq = 0;++function call(method, p, user, body) {++ return new Promise((resolve, reject) => {++ const data = body ? JSON.stringify(body) : null;++ const headers = { 'content-type': 'application/json', 'cf-connecting-ip': `10.0.0.${++ipSeq}` };++ if (user) headers['x-test-user'] = String(user);++ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text, headers: res.headers }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++/** The refused answer must be byte-for-byte the missing-id answer. */++async function sameAsMissing(method, hidden, missingPath, user, body) {++ const a = await call(method, hidden, user, body);++ const b = await call(method, missingPath, user, body);++ assert.deepStrictEqual([a.status, a.text], [b.status, b.text], `${method} ${hidden} (user ${user || 'anon'}) must look like ${missingPath}`);++ assert.strictEqual(a.status, 404, `${method} ${hidden} → 404`);++ assert.ok(!/Secret|secret/.test(a.text), 'nothing of the private item leaks');++ return a;++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.message); }++}++++(async () => {++ await new Promise((r) => server.once('listening', r));++++ await check('VOD detail: private and legacy-private look missing to anonymous and other users', async () => {++ for (const user of [null, 7, 5]) {++ await sameAsMissing('GET', '/api/vods/101', '/api/vods/999', user);++ await sameAsMissing('GET', '/api/vods/102', '/api/vods/999', user);++ }++ });++++ await check('VOD detail: owner and staff see a private VOD, with a non-shareable Cache-Control', async () => {++ for (const user of [3, 6, 1]) {++ const r = await call('GET', '/api/vods/101', user);++ assert.strictEqual(r.status, 200, `user ${user}`);++ assert.strictEqual(r.json.vod.title, 'Secret VOD');++ assert.strictEqual(r.headers['cache-control'], 'private, no-store');++ }++ assert.strictEqual((await call('GET', '/api/vods/100')).status, 200, 'public VOD');++ assert.strictEqual((await call('GET', '/api/vods/103')).status, 200, 'unlisted VOD by direct link');++ });++++ await check('VOD companions: live-info, memories, context look missing', async () => {++ for (const sub of ['live-info', 'memories', 'context']) {++ await sameAsMissing('GET', `/api/vods/101/${sub}`, `/api/vods/999/${sub}`, null);++ await sameAsMissing('GET', `/api/vods/102/${sub}`, `/api/vods/999/${sub}`, 7);++ assert.strictEqual((await call('GET', `/api/vods/101/${sub}`, 3)).status, 200, `owner: ${sub}`);++ }++ });++++ await check('live recording of a stream: a private VOD is not handed to viewers', async () => {++ const anon = await call('GET', `/api/vods/stream/${streamA}/live`);++ const none = await call('GET', '/api/vods/stream/424242/live');++ assert.deepStrictEqual([anon.status, anon.text], [none.status, none.text]);++ assert.strictEqual((await call('GET', `/api/vods/stream/${streamA}/live`, 3)).status, 200, 'the streamer still gets it');++ });++++ await check('VOD writes by strangers: 404 (not 403) for a private VOD', async () => {++ await sameAsMissing('PUT', '/api/vods/101', '/api/vods/999', 7, { title: 'x' });++ await sameAsMissing('DELETE', '/api/vods/101', '/api/vods/999', 7);++ await sameAsMissing('POST', '/api/vods/101/publish', '/api/vods/999/publish', 7);++ assert.strictEqual((await call('PUT', '/api/vods/100', 7, { title: 'x' })).status, 403, 'a public VOD may still say 403');++ });++++ await check('clipping someone else\'s private VOD looks like clipping a missing one', async () => {++ const a = await call('POST', '/api/vods/clips', 7, { vod_id: 101, start_time: 0, end_time: 5 });++ const b = await call('POST', '/api/vods/clips', 8, { vod_id: 999, start_time: 0, end_time: 5 });++ assert.deepStrictEqual([a.status, a.text], [b.status, b.text]);++ assert.strictEqual(a.status, 404);++ assert.strictEqual((await call('POST', '/api/vods/clips', 3, { vod_id: 101, start_time: 0, end_time: 5 })).status, 201, 'the owner may clip it');++ });++++ await check('clip detail: private looks missing; clipper, channel owner and staff see it', async () => {++ for (const user of [null, 7]) await sameAsMissing('GET', '/api/clips/201', '/api/clips/999', user);++ for (const user of [5, 3, 6]) {++ const r = await call('GET', '/api/clips/201', user);++ assert.strictEqual(r.status, 200, `user ${user}`);++ assert.strictEqual(r.json.clip.title, 'Secret clip');++ }++ const pub = await call('GET', '/api/clips/200', 7);++ assert.strictEqual(pub.status, 200);++ assert.strictEqual(pub.json.clip.vod_available, true);++ const own = await call('GET', '/api/clips/201', 5);++ assert.strictEqual(own.json.clip.vod_available, false, 'the clipper may not see the source VOD, so it is not offered');++ });++++ await check('clip writes by strangers: 404 (not 403) for a private clip', async () => {++ await sameAsMissing('PUT', '/api/clips/201/title', '/api/clips/999/title', 7, { title: 'x' });++ await sameAsMissing('PUT', '/api/clips/201/visibility', '/api/clips/999/visibility', 7, { visibility: 'public' });++ await sameAsMissing('DELETE', '/api/clips/201', '/api/clips/999', 7);++ await sameAsMissing('POST', '/api/clips/201/recut', '/api/clips/999/recut', 7);++ });++++ await check('thumbnail regeneration: a private item looks missing', async () => {++ await sameAsMissing('POST', '/api/thumbnails/generate/vod/101', '/api/thumbnails/generate/vod/999', null);++ await sameAsMissing('POST', '/api/thumbnails/generate/clip/201', '/api/thumbnails/generate/clip/999', 7);++ });++++ await check('comments on a private item: unreadable and unpostable for strangers', async () => {++ const posted = await call('POST', '/api/comments/vod/101', 3, { message: 'secret note' });++ assert.strictEqual(posted.status, 201, 'the owner may comment');++ await sameAsMissing('GET', '/api/comments/vod/101', '/api/comments/vod/999', null);++ await sameAsMissing('GET', '/api/comments/clip/201', '/api/comments/clip/999', 7);++ await sameAsMissing('POST', '/api/comments/vod/101', '/api/comments/vod/999', 7, { message: 'hi' });++ // (/:commentId/replies is shadowed by /:type/:id today and answers 400; either way nothing leaks.)++ const r = await call('GET', `/api/comments/${posted.json.comment.id}/replies`);++ assert.ok(r.status >= 400 && !/secret/.test(r.text), 'replies under a private VOD');++ assert.strictEqual((await call('GET', '/api/comments/vod/101', 6)).status, 200, 'staff read them');++ assert.strictEqual((await call('GET', '/api/comments/vod/100')).status, 200, 'public item comments stay open');++ });++++ await check('batch transcripts leave private VODs out', async () => {++ const r = await call('GET', '/api/chat-ai/vod-transcripts?ids=100,101,102,999');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(Object.keys(r.json.transcripts), ['100']);++ assert.ok(!/secret/.test(r.text));++ });++++ await check('server-rendered /vod/:id and /clip/:id: private is the unknown-id fall-through', async () => {++ const seo = require('../server/seo/seo');++ assert.strictEqual(await seo._pageMeta('/vod/101'), null);++ assert.strictEqual(await seo._pageMeta('/vod/102'), null);++ assert.strictEqual(await seo._pageMeta('/clip/201'), null);++ assert.strictEqual(await seo._pageMeta('/vod/999'), null);++ assert.ok(await seo._pageMeta('/vod/100'), 'public VODs still render');++ assert.ok(await seo._pageMeta('/clip/200'), 'public clips still render');++ });++++ await check('recently-ended streams (legacy vods table): only a public VOD rides along', async () => {++ db.endStream(streamA);++ // Test fixture rows in the temp database; server code never writes the frozen table.++ const legacy = raw.prepare(`INSERT INTO vods (stream_id, user_id, title, file_path, is_public, visibility, thumbnail_url) VALUES (?, 3, ?, '/x', ?, ?, ?)`);++ legacy.run(streamA, 'Secret legacy', 0, 'private', '/t/secret.jpg');++ let row = db.getRecentStreams(10).find((s) => s.id === streamA);++ assert.ok(row, 'the stream is listed');++ assert.strictEqual(row.vod_id, null, 'no private VOD id');++ assert.strictEqual(row.vod_thumbnail_url, null, 'no private VOD thumbnail');++ raw.prepare('DELETE FROM vods').run();++ legacy.run(streamA, 'Public legacy', 1, 'public', '/t/pub.jpg');++ row = db.getRecentStreams(10).find((s) => s.id === streamA);++ assert.ok(row.vod_id, 'a public VOD is still attached');++ });++++ server.close();++ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }++ quiet('media privacy: all checks passed');++ process.exit(0);++})().catch((err) => { quiet(err); server.close(); process.exit(1); });@@@ -251,7 +251,7 @@ const ok = (name) => { pass++; console.log(' ok -', name); };const src = fs.readFileSync(path.join(ROOT, 'server/media-proxy/vods.js'), 'utf8');const h = src.match(/router\.get\('\/:id\/context'[\s\S]*?res\.json\(data\);/);assert(h, 'the VOD context route should exist');-- const checkAt = h[0].indexOf("vod.visibility === 'private'");++ const checkAt = h[0].indexOf('access.canView(req.user, vod)');const cacheAt = h[0].indexOf('_ctxCache.get(id)');assert(checkAt > 0 && cacheAt > 0, 'both the visibility check and the cache read should be present');assert(checkAt < cacheAt, 'the visibility check must run before the cache is read');
@@ -53,6 +53,10 @@ From the dashboard:
@@ -53,6 +53,10 @@ From the dashboard:
- **Bulk delete** old media by age (e.g., delete VODs older than 30 days)
- **Bulk delete** old media by age (e.g., delete VODs older than 30 days)
- **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load
- **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load
+## Who sees a private VOD or clip
+
+A private item (visibility `private`, or a legacy row with no visibility and `is_public = 0`) is visible only to its owners — the uploader or clipper, the clipped channel's streamer, the streamer whose stream it came from — and to staff (admin, global mod). Everyone else gets exactly the answer an unknown id gets: the same 404 and body on `/api/vods/:id` and its `live-info`/`memories`/`context`, `/api/clips/:id`, comments, thumbnail regeneration and the write routes (a 403 would confirm the id exists). It is also left out of `/api/chat-ai/vod-transcripts`, the server-rendered `/vod/:id` and `/clip/:id` pages, and `/api/streams/recent`. The rule lives in `server/media-proxy/access.js`; Media applies the same rule to `openvibe.media/v/:id` and `/c/:id`. Unlisted items stay reachable by direct link.
+
## Chat Replay
## Chat Replay
VODs include synchronized chat replay. Messages are stored in the database with timestamps relative to the stream start. Deleted messages are automatically excluded from replay (soft-delete with `is_deleted` flag).
VODs include synchronized chat replay. Messages are stored in the database with timestamps relative to the stream start. Deleted messages are automatically excluded from replay (soft-delete with `is_deleted` flag).
@@ -264,14 +264,21 @@ router.get('/live-captions/:username', (req, res) => {
@@ -264,14 +264,21 @@ router.get('/live-captions/:username', (req, res) => {
* openvibe.media/live/:sel/transcript.json served null for every post-cutover VOD.
* openvibe.media/live/:sel/transcript.json served null for every post-cutover VOD.
* This is the endpoint Media reads instead of its own dead column.
* This is the endpoint Media reads instead of its own dead column.
*/
*/
-router.get('/vod-transcripts', (req, res) => {
+router.get('/vod-transcripts', async (req, res) => {
try {
try {
const ids = String(req.query.ids || '')
const ids = String(req.query.ids || '')
.split(',').map(x => parseInt(x, 10)).filter(Number.isFinite).slice(0, 50);
.split(',').map(x => parseInt(x, 10)).filter(Number.isFinite).slice(0, 50);
if (!ids.length) return res.json({ transcripts: {} });
if (!ids.length) return res.json({ transcripts: {} });
+ // Public, unauthenticated endpoint: a private VOD's transcript and overview are left out,
+ // exactly like an id with no VOD. Media is asked for each VOD's visibility; an id it cannot
+ // vouch for (missing, or Media unreachable) is left out too.
+ const media = require('../media-client');
+ const { isPrivate } = require('../media-proxy/access');
+ const shown = await Promise.all(ids.map((id) => media.getVod(id).then((v) => (v && !isPrivate(v) ? id : null), () => null)));
+
const out = {};
const out = {};
- for (const vodId of ids) {
+ for (const vodId of shown.filter((id) => id != null)) {
let segments = [];
let segments = [];
let events = [];
let events = [];
// Prefer the timeline: it covers the whole stream and carries sound events.
// Prefer the timeline: it covers the whole stream and carries sound events.
@@ -334,8 +341,11 @@ router.get('/transcript/:streamId', async (req, res) => {
@@ -334,8 +341,11 @@ router.get('/transcript/:streamId', async (req, res) => {
} catch { /* */ }
} catch { /* */ }
if (!vodId) {
if (!vodId) {
try {
try {
- const v = db.get('SELECT id FROM vods WHERE stream_id = ? AND COALESCE(is_recording, 0) = 0 ORDER BY COALESCE(is_public,1) DESC, id DESC LIMIT 1', [sid]);
- vodId = v ? v.id : null; // legacy pre-migration rows
+ // Legacy pre-migration rows; never a private one (Media's list above is public-only too).
+ const v = db.get(`SELECT id FROM vods WHERE stream_id = ? AND COALESCE(is_recording, 0) = 0
+ AND COALESCE(visibility, CASE WHEN is_public = 1 THEN 'public' ELSE 'private' END) != 'private'
+ ORDER BY COALESCE(is_public,1) DESC, id DESC LIMIT 1`, [sid]);
+ vodId = v ? v.id : null;
} catch { /* */ }
} catch { /* */ }
}
}
// Sound events ride alongside the speech segments so the transcript view can show
// Sound events ride alongside the speech segments so the transcript view can show
@@ -2838,6 +2838,8 @@ function getRecentStreams(limit = 20) {
@@ -2838,6 +2838,8 @@ function getRecentStreams(limit = 20) {
) latest ON latest.user_id = s.user_id AND latest.latest_ended_at = s.ended_at
) latest ON latest.user_id = s.user_id AND latest.latest_ended_at = s.ended_at
JOIN users u ON s.user_id = u.id
JOIN users u ON s.user_id = u.id
LEFT JOIN vods v ON v.stream_id = s.id AND COALESCE(v.is_recording, 0) = 0
LEFT JOIN vods v ON v.stream_id = s.id AND COALESCE(v.is_recording, 0) = 0
+ -- a public list: only a public VOD's id and thumbnail ride along (never a private or unlisted one)
+ AND COALESCE(v.visibility, CASE WHEN v.is_public = 1 THEN 'public' ELSE 'private' END) = 'public'
WHERE s.is_live = 0 AND s.ended_at IS NOT NULL
WHERE s.is_live = 0 AND s.ended_at IS NOT NULL
ORDER BY s.ended_at DESC
ORDER BY s.ended_at DESC
LIMIT ?
LIMIT ?
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+/**
+ * OpenVibe.Live — who may see a VOD or clip (Media rows and the frozen legacy rows alike).
+ *
+ * A private item is invisible to everyone but its owners and staff, and "invisible" means the
+ * caller gets exactly what a missing id gets: the same 404 and body, never a stub with the
+ * title and username, never a 403 that confirms the id is real.
+ *
+ * private visibility 'private', or no visibility and a falsy is_public (legacy rows)
+ * owners the uploader/clipper (user_id), the clipped channel (channel_user_id) and the
+ * streamer whose stream it came from (stream_id → streams.user_id)
+ * staff admin or global_mod (permissions.isStaff)
+ *
+ * Unlisted items stay reachable by direct link — that is what unlisted means.
+ */
+'use strict';
+const db = require('../db/database');
+const permissions = require('../auth/permissions');
+
+function isPrivate(row) {
+ if (!row) return false;
+ if (row.visibility) return row.visibility === 'private';
+ return !row.is_public || row.is_public === '0';
+}
+
+function ownerIds(row) {
+ const ids = [row.user_id, row.channel_user_id];
+ if (row.stream_id) {
+ try { const s = db.getStreamById(row.stream_id); if (s) ids.push(s.user_id); } catch { /* no stream */ }
+ }
+ return ids.filter((x) => x != null).map(Number);
+}
+
+/** Owner or staff — may see the item whatever its visibility. */
+function canSeePrivate(user, row) {
+ if (!user || !row) return false;
+ if (permissions.isStaff(user)) return true;
+ return ownerIds(row).includes(Number(user.id));
+}
+
+/** May this caller (req.user, or null) see this row at all? */
+function canView(user, row) {
+ return !!row && (!isPrivate(row) || canSeePrivate(user, row));
+}
+
+module.exports = { isPrivate, canSeePrivate, canView, ownerIds };
@@ -10,9 +10,24 @@ const db = require('../db/database');
@@ -10,9 +10,24 @@ const db = require('../db/database');
const media = require('../media-client');
const media = require('../media-client');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
+const access = require('./access');
const router = express.Router();
const router = express.Router();
+/** The answer for a missing clip — also what anyone who may not see a private one gets. */
+function clipNotFound(res) {
+ return res.status(404).json({ error: 'Clip not found' });
+}
+
+/**
+ * A refusal on a clip the caller may not act on: 403 when they can see it anyway, the
+ * missing-clip 404 when it is private to them (a 403 would confirm that it exists).
+ */
+function refuse(req, res, clip, message) {
+ if (!access.canView(req.user, clip)) return clipNotFound(res);
+ return res.status(403).json({ error: message });
+}
+
function mediaErr(res, err, fallback) {
function mediaErr(res, err, fallback) {
if (err && err.name === 'MediaApiError' && err.status) {
if (err && err.name === 'MediaApiError' && err.status) {
return res.status(err.status).json(err.body || { error: err.message });
return res.status(err.status).json(err.body || { error: err.message });
@@ -177,18 +192,19 @@ router.get('/', async (req, res) => {
@@ -177,18 +192,19 @@ router.get('/', async (req, res) => {
// ── Clip detail ──────────────────────────────────────────────
// ── Clip detail ──────────────────────────────────────────────
router.get('/:id', optionalAuth, async (req, res) => {
router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Clip not found' });
+ if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);
+ // Fetched with the app's authority: Live applies the visibility rule (./access), so staff and
+ // the clipped channel's streamer can open a private clip and everyone else gets the
+ // missing-clip answer.
let clip;
let clip;
- try { clip = await media.getClip(req.params.id, { actingUser: media.actingUserFrom(req) }); }
- catch (err) { return mediaErr(res, err, 'Failed to get clip'); }
- if (!clip) return res.status(404).json({ error: 'Clip not found' });
- withUserFields(clip);
-
- if (clip.visibility === 'private') {
- let allowed = req.user && (req.user.id === clip.user_id || req.user.role === 'admin');
- if (!allowed && req.user && clip.stream_id) { const s = db.getStreamById(clip.stream_id); if (s && s.user_id === req.user.id) allowed = true; }
- if (!allowed) return res.status(404).json({ error: 'Clip not found' });
+ try { clip = await media.getClip(req.params.id); }
+ catch (err) {
+ if (err && err.name === 'MediaApiError' && err.status === 404) return clipNotFound(res);
+ return mediaErr(res, err, 'Failed to get clip');
}
}
+ if (!clip || !access.canView(req.user, clip)) return clipNotFound(res);
+ if (access.isPrivate(clip)) res.set('Cache-Control', 'private, no-store');
+ withUserFields(clip);
// Unique-view tracking (content_views stays in live.db).
// Unique-view tracking (content_views stays in live.db).
try {
try {
@@ -237,7 +253,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -237,7 +253,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
const v = await media.getVod(clip.vod_id);
const v = await media.getVod(clip.vod_id);
const recording = v && (v.status === 'recording' || v.is_recording);
const recording = v && (v.status === 'recording' || v.is_recording);
- if (v && v.visibility !== 'private' && !recording) clip.vod_available = true;
+ if (v && access.canView(req.user, v) && !recording) clip.vod_available = true;
} catch { /* */ }
} catch { /* */ }
}
}
@@ -253,7 +269,7 @@ router.put('/:id/title', requireAuth, async (req, res) => {
@@ -253,7 +269,7 @@ router.put('/:id/title', requireAuth, async (req, res) => {
let clip;
let clip;
try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!clip) return res.status(404).json({ error: 'Clip not found' });
- if (!(await canActorModerateClip(req.user, clip))) return res.status(403).json({ error: 'Not authorized to edit this clip' });
+ if (!(await canActorModerateClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to edit this clip');
const title = (req.body.title || '').trim();
const title = (req.body.title || '').trim();
if (!title || title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });
if (!title || title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });
await media.updateClip(clip.id, { title });
await media.updateClip(clip.id, { title });
@@ -276,7 +292,7 @@ router.put('/:id/visibility', requireAuth, async (req, res) => {
@@ -276,7 +292,7 @@ router.put('/:id/visibility', requireAuth, async (req, res) => {
const canByChannel = ownerId && (req.user.id === ownerId || (() => { const ch = db.getChannelByUserId(ownerId); return !!(ch && db.isChannelModerator(req.user.id, ch.id)); })());
const canByChannel = ownerId && (req.user.id === ownerId || (() => { const ch = db.getChannelByUserId(ownerId); return !!(ch && db.isChannelModerator(req.user.id, ch.id)); })());
const canByStaff = !canByChannel && req.user.id !== clip.user_id && await canActorModerateClip(req.user, clip);
const canByStaff = !canByChannel && req.user.id !== clip.user_id && await canActorModerateClip(req.user, clip);
const ownClipNoChannel = !ownerId && req.user.id === clip.user_id;
const ownClipNoChannel = !ownerId && req.user.id === clip.user_id;
- if (!(canByChannel || canByStaff || ownClipNoChannel)) return res.status(403).json({ error: 'Only the streamer can change clip visibility' });
+ if (!(canByChannel || canByStaff || ownClipNoChannel)) return refuse(req, res, clip, 'Only the streamer can change clip visibility');
if (req.body.visibility !== undefined) {
if (req.body.visibility !== undefined) {
await media.updateClip(clip.id, { visibility: req.body.visibility });
await media.updateClip(clip.id, { visibility: req.body.visibility });
return res.json({ message: `Clip is now ${req.body.visibility}`, visibility: req.body.visibility, is_public: req.body.visibility === 'public' ? 1 : 0 });
return res.json({ message: `Clip is now ${req.body.visibility}`, visibility: req.body.visibility, is_public: req.body.visibility === 'public' ? 1 : 0 });
@@ -319,7 +335,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -319,7 +335,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
let clip;
let clip;
try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!clip) return res.status(404).json({ error: 'Clip not found' });
- if (!(await canActorDeleteClip(req.user, clip))) return res.status(403).json({ error: 'Not authorized to delete this clip' });
+ if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to delete this clip');
await media.deleteClip(clip.id);
await media.deleteClip(clip.id);
res.json({ message: 'Clip deleted' });
res.json({ message: 'Clip deleted' });
} catch (err) {
} catch (err) {
@@ -337,7 +353,7 @@ router.post('/:id/recut', requireAuth, async (req, res) => {
@@ -337,7 +353,7 @@ router.post('/:id/recut', requireAuth, async (req, res) => {
let clip;
let clip;
try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!clip) return res.status(404).json({ error: 'Clip not found' });
- if (!(await canActorDeleteClip(req.user, clip))) return res.status(403).json({ error: 'Not authorized to re-cut this clip' });
+ if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to re-cut this clip');
const out = await media.recutClip(clip.id);
const out = await media.recutClip(clip.id);
res.json({ message: 'Re-cutting clip', status: out?.status || 'processing' });
res.json({ message: 'Re-cutting clip', status: out?.status || 'processing' });
} catch (err) {
} catch (err) {
@@ -11,6 +11,7 @@ const db = require('../db/database');
@@ -11,6 +11,7 @@ const db = require('../db/database');
const media = require('../media-client');
const media = require('../media-client');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { pushNotification, actorInfo: notificationActor } = require('../utils/notify');
const { pushNotification, actorInfo: notificationActor } = require('../utils/notify');
+const access = require('./access');
const router = express.Router();
const router = express.Router();
@@ -25,25 +26,35 @@ async function getCommentTarget(contentType, contentId) {
@@ -25,25 +26,35 @@ async function getCommentTarget(contentType, contentId) {
if (contentType === 'vod') {
if (contentType === 'vod') {
const vod = await media.getVod(contentId);
const vod = await media.getVod(contentId);
if (!vod) return null;
if (!vod) return null;
- return { user_id: vod.user_id, title: vod.title || 'your VOD', label: 'VOD', url: `https://openvibe.live/vod/${contentId}` };
+ return { row: vod, user_id: vod.user_id, title: vod.title || 'your VOD', label: 'VOD', url: `https://openvibe.live/vod/${contentId}` };
}
}
if (contentType === 'clip') {
if (contentType === 'clip') {
const clip = await media.getClip(contentId);
const clip = await media.getClip(contentId);
if (!clip) return null;
if (!clip) return null;
- return { user_id: clip.user_id, title: clip.title || 'your clip', label: 'clip', url: `https://openvibe.live/clip/${contentId}`, stream_id: clip.stream_id || null };
+ return { row: clip, user_id: clip.user_id, title: clip.title || 'your clip', label: 'clip', url: `https://openvibe.live/clip/${contentId}`, stream_id: clip.stream_id || null };
}
}
} catch { /* Media unreachable → treat as not found */ }
} catch { /* Media unreachable → treat as not found */ }
return null;
return null;
}
}
+/**
+ * The commented-on VOD/clip, if this caller may see it. A private one is missing to everyone but
+ * its owners and staff (./access), so its comments are too — they quote and discuss it.
+ */
+async function visibleTarget(user, contentType, contentId) {
+ const target = await getCommentTarget(contentType, contentId);
+ return target && access.canView(user, target.row) ? target : null;
+}
+
// ── List Comments ────────────────────────────────────────────
// ── List Comments ────────────────────────────────────────────
-router.get('/:type/:id', optionalAuth, (req, res) => {
+router.get('/:type/:id', optionalAuth, async (req, res) => {
try {
try {
const contentType = req.params.type;
const contentType = req.params.type;
const contentId = parseInt(req.params.id);
const contentId = parseInt(req.params.id);
if (!['vod', 'clip'].includes(contentType) || !contentId) {
if (!['vod', 'clip'].includes(contentType) || !contentId) {
return res.status(400).json({ error: 'Invalid content type or ID' });
return res.status(400).json({ error: 'Invalid content type or ID' });
}
}
+ if (!(await visibleTarget(req.user, contentType, contentId))) return res.status(404).json({ error: 'Content not found' });
const limit = Math.min(parseInt(req.query.limit || '50'), 100);
const limit = Math.min(parseInt(req.query.limit || '50'), 100);
const offset = parseInt(req.query.offset || '0');
const offset = parseInt(req.query.offset || '0');
const comments = db.getComments(contentType, contentId, limit, offset);
const comments = db.getComments(contentType, contentId, limit, offset);
@@ -73,7 +84,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
@@ -73,7 +84,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Comment must be 1-2000 characters' });
return res.status(400).json({ error: 'Comment must be 1-2000 characters' });
}
}
const parentId = req.body.parent_id ? parseInt(req.body.parent_id) : null;
const parentId = req.body.parent_id ? parseInt(req.body.parent_id) : null;
- const target = await getCommentTarget(contentType, contentId);
+ const target = await visibleTarget(req.user, contentType, contentId);
if (!target) return res.status(404).json({ error: 'Content not found' });
if (!target) return res.status(404).json({ error: 'Content not found' });
let parent = null;
let parent = null;
@@ -146,9 +157,13 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
@@ -146,9 +157,13 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
});
});
// ── Get Replies ──────────────────────────────────────────────
// ── Get Replies ──────────────────────────────────────────────
-router.get('/:commentId/replies', optionalAuth, (req, res) => {
+router.get('/:commentId/replies', optionalAuth, async (req, res) => {
try {
try {
- const replies = db.getCommentReplies(parseInt(req.params.commentId));
+ const parent = db.getCommentById(parseInt(req.params.commentId));
+ if (!parent || !(await visibleTarget(req.user, parent.content_type, parent.content_id))) {
+ return res.status(404).json({ error: 'Comment not found' });
+ }
+ const replies = db.getCommentReplies(parent.id);
res.json({ replies });
res.json({ replies });
} catch {
} catch {
res.status(500).json({ error: 'Failed to get replies' });
res.status(500).json({ error: 'Failed to get replies' });
@@ -13,6 +13,7 @@ const fs = require('fs');
@@ -13,6 +13,7 @@ const fs = require('fs');
const multer = require('multer');
const multer = require('multer');
const db = require('../db/database');
const db = require('../db/database');
const media = require('../media-client');
const media = require('../media-client');
+const access = require('./access');
const liveThumbs = require('./live-thumbs');
const liveThumbs = require('./live-thumbs');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
@@ -50,7 +51,9 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), (req, re
@@ -50,7 +51,9 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), (req, re
async function generateFor(kind, id, req, res) {
async function generateFor(kind, id, req, res) {
try {
try {
const meta = kind === 'vod' ? await media.getVod(id) : await media.getClip(id);
const meta = kind === 'vod' ? await media.getVod(id) : await media.getClip(id);
- if (!meta) return res.status(404).json({ error: `${kind === 'vod' ? 'VOD' : 'Clip'} not found` });
+ const notFound = () => res.status(404).json({ error: `${kind === 'vod' ? 'VOD' : 'Clip'} not found` });
+ // A private item is missing to anyone who may not see it (a 403 would confirm it exists).
+ if (!meta || !access.canView(req.user, meta)) return notFound();
const canManage = !!req.user && (meta.user_id === req.user.id || req.user.role === 'admin');
const canManage = !!req.user && (meta.user_id === req.user.id || req.user.role === 'admin');
const isPublic = meta.visibility ? meta.visibility === 'public' : !!meta.is_public;
const isPublic = meta.visibility ? meta.visibility === 'public' : !!meta.is_public;
if (!canManage && !isPublic) return res.status(403).json({ error: `Not your ${kind}` });
if (!canManage && !isPublic) return res.status(403).json({ error: `Not your ${kind}` });
@@ -4,8 +4,8 @@
@@ -4,8 +4,8 @@
* Thin proxy that preserves the public API paths the SPA already calls and
* Thin proxy that preserves the public API paths the SPA already calls and
* forwards to OpenVibe.Media (Media API v1). Metadata is proxied as JSON; big
* forwards to OpenVibe.Media (Media API v1). Metadata is proxied as JSON; big
* media file payloads 302-redirect to MEDIA_PUBLIC_URL. Auth/ownership checks
* media file payloads 302-redirect to MEDIA_PUBLIC_URL. Auth/ownership checks
- * stay here (Live is the authority for its own users); Media additionally
- * applies user ACLs when the caller's Network JWT is forwarded.
+ * stay here (Live is the authority for its own users): reads use the app's
+ * authority and ./access decides who may see a private VOD.
*/
*/
'use strict';
'use strict';
const express = require('express');
const express = require('express');
@@ -15,6 +15,7 @@ const media = require('../media-client');
@@ -15,6 +15,7 @@ const media = require('../media-client');
const recorder = require('../streaming/recorder');
const recorder = require('../streaming/recorder');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
+const access = require('./access');
const router = express.Router();
const router = express.Router();
@@ -72,6 +73,27 @@ function defaultClipTitle(sanitized, sourceTitle) {
@@ -72,6 +73,27 @@ function defaultClipTitle(sanitized, sourceTitle) {
return (src ? `Clip: ${src}` : 'Untitled Clip').slice(0, 200);
return (src ? `Clip: ${src}` : 'Untitled Clip').slice(0, 200);
}
}
+/** The answer for a missing VOD — also what anyone who may not see a private one gets. */
+function vodNotFound(res) {
+ return res.status(404).json({ error: 'VOD not found' });
+}
+
+/**
+ * Load a VOD with the app's authority and apply Live's visibility rule (./access). Resolves to
+ * the row, or null after answering: 404 for missing or hidden, 502 when Media is unreachable.
+ */
+async function loadVisibleVod(req, res, id) {
+ if (!/^\d+$/.test(String(id))) { vodNotFound(res); return null; }
+ let vod;
+ try { vod = await media.getVod(id); } catch (err) {
+ if (err && err.name === 'MediaApiError' && err.status === 404) vodNotFound(res);
+ else mediaErr(res, err, 'Failed to get VOD');
+ return null;
+ }
+ if (!vod || !access.canView(req.user, vod)) { vodNotFound(res); return null; }
+ return vod;
+}
+
function mediaErr(res, err, fallback) {
function mediaErr(res, err, fallback) {
if (err && err.name === 'MediaApiError' && err.status) {
if (err && err.name === 'MediaApiError' && err.status) {
return res.status(err.status).json(err.body || { error: err.message });
return res.status(err.status).json(err.body || { error: err.message });
@@ -143,7 +165,12 @@ async function ownVodOrModerator(req, res) {
@@ -143,7 +165,12 @@ async function ownVodOrModerator(req, res) {
let owns = vod.user_id === req.user.id;
let owns = vod.user_id === req.user.id;
if (!owns && vod.stream_id) { const s = db.getStreamById(vod.stream_id); if (s && s.user_id === req.user.id) owns = true; }
if (!owns && vod.stream_id) { const s = db.getStreamById(vod.stream_id); if (s && s.user_id === req.user.id) owns = true; }
if (!owns) owns = permissions.canModerateContentOwner(req.user, vod.user_id ? db.getUserById(vod.user_id) : null);
if (!owns) owns = permissions.canModerateContentOwner(req.user, vod.user_id ? db.getUserById(vod.user_id) : null);
- if (!owns) { res.status(403).json({ error: 'Not authorized' }); return null; }
+ if (!owns) {
+ // Someone who may not even see a private VOD is not told it exists.
+ if (!access.canView(req.user, vod)) vodNotFound(res);
+ else res.status(403).json({ error: 'Not authorized' });
+ return null;
+ }
return vod;
return vod;
}
}
@@ -232,6 +259,11 @@ router.get('/stream/:streamId/live', optionalAuth, async (req, res) => {
@@ -232,6 +259,11 @@ router.get('/stream/:streamId/live', optionalAuth, async (req, res) => {
if (!rec || !rec.vodId || rec.clipsOnly) return res.status(404).json({ error: 'No active VOD recording' });
if (!rec || !rec.vodId || rec.clipsOnly) return res.status(404).json({ error: 'No active VOD recording' });
let vod = null;
let vod = null;
try { vod = await media.getVod(rec.vodId); } catch { /* Media hiccup — synthesize */ }
try { vod = await media.getVod(rec.vodId); } catch { /* Media hiccup — synthesize */ }
+ // A private recording of a public live stream is still private: without Media's row, the
+ // stream's own VOD visibility decides.
+ const stream = db.getStreamById(streamId);
+ const probe = vod || { user_id: stream?.user_id, stream_id: streamId, visibility: stream ? db.resolveStreamVodVisibility(stream) : 'private' };
+ if (!access.canView(req.user, probe)) return res.status(404).json({ error: 'No active VOD recording' });
res.json({
res.json({
vod: vod || {
vod: vod || {
id: rec.vodId,
id: rec.vodId,
@@ -247,9 +279,8 @@ router.get('/stream/:streamId/live', optionalAuth, async (req, res) => {
@@ -247,9 +279,8 @@ router.get('/stream/:streamId/live', optionalAuth, async (req, res) => {
router.get('/:id/live-info', optionalAuth, async (req, res) => {
router.get('/:id/live-info', optionalAuth, async (req, res) => {
try {
try {
- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
- const vod = await media.getVod(req.params.id);
- if (!vod) return res.status(404).json({ error: 'VOD not found' });
+ const vod = await loadVisibleVod(req, res, req.params.id);
+ if (!vod) return;
const recording = vod.status === 'recording' || !!vod.is_recording;
const recording = vod.status === 'recording' || !!vod.is_recording;
res.json({
res.json({
id: vod.id,
id: vod.id,
@@ -343,9 +374,9 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
@@ -343,9 +374,9 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
// AI "memory" timeline for a VOD (stream_memories stays in live.db).
// AI "memory" timeline for a VOD (stream_memories stays in live.db).
router.get('/:id/memories', optionalAuth, async (req, res) => {
router.get('/:id/memories', optionalAuth, async (req, res) => {
try {
try {
- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
- let streamId = null;
- try { const vod = await media.getVod(req.params.id); streamId = vod?.stream_id || null; } catch { /* */ }
+ const vod = await loadVisibleVod(req, res, req.params.id);
+ if (!vod) return;
+ const streamId = vod.stream_id || null;
if (!streamId) return res.json({ memories: [] });
if (!streamId) return res.json({ memories: [] });
const memories = (db.getStreamMemories(streamId) || []).map(m => ({
const memories = (db.getStreamMemories(streamId) || []).map(m => ({
offset_seconds: m.offset_seconds,
offset_seconds: m.offset_seconds,
@@ -375,11 +406,9 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
@@ -375,11 +406,9 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
// is served from the cache and never reaches the visibility check below.
// is served from the cache and never reaches the visibility check below.
let vod = null;
let vod = null;
try { vod = await media.getVod(id); } catch { vod = null; }
try { vod = await media.getVod(id); } catch { vod = null; }
- if (!vod) return res.status(404).json({ error: 'VOD not found' });
- const isPrivate = vod.visibility === 'private';
- if (isPrivate && !(req.user && (req.user.id === vod.user_id || req.user.role === 'admin'))) {
- return res.status(404).json({ error: 'VOD not found' });
- }
+ if (!vod) return vodNotFound(res);
+ const isPrivate = access.isPrivate(vod);
+ if (!access.canView(req.user, vod)) return vodNotFound(res);
// A private VOD's context must not sit in a shared cache anywhere on the way back either.
// A private VOD's context must not sit in a shared cache anywhere on the way back either.
const cacheHeader = isPrivate ? 'private, no-store' : 'public, max-age=60';
const cacheHeader = isPrivate ? 'private, no-store' : 'public, max-age=60';
@@ -428,11 +457,12 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
@@ -428,11 +457,12 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
router.get('/:id', optionalAuth, async (req, res) => {
router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
- let vod;
- try { vod = await media.getVod(req.params.id, { actingUser: media.actingUserFrom(req) }); }
- catch (err) { return mediaErr(res, err, 'Failed to get VOD'); }
- if (!vod) return res.status(404).json({ error: 'VOD not found' });
+ // Fetched with the app's authority (no acting user): Live decides who sees what, so staff
+ // can open a private VOD, and everyone else gets the missing-VOD answer — never a stub
+ // carrying the title and username, which is what anonymous callers used to get.
+ const vod = await loadVisibleVod(req, res, req.params.id);
+ if (!vod) return;
+ if (access.isPrivate(vod)) res.set('Cache-Control', 'private, no-store');
withUserFields(vod);
withUserFields(vod);
// Enrich with local stream details for chat replay.
// Enrich with local stream details for chat replay.
@@ -461,24 +491,12 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -461,24 +491,12 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
}
} catch { /* */ }
} catch { /* */ }
- const isOwnerOrAdmin = req.user && (req.user.id === vod.user_id || req.user.role === 'admin');
- const isPrivate = (vod.visibility ? vod.visibility === 'private' : !vod.is_public);
let clips = [];
let clips = [];
try {
try {
const q = vod.stream_id ? { stream_id: vod.stream_id } : { vod_id: vod.id };
const q = vod.stream_id ? { stream_id: vod.stream_id } : { vod_id: vod.id };
const co = await media.listClips(q);
const co = await media.listClips(q);
clips = co?.clips || (Array.isArray(co) ? co : []);
clips = co?.clips || (Array.isArray(co) ? co : []);
} catch { /* */ }
} catch { /* */ }
- if (isPrivate && !isOwnerOrAdmin) {
- return res.json({
- vod: {
- id: vod.id, title: vod.title, username: vod.username, display_name: vod.display_name,
- avatar_url: vod.avatar_url, is_public: 0, is_private: true, stream_id: vod.stream_id,
- created_at: vod.created_at, user_id: vod.user_id,
- },
- clips,
- });
- }
// Unique-view tracking stays local (content_views is a Live table).
// Unique-view tracking stays local (content_views is a Live table).
try {
try {
@@ -557,7 +575,7 @@ router.post('/:id/publish', requireAuth, async (req, res) => {
@@ -557,7 +575,7 @@ router.post('/:id/publish', requireAuth, async (req, res) => {
try {
try {
let vod;
let vod;
try { vod = await media.getVod(req.params.id); } catch (err) { return mediaErr(res, err, 'VOD not found'); }
try { vod = await media.getVod(req.params.id); } catch (err) { return mediaErr(res, err, 'VOD not found'); }
- if (!vod) return res.status(404).json({ error: 'VOD not found' });
+ if (!vod || !access.canView(req.user, vod)) return vodNotFound(res);
if (vod.user_id !== req.user.id) return res.status(403).json({ error: 'Not your VOD' });
if (vod.user_id !== req.user.id) return res.status(403).json({ error: 'Not your VOD' });
await media.updateVod(req.params.id, { visibility: 'public', is_public: 1 });
await media.updateVod(req.params.id, { visibility: 'public', is_public: 1 });
res.json({ message: 'VOD published', is_public: true });
res.json({ message: 'VOD published', is_public: true });
@@ -715,9 +733,8 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
@@ -715,9 +733,8 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
let vod;
let vod;
try { vod = await media.getVod(parsedVodId); } catch (err) { return mediaErr(res, err, 'VOD not found'); }
try { vod = await media.getVod(parsedVodId); } catch (err) { return mediaErr(res, err, 'VOD not found'); }
if (!vod) return res.status(404).json({ error: 'VOD not found or file missing' });
if (!vod) return res.status(404).json({ error: 'VOD not found or file missing' });
- if ((vod.visibility === 'private' || (!vod.is_public && !vod.visibility)) && vod.user_id !== req.user.id && req.user.role !== 'admin') {
- return res.status(403).json({ error: 'Cannot create clips from private videos' });
- }
+ // Clipping someone else's private VOD: the same answer as a missing one.
+ if (!access.canView(req.user, vod)) return vodNotFound(res);
const effStreamId = parsedStreamId || vod.stream_id || null;
const effStreamId = parsedStreamId || vod.stream_id || null;
const effStream = effStreamId ? db.getStreamById(effStreamId) : null;
const effStream = effStreamId ? db.getStreamById(effStreamId) : null;
if (effStream && !db.isStreamClipRecordingEnabled(effStream)) {
if (effStream && !db.isStreamClipRecordingEnabled(effStream)) {
@@ -31,6 +31,7 @@ async function _cached(key, fn) {
@@ -31,6 +31,7 @@ async function _cached(key, fn) {
const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);
const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);
const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public' }))?.pastes || []);
const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public' }))?.pastes || []);
+const { isPrivate } = require('../media-proxy/access');
const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));
const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));
const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));
const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));
const _pasteGet = (slug) => _cached(`p:${slug}`, () => require('../pastes-client').getPaste(slug));
const _pasteGet = (slug) => _cached(`p:${slug}`, () => require('../pastes-client').getPaste(slug));
@@ -289,7 +290,9 @@ function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name,
@@ -289,7 +290,9 @@ function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name,
async function _vodMeta(id) {
async function _vodMeta(id) {
let v; try { v = await _vodGet(id); } catch { v = null; }
let v; try { v = await _vodGet(id); } catch { v = null; }
- if (!v) return null;
+ // Private: fall through exactly like an unknown id. This HTML is cached per id and shared by
+ // every visitor, so it never carries a private VOD's title, overview or transcript.
+ if (!v || isPrivate(v)) return null;
if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;
if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;
_overlayAiState(v, 'vod');
_overlayAiState(v, 'vod');
const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');
const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');
@@ -322,7 +325,7 @@ async function _vodMeta(id) {
@@ -322,7 +325,7 @@ async function _vodMeta(id) {
async function _clipMeta(id) {
async function _clipMeta(id) {
let c; try { c = await _clipGet(id); } catch { c = null; }
let c; try { c = await _clipGet(id); } catch { c = null; }
- if (!c) return null;
+ if (!c || isPrivate(c)) return null; // private: same as unknown (see _vodMeta)
if (c.duration_seconds == null && c.duration != null) c.duration_seconds = c.duration;
if (c.duration_seconds == null && c.duration != null) c.duration_seconds = c.duration;
_overlayAiState(c, 'clip');
_overlayAiState(c, 'clip');
const indexable = Number(c.is_public) === 1 && (!c.visibility || c.visibility === 'public');
const indexable = Number(c.is_public) === 1 && (!c.visibility || c.visibility === 'public');
new file mode 100644
new file mode 100644
@@ -0,0 +1,258 @@
@@ -0,0 +1,258 @@
+/**
+ * Private VODs and clips are missing to everyone but their owners and staff.
+ *
+ * GET /api/vods/:id used to answer an anonymous caller with a stub carrying a private VOD's title,
+ * username and avatar; its companions (live-info, memories, context), the clip-from-VOD, publish,
+ * edit and thumbnail routes answered 403, which confirms the id; comments on private items were
+ * readable; the batch transcript endpoint returned any VOD's transcript; the server-rendered
+ * /vod/:id and /clip/:id pages put a private item's title and overview into shared HTML; and the
+ * recently-ended list carried a private legacy VOD's id and thumbnail. Every one of those now gives
+ * the exact answer an unknown id gets.
+ *
+ * The real routers run on a temp database with Media stubbed in-process and sign-in stubbed by an
+ * `x-test-user` header (as in authorization.test.js).
+ *
+ * node test/media-privacy.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = path.join(os.tmpdir(), `ov-media-privacy-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+// ── Sign-in stub (before any router captures the middleware) ──
+const auth = require('../server/auth/auth');
+const signIn = (req) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? db.getUserById(id) : null;
+ if (u) { req.user = u; req.authSource = 'network'; }
+ return u;
+};
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
+
+const addUser = (id, username, role) => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
+ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);
+addUser(1, 'admin', 'admin');
+addUser(3, 'alice', 'streamer'); // owns the private VOD; her channel was clipped
+addUser(5, 'carol', 'user'); // clipped alice's stream (the private clip's creator)
+addUser(6, 'mod', 'global_mod'); // staff
+addUser(7, 'mallory', 'user'); // nobody special
+addUser(8, 'eve', 'user'); // nobody special, second account (clip cooldowns are per user)
+db.ensureChannel(3);
+const chanA = db.getChannelByUserId(3);
+const streamA = Number(db.createStream({ user_id: 3, channel_id: chanA.id, title: 'A', protocol: 'webrtc' }).lastInsertRowid);
+
+// ── Media stub ──
+const media = require('../server/media-client');
+const VODS = {
+ 100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },
+ 101: { id: 101, user_id: 3, stream_id: streamA, title: 'Secret VOD', visibility: 'private', is_public: 0, status: 'ready', description: 'secret description' },
+ 102: { id: 102, user_id: 3, title: 'Legacy hidden', is_public: 0, status: 'ready' }, // no visibility: legacy private
+ 103: { id: 103, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },
+};
+const CLIPS = {
+ 200: { id: 200, user_id: 5, vod_id: 100, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' },
+ 201: { id: 201, user_id: 5, channel_user_id: 3, stream_id: streamA, vod_id: 101, title: 'Secret clip', visibility: 'private', is_public: 0, status: 'ready' },
+};
+const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
+media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };
+media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
+media.listClips = async () => ({ clips: [] });
+media.listVods = async () => ({ vods: [] });
+media.generateThumbnail = async () => ({ url: '/t/x.jpg' });
+media.createClip = async () => ({ id: 999, status: 'processing' });
+media.updateVod = async () => ({});
+media.updateClip = async () => ({});
+media.deleteClip = async () => ({});
+const recorder = require('../server/streaming/recorder');
+recorder.getActiveRecording = (sid) => (Number(sid) === streamA ? { vodId: 101, startedAt: Date.now() - 60000 } : null);
+
+raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview_short, ai_transcript_json) VALUES (?, ?, ?)')
+ .run(101, 'secret overview', JSON.stringify([{ start: 0, end: 1, text: 'secret words' }]));
+raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview_short, ai_transcript_json) VALUES (?, ?, ?)')
+ .run(100, 'public overview', JSON.stringify([{ start: 0, end: 1, text: 'public words' }]));
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/vods', require('../server/media-proxy/vods'));
+app.use('/api/clips', require('../server/media-proxy/clips'));
+app.use('/api/comments', require('../server/media-proxy/comments'));
+app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));
+app.use('/api/chat-ai', require('../server/ai/chat-ai-routes'));
+const server = http.createServer(app).listen(0);
+
+let ipSeq = 0;
+function call(method, p, user, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const headers = { 'content-type': 'application/json', 'cf-connecting-ip': `10.0.0.${++ipSeq}` };
+ if (user) headers['x-test-user'] = String(user);
+ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text, headers: res.headers }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+/** The refused answer must be byte-for-byte the missing-id answer. */
+async function sameAsMissing(method, hidden, missingPath, user, body) {
+ const a = await call(method, hidden, user, body);
+ const b = await call(method, missingPath, user, body);
+ assert.deepStrictEqual([a.status, a.text], [b.status, b.text], `${method} ${hidden} (user ${user || 'anon'}) must look like ${missingPath}`);
+ assert.strictEqual(a.status, 404, `${method} ${hidden} → 404`);
+ assert.ok(!/Secret|secret/.test(a.text), 'nothing of the private item leaks');
+ return a;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', e.message); }
+}
+
+(async () => {
+ await new Promise((r) => server.once('listening', r));
+
+ await check('VOD detail: private and legacy-private look missing to anonymous and other users', async () => {
+ for (const user of [null, 7, 5]) {
+ await sameAsMissing('GET', '/api/vods/101', '/api/vods/999', user);
+ await sameAsMissing('GET', '/api/vods/102', '/api/vods/999', user);
+ }
+ });
+
+ await check('VOD detail: owner and staff see a private VOD, with a non-shareable Cache-Control', async () => {
+ for (const user of [3, 6, 1]) {
+ const r = await call('GET', '/api/vods/101', user);
+ assert.strictEqual(r.status, 200, `user ${user}`);
+ assert.strictEqual(r.json.vod.title, 'Secret VOD');
+ assert.strictEqual(r.headers['cache-control'], 'private, no-store');
+ }
+ assert.strictEqual((await call('GET', '/api/vods/100')).status, 200, 'public VOD');
+ assert.strictEqual((await call('GET', '/api/vods/103')).status, 200, 'unlisted VOD by direct link');
+ });
+
+ await check('VOD companions: live-info, memories, context look missing', async () => {
+ for (const sub of ['live-info', 'memories', 'context']) {
+ await sameAsMissing('GET', `/api/vods/101/${sub}`, `/api/vods/999/${sub}`, null);
+ await sameAsMissing('GET', `/api/vods/102/${sub}`, `/api/vods/999/${sub}`, 7);
+ assert.strictEqual((await call('GET', `/api/vods/101/${sub}`, 3)).status, 200, `owner: ${sub}`);
+ }
+ });
+
+ await check('live recording of a stream: a private VOD is not handed to viewers', async () => {
+ const anon = await call('GET', `/api/vods/stream/${streamA}/live`);
+ const none = await call('GET', '/api/vods/stream/424242/live');
+ assert.deepStrictEqual([anon.status, anon.text], [none.status, none.text]);
+ assert.strictEqual((await call('GET', `/api/vods/stream/${streamA}/live`, 3)).status, 200, 'the streamer still gets it');
+ });
+
+ await check('VOD writes by strangers: 404 (not 403) for a private VOD', async () => {
+ await sameAsMissing('PUT', '/api/vods/101', '/api/vods/999', 7, { title: 'x' });
+ await sameAsMissing('DELETE', '/api/vods/101', '/api/vods/999', 7);
+ await sameAsMissing('POST', '/api/vods/101/publish', '/api/vods/999/publish', 7);
+ assert.strictEqual((await call('PUT', '/api/vods/100', 7, { title: 'x' })).status, 403, 'a public VOD may still say 403');
+ });
+
+ await check('clipping someone else\'s private VOD looks like clipping a missing one', async () => {
+ const a = await call('POST', '/api/vods/clips', 7, { vod_id: 101, start_time: 0, end_time: 5 });
+ const b = await call('POST', '/api/vods/clips', 8, { vod_id: 999, start_time: 0, end_time: 5 });
+ assert.deepStrictEqual([a.status, a.text], [b.status, b.text]);
+ assert.strictEqual(a.status, 404);
+ assert.strictEqual((await call('POST', '/api/vods/clips', 3, { vod_id: 101, start_time: 0, end_time: 5 })).status, 201, 'the owner may clip it');
+ });
+
+ await check('clip detail: private looks missing; clipper, channel owner and staff see it', async () => {
+ for (const user of [null, 7]) await sameAsMissing('GET', '/api/clips/201', '/api/clips/999', user);
+ for (const user of [5, 3, 6]) {
+ const r = await call('GET', '/api/clips/201', user);
+ assert.strictEqual(r.status, 200, `user ${user}`);
+ assert.strictEqual(r.json.clip.title, 'Secret clip');
+ }
+ const pub = await call('GET', '/api/clips/200', 7);
+ assert.strictEqual(pub.status, 200);
+ assert.strictEqual(pub.json.clip.vod_available, true);
+ const own = await call('GET', '/api/clips/201', 5);
+ assert.strictEqual(own.json.clip.vod_available, false, 'the clipper may not see the source VOD, so it is not offered');
+ });
+
+ await check('clip writes by strangers: 404 (not 403) for a private clip', async () => {
+ await sameAsMissing('PUT', '/api/clips/201/title', '/api/clips/999/title', 7, { title: 'x' });
+ await sameAsMissing('PUT', '/api/clips/201/visibility', '/api/clips/999/visibility', 7, { visibility: 'public' });
+ await sameAsMissing('DELETE', '/api/clips/201', '/api/clips/999', 7);
+ await sameAsMissing('POST', '/api/clips/201/recut', '/api/clips/999/recut', 7);
+ });
+
+ await check('thumbnail regeneration: a private item looks missing', async () => {
+ await sameAsMissing('POST', '/api/thumbnails/generate/vod/101', '/api/thumbnails/generate/vod/999', null);
+ await sameAsMissing('POST', '/api/thumbnails/generate/clip/201', '/api/thumbnails/generate/clip/999', 7);
+ });
+
+ await check('comments on a private item: unreadable and unpostable for strangers', async () => {
+ const posted = await call('POST', '/api/comments/vod/101', 3, { message: 'secret note' });
+ assert.strictEqual(posted.status, 201, 'the owner may comment');
+ await sameAsMissing('GET', '/api/comments/vod/101', '/api/comments/vod/999', null);
+ await sameAsMissing('GET', '/api/comments/clip/201', '/api/comments/clip/999', 7);
+ await sameAsMissing('POST', '/api/comments/vod/101', '/api/comments/vod/999', 7, { message: 'hi' });
+ // (/:commentId/replies is shadowed by /:type/:id today and answers 400; either way nothing leaks.)
+ const r = await call('GET', `/api/comments/${posted.json.comment.id}/replies`);
+ assert.ok(r.status >= 400 && !/secret/.test(r.text), 'replies under a private VOD');
+ assert.strictEqual((await call('GET', '/api/comments/vod/101', 6)).status, 200, 'staff read them');
+ assert.strictEqual((await call('GET', '/api/comments/vod/100')).status, 200, 'public item comments stay open');
+ });
+
+ await check('batch transcripts leave private VODs out', async () => {
+ const r = await call('GET', '/api/chat-ai/vod-transcripts?ids=100,101,102,999');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(Object.keys(r.json.transcripts), ['100']);
+ assert.ok(!/secret/.test(r.text));
+ });
+
+ await check('server-rendered /vod/:id and /clip/:id: private is the unknown-id fall-through', async () => {
+ const seo = require('../server/seo/seo');
+ assert.strictEqual(await seo._pageMeta('/vod/101'), null);
+ assert.strictEqual(await seo._pageMeta('/vod/102'), null);
+ assert.strictEqual(await seo._pageMeta('/clip/201'), null);
+ assert.strictEqual(await seo._pageMeta('/vod/999'), null);
+ assert.ok(await seo._pageMeta('/vod/100'), 'public VODs still render');
+ assert.ok(await seo._pageMeta('/clip/200'), 'public clips still render');
+ });
+
+ await check('recently-ended streams (legacy vods table): only a public VOD rides along', async () => {
+ db.endStream(streamA);
+ // Test fixture rows in the temp database; server code never writes the frozen table.
+ const legacy = raw.prepare(`INSERT INTO vods (stream_id, user_id, title, file_path, is_public, visibility, thumbnail_url) VALUES (?, 3, ?, '/x', ?, ?, ?)`);
+ legacy.run(streamA, 'Secret legacy', 0, 'private', '/t/secret.jpg');
+ let row = db.getRecentStreams(10).find((s) => s.id === streamA);
+ assert.ok(row, 'the stream is listed');
+ assert.strictEqual(row.vod_id, null, 'no private VOD id');
+ assert.strictEqual(row.vod_thumbnail_url, null, 'no private VOD thumbnail');
+ raw.prepare('DELETE FROM vods').run();
+ legacy.run(streamA, 'Public legacy', 1, 'public', '/t/pub.jpg');
+ row = db.getRecentStreams(10).find((s) => s.id === streamA);
+ assert.ok(row.vod_id, 'a public VOD is still attached');
+ });
+
+ server.close();
+ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
+ quiet('media privacy: all checks passed');
+ process.exit(0);
+})().catch((err) => { quiet(err); server.close(); process.exit(1); });
@@ -251,7 +251,7 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
@@ -251,7 +251,7 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
const src = fs.readFileSync(path.join(ROOT, 'server/media-proxy/vods.js'), 'utf8');
const src = fs.readFileSync(path.join(ROOT, 'server/media-proxy/vods.js'), 'utf8');
const h = src.match(/router\.get\('\/:id\/context'[\s\S]*?res\.json\(data\);/);
const h = src.match(/router\.get\('\/:id\/context'[\s\S]*?res\.json\(data\);/);
assert(h, 'the VOD context route should exist');
assert(h, 'the VOD context route should exist');
- const checkAt = h[0].indexOf("vod.visibility === 'private'");
+ const checkAt = h[0].indexOf('access.canView(req.user, vod)');
const cacheAt = h[0].indexOf('_ctxCache.get(id)');
const cacheAt = h[0].indexOf('_ctxCache.get(id)');
assert(checkAt > 0 && cacheAt > 0, 'both the visibility check and the cache read should be present');
assert(checkAt > 0 && cacheAt > 0, 'both the visibility check and the cache read should be present');
assert(checkAt < cacheAt, 'the visibility check must run before the cache is read');
assert(checkAt < cacheAt, 'the visibility check must run before the cache is read');