openvibers/openvibe.media Commits
The composer's attachments: one upload route for every site (#406)
Changes
@@@ -2,6 +2,14 @@## Unreleased++- **The shared composer's attachments (2026-11-10, composer.attachment@1).** `POST /api/v1/attachments` takes one++ multipart `file` from a person (a Network user JWT, or a first-party site's service token naming the person in++ `X-OV-Subject`), sniffs the kind from the bytes — image, video, audio, file or code — and refuses an executable.++ Limits are image 20 MB, video 300 MB, audio 50 MB, anything else 25 MB; images get a 640 px JPEG thumbnail and a++ video its first frame (ffmpeg). `POST /api/v1/attachments/:id/keep` names the message it belongs to, `DELETE` removes++ the person's own file, and an unkept attachment is swept 24 hours after upload (migrations/0027_attachments.sql).++ Bytes are served at `/a/<id>/<name>`; CORS is the first-party origin list (server/attachments/origins.js).++- `scripts/orientation-backfill.js` inspects stored JPEG, PNG and WebP originals in batches (dry run by default), bakesany surviving EXIF orientation into pixels, removes the embedded details, updates object/location hashes and sizes,and rebuilds 320/640 WebP variants. Images without source orientation data are marked `orientation unknown` for thennew file mode 100644@@@ -0,0 +1,25 @@++-- phase: expand++-- The composer's attachments (composer.attachment@1): one row per file a person attached to a message before it is++-- sent. Bytes live under ATTACHMENTS_PATH/<id>/; the row is the authority on who may keep or delete it. An unkept++-- attachment expires 24 hours after upload (expires_at); a site that keeps it names the message it belongs to++-- (message_id, kept_at) and the window moves to the kept one. Deleting the row removes the directory.++CREATE TABLE IF NOT EXISTS media_attachments (++ id text COLLATE "C" PRIMARY KEY,++ subject text COLLATE "C" NOT NULL,++ principal text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ stored_name text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('image', 'video', 'audio', 'file', 'code')),++ mime text COLLATE "C" NOT NULL,++ size_bytes bigint NOT NULL CHECK (size_bytes > 0),++ sha256 text COLLATE "C",++ width bigint,++ height bigint,++ thumb_name text COLLATE "C",++ message_id text COLLATE "C",++ kept_at text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ expires_at text COLLATE "C" NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_media_attachments_subject ON media_attachments(subject, created_at);++CREATE INDEX IF NOT EXISTS idx_media_attachments_expires ON media_attachments(expires_at);@@@ -19,6 +19,8 @@* Media then confirms with counts (POST /internal/account-deletions/:id/confirmations).*/const fs = require('fs');++const path = require('path');++const config = require('./config');const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -46,6 +48,11 @@ async function exportPart(db, subjects) {if (vods.length) files.push({ name: 'vods.json', content: vods });if (clips.length) files.push({ name: 'clips.json', content: clips });if (plain.length) files.push({ name: 'files.json', content: plain });++ // Composer attachments are not media_objects: the person's own files before (or instead of) a message. Their++ // metadata goes in attachments.json; the bytes are downloaded from openvibe.media/a/<id>/<name>.++ const attachments = await db.prepare(`SELECT id, name, kind, mime, size_bytes, message_id, kept_at, created_at, expires_at++ FROM media_attachments WHERE subject IN ${inList(subjects)} ORDER BY created_at DESC LIMIT ${ROW_LIMIT}`).all(...subjects);++ if (attachments.length) files.push({ name: 'attachments.json', content: attachments });// The share.v1 rows the person owns or is on (step 5): the recording itself is above; this is who it was// shared with. `people` is a list of subject ids, never a name.const owned = await db.prepare(`SELECT service, kind, resource_id, audience, lock, revision FROM shares WHERE owner_subject->>'id' IN ${inList(subjects)} ORDER BY resource_id DESC LIMIT ${ROW_LIMIT}`).all(...subjects);@@@ -66,6 +73,13 @@ async function erase(db, subjects) {const erased = {};const retained = {};const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };++ // Composer attachments: the row and the stored bytes, like DELETE /api/v1/attachments/:id does.++ const attachments = await db.prepare(`SELECT id FROM media_attachments WHERE subject IN ${inList(subjects)}`).all(...subjects);++ for (const a of attachments) {++ await db.prepare('DELETE FROM media_attachments WHERE id = ?').run(a.id);++ try { fs.rmSync(path.join(path.resolve(config.attachments.path), a.id), { recursive: true, force: true }); } catch { /* gone */ }++ bump(erased, 'attachments');++ }const objs = await db.prepare(`SELECT * FROM media_objects WHERE owner_subject IN ${inList(subjects)} AND lifecycle_status != 'deleted' ORDER BY (kind = 'thumbnail'), created_at`).all(...subjects);for (const o of objs) {if (await model.isHeld(o.id)) { bump(retained, 'held_media'); continue; }nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++/**++ * The origins the attachment routes answer: every first-party origin the service manifests declare (the same list++ * Network's server/first-party-origins.js builds), plus the hard-coded ones that must survive a registry failure,++ * plus the local development ports outside production. Only exact https origins: a tenant subdomain is never one.++ */++const contracts = require('openvibe-contracts');++++const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;++const BASE = [++ 'https://openvibe.network',++ 'https://openvibe.live', 'https://www.openvibe.live',++ 'https://openvibe.tools',++ 'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games',++ 'https://openvibe.media',++ 'https://openvibe.community',++ 'https://openvibe.space',++];++const DEV = [++ 'http://localhost:3000', 'http://localhost:4000', 'http://127.0.0.1:4000',++ 'http://localhost:4001', 'http://localhost:4100', 'http://localhost:4200',++ 'http://localhost:4400', 'http://localhost:4940', 'http://localhost:8000',++ 'http://localhost:5173',++];++++let cached = null;++++function openVibeOrigins() {++ if (cached) return cached;++ const out = new Set(BASE);++ for (const m of (contracts.services && contracts.services.manifests) || []) {++ for (const d of m.domains || []) {++ const host = String(d || '').trim().toLowerCase();++ if (HOST_RE.test(host)) out.add(`https://${host}`);++ }++ }++ if (process.env.NODE_ENV !== 'production') for (const o of DEV) out.add(o);++ cached = out;++ return out;++}++++module.exports = { openVibeOrigins };nnew file mode 100644@@@ -0,0 +1,371 @@++/**++ * OpenVibe.Media — composer attachments (composer.attachment@1)++ *++ * POST /api/v1/attachments multipart field `file` -> composer.attachment@1++ * POST /api/v1/attachments/:id/keep { message_id } -> composer.attachment@1 (kept with a message)++ * DELETE /api/v1/attachments/:id the person who uploaded it -> 204++ * GET /a/:id/:name the bytes (public, unguessable id), and /a/:id/thumb.jpg++ *++ * One person's file, stored before it is sent, so every site's composer attaches the same way. Credentials:++ * a Network user JWT (the browser), or a first-party service token holding media.object.upload with the person++ * named in X-OV-Subject (a site's server). Media sniffs the bytes and decides the kind; an executable is refused.++ * Limits: image 20 MB, video 300 MB, audio 50 MB, anything else 25 MB (code included). An unkept attachment++ * expires 24 hours after upload; a keep names the message it belongs to and moves it to the kept window.++ *++ * The CORS allow-list is every first-party origin (the service manifests' domains, server/attachments/origins.js).++ * A foreign origin gets no Access-Control-Allow-Origin and is refused by the browser; the bytes route is public.++ */++'use strict';++++const express = require('express');++const multer = require('multer');++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const contracts = require('openvibe-contracts');++const sdkAuth = require('openvibe-sdk/auth');++const sharp = require('sharp');++const db = require('../db/database');++const config = require('../config');++const auth = require('../auth');++const ctypes = require('../objects/content-type');++const { limits } = require('../actor-limits');++const { openVibeOrigins } = require('./origins');++++const { capabilities, http, ids } = contracts;++const MB = 1024 * 1024;++const MAX_BYTES = { image: 20 * MB, video: 300 * MB, audio: 50 * MB, file: 25 * MB, code: 25 * MB };++const UPLOAD_MAX = 300 * MB;++const ttlMs = () => (Number(config.attachments.ttlHours) || 24) * 60 * 60 * 1000;++const keepMs = () => (Number(config.attachments.keepDays) || 30) * 24 * 60 * 60 * 1000;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const NAME_MAX = 255;++++const router = express.Router();++const publicRouter = express.Router();++++function problem(res, status, code, detail) {++ return http.sendProblem(res, status, code, { detail });++}++++// ── CORS: first-party origins only ───────────────────────────++function cors(req, res, next) {++ const origin = req.headers.origin;++ if (origin) res.set('Vary', 'Origin');++ if (origin && openVibeOrigins().has(origin)) {++ res.set('Access-Control-Allow-Origin', origin);++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-OV-Subject, X-OV-User-Id, Idempotency-Key, traceparent, X-OpenVibe-Request-Id');++ res.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');++ res.set('Access-Control-Max-Age', '600');++ }++ if (req.method === 'OPTIONS') return res.status(204).end();++ next();++}++router.use(cors);++++// ── Storage ──────────────────────────────────────────────────++function root() { return path.resolve(config.attachments.path); }++function dirOf(id) { return path.join(root(), id); }++function sanitizeName(name) {++ const base = path.basename(String(name || 'file')).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, NAME_MAX);++ return base.trim() || 'file';++}++function publicUrl(id, name) { return `${config.publicUrl}/a/${encodeURIComponent(id)}/${encodeURIComponent(name)}`; }++function sha256File(file) {++ return new Promise((resolve, reject) => {++ const hash = crypto.createHash('sha256');++ const stream = fs.createReadStream(file);++ stream.on('data', (d) => hash.update(d));++ stream.on('end', () => resolve(hash.digest('hex')));++ stream.on('error', reject);++ });++}++++function ensureDirs() {++ if (!fs.existsSync(root())) fs.mkdirSync(root(), { recursive: true });++}++function tmpDir() {++ const d = path.join(root(), '.tmp');++ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });++ return d;++}++++// ── Credentials: a user JWT, or a first-party service token naming the person ──++function bearer(req) {++ const h = String(req.headers.authorization || '');++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;++}++function namedSubject(req) {++ const raw = req.headers['x-ov-subject'];++ if (raw == null || raw === '') return null;++ const s = String(raw).trim();++ const parsed = ids.parseSubject(s);++ if (parsed && parsed.type === 'user') return parsed.id;++ return SUBJECT_RE.test(s) ? s : null;++}++async function verifyUser(token) {++ const keys = auth.networkKeys();++ let p;++ try { p = await sdkAuth.verifyUserToken(token, { ...keys.verifyOptions, issuer: config.network.url }); } catch { return null; }++ if (p.aud) {++ const aud = Array.isArray(p.aud) ? p.aud : [p.aud];++ if (!aud.includes('openvibe.media')) return null;++ }++ // Network's session token carries the numeric account id in `sub` and the person's subject in `subject_id`.++ const subject = String(p.subject_id || p.sub || '');++ return SUBJECT_RE.test(subject) ? { payload: p, subject } : null;++}++async function attachmentAuth(req, res, next) {++ const token = bearer(req);++ if (!token) return problem(res, 401, 'token.missing', 'a Network token is required');++ let subject = null;++ let principal = null;++ if (token.split('.').length === 3) {++ const svc = await auth.verifyServiceTokenResult(token);++ if (svc.ok) {++ if (!String(svc.claims.sub || '').startsWith('svc:')) {++ return problem(res, 403, 'capability.denied', 'only a first-party service token or a person may attach a file');++ }++ const c = capabilities.check(svc.claims, 'media.object.upload');++ if (!c.allowed) return problem(res, 403, c.code || 'capability.denied', c.reason || 'media.object.upload is not granted');++ subject = namedSubject(req);++ if (!subject) return problem(res, 403, 'attachments.subject_required', 'a service token must name the person in X-OV-Subject (usr_…)');++ principal = svc.claims.sub;++ }++ }++ if (!subject) {++ const user = await verifyUser(token);++ if (!user) return problem(res, 401, 'token.invalid', 'the token is not a valid Network person or media service token');++ subject = user.subject;++ principal = user.subject;++ }++ req.person = { subject, principal };++ next();++}++++// ── Bytes sniffing ───────────────────────────────────────────++const MIME = {++ jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp',++ avif: 'image/avif', heic: 'image/heic', heix: 'image/heic', mif1: 'image/heic', msf1: 'image/heic',++ mp4: 'video/mp4', matroska: 'video/webm', ogg: 'video/ogg', avi: 'video/x-msvideo',++ wav: 'audio/wav', flac: 'audio/flac', mp3: 'audio/mpeg', m4a: 'audio/mp4', mpegts: 'video/mp2t', pdf: 'application/pdf',++};++const CODE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|c|h|cc|cpp|hpp|cs|java|kt|swift|php|sh|bash|zsh|fish|ps1|sql|html|htm|css|scss|sass|less|json|jsonc|yaml|yml|toml|ini|xml|svg|vue|svelte|astro|md|mdx|lua|pl|r|dart|ex|exs|erl|hs|scala|clj|sol|zig|nim|graphql|gql|tf|dockerfile|make|cmake)$/i;++const CODE_MIME = /^(application\/(javascript|ecmascript|typescript|json|xml|x-sh|x-httpd-php|x-ruby|x-python|x-perl|x-shellscript|x-csh|x-c\+\+|x-java-source|x-lua|x-rust|x-go)|text\/(javascript|typescript|css|html|x-python|x-sh|x-c|x-c\+\+|x-java-source|x-markdown|x-rust|x-go))/;++const EXEC_MIME = /^(application\/(x-msdownload|x-msdos-program|x-dosexec|x-executable|x-mach-binary|x-sharedlib|x-object|x-elf|vnd\.microsoft\.portable-executable|java-archive|vnd\.android\.package-archive|x-apple-diskimage|x-msi|x-rpm|x-deb))/;++const EXEC_EXT = /\.(exe|dll|sys|so|dylib|bin|com|msi|bat|cmd|ps1|vbs|wsf|scr|jar|apk|appimage|deb|rpm|dmg|pkg|elf)$/i;++++/** Is this a binary executable or an installer? (refused before it is stored) */++function isExecutable(head, declaredMime, name) {++ if (EXEC_MIME.test(String(declaredMime || '').toLowerCase())) return true;++ if (EXEC_EXT.test(String(name || ''))) return true;++ const b = Buffer.isBuffer(head) ? head : Buffer.from(head || []);++ if (b.length >= 2 && b[0] === 0x4d && b[1] === 0x5a) return true; // MZ / PE++ if (b.length >= 4 && b[0] === 0x7f && b[1] === 0x45 && b[2] === 0x4c && b[3] === 0x46) return true; // ELF++ if (b.length >= 4) {++ const magic = b.readUInt32BE(0);++ if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca].includes(magic)) return true; // Mach-O / fat++ }++ return false;++}++++/** { kind, mime } from the bytes (never the browser's claim), with the extension only for source text. */++function classify(head, declaredMime, name) {++ const sniffed = ctypes.sniff(head);++ if (sniffed) {++ const mime = MIME[sniffed.format] || (sniffed.family === 'pdf' ? 'application/pdf' : declaredMime);++ if (sniffed.family === 'image') return { kind: 'image', mime };++ if (sniffed.family === 'video') return { kind: 'video', mime };++ if (sniffed.family === 'audio') return { kind: 'audio', mime };++ if (sniffed.family === 'av') return /^audio\//.test(String(declaredMime || '')) ? { kind: 'audio', mime } : { kind: 'video', mime };++ return { kind: 'file', mime };++ }++ const declared = ctypes.normalize(declaredMime);++ if (CODE_EXT.test(String(name || '')) || CODE_MIME.test(String(declared || ''))) return { kind: 'code', mime: declared || 'text/plain; charset=utf-8' };++ return { kind: 'file', mime: declared || 'application/octet-stream' };++}++++// ── Thumbnails and size ──────────────────────────────────────++async function imageInfo(file, dir) {++ const meta = await sharp(file).metadata();++ let w = Number(meta.width) || null;++ let h = Number(meta.height) || null;++ if (meta.orientation && meta.orientation >= 5) [w, h] = [h, w];++ const thumb = path.join(dir, 'thumb.jpg');++ try {++ await sharp(file).rotate().resize({ width: 640, height: 640, fit: 'inside', withoutEnlargement: true }).jpeg({ quality: 82 }).toFile(thumb);++ return { width: w, height: h, thumb_name: 'thumb.jpg' };++ } catch { return { width: w, height: h, thumb_name: null }; }++}++async function videoInfo(file, dir) {++ let width = null, height = null;++ try {++ const info = await require('../vod/media-tools').probeDuration(file);++ const v = (info.streams || []).find(s => s.codec_type === 'video' && Number(s.width) > 0);++ if (v) { width = Number(v.width); height = Number(v.height); }++ } catch { /* no ffprobe: no size */ }++ let thumbName = null;++ try {++ const ok = await require('../thumbnails/thumbnail-service').extractFrameToFile(file, 1, path.join(dir, 'thumb.jpg'));++ if (ok) thumbName = 'thumb.jpg';++ } catch { /* best effort */ }++ return { width, height, thumb_name: thumbName };++}++++// ── The answer ───────────────────────────────────────────────++function shape(row) {++ return {++ id: row.id,++ kind: row.kind,++ name: row.name,++ size: Number(row.size_bytes),++ mime: row.mime,++ url: publicUrl(row.id, row.stored_name),++ thumb: row.thumb_name ? publicUrl(row.id, row.thumb_name) : null,++ w: row.width != null ? Number(row.width) : null,++ h: row.height != null ? Number(row.height) : null,++ expires_at: row.expires_at,++ };++}++const getRow = (id) => db.get('SELECT * FROM media_attachments WHERE id = ?', [id]);++const deleteRow = (id) => db.run('DELETE FROM media_attachments WHERE id = ?', [id]);++++// ── Upload ───────────────────────────────────────────────────++// multer creates its destination when built; a restore drill takes no upload and writes nothing.++const upload = multer({++ storage: require('../drill').enabled ? multer.memoryStorage() : multer.diskStorage({ destination: (_req, _file, cb) => cb(null, tmpDir()) }),++ limits: { fileSize: UPLOAD_MAX, files: 1, fields: 10 },++});++++router.post('/', attachmentAuth, limits('media.attachment.upload', { minute: 30, hour: 600 }), upload.single('file'), async (req, res) => {++ const tmp = req.file && req.file.path;++ try {++ if (!req.file) return problem(res, 400, 'attachments.file_required', 'send one file in the multipart field `file`');++ const head = ctypes.readHead(req.file.path, 512);++ const name = sanitizeName(req.file.originalname);++ if (isExecutable(head, req.file.mimetype, name)) return problem(res, 415, 'media.attachment.executable', 'executables and installers are not accepted');++ const { kind, mime } = classify(head, req.file.mimetype, name);++ const max = MAX_BYTES[kind];++ if (req.file.size > max) {++ return problem(res, 413, 'media.attachment.too_large', `a ${kind} may be at most ${Math.round(max / MB)} MB`);++ }++ const id = `att_${ids.ulid()}`;++ const dir = dirOf(id);++ fs.mkdirSync(dir, { recursive: true });++ const stored = name;++ const dest = path.join(dir, stored);++ try { fs.renameSync(tmp, dest); } catch { fs.copyFileSync(tmp, dest); try { fs.unlinkSync(tmp); } catch { /* */ } }++ const sha256 = await sha256File(dest);++ let info = { width: null, height: null, thumb_name: null };++ if (kind === 'image') info = await imageInfo(dest, dir).catch(() => info);++ else if (kind === 'video') info = await videoInfo(dest, dir).catch(() => info);++ const expiresAt = new Date(Date.now() + ttlMs()).toISOString();++ await db.run(++ `INSERT INTO media_attachments (id, subject, principal, name, stored_name, kind, mime, size_bytes, sha256, width, height, thumb_name, expires_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,++ [id, req.person.subject, req.person.principal, req.file.originalname || name, stored, kind, mime, req.file.size, sha256, info.width, info.height, info.thumb_name, expiresAt]);++ const row = await getRow(id);++ res.status(201).json(shape(row));++ } catch (err) {++ if (tmp) { try { fs.unlinkSync(tmp); } catch { /* */ } }++ if (err && err.code === 'LIMIT_FILE_SIZE') return problem(res, 413, 'media.attachment.too_large', 'the file is larger than 300 MB');++ console.error('[Attachments] Upload error:', err.message);++ if (!res.headersSent) problem(res, 500, 'media.attachment.store_failed', 'the file could not be stored');++ }++});++++// ── Keep with a message ──────────────────────────────────────++router.post('/:id/keep', attachmentAuth, limits('media.attachment.keep', { minute: 120, hour: 1200 }), async (req, res) => {++ try {++ const row = await getRow(String(req.params.id || ''));++ if (!row || row.subject !== req.person.subject) return problem(res, 404, 'media.attachment.not_found', 'no such attachment for this person');++ const messageId = String((req.body && req.body.message_id) || '').trim();++ if (!messageId || messageId.length > 200) return problem(res, 422, 'attachments.message_required', 'name the message this attachment belongs to in `message_id`');++ const expiresAt = new Date(Date.now() + keepMs()).toISOString();++ await db.run('UPDATE media_attachments SET message_id = ?, kept_at = ?, expires_at = ? WHERE id = ?', [messageId, new Date().toISOString(), expiresAt, row.id]);++ res.json(shape(await getRow(row.id)));++ } catch (err) {++ console.error('[Attachments] Keep error:', err.message);++ problem(res, 500, 'media.attachment.keep_failed', 'the attachment could not be kept');++ }++});++++// ── Delete (the person who uploaded it) ─────────────────────++router.delete('/:id', attachmentAuth, limits('media.attachment.delete', { minute: 60, hour: 1200 }), async (req, res) => {++ try {++ const row = await getRow(String(req.params.id || ''));++ if (!row || row.subject !== req.person.subject) return problem(res, 404, 'media.attachment.not_found', 'no such attachment for this person');++ await deleteRow(row.id);++ fs.rmSync(dirOf(row.id), { recursive: true, force: true });++ res.status(204).end();++ } catch (err) {++ console.error('[Attachments] Delete error:', err.message);++ problem(res, 500, 'media.attachment.delete_failed', 'the attachment could not be deleted');++ }++});++++// ── Expiry sweep ─────────────────────────────────────────────++/** Remove the rows (and directories) whose expires_at has passed. Returns how many went. */++async function cleanupExpired({ now = new Date(), limit = 500 } = {}) {++ const rows = await db.all('SELECT id FROM media_attachments WHERE expires_at < ? ORDER BY expires_at LIMIT ?', [now.toISOString(), limit]);++ for (const r of rows) {++ try { await deleteRow(r.id); } catch { continue; }++ fs.rmSync(dirOf(r.id), { recursive: true, force: true });++ }++ return rows.length;++}++function startCleanup() {++ const timer = setInterval(() => { cleanupExpired().catch(err => console.warn('[Attachments] cleanup:', err.message)); }, 60 * 60 * 1000);++ if (timer.unref) timer.unref();++ return timer;++}++++// ── The bytes ────────────────────────────────────────────────++function sendBytes(req, res, row, filename, mime) {++ const file = path.join(dirOf(row.id), filename);++ if (!fs.existsSync(file)) return problem(res, 404, 'media.attachment.not_found', 'the bytes are gone');++ res.set('X-Content-Type-Options', 'nosniff');++ res.set('Cross-Origin-Resource-Policy', 'cross-origin');++ res.set('Access-Control-Allow-Origin', '*');++ res.set('Cache-Control', 'public, max-age=31536000, immutable');++ const inline = /^(image|video|audio)\//.test(mime) || mime === 'application/pdf';++ const safe = String(filename).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');++ res.set('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(filename)}`);++ res.type(mime);++ res.sendFile(file);++}++publicRouter.get('/:id/thumb.jpg', async (req, res, next) => {++ try {++ const row = await getRow(String(req.params.id || ''));++ if (!row || !row.thumb_name) return next();++ sendBytes(req, res, row, row.thumb_name, 'image/jpeg');++ } catch (err) { next(err); }++});++publicRouter.get('/:id/:name', async (req, res, next) => {++ try {++ const row = await getRow(String(req.params.id || ''));++ if (!row) return problem(res, 404, 'media.attachment.not_found', 'no such attachment');++ sendBytes(req, res, row, row.stored_name, row.mime);++ } catch (err) { next(err); }++});++++// Multer's own refusals become problem+json.++router.use((err, req, res, next) => {++ if (!err) return next();++ if (err instanceof multer.MulterError) {++ const code = err.code === 'LIMIT_FILE_SIZE' ? 'media.attachment.too_large' : 'attachments.invalid_upload';++ return problem(res, err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, code, err.message);++ }++ console.error('[Attachments] Unhandled route error:', err.message);++ if (!res.headersSent) problem(res, 500, 'media.attachment.failed', 'the request could not be handled');++});++++module.exports = router;++module.exports.publicRouter = publicRouter;++module.exports.cleanupExpired = cleanupExpired;++module.exports.startCleanup = startCleanup;++module.exports.shape = shape;++module.exports.classify = classify;++module.exports.isExecutable = isExecutable;++module.exports.MAX_BYTES = MAX_BYTES;++module.exports._ensureDirs = ensureDirs;@@@ -67,6 +67,14 @@ const config = {maxSizeMb: intEnv('MAX_FILE_SIZE_MB', 256),},++ // Composer attachments (server/attachments/routes.js): one person's file before it is sent. An unkept one++ // expires after 24 hours; a keep moves it to the kept window. Bytes live under ATTACHMENTS_PATH/<att_…>/.++ attachments: {++ path: process.env.ATTACHMENTS_PATH || './data/attachments',++ ttlHours: intEnv('MEDIA_ATTACHMENT_TTL_HOURS', 24),++ keepDays: intEnv('MEDIA_ATTACHMENT_KEEP_DAYS', 30),++ },++// Canonical object model (Wave 4): native v2 uploads, signed delivery, retention,// and the public playback object-size invariant (policy, not a copied constant).objects: {@@@ -296,6 +296,15 @@ const ready = (async () => {}}++ // Composer attachments (composer.attachment@1): every site's composer uploads one person's file the same way.++ // It sits before /api/v1/:app so `attachments` is never read as a tenant. Public bytes are /a/<id>/<name>.++ {++ const attachments = require('./attachments/routes');++ attachments._ensureDirs();++ app.use('/api/v1/attachments', attachments);++ app.use('/a', attachments.publicRouter);++ if (!drill.enabled) app.locals.attachmentsCleanup = attachments.startCleanup();++ }app.use('/api/v1/:app/views', require('./views/routes'));// GET /api/v1/:app/watch/:watch_id: which item a watch id is (server/watch-routes.js, plan track U).app.use('/api/v1/:app/watch', require('./watch-routes'));@@@ -22,7 +22,7 @@ const path = require('path');const db = require('../server/db/database').getDb();{const missing = await uncovered(db, {-- taskKeyed: ['vods', 'clips', 'files'],++ taskKeyed: ['vods', 'clips', 'files', 'media_attachments'],exempt: {account_data_events: 'the ledger of requests itself: which export and deletion Media applied',assets: 'legacy rows keyed by Live\'s numeric user id, not a Network subject',nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++// Composer attachments (composer.attachment@1, server/attachments/routes.js): the person's upload through a Network++// user JWT, a site's service token naming the person in X-OV-Subject, sniffed kinds, the per-kind limits, executable++// refusal, keep, delete, the 24-hour expiry and the CORS allow-list (a first-party origin yes, a foreign one no).++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++const express = require('express');++const sharp = require('sharp');++const jwt = require('jsonwebtoken');++const { serviceAuth, ids } = require('openvibe-contracts');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-attachments-'));++ process.env.ATTACHMENTS_PATH = path.join(tmp, 'attachments');++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++ const db = require('../server/db/database');++ const config = require('../server/config');++ const auth = require('../server/auth');++ const routes = require('../server/attachments/routes');++ const MB = 1024 * 1024;++++ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ auth._setNetworkPublicKeyForTests(publicKey.export({ type: 'spki', format: 'pem' }));++ const now = Math.floor(Date.now() / 1000);++ const SUBJECT = ids.newId('user');++ const OTHER = ids.newId('user');++ // Network's session token: the numeric account id in `sub`, the person's subject in `subject_id`.++ const personToken = (subject = SUBJECT) => jwt.sign({ iss: 'https://openvibe.network', sub: 42, subject_id: subject, aud: ['openvibe.media', 'openvibe.network'], iat: now, exp: now + 600 }, privateKey.export({ type: 'pkcs8', format: 'pem' }), { algorithm: 'RS256' });++ const serviceToken = (cap = ['media.object.upload']) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, privateKey.export({ type: 'pkcs8', format: 'pem' }));++++ const app = express();++ app.use(express.json());++ app.use('/api/v1/attachments', routes);++ app.use('/a', routes.publicRouter);++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++++ const form = (bytes, type, name, extra = {}) => {++ const f = new FormData();++ for (const [k, v] of Object.entries(extra)) f.append(k, String(v));++ f.append('file', new Blob([bytes], { type }), name);++ return f;++ };++ const upload = async (bytes, type, name, { token = personToken(), headers = {}, extra } = {}) => {++ const r = await fetch(`${base}/api/v1/attachments`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, ...headers }, body: form(bytes, type, name, extra) });++ return { status: r.status, body: await r.json().catch(() => null) };++ };++++ // ── CORS: a first-party origin is allowed, a foreign one is not ──++ const preflight = (origin) => fetch(`${base}/api/v1/attachments`, { method: 'OPTIONS', headers: { Origin: origin, 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'authorization' } });++ let r = await preflight('https://openvibe.community');++ assert.strictEqual(r.status, 204);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.community', 'an OpenVibe origin is allowed');++ assert.match(r.headers.get('access-control-allow-headers') || '', /Authorization/);++ r = await preflight('https://evil.example');++ assert.strictEqual(r.status, 204);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), null, 'a foreign origin gets no allow header');++++ // ── A person's image: sniffed kind, thumb and size, 24-hour expiry ──++ const png = await sharp({ create: { width: 48, height: 24, channels: 3, background: '#3366ff' } }).png().toBuffer();++ let out = await upload(png, 'image/png', 'shot.png');++ assert.strictEqual(out.status, 201, JSON.stringify(out.body));++ assert.match(out.body.id, /^att_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(out.body.kind, 'image');++ assert.strictEqual(out.body.size, png.length);++ assert.strictEqual(out.body.mime, 'image/png');++ assert.match(out.body.url, /^https:\/\/media\.test\/a\/att_[^/]+\/shot\.png$/);++ assert.match(out.body.thumb, /^https:\/\/media\.test\/a\/att_[^/]+\/thumb\.jpg$/);++ assert.deepStrictEqual([out.body.w, out.body.h], [48, 24]);++ const ttl = Date.parse(out.body.expires_at) - Date.now();++ assert.ok(ttl > 23 * 3600_000 && ttl <= 24 * 3600_000, 'expires in 24 hours');++ const imageId = out.body.id;++++ let bytes = await fetch(`${base}/a/${imageId}/shot.png`);++ assert.strictEqual(bytes.status, 200);++ assert.strictEqual(bytes.headers.get('content-type'), 'image/png');++ assert.strictEqual(bytes.headers.get('x-content-type-options'), 'nosniff');++ bytes = await fetch(`${base}/a/${imageId}/thumb.jpg`);++ assert.strictEqual(bytes.status, 200);++ assert.strictEqual(bytes.headers.get('content-type'), 'image/jpeg');++++ // ── Kinds come from the bytes, not the browser's claim ──++ const fakeMp4 = Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftypisom'), Buffer.alloc(64, 1)]);++ out = await upload(fakeMp4, 'application/octet-stream', 'clip.mp4');++ assert.strictEqual(out.status, 201);++ assert.strictEqual(out.body.kind, 'video', 'an ftyp header is a video, whatever the browser said');++ out = await upload(Buffer.from('a plain note\n'), 'text/plain', 'note.txt');++ assert.strictEqual(out.body.kind, 'file');++ out = await upload(Buffer.from('export const x = 1;\n'), 'text/plain', 'x.js');++ assert.strictEqual(out.body.kind, 'code');++ out = await upload(Buffer.from('OggS' + 'x'.repeat(28)), 'audio/ogg', 'take.ogg');++ assert.strictEqual(out.body.kind, 'audio');++++ // ── An executable is refused ──++ out = await upload(Buffer.from('MZ\x90\x00'), 'application/octet-stream', 'setup.exe');++ assert.strictEqual(out.status, 415);++ assert.strictEqual(out.body.code, 'media.attachment.executable');++ out = await upload(Buffer.from('\x7fELFrest'), 'application/octet-stream', 'tool');++ assert.strictEqual(out.status, 415);++++ // ── The per-kind limits (the picture is over 20 MB) ──++ assert.deepStrictEqual(routes.MAX_BYTES, { image: 20 * MB, video: 300 * MB, audio: 50 * MB, file: 25 * MB, code: 25 * MB });++ out = await upload(Buffer.concat([png, Buffer.alloc(20 * MB)]), 'image/png', 'huge.png');++ assert.strictEqual(out.status, 413);++ assert.strictEqual(out.body.code, 'media.attachment.too_large');++++ // ── A site's service token names the person it acts for ──++ out = await upload(png, 'image/png', 'via-site.png', { token: serviceToken(), headers: { 'X-OV-Subject': SUBJECT } });++ assert.strictEqual(out.status, 201, JSON.stringify(out.body));++ const siteId = out.body.id;++ out = await upload(png, 'image/png', 'no-person.png', { token: serviceToken() });++ assert.strictEqual(out.status, 403);++ assert.strictEqual(out.body.code, 'attachments.subject_required');++ out = await upload(png, 'image/png', 'no-cap.png', { token: serviceToken(['media.object.read']), headers: { 'X-OV-Subject': SUBJECT } });++ assert.strictEqual(out.status, 403);++++ // ── Keep with a message: only the owner, and it moves the expiry out ──++ let res = await fetch(`${base}/api/v1/attachments/${siteId}/keep`, { method: 'POST', headers: { Authorization: `Bearer ${personToken(OTHER)}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ message_id: 'msg_1' }) });++ assert.strictEqual(res.status, 404, 'another person cannot keep it');++ res = await fetch(`${base}/api/v1/attachments/${siteId}/keep`, { method: 'POST', headers: { Authorization: `Bearer ${personToken()}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ message_id: 'msg_1' }) });++ assert.strictEqual(res.status, 200);++ const kept = await res.json();++ assert.ok(Date.parse(kept.expires_at) - Date.now() > 24 * 3600_000, 'a kept attachment lives past the unkept 24 hours');++ const row = await db.get('SELECT message_id, kept_at, expires_at FROM media_attachments WHERE id = ?', [siteId]);++ assert.strictEqual(row.message_id, 'msg_1');++++ // ── Delete: the owner only ──++ res = await fetch(`${base}/api/v1/attachments/${siteId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${personToken(OTHER)}` } });++ assert.strictEqual(res.status, 404);++ res = await fetch(`${base}/api/v1/attachments/${siteId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${serviceToken()}`, 'X-OV-Subject': SUBJECT } });++ assert.strictEqual(res.status, 204);++ assert.strictEqual(await db.get('SELECT id FROM media_attachments WHERE id = ?', [siteId]), undefined);++ assert.strictEqual(fs.existsSync(path.join(config.attachments.path, siteId)), false);++++ // ── No credential, no answer ──++ res = await fetch(`${base}/api/v1/attachments`, { method: 'POST', body: form(png, 'image/png', 'x.png') });++ assert.strictEqual(res.status, 401);++++ // ── Expiry: a row past its time goes, bytes and all ──++ const expiredId = `att_${ids.ulid()}`;++ const expiredDir = path.join(config.attachments.path, expiredId);++ fs.mkdirSync(expiredDir, { recursive: true });++ fs.writeFileSync(path.join(expiredDir, 'old.txt'), 'old');++ await db.run(`INSERT INTO media_attachments (id, subject, principal, name, stored_name, kind, mime, size_bytes, expires_at)++ VALUES (?, ?, ?, 'old.txt', 'old.txt', 'file', 'text/plain', 3, ?)`, [expiredId, SUBJECT, SUBJECT, new Date(Date.now() - 1000).toISOString()]);++ assert.strictEqual(await routes.cleanupExpired(), 1);++ assert.strictEqual(fs.existsSync(expiredDir), false);++ assert.strictEqual(await db.get('SELECT id FROM media_attachments WHERE id = ?', [expiredId]), undefined);++++ // The image from the first upload is still there and still served.++ assert.strictEqual((await fetch(`${base}/a/${imageId}/shot.png`)).status, 200);++++ await new Promise((r) => server.close(r));++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('attachments: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -2,6 +2,14 @@
@@ -2,6 +2,14 @@
## Unreleased
## Unreleased
+- **The shared composer's attachments (2026-11-10, composer.attachment@1).** `POST /api/v1/attachments` takes one
+ multipart `file` from a person (a Network user JWT, or a first-party site's service token naming the person in
+ `X-OV-Subject`), sniffs the kind from the bytes — image, video, audio, file or code — and refuses an executable.
+ Limits are image 20 MB, video 300 MB, audio 50 MB, anything else 25 MB; images get a 640 px JPEG thumbnail and a
+ video its first frame (ffmpeg). `POST /api/v1/attachments/:id/keep` names the message it belongs to, `DELETE` removes
+ the person's own file, and an unkept attachment is swept 24 hours after upload (migrations/0027_attachments.sql).
+ Bytes are served at `/a/<id>/<name>`; CORS is the first-party origin list (server/attachments/origins.js).
+
- `scripts/orientation-backfill.js` inspects stored JPEG, PNG and WebP originals in batches (dry run by default), bakes
- `scripts/orientation-backfill.js` inspects stored JPEG, PNG and WebP originals in batches (dry run by default), bakes
any surviving EXIF orientation into pixels, removes the embedded details, updates object/location hashes and sizes,
any surviving EXIF orientation into pixels, removes the embedded details, updates object/location hashes and sizes,
and rebuilds 320/640 WebP variants. Images without source orientation data are marked `orientation unknown` for the
and rebuilds 320/640 WebP variants. Images without source orientation data are marked `orientation unknown` for the
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+-- phase: expand
+-- The composer's attachments (composer.attachment@1): one row per file a person attached to a message before it is
+-- sent. Bytes live under ATTACHMENTS_PATH/<id>/; the row is the authority on who may keep or delete it. An unkept
+-- attachment expires 24 hours after upload (expires_at); a site that keeps it names the message it belongs to
+-- (message_id, kept_at) and the window moves to the kept one. Deleting the row removes the directory.
+CREATE TABLE IF NOT EXISTS media_attachments (
+ id text COLLATE "C" PRIMARY KEY,
+ subject text COLLATE "C" NOT NULL,
+ principal text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ stored_name text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('image', 'video', 'audio', 'file', 'code')),
+ mime text COLLATE "C" NOT NULL,
+ size_bytes bigint NOT NULL CHECK (size_bytes > 0),
+ sha256 text COLLATE "C",
+ width bigint,
+ height bigint,
+ thumb_name text COLLATE "C",
+ message_id text COLLATE "C",
+ kept_at text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ expires_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_media_attachments_subject ON media_attachments(subject, created_at);
+CREATE INDEX IF NOT EXISTS idx_media_attachments_expires ON media_attachments(expires_at);
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
* Media then confirms with counts (POST /internal/account-deletions/:id/confirmations).
* Media then confirms with counts (POST /internal/account-deletions/:id/confirmations).
*/
*/
const fs = require('fs');
const fs = require('fs');
+const path = require('path');
+const config = require('./config');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -46,6 +48,11 @@ async function exportPart(db, subjects) {
@@ -46,6 +48,11 @@ async function exportPart(db, subjects) {
if (vods.length) files.push({ name: 'vods.json', content: vods });
if (vods.length) files.push({ name: 'vods.json', content: vods });
if (clips.length) files.push({ name: 'clips.json', content: clips });
if (clips.length) files.push({ name: 'clips.json', content: clips });
if (plain.length) files.push({ name: 'files.json', content: plain });
if (plain.length) files.push({ name: 'files.json', content: plain });
+ // Composer attachments are not media_objects: the person's own files before (or instead of) a message. Their
+ // metadata goes in attachments.json; the bytes are downloaded from openvibe.media/a/<id>/<name>.
+ const attachments = await db.prepare(`SELECT id, name, kind, mime, size_bytes, message_id, kept_at, created_at, expires_at
+ FROM media_attachments WHERE subject IN ${inList(subjects)} ORDER BY created_at DESC LIMIT ${ROW_LIMIT}`).all(...subjects);
+ if (attachments.length) files.push({ name: 'attachments.json', content: attachments });
// The share.v1 rows the person owns or is on (step 5): the recording itself is above; this is who it was
// The share.v1 rows the person owns or is on (step 5): the recording itself is above; this is who it was
// shared with. `people` is a list of subject ids, never a name.
// shared with. `people` is a list of subject ids, never a name.
const owned = await db.prepare(`SELECT service, kind, resource_id, audience, lock, revision FROM shares WHERE owner_subject->>'id' IN ${inList(subjects)} ORDER BY resource_id DESC LIMIT ${ROW_LIMIT}`).all(...subjects);
const owned = await db.prepare(`SELECT service, kind, resource_id, audience, lock, revision FROM shares WHERE owner_subject->>'id' IN ${inList(subjects)} ORDER BY resource_id DESC LIMIT ${ROW_LIMIT}`).all(...subjects);
@@ -66,6 +73,13 @@ async function erase(db, subjects) {
@@ -66,6 +73,13 @@ async function erase(db, subjects) {
const erased = {};
const erased = {};
const retained = {};
const retained = {};
const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };
const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };
+ // Composer attachments: the row and the stored bytes, like DELETE /api/v1/attachments/:id does.
+ const attachments = await db.prepare(`SELECT id FROM media_attachments WHERE subject IN ${inList(subjects)}`).all(...subjects);
+ for (const a of attachments) {
+ await db.prepare('DELETE FROM media_attachments WHERE id = ?').run(a.id);
+ try { fs.rmSync(path.join(path.resolve(config.attachments.path), a.id), { recursive: true, force: true }); } catch { /* gone */ }
+ bump(erased, 'attachments');
+ }
const objs = await db.prepare(`SELECT * FROM media_objects WHERE owner_subject IN ${inList(subjects)} AND lifecycle_status != 'deleted' ORDER BY (kind = 'thumbnail'), created_at`).all(...subjects);
const objs = await db.prepare(`SELECT * FROM media_objects WHERE owner_subject IN ${inList(subjects)} AND lifecycle_status != 'deleted' ORDER BY (kind = 'thumbnail'), created_at`).all(...subjects);
for (const o of objs) {
for (const o of objs) {
if (await model.isHeld(o.id)) { bump(retained, 'held_media'); continue; }
if (await model.isHeld(o.id)) { bump(retained, 'held_media'); continue; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+/**
+ * The origins the attachment routes answer: every first-party origin the service manifests declare (the same list
+ * Network's server/first-party-origins.js builds), plus the hard-coded ones that must survive a registry failure,
+ * plus the local development ports outside production. Only exact https origins: a tenant subdomain is never one.
+ */
+const contracts = require('openvibe-contracts');
+
+const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
+const BASE = [
+ 'https://openvibe.network',
+ 'https://openvibe.live', 'https://www.openvibe.live',
+ 'https://openvibe.tools',
+ 'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games',
+ 'https://openvibe.media',
+ 'https://openvibe.community',
+ 'https://openvibe.space',
+];
+const DEV = [
+ 'http://localhost:3000', 'http://localhost:4000', 'http://127.0.0.1:4000',
+ 'http://localhost:4001', 'http://localhost:4100', 'http://localhost:4200',
+ 'http://localhost:4400', 'http://localhost:4940', 'http://localhost:8000',
+ 'http://localhost:5173',
+];
+
+let cached = null;
+
+function openVibeOrigins() {
+ if (cached) return cached;
+ const out = new Set(BASE);
+ for (const m of (contracts.services && contracts.services.manifests) || []) {
+ for (const d of m.domains || []) {
+ const host = String(d || '').trim().toLowerCase();
+ if (HOST_RE.test(host)) out.add(`https://${host}`);
+ }
+ }
+ if (process.env.NODE_ENV !== 'production') for (const o of DEV) out.add(o);
+ cached = out;
+ return out;
+}
+
+module.exports = { openVibeOrigins };
new file mode 100644
new file mode 100644
@@ -0,0 +1,371 @@
@@ -0,0 +1,371 @@
+/**
+ * OpenVibe.Media — composer attachments (composer.attachment@1)
+ *
+ * POST /api/v1/attachments multipart field `file` -> composer.attachment@1
+ * POST /api/v1/attachments/:id/keep { message_id } -> composer.attachment@1 (kept with a message)
+ * DELETE /api/v1/attachments/:id the person who uploaded it -> 204
+ * GET /a/:id/:name the bytes (public, unguessable id), and /a/:id/thumb.jpg
+ *
+ * One person's file, stored before it is sent, so every site's composer attaches the same way. Credentials:
+ * a Network user JWT (the browser), or a first-party service token holding media.object.upload with the person
+ * named in X-OV-Subject (a site's server). Media sniffs the bytes and decides the kind; an executable is refused.
+ * Limits: image 20 MB, video 300 MB, audio 50 MB, anything else 25 MB (code included). An unkept attachment
+ * expires 24 hours after upload; a keep names the message it belongs to and moves it to the kept window.
+ *
+ * The CORS allow-list is every first-party origin (the service manifests' domains, server/attachments/origins.js).
+ * A foreign origin gets no Access-Control-Allow-Origin and is refused by the browser; the bytes route is public.
+ */
+'use strict';
+
+const express = require('express');
+const multer = require('multer');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const contracts = require('openvibe-contracts');
+const sdkAuth = require('openvibe-sdk/auth');
+const sharp = require('sharp');
+const db = require('../db/database');
+const config = require('../config');
+const auth = require('../auth');
+const ctypes = require('../objects/content-type');
+const { limits } = require('../actor-limits');
+const { openVibeOrigins } = require('./origins');
+
+const { capabilities, http, ids } = contracts;
+const MB = 1024 * 1024;
+const MAX_BYTES = { image: 20 * MB, video: 300 * MB, audio: 50 * MB, file: 25 * MB, code: 25 * MB };
+const UPLOAD_MAX = 300 * MB;
+const ttlMs = () => (Number(config.attachments.ttlHours) || 24) * 60 * 60 * 1000;
+const keepMs = () => (Number(config.attachments.keepDays) || 30) * 24 * 60 * 60 * 1000;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const NAME_MAX = 255;
+
+const router = express.Router();
+const publicRouter = express.Router();
+
+function problem(res, status, code, detail) {
+ return http.sendProblem(res, status, code, { detail });
+}
+
+// ── CORS: first-party origins only ───────────────────────────
+function cors(req, res, next) {
+ const origin = req.headers.origin;
+ if (origin) res.set('Vary', 'Origin');
+ if (origin && openVibeOrigins().has(origin)) {
+ res.set('Access-Control-Allow-Origin', origin);
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-OV-Subject, X-OV-User-Id, Idempotency-Key, traceparent, X-OpenVibe-Request-Id');
+ res.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
+ res.set('Access-Control-Max-Age', '600');
+ }
+ if (req.method === 'OPTIONS') return res.status(204).end();
+ next();
+}
+router.use(cors);
+
+// ── Storage ──────────────────────────────────────────────────
+function root() { return path.resolve(config.attachments.path); }
+function dirOf(id) { return path.join(root(), id); }
+function sanitizeName(name) {
+ const base = path.basename(String(name || 'file')).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, NAME_MAX);
+ return base.trim() || 'file';
+}
+function publicUrl(id, name) { return `${config.publicUrl}/a/${encodeURIComponent(id)}/${encodeURIComponent(name)}`; }
+function sha256File(file) {
+ return new Promise((resolve, reject) => {
+ const hash = crypto.createHash('sha256');
+ const stream = fs.createReadStream(file);
+ stream.on('data', (d) => hash.update(d));
+ stream.on('end', () => resolve(hash.digest('hex')));
+ stream.on('error', reject);
+ });
+}
+
+function ensureDirs() {
+ if (!fs.existsSync(root())) fs.mkdirSync(root(), { recursive: true });
+}
+function tmpDir() {
+ const d = path.join(root(), '.tmp');
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
+ return d;
+}
+
+// ── Credentials: a user JWT, or a first-party service token naming the person ──
+function bearer(req) {
+ const h = String(req.headers.authorization || '');
+ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
+}
+function namedSubject(req) {
+ const raw = req.headers['x-ov-subject'];
+ if (raw == null || raw === '') return null;
+ const s = String(raw).trim();
+ const parsed = ids.parseSubject(s);
+ if (parsed && parsed.type === 'user') return parsed.id;
+ return SUBJECT_RE.test(s) ? s : null;
+}
+async function verifyUser(token) {
+ const keys = auth.networkKeys();
+ let p;
+ try { p = await sdkAuth.verifyUserToken(token, { ...keys.verifyOptions, issuer: config.network.url }); } catch { return null; }
+ if (p.aud) {
+ const aud = Array.isArray(p.aud) ? p.aud : [p.aud];
+ if (!aud.includes('openvibe.media')) return null;
+ }
+ // Network's session token carries the numeric account id in `sub` and the person's subject in `subject_id`.
+ const subject = String(p.subject_id || p.sub || '');
+ return SUBJECT_RE.test(subject) ? { payload: p, subject } : null;
+}
+async function attachmentAuth(req, res, next) {
+ const token = bearer(req);
+ if (!token) return problem(res, 401, 'token.missing', 'a Network token is required');
+ let subject = null;
+ let principal = null;
+ if (token.split('.').length === 3) {
+ const svc = await auth.verifyServiceTokenResult(token);
+ if (svc.ok) {
+ if (!String(svc.claims.sub || '').startsWith('svc:')) {
+ return problem(res, 403, 'capability.denied', 'only a first-party service token or a person may attach a file');
+ }
+ const c = capabilities.check(svc.claims, 'media.object.upload');
+ if (!c.allowed) return problem(res, 403, c.code || 'capability.denied', c.reason || 'media.object.upload is not granted');
+ subject = namedSubject(req);
+ if (!subject) return problem(res, 403, 'attachments.subject_required', 'a service token must name the person in X-OV-Subject (usr_…)');
+ principal = svc.claims.sub;
+ }
+ }
+ if (!subject) {
+ const user = await verifyUser(token);
+ if (!user) return problem(res, 401, 'token.invalid', 'the token is not a valid Network person or media service token');
+ subject = user.subject;
+ principal = user.subject;
+ }
+ req.person = { subject, principal };
+ next();
+}
+
+// ── Bytes sniffing ───────────────────────────────────────────
+const MIME = {
+ jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp',
+ avif: 'image/avif', heic: 'image/heic', heix: 'image/heic', mif1: 'image/heic', msf1: 'image/heic',
+ mp4: 'video/mp4', matroska: 'video/webm', ogg: 'video/ogg', avi: 'video/x-msvideo',
+ wav: 'audio/wav', flac: 'audio/flac', mp3: 'audio/mpeg', m4a: 'audio/mp4', mpegts: 'video/mp2t', pdf: 'application/pdf',
+};
+const CODE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|c|h|cc|cpp|hpp|cs|java|kt|swift|php|sh|bash|zsh|fish|ps1|sql|html|htm|css|scss|sass|less|json|jsonc|yaml|yml|toml|ini|xml|svg|vue|svelte|astro|md|mdx|lua|pl|r|dart|ex|exs|erl|hs|scala|clj|sol|zig|nim|graphql|gql|tf|dockerfile|make|cmake)$/i;
+const CODE_MIME = /^(application\/(javascript|ecmascript|typescript|json|xml|x-sh|x-httpd-php|x-ruby|x-python|x-perl|x-shellscript|x-csh|x-c\+\+|x-java-source|x-lua|x-rust|x-go)|text\/(javascript|typescript|css|html|x-python|x-sh|x-c|x-c\+\+|x-java-source|x-markdown|x-rust|x-go))/;
+const EXEC_MIME = /^(application\/(x-msdownload|x-msdos-program|x-dosexec|x-executable|x-mach-binary|x-sharedlib|x-object|x-elf|vnd\.microsoft\.portable-executable|java-archive|vnd\.android\.package-archive|x-apple-diskimage|x-msi|x-rpm|x-deb))/;
+const EXEC_EXT = /\.(exe|dll|sys|so|dylib|bin|com|msi|bat|cmd|ps1|vbs|wsf|scr|jar|apk|appimage|deb|rpm|dmg|pkg|elf)$/i;
+
+/** Is this a binary executable or an installer? (refused before it is stored) */
+function isExecutable(head, declaredMime, name) {
+ if (EXEC_MIME.test(String(declaredMime || '').toLowerCase())) return true;
+ if (EXEC_EXT.test(String(name || ''))) return true;
+ const b = Buffer.isBuffer(head) ? head : Buffer.from(head || []);
+ if (b.length >= 2 && b[0] === 0x4d && b[1] === 0x5a) return true; // MZ / PE
+ if (b.length >= 4 && b[0] === 0x7f && b[1] === 0x45 && b[2] === 0x4c && b[3] === 0x46) return true; // ELF
+ if (b.length >= 4) {
+ const magic = b.readUInt32BE(0);
+ if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca].includes(magic)) return true; // Mach-O / fat
+ }
+ return false;
+}
+
+/** { kind, mime } from the bytes (never the browser's claim), with the extension only for source text. */
+function classify(head, declaredMime, name) {
+ const sniffed = ctypes.sniff(head);
+ if (sniffed) {
+ const mime = MIME[sniffed.format] || (sniffed.family === 'pdf' ? 'application/pdf' : declaredMime);
+ if (sniffed.family === 'image') return { kind: 'image', mime };
+ if (sniffed.family === 'video') return { kind: 'video', mime };
+ if (sniffed.family === 'audio') return { kind: 'audio', mime };
+ if (sniffed.family === 'av') return /^audio\//.test(String(declaredMime || '')) ? { kind: 'audio', mime } : { kind: 'video', mime };
+ return { kind: 'file', mime };
+ }
+ const declared = ctypes.normalize(declaredMime);
+ if (CODE_EXT.test(String(name || '')) || CODE_MIME.test(String(declared || ''))) return { kind: 'code', mime: declared || 'text/plain; charset=utf-8' };
+ return { kind: 'file', mime: declared || 'application/octet-stream' };
+}
+
+// ── Thumbnails and size ──────────────────────────────────────
+async function imageInfo(file, dir) {
+ const meta = await sharp(file).metadata();
+ let w = Number(meta.width) || null;
+ let h = Number(meta.height) || null;
+ if (meta.orientation && meta.orientation >= 5) [w, h] = [h, w];
+ const thumb = path.join(dir, 'thumb.jpg');
+ try {
+ await sharp(file).rotate().resize({ width: 640, height: 640, fit: 'inside', withoutEnlargement: true }).jpeg({ quality: 82 }).toFile(thumb);
+ return { width: w, height: h, thumb_name: 'thumb.jpg' };
+ } catch { return { width: w, height: h, thumb_name: null }; }
+}
+async function videoInfo(file, dir) {
+ let width = null, height = null;
+ try {
+ const info = await require('../vod/media-tools').probeDuration(file);
+ const v = (info.streams || []).find(s => s.codec_type === 'video' && Number(s.width) > 0);
+ if (v) { width = Number(v.width); height = Number(v.height); }
+ } catch { /* no ffprobe: no size */ }
+ let thumbName = null;
+ try {
+ const ok = await require('../thumbnails/thumbnail-service').extractFrameToFile(file, 1, path.join(dir, 'thumb.jpg'));
+ if (ok) thumbName = 'thumb.jpg';
+ } catch { /* best effort */ }
+ return { width, height, thumb_name: thumbName };
+}
+
+// ── The answer ───────────────────────────────────────────────
+function shape(row) {
+ return {
+ id: row.id,
+ kind: row.kind,
+ name: row.name,
+ size: Number(row.size_bytes),
+ mime: row.mime,
+ url: publicUrl(row.id, row.stored_name),
+ thumb: row.thumb_name ? publicUrl(row.id, row.thumb_name) : null,
+ w: row.width != null ? Number(row.width) : null,
+ h: row.height != null ? Number(row.height) : null,
+ expires_at: row.expires_at,
+ };
+}
+const getRow = (id) => db.get('SELECT * FROM media_attachments WHERE id = ?', [id]);
+const deleteRow = (id) => db.run('DELETE FROM media_attachments WHERE id = ?', [id]);
+
+// ── Upload ───────────────────────────────────────────────────
+// multer creates its destination when built; a restore drill takes no upload and writes nothing.
+const upload = multer({
+ storage: require('../drill').enabled ? multer.memoryStorage() : multer.diskStorage({ destination: (_req, _file, cb) => cb(null, tmpDir()) }),
+ limits: { fileSize: UPLOAD_MAX, files: 1, fields: 10 },
+});
+
+router.post('/', attachmentAuth, limits('media.attachment.upload', { minute: 30, hour: 600 }), upload.single('file'), async (req, res) => {
+ const tmp = req.file && req.file.path;
+ try {
+ if (!req.file) return problem(res, 400, 'attachments.file_required', 'send one file in the multipart field `file`');
+ const head = ctypes.readHead(req.file.path, 512);
+ const name = sanitizeName(req.file.originalname);
+ if (isExecutable(head, req.file.mimetype, name)) return problem(res, 415, 'media.attachment.executable', 'executables and installers are not accepted');
+ const { kind, mime } = classify(head, req.file.mimetype, name);
+ const max = MAX_BYTES[kind];
+ if (req.file.size > max) {
+ return problem(res, 413, 'media.attachment.too_large', `a ${kind} may be at most ${Math.round(max / MB)} MB`);
+ }
+ const id = `att_${ids.ulid()}`;
+ const dir = dirOf(id);
+ fs.mkdirSync(dir, { recursive: true });
+ const stored = name;
+ const dest = path.join(dir, stored);
+ try { fs.renameSync(tmp, dest); } catch { fs.copyFileSync(tmp, dest); try { fs.unlinkSync(tmp); } catch { /* */ } }
+ const sha256 = await sha256File(dest);
+ let info = { width: null, height: null, thumb_name: null };
+ if (kind === 'image') info = await imageInfo(dest, dir).catch(() => info);
+ else if (kind === 'video') info = await videoInfo(dest, dir).catch(() => info);
+ const expiresAt = new Date(Date.now() + ttlMs()).toISOString();
+ await db.run(
+ `INSERT INTO media_attachments (id, subject, principal, name, stored_name, kind, mime, size_bytes, sha256, width, height, thumb_name, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [id, req.person.subject, req.person.principal, req.file.originalname || name, stored, kind, mime, req.file.size, sha256, info.width, info.height, info.thumb_name, expiresAt]);
+ const row = await getRow(id);
+ res.status(201).json(shape(row));
+ } catch (err) {
+ if (tmp) { try { fs.unlinkSync(tmp); } catch { /* */ } }
+ if (err && err.code === 'LIMIT_FILE_SIZE') return problem(res, 413, 'media.attachment.too_large', 'the file is larger than 300 MB');
+ console.error('[Attachments] Upload error:', err.message);
+ if (!res.headersSent) problem(res, 500, 'media.attachment.store_failed', 'the file could not be stored');
+ }
+});
+
+// ── Keep with a message ──────────────────────────────────────
+router.post('/:id/keep', attachmentAuth, limits('media.attachment.keep', { minute: 120, hour: 1200 }), async (req, res) => {
+ try {
+ const row = await getRow(String(req.params.id || ''));
+ if (!row || row.subject !== req.person.subject) return problem(res, 404, 'media.attachment.not_found', 'no such attachment for this person');
+ const messageId = String((req.body && req.body.message_id) || '').trim();
+ if (!messageId || messageId.length > 200) return problem(res, 422, 'attachments.message_required', 'name the message this attachment belongs to in `message_id`');
+ const expiresAt = new Date(Date.now() + keepMs()).toISOString();
+ await db.run('UPDATE media_attachments SET message_id = ?, kept_at = ?, expires_at = ? WHERE id = ?', [messageId, new Date().toISOString(), expiresAt, row.id]);
+ res.json(shape(await getRow(row.id)));
+ } catch (err) {
+ console.error('[Attachments] Keep error:', err.message);
+ problem(res, 500, 'media.attachment.keep_failed', 'the attachment could not be kept');
+ }
+});
+
+// ── Delete (the person who uploaded it) ─────────────────────
+router.delete('/:id', attachmentAuth, limits('media.attachment.delete', { minute: 60, hour: 1200 }), async (req, res) => {
+ try {
+ const row = await getRow(String(req.params.id || ''));
+ if (!row || row.subject !== req.person.subject) return problem(res, 404, 'media.attachment.not_found', 'no such attachment for this person');
+ await deleteRow(row.id);
+ fs.rmSync(dirOf(row.id), { recursive: true, force: true });
+ res.status(204).end();
+ } catch (err) {
+ console.error('[Attachments] Delete error:', err.message);
+ problem(res, 500, 'media.attachment.delete_failed', 'the attachment could not be deleted');
+ }
+});
+
+// ── Expiry sweep ─────────────────────────────────────────────
+/** Remove the rows (and directories) whose expires_at has passed. Returns how many went. */
+async function cleanupExpired({ now = new Date(), limit = 500 } = {}) {
+ const rows = await db.all('SELECT id FROM media_attachments WHERE expires_at < ? ORDER BY expires_at LIMIT ?', [now.toISOString(), limit]);
+ for (const r of rows) {
+ try { await deleteRow(r.id); } catch { continue; }
+ fs.rmSync(dirOf(r.id), { recursive: true, force: true });
+ }
+ return rows.length;
+}
+function startCleanup() {
+ const timer = setInterval(() => { cleanupExpired().catch(err => console.warn('[Attachments] cleanup:', err.message)); }, 60 * 60 * 1000);
+ if (timer.unref) timer.unref();
+ return timer;
+}
+
+// ── The bytes ────────────────────────────────────────────────
+function sendBytes(req, res, row, filename, mime) {
+ const file = path.join(dirOf(row.id), filename);
+ if (!fs.existsSync(file)) return problem(res, 404, 'media.attachment.not_found', 'the bytes are gone');
+ res.set('X-Content-Type-Options', 'nosniff');
+ res.set('Cross-Origin-Resource-Policy', 'cross-origin');
+ res.set('Access-Control-Allow-Origin', '*');
+ res.set('Cache-Control', 'public, max-age=31536000, immutable');
+ const inline = /^(image|video|audio)\//.test(mime) || mime === 'application/pdf';
+ const safe = String(filename).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
+ res.set('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
+ res.type(mime);
+ res.sendFile(file);
+}
+publicRouter.get('/:id/thumb.jpg', async (req, res, next) => {
+ try {
+ const row = await getRow(String(req.params.id || ''));
+ if (!row || !row.thumb_name) return next();
+ sendBytes(req, res, row, row.thumb_name, 'image/jpeg');
+ } catch (err) { next(err); }
+});
+publicRouter.get('/:id/:name', async (req, res, next) => {
+ try {
+ const row = await getRow(String(req.params.id || ''));
+ if (!row) return problem(res, 404, 'media.attachment.not_found', 'no such attachment');
+ sendBytes(req, res, row, row.stored_name, row.mime);
+ } catch (err) { next(err); }
+});
+
+// Multer's own refusals become problem+json.
+router.use((err, req, res, next) => {
+ if (!err) return next();
+ if (err instanceof multer.MulterError) {
+ const code = err.code === 'LIMIT_FILE_SIZE' ? 'media.attachment.too_large' : 'attachments.invalid_upload';
+ return problem(res, err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, code, err.message);
+ }
+ console.error('[Attachments] Unhandled route error:', err.message);
+ if (!res.headersSent) problem(res, 500, 'media.attachment.failed', 'the request could not be handled');
+});
+
+module.exports = router;
+module.exports.publicRouter = publicRouter;
+module.exports.cleanupExpired = cleanupExpired;
+module.exports.startCleanup = startCleanup;
+module.exports.shape = shape;
+module.exports.classify = classify;
+module.exports.isExecutable = isExecutable;
+module.exports.MAX_BYTES = MAX_BYTES;
+module.exports._ensureDirs = ensureDirs;
@@ -67,6 +67,14 @@ const config = {
@@ -67,6 +67,14 @@ const config = {
maxSizeMb: intEnv('MAX_FILE_SIZE_MB', 256),
maxSizeMb: intEnv('MAX_FILE_SIZE_MB', 256),
},
},
+ // Composer attachments (server/attachments/routes.js): one person's file before it is sent. An unkept one
+ // expires after 24 hours; a keep moves it to the kept window. Bytes live under ATTACHMENTS_PATH/<att_…>/.
+ attachments: {
+ path: process.env.ATTACHMENTS_PATH || './data/attachments',
+ ttlHours: intEnv('MEDIA_ATTACHMENT_TTL_HOURS', 24),
+ keepDays: intEnv('MEDIA_ATTACHMENT_KEEP_DAYS', 30),
+ },
+
// Canonical object model (Wave 4): native v2 uploads, signed delivery, retention,
// Canonical object model (Wave 4): native v2 uploads, signed delivery, retention,
// and the public playback object-size invariant (policy, not a copied constant).
// and the public playback object-size invariant (policy, not a copied constant).
objects: {
objects: {
@@ -296,6 +296,15 @@ const ready = (async () => {
@@ -296,6 +296,15 @@ const ready = (async () => {
}
}
}
}
+ // Composer attachments (composer.attachment@1): every site's composer uploads one person's file the same way.
+ // It sits before /api/v1/:app so `attachments` is never read as a tenant. Public bytes are /a/<id>/<name>.
+ {
+ const attachments = require('./attachments/routes');
+ attachments._ensureDirs();
+ app.use('/api/v1/attachments', attachments);
+ app.use('/a', attachments.publicRouter);
+ if (!drill.enabled) app.locals.attachmentsCleanup = attachments.startCleanup();
+ }
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/views', require('./views/routes'));
// GET /api/v1/:app/watch/:watch_id: which item a watch id is (server/watch-routes.js, plan track U).
// GET /api/v1/:app/watch/:watch_id: which item a watch id is (server/watch-routes.js, plan track U).
app.use('/api/v1/:app/watch', require('./watch-routes'));
app.use('/api/v1/:app/watch', require('./watch-routes'));
@@ -22,7 +22,7 @@ const path = require('path');
@@ -22,7 +22,7 @@ const path = require('path');
const db = require('../server/db/database').getDb();
const db = require('../server/db/database').getDb();
{
{
const missing = await uncovered(db, {
const missing = await uncovered(db, {
- taskKeyed: ['vods', 'clips', 'files'],
+ taskKeyed: ['vods', 'clips', 'files', 'media_attachments'],
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Media applied',
account_data_events: 'the ledger of requests itself: which export and deletion Media applied',
assets: 'legacy rows keyed by Live\'s numeric user id, not a Network subject',
assets: 'legacy rows keyed by Live\'s numeric user id, not a Network subject',
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+// Composer attachments (composer.attachment@1, server/attachments/routes.js): the person's upload through a Network
+// user JWT, a site's service token naming the person in X-OV-Subject, sniffed kinds, the per-kind limits, executable
+// refusal, keep, delete, the 24-hour expiry and the CORS allow-list (a first-party origin yes, a foreign one no).
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+const express = require('express');
+const sharp = require('sharp');
+const jwt = require('jsonwebtoken');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-attachments-'));
+ process.env.ATTACHMENTS_PATH = path.join(tmp, 'attachments');
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+ const db = require('../server/db/database');
+ const config = require('../server/config');
+ const auth = require('../server/auth');
+ const routes = require('../server/attachments/routes');
+ const MB = 1024 * 1024;
+
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ auth._setNetworkPublicKeyForTests(publicKey.export({ type: 'spki', format: 'pem' }));
+ const now = Math.floor(Date.now() / 1000);
+ const SUBJECT = ids.newId('user');
+ const OTHER = ids.newId('user');
+ // Network's session token: the numeric account id in `sub`, the person's subject in `subject_id`.
+ const personToken = (subject = SUBJECT) => jwt.sign({ iss: 'https://openvibe.network', sub: 42, subject_id: subject, aud: ['openvibe.media', 'openvibe.network'], iat: now, exp: now + 600 }, privateKey.export({ type: 'pkcs8', format: 'pem' }), { algorithm: 'RS256' });
+ const serviceToken = (cap = ['media.object.upload']) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, privateKey.export({ type: 'pkcs8', format: 'pem' }));
+
+ const app = express();
+ app.use(express.json());
+ app.use('/api/v1/attachments', routes);
+ app.use('/a', routes.publicRouter);
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const form = (bytes, type, name, extra = {}) => {
+ const f = new FormData();
+ for (const [k, v] of Object.entries(extra)) f.append(k, String(v));
+ f.append('file', new Blob([bytes], { type }), name);
+ return f;
+ };
+ const upload = async (bytes, type, name, { token = personToken(), headers = {}, extra } = {}) => {
+ const r = await fetch(`${base}/api/v1/attachments`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, ...headers }, body: form(bytes, type, name, extra) });
+ return { status: r.status, body: await r.json().catch(() => null) };
+ };
+
+ // ── CORS: a first-party origin is allowed, a foreign one is not ──
+ const preflight = (origin) => fetch(`${base}/api/v1/attachments`, { method: 'OPTIONS', headers: { Origin: origin, 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'authorization' } });
+ let r = await preflight('https://openvibe.community');
+ assert.strictEqual(r.status, 204);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.community', 'an OpenVibe origin is allowed');
+ assert.match(r.headers.get('access-control-allow-headers') || '', /Authorization/);
+ r = await preflight('https://evil.example');
+ assert.strictEqual(r.status, 204);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), null, 'a foreign origin gets no allow header');
+
+ // ── A person's image: sniffed kind, thumb and size, 24-hour expiry ──
+ const png = await sharp({ create: { width: 48, height: 24, channels: 3, background: '#3366ff' } }).png().toBuffer();
+ let out = await upload(png, 'image/png', 'shot.png');
+ assert.strictEqual(out.status, 201, JSON.stringify(out.body));
+ assert.match(out.body.id, /^att_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(out.body.kind, 'image');
+ assert.strictEqual(out.body.size, png.length);
+ assert.strictEqual(out.body.mime, 'image/png');
+ assert.match(out.body.url, /^https:\/\/media\.test\/a\/att_[^/]+\/shot\.png$/);
+ assert.match(out.body.thumb, /^https:\/\/media\.test\/a\/att_[^/]+\/thumb\.jpg$/);
+ assert.deepStrictEqual([out.body.w, out.body.h], [48, 24]);
+ const ttl = Date.parse(out.body.expires_at) - Date.now();
+ assert.ok(ttl > 23 * 3600_000 && ttl <= 24 * 3600_000, 'expires in 24 hours');
+ const imageId = out.body.id;
+
+ let bytes = await fetch(`${base}/a/${imageId}/shot.png`);
+ assert.strictEqual(bytes.status, 200);
+ assert.strictEqual(bytes.headers.get('content-type'), 'image/png');
+ assert.strictEqual(bytes.headers.get('x-content-type-options'), 'nosniff');
+ bytes = await fetch(`${base}/a/${imageId}/thumb.jpg`);
+ assert.strictEqual(bytes.status, 200);
+ assert.strictEqual(bytes.headers.get('content-type'), 'image/jpeg');
+
+ // ── Kinds come from the bytes, not the browser's claim ──
+ const fakeMp4 = Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftypisom'), Buffer.alloc(64, 1)]);
+ out = await upload(fakeMp4, 'application/octet-stream', 'clip.mp4');
+ assert.strictEqual(out.status, 201);
+ assert.strictEqual(out.body.kind, 'video', 'an ftyp header is a video, whatever the browser said');
+ out = await upload(Buffer.from('a plain note\n'), 'text/plain', 'note.txt');
+ assert.strictEqual(out.body.kind, 'file');
+ out = await upload(Buffer.from('export const x = 1;\n'), 'text/plain', 'x.js');
+ assert.strictEqual(out.body.kind, 'code');
+ out = await upload(Buffer.from('OggS' + 'x'.repeat(28)), 'audio/ogg', 'take.ogg');
+ assert.strictEqual(out.body.kind, 'audio');
+
+ // ── An executable is refused ──
+ out = await upload(Buffer.from('MZ\x90\x00'), 'application/octet-stream', 'setup.exe');
+ assert.strictEqual(out.status, 415);
+ assert.strictEqual(out.body.code, 'media.attachment.executable');
+ out = await upload(Buffer.from('\x7fELFrest'), 'application/octet-stream', 'tool');
+ assert.strictEqual(out.status, 415);
+
+ // ── The per-kind limits (the picture is over 20 MB) ──
+ assert.deepStrictEqual(routes.MAX_BYTES, { image: 20 * MB, video: 300 * MB, audio: 50 * MB, file: 25 * MB, code: 25 * MB });
+ out = await upload(Buffer.concat([png, Buffer.alloc(20 * MB)]), 'image/png', 'huge.png');
+ assert.strictEqual(out.status, 413);
+ assert.strictEqual(out.body.code, 'media.attachment.too_large');
+
+ // ── A site's service token names the person it acts for ──
+ out = await upload(png, 'image/png', 'via-site.png', { token: serviceToken(), headers: { 'X-OV-Subject': SUBJECT } });
+ assert.strictEqual(out.status, 201, JSON.stringify(out.body));
+ const siteId = out.body.id;
+ out = await upload(png, 'image/png', 'no-person.png', { token: serviceToken() });
+ assert.strictEqual(out.status, 403);
+ assert.strictEqual(out.body.code, 'attachments.subject_required');
+ out = await upload(png, 'image/png', 'no-cap.png', { token: serviceToken(['media.object.read']), headers: { 'X-OV-Subject': SUBJECT } });
+ assert.strictEqual(out.status, 403);
+
+ // ── Keep with a message: only the owner, and it moves the expiry out ──
+ let res = await fetch(`${base}/api/v1/attachments/${siteId}/keep`, { method: 'POST', headers: { Authorization: `Bearer ${personToken(OTHER)}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ message_id: 'msg_1' }) });
+ assert.strictEqual(res.status, 404, 'another person cannot keep it');
+ res = await fetch(`${base}/api/v1/attachments/${siteId}/keep`, { method: 'POST', headers: { Authorization: `Bearer ${personToken()}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ message_id: 'msg_1' }) });
+ assert.strictEqual(res.status, 200);
+ const kept = await res.json();
+ assert.ok(Date.parse(kept.expires_at) - Date.now() > 24 * 3600_000, 'a kept attachment lives past the unkept 24 hours');
+ const row = await db.get('SELECT message_id, kept_at, expires_at FROM media_attachments WHERE id = ?', [siteId]);
+ assert.strictEqual(row.message_id, 'msg_1');
+
+ // ── Delete: the owner only ──
+ res = await fetch(`${base}/api/v1/attachments/${siteId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${personToken(OTHER)}` } });
+ assert.strictEqual(res.status, 404);
+ res = await fetch(`${base}/api/v1/attachments/${siteId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${serviceToken()}`, 'X-OV-Subject': SUBJECT } });
+ assert.strictEqual(res.status, 204);
+ assert.strictEqual(await db.get('SELECT id FROM media_attachments WHERE id = ?', [siteId]), undefined);
+ assert.strictEqual(fs.existsSync(path.join(config.attachments.path, siteId)), false);
+
+ // ── No credential, no answer ──
+ res = await fetch(`${base}/api/v1/attachments`, { method: 'POST', body: form(png, 'image/png', 'x.png') });
+ assert.strictEqual(res.status, 401);
+
+ // ── Expiry: a row past its time goes, bytes and all ──
+ const expiredId = `att_${ids.ulid()}`;
+ const expiredDir = path.join(config.attachments.path, expiredId);
+ fs.mkdirSync(expiredDir, { recursive: true });
+ fs.writeFileSync(path.join(expiredDir, 'old.txt'), 'old');
+ await db.run(`INSERT INTO media_attachments (id, subject, principal, name, stored_name, kind, mime, size_bytes, expires_at)
+ VALUES (?, ?, ?, 'old.txt', 'old.txt', 'file', 'text/plain', 3, ?)`, [expiredId, SUBJECT, SUBJECT, new Date(Date.now() - 1000).toISOString()]);
+ assert.strictEqual(await routes.cleanupExpired(), 1);
+ assert.strictEqual(fs.existsSync(expiredDir), false);
+ assert.strictEqual(await db.get('SELECT id FROM media_attachments WHERE id = ?', [expiredId]), undefined);
+
+ // The image from the first upload is still there and still served.
+ assert.strictEqual((await fetch(`${base}/a/${imageId}/shot.png`)).status, 200);
+
+ await new Promise((r) => server.close(r));
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('attachments: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });