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)
Changes
nnew file mode 100644@@@ -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); });@@@ -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;@@@ -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 });@@@ -405,7 +405,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limconst 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 = {}, limif (!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');@@@ -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')));});@@@ -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 completelet 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]);nnew file mode 100644@@@ -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');@@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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); });
@@ -137,7 +137,17 @@ function createApp(opts = {}) {
@@ -137,7 +137,17 @@ function createApp(opts = {}) {
if (community) {
if (community) {
const { createMediaFiles } = require('./media/files');
const { createMediaFiles } = require('./media/files');
const { createPasteService } = require('./pastes/service');
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 });
const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits, pulse });
source.use(service);
source.use(service);
app.locals.pastes = service;
app.locals.pastes = service;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
'use strict';
'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
* `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,
* the person who attached them (X-OV-Subject), with Community's service token (media.object.upload,
* namespace community):
* 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
* PUT /api/v2/community/objects/:id/content the bytes
* POST /api/v2/community/objects/:id/complete Media checks size, hash and type → ready
* POST /api/v2/community/objects/:id/complete Media checks size, hash and type → ready
*
*
@@ -41,13 +41,18 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -41,13 +41,18 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return data || {};
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; }
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 sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
const init = await call('POST', '', {
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);
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 });
if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw Object.assign(new Error('Media returned no object id'), { status: 502 });
@@ -405,7 +405,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -405,7 +405,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const bytes = stripImageMetadata(file.buffer, file.mimetype);
const bytes = stripImageMetadata(file.buffer, file.mimetype);
let stored;
let stored;
try {
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) {
} catch (err) {
console.warn('[Pastes] screenshot upload failed:', err.message);
console.warn('[Pastes] screenshot upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
@@ -635,7 +635,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -635,7 +635,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!media) fail(503, 'Media service unavailable');
if (!media) fail(503, 'Media service unavailable');
let stored;
let stored;
try {
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) {
} catch (err) {
console.warn('[Pastes] censor upload failed:', err.message);
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
@@ -87,7 +87,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
@@ -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.match(first.media_id, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.strictEqual(first.url, `https://openvibe.media/o/${first.media_id}`);
assert.strictEqual(first.url, `https://openvibe.media/o/${first.media_id}`);
const o = objects.get(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(!o.bytes.includes(Buffer.from('secret')), 'the tEXt chunk never reached Media');
assert.ok(PNG.includes(Buffer.from('secret')));
assert.ok(PNG.includes(Buffer.from('secret')));
});
});
@@ -1,15 +1,17 @@
@@ -1,15 +1,17 @@
'use strict';
'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 http = require('http');
const crypto = require('crypto');
const crypto = require('crypto');
-const { serviceAuth } = require('openvibe-contracts');
+const { serviceAuth, ids } = require('openvibe-contracts');
function start({ publicPem, issuer }) {
function start({ publicPem, issuer }) {
const uploads = [];
const uploads = [];
+ const pending = new Map(); // v2 objects between init and complete
let failNext = false;
let failNext = false;
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const chunks = [];
const chunks = [];
@@ -17,12 +19,33 @@ function start({ publicPem, issuer }) {
@@ -17,12 +19,33 @@ function start({ publicPem, issuer }) {
req.on('end', () => {
req.on('end', () => {
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
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 === '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' });
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.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 (!(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);
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.
// Pull the `file` part out of the multipart body.
const m = String(req.headers['content-type']).match(/boundary=(?:"([^"]+)"|([^;]+))/);
const m = String(req.headers['content-type']).match(/boundary=(?:"([^"]+)"|([^;]+))/);
const boundary = m && (m[1] || m[2]);
const boundary = m && (m[1] || m[2]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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');
@@ -248,14 +248,16 @@ const { boot, check, done } = require('./helpers/app');
@@ -248,14 +248,16 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(out.slug, p.slug);
assert.strictEqual(out.slug, p.slug);
assert.strictEqual(p.type, 'screenshot');
assert.strictEqual(p.type, 'screenshot');
assert.strictEqual(p.owner_subject, alex.subject_id);
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.title, 'My desk');
assert.strictEqual(p.content, 'look');
assert.strictEqual(p.content, 'look');
const meta = JSON.parse(p.metadata);
const meta = JSON.parse(p.metadata);
assert.strictEqual(meta.page_url, 'https://example.com/x');
assert.strictEqual(meta.page_url, 'https://example.com/x');
assert.strictEqual(meta.original_name, 'desk.jpg');
assert.strictEqual(meta.original_name, 'desk.jpg');
const row = t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);
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;
shotSlug = p.slug;
const viaCreate = new FormData();
const viaCreate = new FormData();
viaCreate.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'b.jpg');
viaCreate.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'b.jpg');
@@ -287,7 +289,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -287,7 +289,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(api.headers.get('location'), `/p/${alexSlug}/raw`);
assert.strictEqual(api.headers.get('location'), `/p/${alexSlug}/raw`);
const shot = await call(`/p/${shotSlug}/screenshot`);
const shot = await call(`/p/${shotSlug}/screenshot`);
assert.strictEqual(shot.status, 302);
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/${shotSlug}/raw`)).headers.get('location'), `/p/${shotSlug}/screenshot`);
assert.strictEqual((await call(`/p/${alexSlug}/screenshot`)).status, 404);
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;
const burn = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'once', burn_after_read: true } })).json().slug;