openvibers/openvibe.mediahub Commits
Content labels on pictures and the video manage forms (L1) (#214)
Changes
nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- Content labels on pictures (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).++--++-- A picture's uploader labels it (PATCH /api/v1/images/:slug { labels }); the AI may suggest a label from what it++-- sees, stored in `suggested` and never applied. A label is not a ban: OpenVibe.Pics never hides a picture because++-- of it, and each viewer's Network label prefs decide what they see. The shape is `{ labels, suggested }`.++--++-- Expand only: an older release never reads the column, and the default keeps every existing picture label-free.++ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;++CREATE INDEX IF NOT EXISTS pics_images_content_labels ON pics_images USING gin (content_labels);nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++/**++ * Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).++ *++ * The one label list and the shape a picture stores and returns: `{ labels, suggested }`. A creator sets a label;++ * the AI may suggest one, and a suggestion never applies itself. A label is not a ban: OpenVibe.Pics never hides a++ * picture because of it, and the viewer's Network prefs decide what they see.++ */++const LABELS = Object.freeze(['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']);++++const isLabel = (x) => typeof x === 'string' && LABELS.includes(x);++++/** A clean label list from a creator's request, or null when it is not a list of known labels. */++function parseLabels(value) {++ if (!Array.isArray(value)) return null;++ const out = [];++ for (const label of value) {++ if (!isLabel(label)) return null;++ if (!out.includes(label)) out.push(label);++ }++ return out;++}++++/** The contract's shape from a jsonb column, a JSON string, or nothing: always two arrays of known labels. */++function labelsOf(row) {++ let raw = row && typeof row === 'object' && 'content_labels' in row ? row.content_labels : row;++ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }++ const pick = (v) => [...new Set((Array.isArray(v) ? v : []).filter(isLabel))];++ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };++}++++/** The shape to store. */++const shape = (labels, suggested = []) => ({ labels: [...new Set((labels || []).filter(isLabel))], suggested: [...new Set((suggested || []).filter(isLabel))] });++++/** Apply a creator's labels and drop the ones they just confirmed from the suggestions. */++function confirmed(current, labels) {++ const now = labelsOf(current);++ return { labels: [...labels], suggested: now.suggested.filter((l) => !labels.includes(l)) };++}++++module.exports = { LABELS, isLabel, parseLabels, labelsOf, shape, confirmed };@@@ -28,6 +28,7 @@ const contracts = require('openvibe-contracts');const { asyncRouter } = require('../http/router');const { sameOrigin } = require('../http/principal');const store = require('./store');++const contentLabels = require('../labels');function createPicsApi(ctx) {const { config, s, principal, limits } = ctx;@@@ -98,7 +99,10 @@ function createPicsApi(ctx) {const row = await loadOwn(req, res);if (!row) return undefined;const b = req.body || {};-- return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description, alt: b.alt }), { owner: true }));++ // L1: the uploader labels the picture; a suggestion they confirm leaves `suggested`. A label never hides it.++ const nextLabels = b.labels !== undefined ? contentLabels.parseLabels(b.labels) : undefined;++ if (nextLabels === null) return problem(req, res, 422, 'pics.labels_invalid', `labels must be a list of: ${contentLabels.LABELS.join(', ')}`);++ return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description, alt: b.alt, labels: nextLabels }), { owner: true }));});r.post('/images/:slug/describe', person, limits.budget('pics.image.describe'), async (req, res) => {@@@ -23,6 +23,7 @@const crypto = require('crypto');const images = require('./images');const store = require('./store');++const contentLabels = require('../labels');const { isStaff } = require('../drive/staff');const SLUG_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';@@@ -130,7 +131,7 @@ function createPics({ s, media, config, log = console, events = null }) {return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };}-- async function edit(row, { title, description, alt }) {++ async function edit(row, { title, description, alt, labels: nextLabels }) {// Alt text the uploader changes is theirs; an AI draft left as it was stays labelled as AI's.const altNow = alt !== undefined ? cleanLine(alt, LIMITS.alt) : undefined;const altPatch = altNow === undefined || altNow === (row.alt || '') ? {} : { alt: altNow, alt_source: altNow ? 'person' : null };@@@ -138,6 +139,8 @@ function createPics({ s, media, config, log = console, events = null }) {...(title !== undefined ? { title: cleanLine(title, LIMITS.title) } : {}),...(description !== undefined ? { description: cleanText(description, LIMITS.description) } : {}),...altPatch,++ // L1: the uploader sets the labels; a suggestion they confirm leaves `suggested`. A label never hides it.++ ...(nextLabels !== undefined ? { content_labels: contentLabels.confirmed(row.content_labels, nextLabels) } : {}),});}@@@ -245,6 +248,8 @@ function createPics({ s, media, config, log = console, events = null }) {width: Number(row.width), height: Number(row.height), bytes: Number(row.bytes), animated: Boolean(row.animated),alt: row.alt || '', alt_source: row.alt_source || null,ai: row.ai_described_at ? { caption: row.ai_caption || null, tags: tagsOf(row), described_at: row.ai_described_at } : null,++ // L1 labels: `{ labels, suggested }` (common.content-labels@1). A suggestion is shown, never applied.++ content_labels: contentLabels.labelsOf(row),state: row.state, created_at: row.created_at, links: links(row),...(owner ? { report_count: Number(row.report_count) } : {}),});@@@ -6,7 +6,7 @@* nobody but its uploader and staff (server/pics/service.js decides who).*/--const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id', 'alt', 'alt_source', 'ai_caption', 'ai_tags', 'ai_described_at'];++const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id', 'alt', 'alt_source', 'ai_caption', 'ai_tags', 'ai_described_at', 'content_labels'];const imageBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_images WHERE slug = $1', [slug]);const imageById = (s, id) => s.db.maybe('SELECT * FROM pics_images WHERE id = $1', [id]);@@@ -183,6 +183,7 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = consoledeleteVod: (token, id) => asPerson(token, 'DELETE', `/api/vods/${id}`),setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),setClipVisibility: (token, id, visibility) => asPerson(token, 'PUT', `/api/clips/${id}/visibility`, { visibility }),++ setClipLabels: (token, id, labels) => asPerson(token, 'PUT', `/api/clips/${id}/labels`, { labels }),deleteClip: (token, id) => asPerson(token, 'DELETE', `/api/clips/${id}`),/** A clip of a recording, cut by Media: { vod_id, start_time, end_time, title } → { clip } (Live's rules and limits). */createClip: (token, fields, viewerIp) => asPerson(token, 'POST', '/api/vods/clips', fields, { viewerIp }),@@@ -37,6 +37,7 @@ const { createLiveClient, LiveRefusedError } = require('./live-client');const { createChatClient } = require('./chat-client');const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');const rules = require('../drive/rules');++const contentLabels = require('../labels');/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');const { createVideoApiRoutes } = require('./api');@@@ -1422,7 +1423,10 @@ ${up.html}if (!title || title.length > 200) return { error: 'A title is 1 to 200 characters.' };if (kind === 'vod' && description.length > 2000) return { error: 'A description is at most 2,000 characters.' };if (!VISIBILITIES.includes(visibility)) return { error: 'Choose public, unlisted or private.' };-- return { title, description, visibility };++ // L1: the creator labels the recording. Repeated form fields arrive as an array; absent keeps what it has.++ const labels = b.labels === undefined ? undefined : contentLabels.parseLabels([].concat(b.labels));++ if (labels === null) return { error: 'Choose the labels from the list.' };++ return { title, description, visibility, labels };}const fieldError = (req, res, text, back) => page(req, res, {title: 'Not saved', cache: cache.htmlHeaders({ private: true }),@@@ -1459,7 +1463,7 @@ ${up.html}const f = ownerFields(req.body || {}, 'vod');if (f.error) return fieldError(req, res, f.error, back);try {-- await asPerson(req, res, (token) => live.updateVod(token, id, { title: f.title, description: f.description, visibility: f.visibility }));++ await asPerson(req, res, (token) => live.updateVod(token, id, { title: f.title, description: f.description, visibility: f.visibility, ...(f.labels !== undefined ? { labels: f.labels } : {}) }));} catch (err) { return ownerRefused(req, res, err, back); }live.forget('vod', id);return res.redirect(303, `${back}?saved=1`);@@@ -1496,6 +1500,7 @@ ${up.html}try {if (f.title !== String(current.title || '')) await asPerson(req, res, (token) => live.setClipTitle(token, id, f.title));if (f.visibility !== String(current.visibility || '')) await asPerson(req, res, (token) => live.setClipVisibility(token, id, f.visibility));++ if (f.labels !== undefined) await asPerson(req, res, (token) => live.setClipLabels(token, id, f.labels));} catch (err) { live.forget('clip', id); return ownerRefused(req, res, err, back); }live.forget('clip', id);return res.redirect(303, `${back}?saved=1`);nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++// Content labels on pictures (L1, 2026-10-31; Contracts 0.257.0 common.content-labels@1): the uploader labels a++// picture, the AI's `suggested` stays a suggestion until confirmed, every read carries the shape, and a label never++// hides the picture (the viewer's Network prefs decide).++// node test/content-labels.test.js++const assert = require('assert');++const http = require('http');++const sharp = require('sharp');++const { boot, check, done } = require('./helpers/boot');++++const PICS = { Host: 'openvibe.pics' };++const SAME = { ...PICS, 'sec-fetch-site': 'same-origin' };++++/** t.get with the Pics Host header (Node's fetch drops Host). */++function hostClient(t) {++ return (p, o = {}) => new Promise((resolve, reject) => {++ const headers = { ...SAME, ...(o.headers || {}) };++ if (o.as) headers.cookie = t.signIn(o.as);++ let body = o.body;++ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }++ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));++ const u = new URL(t.base + p);++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => {++ const text = Buffer.concat(chunks).toString('utf8');++ resolve({ status: res.statusCode, text, json() { return JSON.parse(text); } });++ });++ });++ req.on('error', reject);++ if (body != null) req.write(body);++ req.end();++ });++}++++(async () => {++ const t0 = await boot();++ const t = { ...t0, get: hostClient(t0) };++ const photo = () => sharp({ create: { width: 64, height: 48, channels: 3, background: '#335' } }).jpeg().toBuffer();++ try {++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++ const up = await t.get('/api/v1/images', { as: kim, method: 'POST', body: await photo(), headers: { ...SAME, 'content-type': 'image/jpeg' } });++ assert.strictEqual(up.status, 201, up.text);++ const slug = up.json().slug;++ const api = `/api/v1/images/${slug}`;++++ await check('a picture is label-free by default, and the uploader sets labels', async () => {++ let r = await t.get(api, { as: kim });++ assert.deepStrictEqual(r.json().content_labels, { labels: [], suggested: [] });++ r = await t.get(api, { as: kim, method: 'PATCH', json: { labels: ['nudity', '18+'] } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json().content_labels, { labels: ['nudity', '18+'], suggested: [] });++ r = await t.get(api, { as: lee });++ assert.deepStrictEqual(r.json().content_labels, { labels: ['nudity', '18+'], suggested: [] }, 'anyone sees the labels on a public picture');++ assert.strictEqual(r.json().state, 'active', 'a label never hides the picture');++ });++++ await check('a suggestion is stored as suggested and only the confirmed one leaves', async () => {++ await t.ctx.s.db.exec(`UPDATE pics_images SET content_labels = '{"labels":["18+"],"suggested":["nudity","language"]}' WHERE slug = $1`, [slug]);++ let r = await t.get(api, { as: kim });++ assert.deepStrictEqual(r.json().content_labels, { labels: ['18+'], suggested: ['nudity', 'language'] });++ r = await t.get(api, { as: kim, method: 'PATCH', json: { labels: ['18+', 'nudity'] } });++ assert.deepStrictEqual(r.json().content_labels, { labels: ['18+', 'nudity'], suggested: ['language'] }, 'confirming nudity keeps the unconfirmed language suggestion');++ });++++ await check('unknown labels are refused, and only the uploader may set them', async () => {++ let r = await t.get(api, { as: kim, method: 'PATCH', json: { labels: ['gore'] } });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.json().code, 'pics.labels_invalid');++ r = await t.get(api, { method: 'PATCH', json: { labels: ['nudity'] } });++ assert.ok([401, 403].includes(r.status), 'signed out cannot label');++ r = await t.get(api, { as: lee, method: 'PATCH', json: { labels: [] } });++ assert.ok([403, 404].includes(r.status), 'someone else cannot label it');++ r = await t.get(api, { as: kim });++ assert.deepStrictEqual(r.json().content_labels, { labels: ['18+', 'nudity'], suggested: ['language'] }, 'refusals changed nothing');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- Content labels on pictures (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).
+--
+-- A picture's uploader labels it (PATCH /api/v1/images/:slug { labels }); the AI may suggest a label from what it
+-- sees, stored in `suggested` and never applied. A label is not a ban: OpenVibe.Pics never hides a picture because
+-- of it, and each viewer's Network label prefs decide what they see. The shape is `{ labels, suggested }`.
+--
+-- Expand only: an older release never reads the column, and the default keeps every existing picture label-free.
+ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;
+CREATE INDEX IF NOT EXISTS pics_images_content_labels ON pics_images USING gin (content_labels);
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+/**
+ * Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).
+ *
+ * The one label list and the shape a picture stores and returns: `{ labels, suggested }`. A creator sets a label;
+ * the AI may suggest one, and a suggestion never applies itself. A label is not a ban: OpenVibe.Pics never hides a
+ * picture because of it, and the viewer's Network prefs decide what they see.
+ */
+const LABELS = Object.freeze(['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']);
+
+const isLabel = (x) => typeof x === 'string' && LABELS.includes(x);
+
+/** A clean label list from a creator's request, or null when it is not a list of known labels. */
+function parseLabels(value) {
+ if (!Array.isArray(value)) return null;
+ const out = [];
+ for (const label of value) {
+ if (!isLabel(label)) return null;
+ if (!out.includes(label)) out.push(label);
+ }
+ return out;
+}
+
+/** The contract's shape from a jsonb column, a JSON string, or nothing: always two arrays of known labels. */
+function labelsOf(row) {
+ let raw = row && typeof row === 'object' && 'content_labels' in row ? row.content_labels : row;
+ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }
+ const pick = (v) => [...new Set((Array.isArray(v) ? v : []).filter(isLabel))];
+ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };
+}
+
+/** The shape to store. */
+const shape = (labels, suggested = []) => ({ labels: [...new Set((labels || []).filter(isLabel))], suggested: [...new Set((suggested || []).filter(isLabel))] });
+
+/** Apply a creator's labels and drop the ones they just confirmed from the suggestions. */
+function confirmed(current, labels) {
+ const now = labelsOf(current);
+ return { labels: [...labels], suggested: now.suggested.filter((l) => !labels.includes(l)) };
+}
+
+module.exports = { LABELS, isLabel, parseLabels, labelsOf, shape, confirmed };
@@ -28,6 +28,7 @@ const contracts = require('openvibe-contracts');
@@ -28,6 +28,7 @@ const contracts = require('openvibe-contracts');
const { asyncRouter } = require('../http/router');
const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
const store = require('./store');
const store = require('./store');
+const contentLabels = require('../labels');
function createPicsApi(ctx) {
function createPicsApi(ctx) {
const { config, s, principal, limits } = ctx;
const { config, s, principal, limits } = ctx;
@@ -98,7 +99,10 @@ function createPicsApi(ctx) {
@@ -98,7 +99,10 @@ function createPicsApi(ctx) {
const row = await loadOwn(req, res);
const row = await loadOwn(req, res);
if (!row) return undefined;
if (!row) return undefined;
const b = req.body || {};
const b = req.body || {};
- return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description, alt: b.alt }), { owner: true }));
+ // L1: the uploader labels the picture; a suggestion they confirm leaves `suggested`. A label never hides it.
+ const nextLabels = b.labels !== undefined ? contentLabels.parseLabels(b.labels) : undefined;
+ if (nextLabels === null) return problem(req, res, 422, 'pics.labels_invalid', `labels must be a list of: ${contentLabels.LABELS.join(', ')}`);
+ return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description, alt: b.alt, labels: nextLabels }), { owner: true }));
});
});
r.post('/images/:slug/describe', person, limits.budget('pics.image.describe'), async (req, res) => {
r.post('/images/:slug/describe', person, limits.budget('pics.image.describe'), async (req, res) => {
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const images = require('./images');
const images = require('./images');
const store = require('./store');
const store = require('./store');
+const contentLabels = require('../labels');
const { isStaff } = require('../drive/staff');
const { isStaff } = require('../drive/staff');
const SLUG_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';
const SLUG_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';
@@ -130,7 +131,7 @@ function createPics({ s, media, config, log = console, events = null }) {
@@ -130,7 +131,7 @@ function createPics({ s, media, config, log = console, events = null }) {
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };
}
}
- async function edit(row, { title, description, alt }) {
+ async function edit(row, { title, description, alt, labels: nextLabels }) {
// Alt text the uploader changes is theirs; an AI draft left as it was stays labelled as AI's.
// Alt text the uploader changes is theirs; an AI draft left as it was stays labelled as AI's.
const altNow = alt !== undefined ? cleanLine(alt, LIMITS.alt) : undefined;
const altNow = alt !== undefined ? cleanLine(alt, LIMITS.alt) : undefined;
const altPatch = altNow === undefined || altNow === (row.alt || '') ? {} : { alt: altNow, alt_source: altNow ? 'person' : null };
const altPatch = altNow === undefined || altNow === (row.alt || '') ? {} : { alt: altNow, alt_source: altNow ? 'person' : null };
@@ -138,6 +139,8 @@ function createPics({ s, media, config, log = console, events = null }) {
@@ -138,6 +139,8 @@ function createPics({ s, media, config, log = console, events = null }) {
...(title !== undefined ? { title: cleanLine(title, LIMITS.title) } : {}),
...(title !== undefined ? { title: cleanLine(title, LIMITS.title) } : {}),
...(description !== undefined ? { description: cleanText(description, LIMITS.description) } : {}),
...(description !== undefined ? { description: cleanText(description, LIMITS.description) } : {}),
...altPatch,
...altPatch,
+ // L1: the uploader sets the labels; a suggestion they confirm leaves `suggested`. A label never hides it.
+ ...(nextLabels !== undefined ? { content_labels: contentLabels.confirmed(row.content_labels, nextLabels) } : {}),
});
});
}
}
@@ -245,6 +248,8 @@ function createPics({ s, media, config, log = console, events = null }) {
@@ -245,6 +248,8 @@ function createPics({ s, media, config, log = console, events = null }) {
width: Number(row.width), height: Number(row.height), bytes: Number(row.bytes), animated: Boolean(row.animated),
width: Number(row.width), height: Number(row.height), bytes: Number(row.bytes), animated: Boolean(row.animated),
alt: row.alt || '', alt_source: row.alt_source || null,
alt: row.alt || '', alt_source: row.alt_source || null,
ai: row.ai_described_at ? { caption: row.ai_caption || null, tags: tagsOf(row), described_at: row.ai_described_at } : null,
ai: row.ai_described_at ? { caption: row.ai_caption || null, tags: tagsOf(row), described_at: row.ai_described_at } : null,
+ // L1 labels: `{ labels, suggested }` (common.content-labels@1). A suggestion is shown, never applied.
+ content_labels: contentLabels.labelsOf(row),
state: row.state, created_at: row.created_at, links: links(row),
state: row.state, created_at: row.created_at, links: links(row),
...(owner ? { report_count: Number(row.report_count) } : {}),
...(owner ? { report_count: Number(row.report_count) } : {}),
});
});
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* nobody but its uploader and staff (server/pics/service.js decides who).
* nobody but its uploader and staff (server/pics/service.js decides who).
*/
*/
-const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id', 'alt', 'alt_source', 'ai_caption', 'ai_tags', 'ai_described_at'];
+const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id', 'alt', 'alt_source', 'ai_caption', 'ai_tags', 'ai_described_at', 'content_labels'];
const imageBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_images WHERE slug = $1', [slug]);
const imageBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_images WHERE slug = $1', [slug]);
const imageById = (s, id) => s.db.maybe('SELECT * FROM pics_images WHERE id = $1', [id]);
const imageById = (s, id) => s.db.maybe('SELECT * FROM pics_images WHERE id = $1', [id]);
@@ -183,6 +183,7 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -183,6 +183,7 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
deleteVod: (token, id) => asPerson(token, 'DELETE', `/api/vods/${id}`),
deleteVod: (token, id) => asPerson(token, 'DELETE', `/api/vods/${id}`),
setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),
setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),
setClipVisibility: (token, id, visibility) => asPerson(token, 'PUT', `/api/clips/${id}/visibility`, { visibility }),
setClipVisibility: (token, id, visibility) => asPerson(token, 'PUT', `/api/clips/${id}/visibility`, { visibility }),
+ setClipLabels: (token, id, labels) => asPerson(token, 'PUT', `/api/clips/${id}/labels`, { labels }),
deleteClip: (token, id) => asPerson(token, 'DELETE', `/api/clips/${id}`),
deleteClip: (token, id) => asPerson(token, 'DELETE', `/api/clips/${id}`),
/** A clip of a recording, cut by Media: { vod_id, start_time, end_time, title } → { clip } (Live's rules and limits). */
/** A clip of a recording, cut by Media: { vod_id, start_time, end_time, title } → { clip } (Live's rules and limits). */
createClip: (token, fields, viewerIp) => asPerson(token, 'POST', '/api/vods/clips', fields, { viewerIp }),
createClip: (token, fields, viewerIp) => asPerson(token, 'POST', '/api/vods/clips', fields, { viewerIp }),
@@ -37,6 +37,7 @@ const { createLiveClient, LiveRefusedError } = require('./live-client');
@@ -37,6 +37,7 @@ const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createChatClient } = require('./chat-client');
const { createChatClient } = require('./chat-client');
const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');
const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');
const rules = require('../drive/rules');
const rules = require('../drive/rules');
+const contentLabels = require('../labels');
/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */
/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */
const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const { createVideoApiRoutes } = require('./api');
const { createVideoApiRoutes } = require('./api');
@@ -1422,7 +1423,10 @@ ${up.html}
@@ -1422,7 +1423,10 @@ ${up.html}
if (!title || title.length > 200) return { error: 'A title is 1 to 200 characters.' };
if (!title || title.length > 200) return { error: 'A title is 1 to 200 characters.' };
if (kind === 'vod' && description.length > 2000) return { error: 'A description is at most 2,000 characters.' };
if (kind === 'vod' && description.length > 2000) return { error: 'A description is at most 2,000 characters.' };
if (!VISIBILITIES.includes(visibility)) return { error: 'Choose public, unlisted or private.' };
if (!VISIBILITIES.includes(visibility)) return { error: 'Choose public, unlisted or private.' };
- return { title, description, visibility };
+ // L1: the creator labels the recording. Repeated form fields arrive as an array; absent keeps what it has.
+ const labels = b.labels === undefined ? undefined : contentLabels.parseLabels([].concat(b.labels));
+ if (labels === null) return { error: 'Choose the labels from the list.' };
+ return { title, description, visibility, labels };
}
}
const fieldError = (req, res, text, back) => page(req, res, {
const fieldError = (req, res, text, back) => page(req, res, {
title: 'Not saved', cache: cache.htmlHeaders({ private: true }),
title: 'Not saved', cache: cache.htmlHeaders({ private: true }),
@@ -1459,7 +1463,7 @@ ${up.html}
@@ -1459,7 +1463,7 @@ ${up.html}
const f = ownerFields(req.body || {}, 'vod');
const f = ownerFields(req.body || {}, 'vod');
if (f.error) return fieldError(req, res, f.error, back);
if (f.error) return fieldError(req, res, f.error, back);
try {
try {
- await asPerson(req, res, (token) => live.updateVod(token, id, { title: f.title, description: f.description, visibility: f.visibility }));
+ await asPerson(req, res, (token) => live.updateVod(token, id, { title: f.title, description: f.description, visibility: f.visibility, ...(f.labels !== undefined ? { labels: f.labels } : {}) }));
} catch (err) { return ownerRefused(req, res, err, back); }
} catch (err) { return ownerRefused(req, res, err, back); }
live.forget('vod', id);
live.forget('vod', id);
return res.redirect(303, `${back}?saved=1`);
return res.redirect(303, `${back}?saved=1`);
@@ -1496,6 +1500,7 @@ ${up.html}
@@ -1496,6 +1500,7 @@ ${up.html}
try {
try {
if (f.title !== String(current.title || '')) await asPerson(req, res, (token) => live.setClipTitle(token, id, f.title));
if (f.title !== String(current.title || '')) await asPerson(req, res, (token) => live.setClipTitle(token, id, f.title));
if (f.visibility !== String(current.visibility || '')) await asPerson(req, res, (token) => live.setClipVisibility(token, id, f.visibility));
if (f.visibility !== String(current.visibility || '')) await asPerson(req, res, (token) => live.setClipVisibility(token, id, f.visibility));
+ if (f.labels !== undefined) await asPerson(req, res, (token) => live.setClipLabels(token, id, f.labels));
} catch (err) { live.forget('clip', id); return ownerRefused(req, res, err, back); }
} catch (err) { live.forget('clip', id); return ownerRefused(req, res, err, back); }
live.forget('clip', id);
live.forget('clip', id);
return res.redirect(303, `${back}?saved=1`);
return res.redirect(303, `${back}?saved=1`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+// Content labels on pictures (L1, 2026-10-31; Contracts 0.257.0 common.content-labels@1): the uploader labels a
+// picture, the AI's `suggested` stays a suggestion until confirmed, every read carries the shape, and a label never
+// hides the picture (the viewer's Network prefs decide).
+// node test/content-labels.test.js
+const assert = require('assert');
+const http = require('http');
+const sharp = require('sharp');
+const { boot, check, done } = require('./helpers/boot');
+
+const PICS = { Host: 'openvibe.pics' };
+const SAME = { ...PICS, 'sec-fetch-site': 'same-origin' };
+
+/** t.get with the Pics Host header (Node's fetch drops Host). */
+function hostClient(t) {
+ return (p, o = {}) => new Promise((resolve, reject) => {
+ const headers = { ...SAME, ...(o.headers || {}) };
+ if (o.as) headers.cookie = t.signIn(o.as);
+ let body = o.body;
+ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }
+ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));
+ const u = new URL(t.base + p);
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8');
+ resolve({ status: res.statusCode, text, json() { return JSON.parse(text); } });
+ });
+ });
+ req.on('error', reject);
+ if (body != null) req.write(body);
+ req.end();
+ });
+}
+
+(async () => {
+ const t0 = await boot();
+ const t = { ...t0, get: hostClient(t0) };
+ const photo = () => sharp({ create: { width: 64, height: 48, channels: 3, background: '#335' } }).jpeg().toBuffer();
+ try {
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+ const up = await t.get('/api/v1/images', { as: kim, method: 'POST', body: await photo(), headers: { ...SAME, 'content-type': 'image/jpeg' } });
+ assert.strictEqual(up.status, 201, up.text);
+ const slug = up.json().slug;
+ const api = `/api/v1/images/${slug}`;
+
+ await check('a picture is label-free by default, and the uploader sets labels', async () => {
+ let r = await t.get(api, { as: kim });
+ assert.deepStrictEqual(r.json().content_labels, { labels: [], suggested: [] });
+ r = await t.get(api, { as: kim, method: 'PATCH', json: { labels: ['nudity', '18+'] } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json().content_labels, { labels: ['nudity', '18+'], suggested: [] });
+ r = await t.get(api, { as: lee });
+ assert.deepStrictEqual(r.json().content_labels, { labels: ['nudity', '18+'], suggested: [] }, 'anyone sees the labels on a public picture');
+ assert.strictEqual(r.json().state, 'active', 'a label never hides the picture');
+ });
+
+ await check('a suggestion is stored as suggested and only the confirmed one leaves', async () => {
+ await t.ctx.s.db.exec(`UPDATE pics_images SET content_labels = '{"labels":["18+"],"suggested":["nudity","language"]}' WHERE slug = $1`, [slug]);
+ let r = await t.get(api, { as: kim });
+ assert.deepStrictEqual(r.json().content_labels, { labels: ['18+'], suggested: ['nudity', 'language'] });
+ r = await t.get(api, { as: kim, method: 'PATCH', json: { labels: ['18+', 'nudity'] } });
+ assert.deepStrictEqual(r.json().content_labels, { labels: ['18+', 'nudity'], suggested: ['language'] }, 'confirming nudity keeps the unconfirmed language suggestion');
+ });
+
+ await check('unknown labels are refused, and only the uploader may set them', async () => {
+ let r = await t.get(api, { as: kim, method: 'PATCH', json: { labels: ['gore'] } });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.json().code, 'pics.labels_invalid');
+ r = await t.get(api, { method: 'PATCH', json: { labels: ['nudity'] } });
+ assert.ok([401, 403].includes(r.status), 'signed out cannot label');
+ r = await t.get(api, { as: lee, method: 'PATCH', json: { labels: [] } });
+ assert.ok([403, 404].includes(r.status), 'someone else cannot label it');
+ r = await t.get(api, { as: kim });
+ assert.deepStrictEqual(r.json().content_labels, { labels: ['18+', 'nudity'], suggested: ['language'] }, 'refusals changed nothing');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });