openvibers/openvibe.media Commits

The composer's attachments: one upload route for every site (#406)

OP OpenVibers committed 2 days ago · d02ad45e4e · 9 files, +639 −1

* The composer's attachments: one upload route for every site (composer.attachment@1)

* Attachments: read the person from a Network session token's subject_id

* Account deletion and export cover composer attachments

* Merge remote-tracking branch 'origin/main' into composer-media

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 134fe09..e4c95fe 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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), bakes
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
ddiff --git a/migrations/0027_attachments.sql b/migrations/0027_attachments.sql
nnew file mode 100644
iindex 0000000..6bc1a6e
---- /dev/null
++++ b/migrations/0027_attachments.sql
@@@ -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);
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 1450c54..f01c368 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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; }
ddiff --git a/server/attachments/origins.js b/server/attachments/origins.js
nnew file mode 100644
iindex 0000000..6a80500
---- /dev/null
++++ b/server/attachments/origins.js
@@@ -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 };
ddiff --git a/server/attachments/routes.js b/server/attachments/routes.js
nnew file mode 100644
iindex 0000000..1ca6686
---- /dev/null
++++ b/server/attachments/routes.js
@@@ -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;
ddiff --git a/server/config.js b/server/config.js
iindex c1e78d0..81aee56 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/index.js b/server/index.js
iindex bcdad6d..61c71dc 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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'));
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 9d60690..f636053 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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',
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
nnew file mode 100644
iindex 0000000..ed93a90
---- /dev/null
++++ b/test/attachments.test.js
@@@ -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); });