openvibers/openvibe.community Commits

C-24: screenshot pastes name media objects. New screenshots (and censored replacements) are OpenVibe.Media v2 objects (kind screenshot, unlisted, owned by the person when signed in; the v1 file store only without Community's service principal), with media_ref med_…; forum images are unlisted too (reachable by link, never listed on openvibe.media). scripts/migrate-screenshot-refs.js moves existing legacy:live:paste / legacy:community:file references to their med_ objects from Media's export (dry run by default, --apply takes a backup first, keeps screenshot_url, idempotent)

OP OpenVibers committed 25 Sept 2026 · 53554230c3 · 8 files, +175 −19


Changes

ddiff --git a/scripts/migrate-screenshot-refs.js b/scripts/migrate-screenshot-refs.js
nnew file mode 100644
iindex 0000000..c5a6123
---- /dev/null
++++ b/scripts/migrate-screenshot-refs.js
@@@ -0,0 +1,66 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * C-24: screenshot pastes stop naming their Media bytes by a legacy reference and name the media object.
++ * Pastes imported from Live carry `legacy:live:paste:<Live id>`; Media knows the same screenshot as the
++ * object whose legacy_ref is `legacy:live:paste:<slug>`. Pastes uploaded to the v1 file store before the
++ * v2 switch carry `legacy:community:file:<key>`, looked up as they are.
++ *
++ * node scripts/export-legacy-refs.js --prefix legacy:live:paste: --out refs.json # on Media (read-only)
++ * node scripts/migrate-screenshot-refs.js --map refs.json # dry run (default)
++ * node scripts/migrate-screenshot-refs.js --map refs.json --apply --backup <file.db> # write
++ *
++ * --map <file> Media's export ({ objects: [{ legacy_ref, id, lifecycle_status }] }); several with commas
++ * --apply write; without it nothing changes and the report shows what would
++ * --backup <file> required with --apply: an online backup of Community's database, taken first
++ * --db <file> Community's database (default: COMMUNITY_DB_PATH / the config)
++ * --report <file> write the report as JSON too
++ *
++ * Only the reference changes (screenshot_url stays, so every page keeps working). Objects Media reports
++ * deleted or failed, and pastes with no object, keep their legacy reference and are listed. Idempotent.
++ * Exit codes: 0 done (or dry run), 2 refused (bad options, backup failed).
++ */
++const fs = require('fs');
++const path = require('path');
++
++const args = process.argv.slice(2);
++const arg = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
++const has = (name) => args.includes(`--${name}`);
++
++async function main() {
++ const mapFiles = String(arg('map', '')).split(',').filter(Boolean);
++ const apply = has('apply');
++ const backup = arg('backup');
++ if (!mapFiles.length || (apply && !backup)) { console.error('usage: migrate-screenshot-refs.js --map <refs.json>[,<more>] [--apply --backup <file.db>] [--db <file>] [--report <file>]'); return 2; }
++ const byRef = new Map();
++ for (const f of mapFiles) for (const o of JSON.parse(fs.readFileSync(f, 'utf8')).objects || []) byRef.set(o.legacy_ref, o);
++
++ const { openDb } = require('../server/db');
++ const dbPath = path.resolve(arg('db', process.env.COMMUNITY_DB_PATH || require('../server/config').dbPath));
++ const db = openDb(dbPath);
++ const rows = db.prepare("SELECT id, slug, media_ref FROM pastes WHERE type = 'screenshot' AND media_ref LIKE 'legacy:%' ORDER BY id").all();
++ const report = { db: dbPath, apply, candidates: rows.length, migrated: 0, would_migrate: 0, no_object: [], not_ready: [], changes: [] };
++ const plan = [];
++ for (const r of rows) {
++ const key = /^legacy:live:paste:/.test(r.media_ref) ? `legacy:live:paste:${r.slug}` : r.media_ref;
++ const o = byRef.get(key);
++ if (!o) { report.no_object.push({ slug: r.slug, media_ref: r.media_ref }); continue; }
++ if (!['ready', 'archived'].includes(o.lifecycle_status) || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(o.id)) { report.not_ready.push({ slug: r.slug, media_ref: r.media_ref, object: o.id, status: o.lifecycle_status }); continue; }
++ plan.push({ id: r.id, slug: r.slug, from: r.media_ref, to: o.id });
++ }
++ report.changes = plan.slice(0, 20);
++ if (!apply) report.would_migrate = plan.length;
++ else {
++ try { await db.backup(path.resolve(backup)); } catch (err) { console.error(`[migrate-screenshot-refs] backup failed: ${err.message}`); return 2; }
++ const set = db.prepare("UPDATE pastes SET media_ref = ? WHERE id = ? AND media_ref = ?");
++ report.migrated = db.transaction(() => plan.reduce((n, p) => n + set.run(p.to, p.id, p.from).changes, 0))();
++ report.backup = path.resolve(backup);
++ }
++ const summary = { ...report, no_object: report.no_object.length, not_ready: report.not_ready.length, changes: undefined };
++ console.log(JSON.stringify(summary, null, 1));
++ if (arg('report')) fs.writeFileSync(arg('report'), JSON.stringify(report, null, 1));
++ db.close();
++ return 0;
++}
++
++main().then((code) => process.exit(code), (err) => { console.error(err); process.exit(1); });
ddiff --git a/server/app.js b/server/app.js
iindex 958d546..07ed708 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -137,7 +137,17 @@ function createApp(opts = {}) {
if (community) {
const { createMediaFiles } = require('./media/files');
const { createPasteService } = require('./pastes/service');
-- const media = opts.media || createMediaFiles({ config });
++ // Screenshot bytes: OpenVibe.Media's Object API v2 (med_ objects, unlisted, owned by the person when signed in;
++ // C-24), or the v1 community file store (legacy:community:file:<key>) without Community's service principal.
++ const files = createMediaFiles({ config });
++ const media = opts.media || {
++ tokens: files.tokens,
++ async upload({ buffer, filename, mime, owner = null }) {
++ if (!mediaObjects.configured) return files.upload({ buffer, filename, mime });
++ const o = await mediaObjects.uploadImage({ buffer, mime, filename, owner, kind: 'screenshot', source: 'community.paste' });
++ return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id };
++ },
++ };
const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits, pulse });
source.use(service);
app.locals.pastes = service;
ddiff --git a/server/media/objects.js b/server/media/objects.js
iindex ef2f550..9d2bf8b 100644
---- a/server/media/objects.js
++++ b/server/media/objects.js
@@@ -1,12 +1,12 @@
'use strict';
/**
-- * OpenVibe.Media Object API v2 client — attachments on forum posts (WS-J task 2). Community keeps a
++ * OpenVibe.Media Object API v2 client — images on forum posts (WS-J task 2) and screenshot pastes (C-24). Community keeps a
* `med_` reference and the public URL; the bytes live in Media under the `community` tenant, owned by
* the person who attached them (X-OV-Subject), with Community's service token (media.object.upload,
* namespace community):
*
-- * POST /api/v2/community/objects init { kind: 'file', visibility: 'public', size_bytes, mime_type, filename, content_hash }
++ * POST /api/v2/community/objects init { kind, visibility: 'unlisted', size_bytes, mime_type, filename, content_hash }
* PUT /api/v2/community/objects/:id/content the bytes
* POST /api/v2/community/objects/:id/complete Media checks size, hash and type → ready
*
@@@ -41,13 +41,18 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return data || {};
}
-- /** Store one image for `owner` (usr_…). → { id (med_…), url, size_bytes, mime } */
-- async function uploadImage({ buffer, mime, filename, owner }) {
++ /**
++ * Store one image. → { id (med_…), url, size_bytes, mime }
++ * owner: the person (usr_…) the object belongs to, when there is one; kind: 'file' (post images) or
++ * 'screenshot' (screenshot pastes); visibility 'unlisted': served to anyone with the link (the object id is
++ * unguessable), never listed on openvibe.media — the post or paste decides who sees the link.
++ */
++ async function uploadImage({ buffer, mime, filename, owner = null, kind = 'file', visibility = 'unlisted', source = 'community.attachment' }) {
if (!configured) { const e = new Error('Attachments need Community\'s service principal (OV_OAUTH_CLIENT_SECRET)'); e.status = 503; throw e; }
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
const init = await call('POST', '', {
-- json: { kind: 'file', visibility: 'public', size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, metadata: { source: 'community.attachment' } },
-- headers: { 'X-OV-Subject': owner },
++ json: { kind, visibility, size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, metadata: { source } },
++ headers: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ? { 'X-OV-Subject': owner } : {},
});
const id = init.id || (init.object && init.object.id);
if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw Object.assign(new Error('Media returned no object id'), { status: 502 });
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 79cffc7..e9712a7 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -405,7 +405,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const bytes = stripImageMetadata(file.buffer, file.mimetype);
let stored;
try {
-- stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime: file.mimetype });
++ stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime: file.mimetype, owner: v.subject || null });
} catch (err) {
console.warn('[Pastes] screenshot upload failed:', err.message);
fail(502, 'Media service unavailable');
@@@ -635,7 +635,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!media) fail(503, 'Media service unavailable');
let stored;
try {
-- stored = await media.upload({ buffer: stripImageMetadata(file.buffer, file.mimetype), filename: file.originalname || 'censored.png', mime: file.mimetype });
++ stored = await media.upload({ buffer: stripImageMetadata(file.buffer, file.mimetype), filename: file.originalname || 'censored.png', mime: file.mimetype, owner: p.owner_subject || null });
} catch (err) {
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
iindex f52f1ac..c1fc001 100644
---- a/test/attachments.test.js
++++ b/test/attachments.test.js
@@@ -87,7 +87,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
assert.match(first.media_id, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.strictEqual(first.url, `https://openvibe.media/o/${first.media_id}`);
const o = objects.get(first.media_id);
-- assert.strictEqual(o.owner, alex.subject_id); assert.strictEqual(o.visibility, 'public'); assert.strictEqual(o.status, 'ready');
++ assert.strictEqual(o.owner, alex.subject_id); assert.strictEqual(o.visibility, 'unlisted', 'reachable by link, never listed on openvibe.media'); assert.strictEqual(o.status, 'ready');
assert.ok(!o.bytes.includes(Buffer.from('secret')), 'the tEXt chunk never reached Media');
assert.ok(PNG.includes(Buffer.from('secret')));
});
ddiff --git a/test/helpers/mock-media.js b/test/helpers/mock-media.js
iindex e9f7d6d..35c0498 100644
---- a/test/helpers/mock-media.js
++++ b/test/helpers/mock-media.js
@@@ -1,15 +1,17 @@
'use strict';
/**
-- * A stand-in for OpenVibe.Media's file store: POST /api/v1/community/files (multipart `file`)
-- * with a Network service token for openvibe.media holding media.object.upload. Keeps each
-- * upload's raw bytes so tests can check what left Community.
++ * A stand-in for OpenVibe.Media: the v1 file store (POST /api/v1/community/files, multipart `file`) and the
++ * Object API v2 upload (POST /api/v2/community/objects → PUT /:id/content → POST /:id/complete), with a
++ * Network service token for openvibe.media holding media.object.upload. Keeps each finished upload's raw
++ * bytes (and, for v2, its kind, visibility and owner) so tests can check what left Community.
*/
const http = require('http');
const crypto = require('crypto');
--const { serviceAuth } = require('openvibe-contracts');
++const { serviceAuth, ids } = require('openvibe-contracts');
function start({ publicPem, issuer }) {
const uploads = [];
++ const pending = new Map(); // v2 objects between init and complete
let failNext = false;
const server = http.createServer((req, res) => {
const chunks = [];
@@@ -17,12 +19,33 @@ function start({ publicPem, issuer }) {
req.on('end', () => {
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (req.method === 'GET' && req.url === '/healthz') return json(200, { ok: true, service: 'mock-media' });
-- if (req.method !== 'POST' || req.url !== '/api/v1/community/files') return json(404, { error: 'not found' });
++ const v2 = req.url.match(/^\/api\/v2\/community\/objects(?:\/(med_[0-9A-Z]+)\/(content|complete))?$/);
++ if (!v2 && (req.method !== 'POST' || req.url !== '/api/v1/community/files')) return json(404, { error: 'not found' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
if (!v.ok) return json(401, { code: v.code, error: v.reason });
if (!(v.claims.cap || []).includes('media.object.upload')) return json(403, { code: 'capability.denied', error: 'not granted' });
-- if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
const body = Buffer.concat(chunks);
++ if (v2) {
++ if (req.method === 'POST' && !v2[1]) {
++ if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
++ const b = JSON.parse(body.toString() || '{}');
++ const id = ids.newId('media');
++ pending.set(id, { ...b, owner: req.headers['x-ov-subject'] || null });
++ return json(201, { id, object: { id } });
++ }
++ const o = pending.get(v2[1]);
++ if (!o) return json(404, { code: 'media.object.not_found' });
++ if (req.method === 'PUT' && v2[2] === 'content') { o.bytes = body; o.mime = String(req.headers['content-type'] || ''); return json(200, { id: v2[1], size_bytes: body.length }); }
++ if (req.method === 'POST' && v2[2] === 'complete') {
++ const hash = crypto.createHash('sha256').update(o.bytes || Buffer.alloc(0)).digest('hex');
++ if (hash !== o.content_hash) return json(400, { code: 'media.object.hash_mismatch' });
++ pending.delete(v2[1]);
++ uploads.push({ key: v2[1], name: o.filename || 'upload.bin', mime: o.mime, bytes: o.bytes, claims: v.claims, kind: o.kind, visibility: o.visibility, owner: o.owner, v2: true });
++ return json(200, { id: v2[1], lifecycle_status: 'ready' });
++ }
++ return json(404, { error: 'not found' });
++ }
++ if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
// Pull the `file` part out of the multipart body.
const m = String(req.headers['content-type']).match(/boundary=(?:"([^"]+)"|([^;]+))/);
const boundary = m && (m[1] || m[2]);
ddiff --git a/test/migrate-screenshot-refs.test.js b/test/migrate-screenshot-refs.test.js
nnew file mode 100644
iindex 0000000..88ac5dc
---- /dev/null
++++ b/test/migrate-screenshot-refs.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++/**
++ * scripts/migrate-screenshot-refs.js (C-24): a dry run changes nothing; --apply needs --backup and takes it
++ * first; imported pastes (legacy:live:paste:<Live id>) are matched by slug, v1 uploads by their reference;
++ * deleted objects and pastes with no object keep their legacy reference; running it again changes nothing.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { execFileSync } = require('child_process');
++const { openDb } = require('../server/db');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-c24-'));
++const dbPath = path.join(dir, 'community.db');
++const db = openDb(dbPath);
++const ins = db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url, media_ref) VALUES (?, 'screenshot', 'Shot', ?, ?)");
++ins.run('a1', 'https://openvibe.media/p/a1/screenshot', 'legacy:live:paste:2');
++ins.run('a2', 'https://openvibe.media/p/a2/screenshot', 'legacy:live:paste:4');
++ins.run('a3', 'https://openvibe.media/p/a3/screenshot', 'legacy:live:paste:9');
++ins.run('k1', 'https://openvibe.media/f/k1', 'legacy:community:file:k1');
++db.prepare("INSERT INTO pastes (slug, type, title, content) VALUES ('t1', 'paste', 'Text', 'x')").run();
++db.close();
++const M1 = 'med_01KKDDF5T0R3JNZSTEBW0X4AH5', M2 = 'med_01KKDDZWZ0SG9ZQ3ABAKDDSGWG', M3 = 'med_01KKDMS07GK1BS5KWRXWC6NAV8';
++const map = path.join(dir, 'refs.json');
++fs.writeFileSync(map, JSON.stringify({ objects: [
++ { legacy_ref: 'legacy:live:paste:a1', id: M1, lifecycle_status: 'ready' },
++ { legacy_ref: 'legacy:live:paste:a2', id: M2, lifecycle_status: 'deleted' },
++ { legacy_ref: 'legacy:community:file:k1', id: M3, lifecycle_status: 'ready' },
++] }));
++const SCRIPT = path.join(__dirname, '..', 'scripts', 'migrate-screenshot-refs.js');
++const run = (...extra) => {
++ try { return { code: 0, out: JSON.parse(execFileSync(process.execPath, [SCRIPT, '--db', dbPath, '--map', map, ...extra], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })) }; } catch (err) { return { code: err.status, out: null }; }
++};
++const refs = () => { const d = openDb(dbPath); const r = Object.fromEntries(d.prepare('SELECT slug, media_ref FROM pastes ORDER BY id').all().map((x) => [x.slug, x.media_ref])); d.close(); return r; };
++
++let r = run();
++assert.strictEqual(r.code, 0);
++assert.deepStrictEqual([r.out.candidates, r.out.would_migrate, r.out.no_object, r.out.not_ready, r.out.migrated], [4, 2, 1, 1, 0]);
++assert.strictEqual(refs().a1, 'legacy:live:paste:2', 'a dry run changes nothing');
++assert.strictEqual(run('--apply').code, 2, '--apply without --backup is refused');
++const backup = path.join(dir, 'before.db');
++r = run('--apply', '--backup', backup);
++assert.strictEqual(r.code, 0); assert.strictEqual(r.out.migrated, 2);
++assert.ok(fs.existsSync(backup) && fs.statSync(backup).size > 0, 'the backup was taken');
++assert.deepStrictEqual(refs(), { a1: M1, a2: 'legacy:live:paste:4', a3: 'legacy:live:paste:9', k1: M3, t1: null });
++r = run('--apply', '--backup', path.join(dir, 'again.db'));
++assert.deepStrictEqual([r.out.candidates, r.out.migrated], [2, 0], 'idempotent');
++fs.rmSync(dir, { recursive: true, force: true });
++console.log('migrate-screenshot-refs: all checks passed');
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex 59caed2..6d4d710 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -248,14 +248,16 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(out.slug, p.slug);
assert.strictEqual(p.type, 'screenshot');
assert.strictEqual(p.owner_subject, alex.subject_id);
-- assert.strictEqual(p.screenshot_url, `https://openvibe.media/f/${up.key}`);
++ assert.ok(up.v2 && up.kind === 'screenshot' && up.visibility === 'unlisted', 'a v2 Media object: unlisted screenshot (C-24)');
++ assert.strictEqual(up.owner, alex.subject_id, 'owned by the person');
++ assert.strictEqual(p.screenshot_url, `https://openvibe.media/o/${up.key}`);
assert.strictEqual(p.title, 'My desk');
assert.strictEqual(p.content, 'look');
const meta = JSON.parse(p.metadata);
assert.strictEqual(meta.page_url, 'https://example.com/x');
assert.strictEqual(meta.original_name, 'desk.jpg');
const row = t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);
-- assert.strictEqual(row.media_ref, `legacy:community:file:${up.key}`);
++ assert.match(row.media_ref, /^med_[0-9A-HJKMNP-TV-Z]{26}$/); assert.strictEqual(row.media_ref, up.key);
shotSlug = p.slug;
const viaCreate = new FormData();
viaCreate.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'b.jpg');
@@@ -287,7 +289,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(api.headers.get('location'), `/p/${alexSlug}/raw`);
const shot = await call(`/p/${shotSlug}/screenshot`);
assert.strictEqual(shot.status, 302);
-- assert.match(shot.headers.get('location'), /^https:\/\/openvibe\.media\/f\//);
++ assert.match(shot.headers.get('location'), /^https:\/\/openvibe\.media\/o\/med_/);
assert.strictEqual((await call(`/p/${shotSlug}/raw`)).headers.get('location'), `/p/${shotSlug}/screenshot`);
assert.strictEqual((await call(`/p/${alexSlug}/screenshot`)).status, 404);
const burn = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'once', burn_after_read: true } })).json().slug;