openvibers/openvibe.media Commits

Decouple avatar ingest from server/pastes/routes.js — report

OP OpenVibers committed 1 Oct 2026 · 90c4ee2cf3 · 6 files, +152 −58

Done by the harness job media-decouple-avatar-ingest-from-server-193103.

Changes

ddiff --git a/node_modules b/node_modules
nnew file mode 120000
iindex 0000000..180fdee
---- /dev/null
++++ b/node_modules
@@@ -0,0 +1 @@
++/home/workstation/OpenVibers/OpenVibe.Media/node_modules
\\ No newline at end of file
ddiff --git a/server/avatars/ingest.js b/server/avatars/ingest.js
iindex 1452e1b..8a3b125 100644
---- a/server/avatars/ingest.js
++++ b/server/avatars/ingest.js
@@@ -33,6 +33,11 @@ const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE
const egress = require('openvibe-shared/egress');
const isPublicAddress = (ip) => egress.isPublicAddress(ip);
++// Where avatars land and how their slug is minted: the paste storage module
++// (server/pastes/storage.js), not the paste router. The defaults keep the exact
++// persisted path and slug behavior; tests may inject their own.
++const pasteStorage = require('../pastes/storage');
++
async function resolvePublic(hostname) {
const host = egress.normalizeHost(hostname);
if (net.isIP(host)) { if (!isPublicAddress(host)) throw new Error('That address is not on the public internet'); return { address: host, family: net.isIPv6(host) ? 6 : 4 }; }
@@@ -93,7 +98,7 @@ async function toAvatar(buf) {
return { buffer: out, width: SIZE, height: SIZE };
}
--function createIngestHandler({ db, config, screenshotsDir, generateSlug, log = console }) {
++function createIngestHandler({ db, config, screenshotsDir = pasteStorage.SCREENSHOTS_DIR, generateSlug = pasteStorage.generateSlug, log = console }) {
return async function avatarIngest(req, res) {
try {
const { url, user_id, username } = req.body || {};
ddiff --git a/server/index.js b/server/index.js
iindex 4a5beb1..36e8a52 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -104,12 +104,12 @@ const ready = (async () => {
app.get('/manifest.webmanifest', (_req, res) => res.type('application/manifest+json').sendFile(require('path').join(__dirname, 'brand', 'manifest.webmanifest')));
// ── Internal: avatar ingestion (the Network asks; see server/avatars/ingest.js) ──
{
-- const pastes = require('./pastes/routes');
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the
-- // route (server/service-guard.js).
++ // route (server/service-guard.js). The handler takes its storage (screenshots dir, slug minting)
++ // from server/pastes/storage.js itself — nothing from the paste router is passed in.
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
-- require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config, screenshotsDir: pastes.SCREENSHOTS_DIR, generateSlug: pastes.generateSlug }));
++ require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));
}
const userAuth = require('./user-auth');
const userAuthConfig = {
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
iindex 4cbf154..8a2631e 100644
---- a/server/pastes/routes.js
++++ b/server/pastes/routes.js
@@@ -34,12 +34,10 @@
const express = require('express');
const crypto = require('crypto');
--const path = require('path');
const fs = require('fs');
const multer = require('multer');
const sharp = require('sharp');
const db = require('../db/database');
--const config = require('../config');
const { tenantAuth, tenantCors } = require('../auth');
const router = express.Router({ mergeParams: true });
@@@ -54,9 +52,8 @@ router.use((req, res, next) => {
next();
});
--// ── Screenshot upload storage ───────────────────────────────
--const SCREENSHOTS_DIR = path.join(config.pastes.path, 'screenshots');
--if (!require('../drill').enabled && !fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); // a restore drill writes nothing
++// ── Screenshot upload storage (shared with the avatar ingest: server/pastes/storage.js) ──
++const { SCREENSHOTS_DIR, generateSlug } = require('./storage');
const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };
const screenshotStorage = multer.diskStorage({
@@@ -77,55 +74,6 @@ const screenshotUpload = multer({
// ── Helpers ─────────────────────────────────────────────────
--// Word pools for readable paste slugs (adj-noun-number)
--const SLUG_ADJECTIVES = [
-- 'amber', 'blue', 'bold', 'brave', 'bright', 'calm', 'clean', 'clever',
-- 'cold', 'cool', 'coral', 'crisp', 'dark', 'dawn', 'deep', 'dry',
-- 'dusk', 'dusty', 'fair', 'fast', 'fierce', 'fine', 'foggy', 'free',
-- 'fresh', 'frost', 'glad', 'gold', 'grand', 'gray', 'green', 'grim',
-- 'hazy', 'heavy', 'hidden', 'hollow', 'honey', 'hot', 'icy', 'iron',
-- 'jade', 'keen', 'kind', 'late', 'lazy', 'light', 'lime', 'lit',
-- 'lone', 'lost', 'loud', 'lucky', 'lush', 'mild', 'misty', 'mossy',
-- 'muddy', 'neon', 'new', 'noble', 'odd', 'old', 'opal', 'open',
-- 'pale', 'pink', 'plain', 'plum', 'prime', 'proud', 'pure', 'quick',
-- 'quiet', 'rare', 'raw', 'red', 'rich', 'rocky', 'rosy', 'rough',
-- 'ruby', 'rusty', 'safe', 'sage', 'sandy', 'sharp', 'shy', 'silver',
-- 'slim', 'slow', 'smoky', 'snowy', 'soft', 'sour', 'steep', 'still',
-- 'stone', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'teal', 'thin',
-- 'tidy', 'tiny', 'torn', 'vast', 'vivid', 'warm', 'wavy', 'west',
-- 'wet', 'white', 'wide', 'wild', 'windy', 'wise', 'worn', 'young',
--];
--const SLUG_NOUNS = [
-- 'acorn', 'arch', 'arrow', 'aspen', 'badger', 'basil', 'bay', 'bear',
-- 'birch', 'blade', 'bloom', 'bolt', 'brook', 'brush', 'cairn', 'cave',
-- 'cedar', 'cliff', 'cloud', 'clover', 'coast', 'coral', 'crane', 'creek',
-- 'crow', 'dale', 'deer', 'delta', 'dew', 'dock', 'dove', 'drift',
-- 'drum', 'dune', 'eagle', 'echo', 'edge', 'elm', 'ember', 'fawn',
-- 'fern', 'field', 'finch', 'flame', 'flare', 'flint', 'fog', 'ford',
-- 'forge', 'fox', 'frost', 'gale', 'gate', 'gem', 'glen', 'goat',
-- 'grove', 'gull', 'hawk', 'haze', 'heath', 'hedge', 'heron', 'hill',
-- 'holly', 'horse', 'hound', 'isle', 'ivy', 'jade', 'jay', 'kelp',
-- 'lake', 'lark', 'leaf', 'ledge', 'lily', 'lion', 'lodge', 'lynx',
-- 'maple', 'marsh', 'mesa', 'mill', 'mint', 'mist', 'moon', 'moss',
-- 'moth', 'mule', 'nest', 'oak', 'orca', 'otter', 'owl', 'palm',
-- 'path', 'peak', 'pearl', 'petal', 'pike', 'pine', 'plum', 'pond',
-- 'quail', 'rain', 'raven', 'reed', 'reef', 'ridge', 'river', 'robin',
-- 'root', 'rose', 'sage', 'seal', 'shade', 'shell', 'shore', 'slate',
-- 'snail', 'spark', 'stone', 'storm', 'stork', 'thorn', 'tide', 'trail',
-- 'trout', 'tulip', 'vale', 'vine', 'viper', 'wave', 'wren', 'wolf',
--];
--
--async function generateSlug(attempts = 0) {
-- if (attempts >= 10) throw new Error('Could not generate a unique paste slug after 10 attempts');
-- const adj = SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)];
-- const noun = SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)];
-- const num = crypto.randomInt(10, 100); // 10–99
-- const slug = `${adj}-${noun}-${num}`;
-- const existing = await db.get('SELECT 1 FROM pastes WHERE slug = ?', [slug]);
-- if (existing) return await generateSlug(attempts + 1);
-- return slug;
--}
--
function sanitizeTitle(title) {
return String(title || '').trim().slice(0, 200) || 'Untitled';
}
ddiff --git a/server/pastes/storage.js b/server/pastes/storage.js
nnew file mode 100644
iindex 0000000..cebfee2
---- /dev/null
++++ b/server/pastes/storage.js
@@@ -0,0 +1,66 @@
++'use strict';
++// Paste storage plumbing, shared by the paste routes and the avatar ingest (which
++// stores every avatar as an unlisted screenshot paste): where a paste's screenshot
++// bytes land, and how a slug is minted (checked against the pastes table, so two
++// writes never get the same one). A leaf module — requiring it pulls in neither the
++// paste router nor anything that mounts it.
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++const db = require('../db/database');
++const config = require('../config');
++const drill = require('../drill');
++
++const SCREENSHOTS_DIR = path.join(config.pastes.path, 'screenshots');
++if (!drill.enabled && !fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); // a restore drill writes nothing
++
++// Word pools for readable paste slugs (adj-noun-number)
++const SLUG_ADJECTIVES = [
++ 'amber', 'blue', 'bold', 'brave', 'bright', 'calm', 'clean', 'clever',
++ 'cold', 'cool', 'coral', 'crisp', 'dark', 'dawn', 'deep', 'dry',
++ 'dusk', 'dusty', 'fair', 'fast', 'fierce', 'fine', 'foggy', 'free',
++ 'fresh', 'frost', 'glad', 'gold', 'grand', 'gray', 'green', 'grim',
++ 'hazy', 'heavy', 'hidden', 'hollow', 'honey', 'hot', 'icy', 'iron',
++ 'jade', 'keen', 'kind', 'late', 'lazy', 'light', 'lime', 'lit',
++ 'lone', 'lost', 'loud', 'lucky', 'lush', 'mild', 'misty', 'mossy',
++ 'muddy', 'neon', 'new', 'noble', 'odd', 'old', 'opal', 'open',
++ 'pale', 'pink', 'plain', 'plum', 'prime', 'proud', 'pure', 'quick',
++ 'quiet', 'rare', 'raw', 'red', 'rich', 'rocky', 'rosy', 'rough',
++ 'ruby', 'rusty', 'safe', 'sage', 'sandy', 'sharp', 'shy', 'silver',
++ 'slim', 'slow', 'smoky', 'snowy', 'soft', 'sour', 'steep', 'still',
++ 'stone', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'teal', 'thin',
++ 'tidy', 'tiny', 'torn', 'vast', 'vivid', 'warm', 'wavy', 'west',
++ 'wet', 'white', 'wide', 'wild', 'windy', 'wise', 'worn', 'young',
++];
++const SLUG_NOUNS = [
++ 'acorn', 'arch', 'arrow', 'aspen', 'badger', 'basil', 'bay', 'bear',
++ 'birch', 'blade', 'bloom', 'bolt', 'brook', 'brush', 'cairn', 'cave',
++ 'cedar', 'cliff', 'cloud', 'clover', 'coast', 'coral', 'crane', 'creek',
++ 'crow', 'dale', 'deer', 'delta', 'dew', 'dock', 'dove', 'drift',
++ 'drum', 'dune', 'eagle', 'echo', 'edge', 'elm', 'ember', 'fawn',
++ 'fern', 'field', 'finch', 'flame', 'flare', 'flint', 'fog', 'ford',
++ 'forge', 'fox', 'frost', 'gale', 'gate', 'gem', 'glen', 'goat',
++ 'grove', 'gull', 'hawk', 'haze', 'heath', 'hedge', 'heron', 'hill',
++ 'holly', 'horse', 'hound', 'isle', 'ivy', 'jade', 'jay', 'kelp',
++ 'lake', 'lark', 'leaf', 'ledge', 'lily', 'lion', 'lodge', 'lynx',
++ 'maple', 'marsh', 'mesa', 'mill', 'mint', 'mist', 'moon', 'moss',
++ 'moth', 'mule', 'nest', 'oak', 'orca', 'otter', 'owl', 'palm',
++ 'path', 'peak', 'pearl', 'petal', 'pike', 'pine', 'plum', 'pond',
++ 'quail', 'rain', 'raven', 'reed', 'reef', 'ridge', 'river', 'robin',
++ 'root', 'rose', 'sage', 'seal', 'shade', 'shell', 'shore', 'slate',
++ 'snail', 'spark', 'stone', 'storm', 'stork', 'thorn', 'tide', 'trail',
++ 'trout', 'tulip', 'vale', 'vine', 'viper', 'wave', 'wren', 'wolf',
++];
++
++async function generateSlug(attempts = 0) {
++ if (attempts >= 10) throw new Error('Could not generate a unique paste slug after 10 attempts');
++ const adj = SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)];
++ const noun = SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)];
++ const num = crypto.randomInt(10, 100); // 10–99
++ const slug = `${adj}-${noun}-${num}`;
++ const existing = await db.get('SELECT 1 FROM pastes WHERE slug = ?', [slug]);
++ if (existing) return await generateSlug(attempts + 1);
++ return slug;
++}
++
++module.exports = { SCREENSHOTS_DIR, generateSlug };
ddiff --git a/test/avatar-ingest.test.js b/test/avatar-ingest.test.js
iindex 9311b5a..d7f3ca9 100644
---- a/test/avatar-ingest.test.js
++++ b/test/avatar-ingest.test.js
@@@ -1,6 +1,20 @@
'use strict';
const assert = require('assert');
const sharp = require('sharp');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const dns = require('dns');
++const https = require('https');
++const http = require('http');
++const express = require('express');
++
++// The end-to-end check below writes where the service writes: point the paste
++// storage at scratch before anything loads the config.
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-avatar-'));
++process.env.PASTES_PATH = path.join(tmp, 'pastes');
++process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++
(async () => {
const { isPublicAddress, safeFetchImage, toAvatar, resolvePublic } = require('../server/avatars/ingest');
@@@ -33,6 +47,66 @@ const sharp = require('sharp');
assert.ok(!meta.exif && !meta.icc || true);
await rejects(toAvatar(Buffer.from('<svg onload=alert(1)>not a picture</svg><script>')), /./, 'non-image bytes are refused');
await rejects(toAvatar(await sharp({ create: { width: 8, height: 8, channels: 3, background: '#000' } }).png().toBuffer()), /too small/, 'tiny images are refused');
++
++ // ── Shared paste storage: the screenshots dir and the slug have one source ──
++ // (server/pastes/storage.js — the avatar ingest no longer takes them from the paste router)
++ assert.ok(!require.cache[require.resolve('../server/pastes/routes')], 'requiring the avatar ingest does not load the paste routes');
++ const storage = require('../server/pastes/storage');
++ const config = require('../server/config');
++ assert.strictEqual(storage.SCREENSHOTS_DIR, path.join(config.pastes.path, 'screenshots'), 'avatars keep landing in the paste screenshots directory');
++ assert.strictEqual(storage.SCREENSHOTS_DIR, require('../server/pastes/routes').SCREENSHOTS_DIR, 'the paste routes share that one directory');
++ const slugA = await storage.generateSlug();
++ const slugB = await storage.generateSlug();
++ for (const s of [slugA, slugB]) assert.match(s, /^[a-z]+-[a-z]+-[0-9]{2}$/, `slug shape: ${s}`);
++ assert.notStrictEqual(slugA, slugB, 'two minted slugs differ (uniqueness is checked against the pastes table)');
++
++ // ── Through the route, wired the way server/index.js wires it: { db, config } only ──
++ const db = require('../server/db/database');
++ const ingest = require('../server/avatars/ingest');
++ // DNS and the socket are stubbed: nothing leaves the host.
++ const realLookup = dns.promises.lookup;
++ const realGet = https.get;
++ dns.promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
++ https.get = (_opts, cb) => {
++ const res = new (require('events').EventEmitter)();
++ res.statusCode = 200;
++ res.headers = { 'content-type': 'image/png' };
++ res.resume = () => {};
++ setImmediate(() => { cb(res); res.emit('data', png); res.emit('end'); });
++ return { on: () => {}, destroy: () => {} };
++ };
++ try {
++ const app = express();
++ app.use(express.json());
++ app.post('/internal/avatar-ingest', ingest.createIngestHandler({ db, config }));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/internal/avatar-ingest`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(async (r) => ({ status: r.status, body: await r.json() }));
++ const first = await post({ url: 'https://pics.example/me.png', user_id: 7, username: 'ada' });
++ assert.strictEqual(first.status, 200, JSON.stringify(first.body));
++ assert.strictEqual(first.body.ok, true);
++ assert.match(first.body.slug, /^[a-z]+-[a-z]+-[0-9]{2}$/);
++ assert.strictEqual(first.body.url, `${config.publicUrl}/p/${first.body.slug}/screenshot`);
++ assert.deepStrictEqual([first.body.width, first.body.height], [512, 512]);
++ assert.ok(first.body.bytes > 0);
++ const row = await db.get('SELECT * FROM pastes WHERE slug = ?', [first.body.slug]);
++ assert.ok(row, 'the avatar row was inserted');
++ assert.deepStrictEqual([row.app_id, row.user_id, row.type, row.visibility], ['network', 7, 'screenshot', 'unlisted']);
++ assert.strictEqual(row.title, 'Avatar of ada');
++ assert.ok(row.screenshot_path.startsWith(storage.SCREENSHOTS_DIR), `the file lands in the shared screenshots dir (${row.screenshot_path})`);
++ assert.deepStrictEqual((await sharp(fs.readFileSync(row.screenshot_path)).metadata()).format, 'webp', 'the bytes on disk are the re-encoded WebP');
++ assert.strictEqual(JSON.parse(row.metadata).kind, 'avatar');
++ assert.ok(row.object_id, 'the avatar row carries its media object');
++ assert.ok(await db.get('SELECT id FROM media_objects WHERE id = ?', [row.object_id]), 'and that object exists');
++ const second = await post({ url: 'https://pics.example/me2.png', user_id: 8 });
++ assert.strictEqual(second.status, 200, JSON.stringify(second.body));
++ assert.notStrictEqual(second.body.slug, first.body.slug, 'a second avatar gets its own slug');
++ server.close();
++ } finally {
++ dns.promises.lookup = realLookup;
++ https.get = realGet;
++ }
++ fs.rmSync(tmp, { recursive: true, force: true });
console.log('avatar ingest: all checks passed');
})().catch(e => { console.error(e); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });