openvibers/openvibe.media Commits
A new clip is never more visible than its recording (item 151, C17) (#296)
Changes
@@@ -123,6 +123,18 @@ function _isPrivate(row) {return (row.visibility || (row.is_public ? 'public' : 'private')) === 'private';}++/** A recording's visibility as stored ('public' | 'unlisted' | 'private'; legacy rows by is_public). */++function _sourceVisibility(row) {++ return VALID_VIS_ORDER[row && row.visibility] != null ? row.visibility : (row && row.is_public ? 'public' : 'private');++}++const VALID_VIS_ORDER = { private: 0, unlisted: 1, public: 2 };++/** The lower of what was asked and what the source allows. */++function capVisibility(wanted, source) {++ const w = VALID_VIS_ORDER[wanted] != null ? wanted : 'public';++ const c = VALID_VIS_ORDER[source] != null ? source : 'private';++ return VALID_VIS_ORDER[w] <= VALID_VIS_ORDER[c] ? w : c;++}++async function _getClipScoped(req, res) {const id = parseInt(req.params.id, 10);if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }@@@ -250,7 +262,10 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing}const userId = body.user_id != null ? body.user_id : (req.userId ?? null);-- const visibility = VALID_VIS.has(body.visibility) ? body.visibility : 'public';++ // A new clip is never more visible than its recording (item 151, C17): a private recording's clip is private++ // and an unlisted one's at most unlisted, whatever was asked (the default, public, included). Its owner can still++ // open it later with PUT, on purpose.++ const visibility = capVisibility(VALID_VIS.has(body.visibility) ? body.visibility : 'public', _sourceVisibility(vod));// The app's own automation (an AI auto-clip) says so with auto_generated; a call acting for// one of its users is a person's clip whatever it claims. The flag and the description used// to be dropped here, so every auto-clip was stored as if a person had made it.@@@ -661,3 +676,4 @@ router.delete('/:id', tenantAuth({ verb: 'delete' }), async (req, res) => {module.exports = router;module.exports.clipPublic = clipPublic;++module.exports.capVisibility = capVisibility;nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++// A new clip is never more visible than its recording (item 151, C17): POST /clips caps what was asked (the public++// default included) by the source's visibility. A private recording's clip is private, an unlisted one's at most++// unlisted; a public recording's clip is what was asked. Its owner can still open it later with PUT, on purpose.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-clip-privacy-'));++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++ process.env.VOD_PATH = dir('vods');++ process.env.CLIPS_PATH = dir('clips');++ process.env.FILES_PATH = dir('files');++ process.env.OBJECTS_PATH = dir('objects');++ process.env.THUMBNAILS_PATH = dir('thumbnails');++ process.env.PASTES_PATH = dir('pastes');++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++ delete process.env.EVENTS_URL;++ console.log = () => {};++ console.warn = () => {};++++ const db = require('../server/db/database');++ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });++ const raw = db.getDb();++ require('../server/vod/clip-cutter').cutClipFile = async () => ({ ok: false, error: 'not in this test' });++ const vodFile = path.join(process.env.VOD_PATH, 'vod.webm');++ fs.writeFileSync(vodFile, Buffer.alloc(64, 1));++ const ins = raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds) OVERRIDING SYSTEM VALUE++ VALUES (?, 'live', 5, ?, ?, ?, ?, 600) RETURNING id`);++ await ins.run(1, 'Public', vodFile, 1, 'public');++ await ins.run(2, 'Unlisted', vodFile, 0, 'unlisted');++ await ins.run(3, 'Private', vodFile, 0, 'private');++ await raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds) OVERRIDING SYSTEM VALUE++ VALUES (4, 'live', 5, 'Legacy private', ?, 0, NULL, 600) RETURNING id`).run(vodFile);++++ const express = require('express');++ const app = express();++ app.use(express.json());++ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}/api/v1/live`;++ // Media hands back the clip already cut of the same window, so every call cuts its own.++ let at = 0;++ const make = async (vodId, visibility) => {++ at += 40;++ const body = { vod_id: vodId, start_s: at, end_s: at + 30, title: 'x', ...(visibility ? { visibility } : {}) };++ const res = await fetch(`${base}/clips`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' }, body: JSON.stringify(body) });++ const json = await res.json().catch(() => null);++ assert.ok(res.status === 201 || res.status === 202 || res.status === 200, `${res.status} ${JSON.stringify(json)}`);++ const id = (json && (json.id || (json.clip && json.clip.id)));++ const row = await raw.prepare('SELECT visibility FROM clips WHERE id = ?').get(id);++ return row.visibility;++ };++++ let failures = 0;++ const check = async (name, fn) => {++ try { await fn(); process.stdout.write(` ok - ${name}\n`); }++ catch (e) { failures++; process.stdout.write(` FAIL - ${name}: ${e.message}\n`); }++ };++++ await check('the lower of what was asked and what the source allows', async () => {++ const { capVisibility } = require('../server/vod/clips-routes');++ assert.strictEqual(capVisibility('public', 'public'), 'public');++ assert.strictEqual(capVisibility('public', 'unlisted'), 'unlisted');++ assert.strictEqual(capVisibility('unlisted', 'public'), 'unlisted');++ assert.strictEqual(capVisibility('public', 'private'), 'private');++ assert.strictEqual(capVisibility('public', undefined), 'private', 'an unknown source is private');++ });++++ await check('POST /clips: a public recording gives what was asked (public by default)', async () => {++ assert.strictEqual(await make(1), 'public');++ assert.strictEqual(await make(1, 'unlisted'), 'unlisted');++ });++++ await check('POST /clips: an unlisted recording gives at most unlisted; a private one gives private', async () => {++ assert.strictEqual(await make(2), 'unlisted', 'the default no longer opens it');++ assert.strictEqual(await make(2, 'public'), 'unlisted');++ assert.strictEqual(await make(3), 'private');++ assert.strictEqual(await make(3, 'public'), 'private', 'asking for public does not open it');++ assert.strictEqual(await make(4, 'public'), 'private', 'a legacy row with is_public = 0 is private');++ });++++ server.close();++ if (failures) { process.stdout.write(`${failures} failed\n`); process.exit(1); }++ process.stdout.write('clip source privacy: all checks passed\n');++ process.exit(0);++})().catch((e) => { process.stdout.write(`FAILED ${e.stack}\n`); process.exit(1); });
@@ -123,6 +123,18 @@ function _isPrivate(row) {
@@ -123,6 +123,18 @@ function _isPrivate(row) {
return (row.visibility || (row.is_public ? 'public' : 'private')) === 'private';
return (row.visibility || (row.is_public ? 'public' : 'private')) === 'private';
}
}
+/** A recording's visibility as stored ('public' | 'unlisted' | 'private'; legacy rows by is_public). */
+function _sourceVisibility(row) {
+ return VALID_VIS_ORDER[row && row.visibility] != null ? row.visibility : (row && row.is_public ? 'public' : 'private');
+}
+const VALID_VIS_ORDER = { private: 0, unlisted: 1, public: 2 };
+/** The lower of what was asked and what the source allows. */
+function capVisibility(wanted, source) {
+ const w = VALID_VIS_ORDER[wanted] != null ? wanted : 'public';
+ const c = VALID_VIS_ORDER[source] != null ? source : 'private';
+ return VALID_VIS_ORDER[w] <= VALID_VIS_ORDER[c] ? w : c;
+}
+
async function _getClipScoped(req, res) {
async function _getClipScoped(req, res) {
const id = parseInt(req.params.id, 10);
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }
if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }
@@ -250,7 +262,10 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
@@ -250,7 +262,10 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
}
}
const userId = body.user_id != null ? body.user_id : (req.userId ?? null);
const userId = body.user_id != null ? body.user_id : (req.userId ?? null);
- const visibility = VALID_VIS.has(body.visibility) ? body.visibility : 'public';
+ // A new clip is never more visible than its recording (item 151, C17): a private recording's clip is private
+ // and an unlisted one's at most unlisted, whatever was asked (the default, public, included). Its owner can still
+ // open it later with PUT, on purpose.
+ const visibility = capVisibility(VALID_VIS.has(body.visibility) ? body.visibility : 'public', _sourceVisibility(vod));
// The app's own automation (an AI auto-clip) says so with auto_generated; a call acting for
// The app's own automation (an AI auto-clip) says so with auto_generated; a call acting for
// one of its users is a person's clip whatever it claims. The flag and the description used
// one of its users is a person's clip whatever it claims. The flag and the description used
// to be dropped here, so every auto-clip was stored as if a person had made it.
// to be dropped here, so every auto-clip was stored as if a person had made it.
@@ -661,3 +676,4 @@ router.delete('/:id', tenantAuth({ verb: 'delete' }), async (req, res) => {
@@ -661,3 +676,4 @@ router.delete('/:id', tenantAuth({ verb: 'delete' }), async (req, res) => {
module.exports = router;
module.exports = router;
module.exports.clipPublic = clipPublic;
module.exports.clipPublic = clipPublic;
+module.exports.capVisibility = capVisibility;
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+// A new clip is never more visible than its recording (item 151, C17): POST /clips caps what was asked (the public
+// default included) by the source's visibility. A private recording's clip is private, an unlisted one's at most
+// unlisted; a public recording's clip is what was asked. Its owner can still open it later with PUT, on purpose.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-clip-privacy-'));
+ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+ process.env.VOD_PATH = dir('vods');
+ process.env.CLIPS_PATH = dir('clips');
+ process.env.FILES_PATH = dir('files');
+ process.env.OBJECTS_PATH = dir('objects');
+ process.env.THUMBNAILS_PATH = dir('thumbnails');
+ process.env.PASTES_PATH = dir('pastes');
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+ delete process.env.EVENTS_URL;
+ console.log = () => {};
+ console.warn = () => {};
+
+ const db = require('../server/db/database');
+ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+ const raw = db.getDb();
+ require('../server/vod/clip-cutter').cutClipFile = async () => ({ ok: false, error: 'not in this test' });
+ const vodFile = path.join(process.env.VOD_PATH, 'vod.webm');
+ fs.writeFileSync(vodFile, Buffer.alloc(64, 1));
+ const ins = raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds) OVERRIDING SYSTEM VALUE
+ VALUES (?, 'live', 5, ?, ?, ?, ?, 600) RETURNING id`);
+ await ins.run(1, 'Public', vodFile, 1, 'public');
+ await ins.run(2, 'Unlisted', vodFile, 0, 'unlisted');
+ await ins.run(3, 'Private', vodFile, 0, 'private');
+ await raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds) OVERRIDING SYSTEM VALUE
+ VALUES (4, 'live', 5, 'Legacy private', ?, 0, NULL, 600) RETURNING id`).run(vodFile);
+
+ const express = require('express');
+ const app = express();
+ app.use(express.json());
+ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/v1/live`;
+ // Media hands back the clip already cut of the same window, so every call cuts its own.
+ let at = 0;
+ const make = async (vodId, visibility) => {
+ at += 40;
+ const body = { vod_id: vodId, start_s: at, end_s: at + 30, title: 'x', ...(visibility ? { visibility } : {}) };
+ const res = await fetch(`${base}/clips`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' }, body: JSON.stringify(body) });
+ const json = await res.json().catch(() => null);
+ assert.ok(res.status === 201 || res.status === 202 || res.status === 200, `${res.status} ${JSON.stringify(json)}`);
+ const id = (json && (json.id || (json.clip && json.clip.id)));
+ const row = await raw.prepare('SELECT visibility FROM clips WHERE id = ?').get(id);
+ return row.visibility;
+ };
+
+ let failures = 0;
+ const check = async (name, fn) => {
+ try { await fn(); process.stdout.write(` ok - ${name}\n`); }
+ catch (e) { failures++; process.stdout.write(` FAIL - ${name}: ${e.message}\n`); }
+ };
+
+ await check('the lower of what was asked and what the source allows', async () => {
+ const { capVisibility } = require('../server/vod/clips-routes');
+ assert.strictEqual(capVisibility('public', 'public'), 'public');
+ assert.strictEqual(capVisibility('public', 'unlisted'), 'unlisted');
+ assert.strictEqual(capVisibility('unlisted', 'public'), 'unlisted');
+ assert.strictEqual(capVisibility('public', 'private'), 'private');
+ assert.strictEqual(capVisibility('public', undefined), 'private', 'an unknown source is private');
+ });
+
+ await check('POST /clips: a public recording gives what was asked (public by default)', async () => {
+ assert.strictEqual(await make(1), 'public');
+ assert.strictEqual(await make(1, 'unlisted'), 'unlisted');
+ });
+
+ await check('POST /clips: an unlisted recording gives at most unlisted; a private one gives private', async () => {
+ assert.strictEqual(await make(2), 'unlisted', 'the default no longer opens it');
+ assert.strictEqual(await make(2, 'public'), 'unlisted');
+ assert.strictEqual(await make(3), 'private');
+ assert.strictEqual(await make(3, 'public'), 'private', 'asking for public does not open it');
+ assert.strictEqual(await make(4, 'public'), 'private', 'a legacy row with is_public = 0 is private');
+ });
+
+ server.close();
+ if (failures) { process.stdout.write(`${failures} failed\n`); process.exit(1); }
+ process.stdout.write('clip source privacy: all checks passed\n');
+ process.exit(0);
+})().catch((e) => { process.stdout.write(`FAILED ${e.stack}\n`); process.exit(1); });