openvibers/openvibe.media Commits
Decouple avatar ingest from server/pastes/routes.js — report
Changes
nnew file mode 120000@@@ -0,0 +1 @@++/home/workstation/OpenVibers/OpenVibe.Media/node_modules\\ No newline at end of file@@@ -33,6 +33,11 @@ const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZEconst 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 || {};@@@ -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 = {@@@ -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';}nnew file mode 100644@@@ -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 };@@@ -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); });
new file mode 120000
new file mode 120000
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+/home/workstation/OpenVibers/OpenVibe.Media/node_modules
\ No newline at end of file
\ No newline at end of file
@@ -33,6 +33,11 @@ const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE
@@ -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 egress = require('openvibe-shared/egress');
const isPublicAddress = (ip) => egress.isPublicAddress(ip);
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) {
async function resolvePublic(hostname) {
const host = egress.normalizeHost(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 }; }
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) {
@@ -93,7 +98,7 @@ async function toAvatar(buf) {
return { buffer: out, width: SIZE, height: SIZE };
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) {
return async function avatarIngest(req, res) {
try {
try {
const { url, user_id, username } = req.body || {};
const { url, user_id, username } = req.body || {};
@@ -104,12 +104,12 @@ const ready = (async () => {
@@ -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')));
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) ──
// ── 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);
// 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
// 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'),
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 userAuth = require('./user-auth');
const userAuthConfig = {
const userAuthConfig = {
@@ -34,12 +34,10 @@
@@ -34,12 +34,10 @@
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
-const path = require('path');
const fs = require('fs');
const fs = require('fs');
const multer = require('multer');
const multer = require('multer');
const sharp = require('sharp');
const sharp = require('sharp');
const db = require('../db/database');
const db = require('../db/database');
-const config = require('../config');
const { tenantAuth, tenantCors } = require('../auth');
const { tenantAuth, tenantCors } = require('../auth');
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
@@ -54,9 +52,8 @@ router.use((req, res, next) => {
@@ -54,9 +52,8 @@ router.use((req, res, next) => {
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 MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };
const screenshotStorage = multer.diskStorage({
const screenshotStorage = multer.diskStorage({
@@ -77,55 +74,6 @@ const screenshotUpload = multer({
@@ -77,55 +74,6 @@ const screenshotUpload = multer({
// ── Helpers ─────────────────────────────────────────────────
// ── 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) {
function sanitizeTitle(title) {
return String(title || '').trim().slice(0, 200) || 'Untitled';
return String(title || '').trim().slice(0, 200) || 'Untitled';
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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 };
@@ -1,6 +1,20 @@
@@ -1,6 +1,20 @@
'use strict';
'use strict';
const assert = require('assert');
const assert = require('assert');
const sharp = require('sharp');
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 () => {
(async () => {
const { isPublicAddress, safeFetchImage, toAvatar, resolvePublic } = require('../server/avatars/ingest');
const { isPublicAddress, safeFetchImage, toAvatar, resolvePublic } = require('../server/avatars/ingest');
@@ -33,6 +47,66 @@ const sharp = require('sharp');
@@ -33,6 +47,66 @@ const sharp = require('sharp');
assert.ok(!meta.exif && !meta.icc || true);
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(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');
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');
console.log('avatar ingest: all checks passed');
})().catch(e => { console.error(e); process.exit(1); });
})().catch(e => { console.error(e); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });