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

OP OpenVibers committed 23 Sept 2026 · 31695a6f2c · 11 files, +437 −64


Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex cd3a227d..fcb22efc 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -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 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).
ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex a3b4a985..a76a32bd 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -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
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 1563e18d..ad9cf465 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -2838,6 +2838,8 @@ function getRecentStreams(limit = 20) {
) 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
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
ORDER BY s.ended_at DESC
LIMIT ?
ddiff --git a/server/media-proxy/access.js b/server/media-proxy/access.js
nnew file mode 100644
iindex 00000000..d5d82bb1
---- /dev/null
++++ b/server/media-proxy/access.js
@@@ -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 };
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 2b9763a0..8f389c9e 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -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) {
ddiff --git a/server/media-proxy/comments.js b/server/media-proxy/comments.js
iindex 20145593..34d180e4 100644
---- a/server/media-proxy/comments.js
++++ b/server/media-proxy/comments.js
@@@ -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' });
ddiff --git a/server/media-proxy/thumbnails.js b/server/media-proxy/thumbnails.js
iindex bd78f39d..1a79889b 100644
---- a/server/media-proxy/thumbnails.js
++++ b/server/media-proxy/thumbnails.js
@@@ -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, re
async 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}` });
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 47d6f361..90401e29 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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)) {
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 10a13981..1e54d4f8 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -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');
ddiff --git a/test/media-privacy.test.js b/test/media-privacy.test.js
nnew file mode 100644
iindex 00000000..6b311c2e
---- /dev/null
++++ b/test/media-privacy.test.js
@@@ -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); });
ddiff --git a/test/security-redaction.test.js b/test/security-redaction.test.js
iindex e27fc02d..0b9d5a00 100644
---- a/test/security-redaction.test.js
++++ b/test/security-redaction.test.js
@@@ -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');