openvibers/openvibe.mediahub Commits
WIP: MediaHub drive (DeepSeek build stopped at 402 balance)
Changes
@@@ -24,5 +24,32 @@ OV_OAUTH_CLIENT_SECRET=# OV_OAUTH_REDIRECT_URI=https://openvibe.download/auth/callback# MEDIAHUB_AUDIENCE=openvibe.download++# OpenVibe.Events: the ADR-033 account export/deletion deliveries (POST /internal/events, v2 signature, loopback++# only). Without it the route answers 503 and Events retries.++MEDIAHUB_EVENTS_SECRET=++++# ── OpenVibe.Media: where the bytes live ────────────────────++# The host-internal address of the Media object API, and the app key of Media's `media-hub` tenant. The key never++# reaches a browser: MediaHub is the only client. Add `media-hub` to Media's MEDIA_APPS_SEED before deploying.++MEDIAHUB_MEDIA_URL=http://127.0.0.1:4100++MEDIAHUB_MEDIA_APP=media-hub++MEDIAHUB_MEDIA_APP_KEY=++#MEDIAHUB_MEDIA_TIMEOUT_MS=30000++++# ── Per person (Media's quotas are per tenant, so these are enforced here) ──++#MEDIAHUB_QUOTA_BYTES=2147483648++#MEDIAHUB_MAX_FILE_BYTES=536870912++#MEDIAHUB_UPLOADS_PER_DAY=50++#MEDIAHUB_MAX_SHARES=20++# The most a plain <input type=file> form may post in one request; keep it in step with client_max_body_size for++# `location = /files/upload` in deploy/nginx/openvibe.download.conf. Larger files go up in parts.++#MEDIAHUB_SINGLE_UPLOAD_BYTES=268435456++# One part of a chunked upload. Media requires at least 5 MB and at most 256 MB; MediaHub clamps to that range.++#MEDIAHUB_PART_BYTES=8388608++# How long an unfinished chunked upload may be resumed.++#MEDIAHUB_UPLOAD_TTL_SECONDS=21600++# Where a no-JavaScript upload is spooled before it goes to Media (default: the system temp directory).++#MEDIAHUB_TMP_DIR=++# optional: defaults to true in production# COOKIE_SECURE=true@@@ -89,8 +89,42 @@ server {proxy_pass http://127.0.0.1:4990;}-- # ── Forms and other writes ──────────────────────────────-- location ~ ^/forms {++ # ── Uploads: bytes, and they stream through ─────────────++ # A plain <input type=file> form posts the whole file here in one request, up to MEDIAHUB_SINGLE_UPLOAD_BYTES++ # (256 MB by default) — keep this number in step with that variable. A larger file goes up in parts under++ # /api/v1/uploads/ below. Neither is buffered to disk first: a 256 MB body must not be a 256 MB nginx temp file.++ location = /files/upload {++ limit_req zone=media-hub_forms burst=5 nodelay;++ limit_req_status 429;++ client_max_body_size 256m;++ proxy_request_buffering off;++ proxy_buffering off;++ proxy_read_timeout 900s;++ proxy_send_timeout 900s;++ proxy_pass http://127.0.0.1:4990;++ }++++ # One part of a chunked upload (at most MEDIAHUB_PART_BYTES, 8 MB) and the session's status and complete calls.++ location ~ ^/api/v1/uploads/ {++ limit_req zone=media-hub_api burst=20 nodelay;++ limit_req_status 429;++ client_max_body_size 16m;++ proxy_request_buffering off;++ proxy_read_timeout 900s;++ proxy_pass http://127.0.0.1:4990;++ }++++ # ── Downloads: streamed from OpenVibe.Media through the app ──++ # The app answers every download itself (Content-Disposition: attachment, nosniff, an opaque type for anything++ # not on the safe list), so these must not be buffered: nginx would hold the whole file before sending a byte.++ location ~ ^/(files/[^/]+/download|s/[^/]+/download|s/[^/]+/f/[^/]+/download)$ {++ proxy_buffering off;++ proxy_read_timeout 900s;++ proxy_pass http://127.0.0.1:4990;++ }++++ # ── Form writes (the upload form is the exact match above) ──++ location ~ ^/(files|folders|shares|staff)(/|$) {limit_req zone=media-hub_forms burst=10 nodelay;limit_req_status 429;proxy_pass http://127.0.0.1:4990;nnew file mode 100644@@@ -0,0 +1,113 @@++-- phase: expand++-- OpenVibe.MediaHub: the private drive (files and folders), the share links people hand out, the reports against++-- those shares and the per-person upload counter. Applied at boot by openvibe-sdk/db (one file per phase,++-- NNNN_name.sql, in order). Nothing written here is ever a credential, a key or a token.++--++-- v1 is deliberately narrow: openvibe.download only, signed in only, private by default. The bytes never live here:++-- every file row names an object in OpenVibe.Media (object_id), and the bytes are served from there.++++CREATE TABLE mh_files (++ id text COLLATE "C" PRIMARY KEY, -- fil_<ULID>++ owner text COLLATE "C" NOT NULL, -- user:usr_… (a file belongs to a person, never to an app)++ object_id text COLLATE "C" NOT NULL, -- med_… : the object in OpenVibe.Media holding the bytes++ name text NOT NULL, -- what the person called it (never a path)++ size bigint NOT NULL,++ content_type text COLLATE "C" NOT NULL,++ sha256 text COLLATE "C", -- what Media computed, when it gave one++ folder_id text COLLATE "C", -- fld_<ULID>, or NULL for the drive's root++ created_at text COLLATE "C" NOT NULL,++ deleted_at text COLLATE "C" -- soft delete: the row stays until the bytes are gone++);++++-- The drive is read as "this person's files, newest first"; a folder view narrows it to one folder.++CREATE INDEX mh_files_owner ON mh_files (owner, id DESC) WHERE deleted_at IS NULL;++CREATE INDEX mh_files_folder ON mh_files (folder_id) WHERE deleted_at IS NULL;++CREATE INDEX mh_files_object ON mh_files (object_id);++++CREATE TABLE mh_folders (++ id text COLLATE "C" PRIMARY KEY, -- fld_<ULID>++ owner text COLLATE "C" NOT NULL, -- user:usr_…++ parent_id text COLLATE "C", -- fld_<ULID>, or NULL for the drive's root++ name text NOT NULL,++ created_at text COLLATE "C" NOT NULL++);++++-- One folder per name per parent per person: two folders called "work" in the same place confuse everybody.++CREATE UNIQUE INDEX mh_folders_once ON mh_folders (owner, coalesce(parent_id, ''), lower(name));++CREATE INDEX mh_folders_owner ON mh_folders (owner, id DESC);++++CREATE TABLE mh_shares (++ slug text COLLATE "C" PRIMARY KEY, -- 24 url-safe random characters (/s/<slug>)++ file_id text COLLATE "C" REFERENCES mh_files (id) ON DELETE CASCADE,++ folder_id text COLLATE "C" REFERENCES mh_folders (id) ON DELETE CASCADE,++ owner text COLLATE "C" NOT NULL, -- user:usr_…++ owner_username text, -- the owner's OpenVibe username: what a share page shows,++ -- and nothing else about them (no subject, no id)++ expires_at text COLLATE "C" NOT NULL, -- 1 hour to 7 days after creation++ allowed_subjects jsonb, -- NULL: any signed-in person; else {"subjects":[usr_…],"usernames":[…]}++ downloads integer NOT NULL DEFAULT 0,++ report_count integer NOT NULL DEFAULT 0, -- the most reports this share has ever had: a high-water++ -- mark, so a share staff restored is not suspended again++ -- by the reports that were already there++ revoked_at text COLLATE "C",++ suspended_at text COLLATE "C", -- set by the report threshold; only staff clear it++ created_at text COLLATE "C" NOT NULL,++ CHECK ((file_id IS NULL) <> (folder_id IS NULL))++);++++CREATE INDEX mh_shares_owner ON mh_shares (owner, created_at DESC);++CREATE INDEX mh_shares_file ON mh_shares (file_id);++CREATE INDEX mh_shares_folder ON mh_shares (folder_id);++++CREATE TABLE mh_reports (++ slug text COLLATE "C" NOT NULL REFERENCES mh_shares (slug) ON DELETE CASCADE,++ reporter text COLLATE "C" NOT NULL, -- user:usr_…++ reason text COLLATE "C" NOT NULL, -- malware | illegal | copyright | other++ note text, -- <= 500 characters++ created_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (slug, reporter)++);++++CREATE INDEX mh_reports_recent ON mh_reports (created_at DESC);++-- The staff queue reads "the shares somebody reported, newest report first"; this index is the join's own order.++CREATE INDEX mh_reports_by_slug ON mh_reports (slug, created_at DESC);++++CREATE TABLE mh_usage (++ owner text COLLATE "C" NOT NULL, -- user:usr_…++ day text COLLATE "C" NOT NULL, -- YYYY-MM-DD, UTC++ uploads integer NOT NULL DEFAULT 0,++ PRIMARY KEY (owner, day)++);++++-- An in-flight chunked upload (the browser sends a big file in parts; /api/v1/uploads/*). It is MediaHub's own++-- state because Media issues the multipart session but knows nothing about whose file it is: this row is what++-- makes a resume (and an abort) belong to one person. Deleted when the upload completes or is abandoned.++CREATE TABLE mh_uploads (++ id text COLLATE "C" PRIMARY KEY, -- upl_<ULID>++ owner text COLLATE "C" NOT NULL,++ object_id text COLLATE "C" NOT NULL, -- med_… : the object being uploaded in Media++ upload_id text COLLATE "C" NOT NULL, -- Media's multipart session id++ name text NOT NULL,++ size bigint NOT NULL,++ content_type text COLLATE "C" NOT NULL,++ folder_id text COLLATE "C",++ part_size integer NOT NULL,++ parts_expected integer NOT NULL,++ file_id text COLLATE "C", -- set once the upload completed into a file++ created_at text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C" NOT NULL -- after this the session is abandoned and its parts dropped++);++++CREATE INDEX mh_uploads_owner ON mh_uploads (owner, id DESC);++++-- openvibe-sdk/account-data (ADR-033): one row per export or deletion event this service has applied, so a++-- redelivered event never erases twice and a confirmation that failed is sent again. ACCOUNT_DATA_SCHEMA,++-- written here rather than at boot so the schema is the migrations' business like every other table.++CREATE TABLE IF NOT EXISTS account_data_events (++ id TEXT PRIMARY KEY,++ kind TEXT NOT NULL,++ subject TEXT NOT NULL,++ outcome JSONB,++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),++ sent_at TIMESTAMPTZ++);@@@ -42,3 +42,20 @@ form.inline { display: inline; }.page .sc-ic .ov-icon { --ovi-size: 28px !important; }button.sc-btn { background: transparent; }button.sc-btn.sc-primary { border: 0; }++++/* The drive: the usage bar, one folder or file per row, and the upload form. */++.card { background: var(--bg-card, #131c2e); border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 14px 16px; margin: 14px 0; }++.usage { margin: 10px 0 16px; }++.usage progress { width: 100%; max-width: 420px; height: 12px; display: block; }++.file-list, .report-list { list-style: none; margin: 0; padding: 0; }++.file-list .file { display: flex; align-items: center; gap: 12px; padding: 9px 2px; border-bottom: 1px solid var(--border, #1f2d47); }++.file-list .file:last-child { border-bottom: 0; }++.file-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }++.file-name { font-weight: 600; overflow-wrap: anywhere; }++.file-meta { margin-top: 2px; }++.upload progress { max-width: 420px; }++.staff-item h2 { margin-top: 0; }++.report-list li { padding: 3px 0; }++.field { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; max-width: 520px; }++.field input[type=text], .field input[type=search], .field select, .field textarea, form.inline input[type=text] { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: inherit; }++.field-row { display: flex; flex-wrap: wrap; gap: 12px; }nnew file mode 100644@@@ -0,0 +1,104 @@++/* OpenVibe.MediaHub — the chunked upload, and the only JavaScript on any page.++ *++ * The form it upgrades works without it: submit posts the whole file to POST /files/upload in one request. With++ * this script the file goes up in parts instead (POST /api/v1/files, one PUT per part, then complete), so a file++ * larger than the single-request limit still uploads, a dropped connection can resume, and the person can watch++ * the progress.++ *++ * Every request is same-origin, so the browser sends Sec-Fetch-Site: same-origin by itself — which is exactly what++ * a session-cookie write on this site requires. No token, no key and no file name is ever put in a URL.++ */++(function () {++ 'use strict';++ var form = document.querySelector('form[data-upload]');++ if (!form || !window.fetch) return;++ var input = form.querySelector('input[type=file]');++ var nameField = form.querySelector('input[name=name]');++ var folderField = form.querySelector('input[name=folder]');++ var bar = form.querySelector('progress');++ var status = form.querySelector('[role=status]');++ var button = form.querySelector('button[type=submit]');++ if (!input) return;++++ function say(text) { if (status) status.textContent = text; }++ function progress(done, total) {++ if (!bar) return;++ bar.hidden = false;++ bar.max = total;++ bar.value = done;++ }++ function problem(res) {++ return res.json().then(function (p) {++ return (p && p.detail) || 'The upload was refused.';++ }, function () { return 'The upload was refused (' + res.status + ').'; });++ }++ function busy(on) {++ if (button) button.disabled = on;++ if (input) input.disabled = on;++ }++++ /** One request, answering a promise of its JSON (or rejecting with the problem's detail). */++ function request(url, options) {++ return fetch(url, options).then(function (res) {++ if (res.ok) return res.status === 204 ? null : res.json().catch(function () { return null; });++ return problem(res).then(function (detail) { throw new Error(detail); });++ });++ }++++ function upload(file) {++ var sent = 0;++ var uploadId = null;++ busy(true);++ progress(0, file.size);++ say('Starting…');++ return request('/api/v1/files', {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({++ name: (nameField && nameField.value) || file.name,++ size: file.size,++ content_type: file.type || 'application/octet-stream',++ folder_id: (folderField && folderField.value) || null,++ }),++ }).then(function (started) {++ var up = started.upload;++ uploadId = up.id;++ // Parts go up one after another: one connection, in order, and the meter moves.++ var chain = Promise.resolve();++ for (var n = 1; n <= up.parts_expected; n++) {++ chain = chain.then(function (part) {++ return function () {++ var start = (part - 1) * up.part_size;++ var slice = file.slice(start, Math.min(start + up.part_size, file.size));++ return request('/api/v1/uploads/' + up.id + '/parts/' + part, {++ method: 'PUT',++ headers: { 'Content-Type': 'application/octet-stream' },++ body: slice,++ }).then(function () {++ sent += slice.size;++ progress(sent, file.size);++ say('Uploading… ' + Math.round((sent / file.size) * 100) + '%');++ });++ };++ }(n));++ }++ return chain;++ }).then(function () {++ say('Finishing…');++ return request('/api/v1/uploads/' + uploadId + '/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });++ }).then(function (done) {++ say('Uploaded. Opening it…');++ window.location.assign('/files/' + done.file.id);++ }).catch(function (err) {++ busy(false);++ say((err && err.message) || 'The upload did not go through.');++ });++ }++++ form.addEventListener('submit', function (event) {++ var file = input.files && input.files[0];++ if (!file) return; // no file chosen: let the server answer the empty form++ event.preventDefault();++ upload(file);++ });++})();nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++++/**++ * This service's share of a person's account export and deletion (ADR-033), through openvibe-sdk/account-data.++ *++ * network.account.export_requested the person's files, folders, shares, reports and upload counts, as JSON++ * network.account.deleted the same rows erased, and every Media object they held deleted++ *++ * The events arrive signed on POST /internal/events (loopback only). `apply` is wrapped here for one reason: an++ * erasure has to tell OpenVibe.Media to delete each object, and the object ids only exist *before* the rows are++ * gone — extraErase runs after the tables are erased, so the ids are read on the way in and handed to it.++ *++ * A Media deletion that fails does not fail the erasure: the person's rows are gone here either way, and Media's++ * own retention decides when the bytes go. It is logged, not swallowed silently.++ */++const { createAccountData, createNetworkSender } = require('openvibe-sdk/account-data');++const { parseDelivery } = require('openvibe-sdk/events');++++/**++ * The tables that hold a person's own data. `file` names the export file; null leaves a table out of it. Every++ * subject column here stores the requester form (`user:usr_…`), so `value` maps the plain subject Network names.++ */++const AS_REQUESTER = (usr) => `user:${usr}`;++// The order matters: mh_shares and mh_reports cascade from mh_files (the foreign keys), so they are erased first —++// otherwise the rows would be gone by the cascade and the counts would read as nothing was erased.++const TABLES = [++ { table: 'mh_reports', subject: 'reporter', value: AS_REQUESTER, file: 'reports.json' },++ { table: 'mh_shares', subject: 'owner', value: AS_REQUESTER, file: 'shares.json' },++ { table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },++ { table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },++ { table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },++ // An upload that never finished is this service's own state, not something the person made: it is erased with++ // the account but is not part of the export.++ { table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },++];++++function createAccount({ db, config, media, log = console }) {++ /** The object ids of a deletion, read before the rows go (extraErase runs after them). */++ let pendingObjects = [];++++ const accountData = createAccountData({++ db,++ service: 'media-hub',++ tables: TABLES,++ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed and your upload counts. The bytes of your files live in OpenVibe.Media.',++ extraErase: async () => {++ const ids = pendingObjects;++ pendingObjects = [];++ for (const id of ids) {++ const out = await media.remove(id);++ if (!out.ok && out.status !== 404 && out.status !== 410) {++ log.warn(`[MediaHub] account deletion: Media refused to delete ${id}: ${out.status} ${out.code}`);++ }++ }++ },++ log,++ });++++ /** The subjects a deletion names, as this service stores them: the account, and any account merged into it. */++ const subjectsOf = (payload) => [payload.subject, ...(Array.isArray(payload.aliases) ? payload.aliases : [])]++ .filter((x) => typeof x === 'string' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(x))++ .map((x) => `user:${x}`);++++ /** One envelope → an outcome (see openvibe-sdk/account-data); throws so Events redelivers. */++ async function apply(ev, { send }) {++ if (ev && ev.event_type === 'network.account.deleted' && ev.source === 'network') {++ const payload = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};++ // Read the media objects first: the erase deletes the rows that name them.++ pendingObjects = (await db.many('SELECT object_id FROM mh_files WHERE owner = ANY($1::text[])', [subjectsOf(payload)]))++ .map((r) => r.object_id).filter(Boolean);++ }++ return await accountData.apply(ev, { send });++ }++++ /**++ * POST /internal/events — signed with MEDIAHUB_EVENTS_SECRET, loopback only. It is mounted before any body++ * parser because the signature covers the raw bytes.++ */++ function consumer({ send }) {++ const reply = (res, status, body) => {++ res.statusCode = status;++ res.setHeader('Content-Type', status < 300 ? 'application/json' : 'application/problem+json');++ res.setHeader('Cache-Control', 'no-store');++ res.end(JSON.stringify(body));++ };++ const problem = (res, status, code, detail) => reply(res, status, { type: 'about:blank', title: code, status, code, detail });++ const secrets = (config.events.secrets || []).filter((k) => typeof k === 'string' && k.length >= 32);++ return async function handle(req, res) {++ if (req.method !== 'POST') return problem(res, 405, 'method_not_allowed', 'POST only');++ // Loopback only: a request that came through a proxy is not Network's.++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {++ return problem(res, 403, 'media-hub.internal_only', 'internal route');++ }++ if (!secrets.length) return problem(res, 503, 'media-hub.events_disabled', 'no events secret is configured');++ const raw = await new Promise((resolve, reject) => {++ const chunks = [];++ let size = 0;++ req.on('data', (c) => { size += c.length; if (size > 256 * 1024) { reject(Object.assign(new Error('too large'), { status: 413 })); req.destroy(); } else chunks.push(c); });++ req.on('end', () => resolve(Buffer.concat(chunks)));++ req.on('error', reject);++ }).catch(() => null);++ if (!raw) return problem(res, 413, 'request.too_large', 'unreadable or oversized body');++ let delivery = null;++ for (const secret of secrets) { delivery = parseDelivery(raw, req.headers, secret, { requireV2: true }); if (delivery) break; }++ if (!delivery || !delivery.event) return problem(res, 401, 'media-hub.bad_signature', 'X-OpenVibe-Signature-V2 does not verify');++ try {++ const outcome = await apply(delivery.event, { send });++ return reply(res, 200, { event_id: delivery.event.event_id, outcome });++ } catch (err) {++ log.warn(`[MediaHub] ${delivery.event.event_type} failed: ${(err && err.message) || err}`);++ return problem(res, 503, 'media-hub.retry', 'not applied; Events will retry');++ }++ };++ }++++ return { accountData, apply, consumer, TABLES };++}++++/**++ * The Network sender the account events are answered with (our own client-credentials token). Without the OAuth++ * client there is nothing to sign with: every send answers 503, which the consumer turns into "Events will retry"++ * rather than a crash at boot (a development process has no client secret).++ */++function createSender({ config, fetchImpl }) {++ if (!config.oauth.clientSecret) {++ return async () => ({ ok: false, status: 503, json: async () => ({}) });++ }++ return createNetworkSender({++ networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ fetch: fetchImpl || globalThis.fetch,++ });++}++++module.exports = { createAccount, createSender, TABLES };@@@ -32,6 +32,13 @@ const { createServiceReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');const { assetVersion, send } = require('./render/layout');const { html } = require('./render/html');++const brand = require('./brand');++const store = require('./drive/store');++const { createMediaClient } = require('./media/client');++const { createIdentity } = require('./network/identity');++const { createService } = require('./drive/service');++const { createUploads } = require('./drive/upload');++const { createAccount, createSender } = require('./account');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const VERSION = require('../package.json').version;@@@ -49,7 +56,13 @@ async function createApp(opts = {}) {const keys = createKeyStore({ config, fetchImpl, log });const sso = createSso({ config, keys, fetchImpl, now: s.now, log });const principal = createPrincipal({ config, keys });-- const ctx = { config, s, keys, sso, principal, log };++ // The product: bytes in OpenVibe.Media, the drive's own rules, and the two upload flows.++ const media = createMediaClient({ config, fetchImpl, log });++ const identity = createIdentity({ config, fetchImpl, log });++ const drive = createService({ s, media, identity, config, log });++ const uploads = createUploads({ s, media, config, service: drive, log });++ const account = createAccount({ db: s.db, config, media, log });++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, log };const app = express();app.disable('x-powered-by');@@@ -88,12 +101,20 @@ async function createApp(opts = {}) {}));app.use(cookieParser());++ // ── Which brand this request is for (the Host header decides; server/brand.js) ──++ app.use(brand.middleware());++// ── Machine endpoints ───────────────────────────────────app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-media-hub', version: VERSION }));release.mount(app, { registry: metrics.registry });const readiness = createServiceReadiness({ s, config, release: release.release, valkey });app.get('/api/ready', readiness.handler);++ // ── Account export and deletion (ADR-033) ───────────────++ // Mounted before every body parser: the Events signature covers the raw bytes. It refuses anything that came++ // through a proxy, so only loopback can reach it even though nginx proxies this path.++ app.use('/internal/events', account.consumer({ send: createSender({ config, fetchImpl }) }));++// ── Who is asking (verified offline; refreshed when expired) ──app.use(sso.middleware());nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++++/**++ * One process, three brand surfaces (the media-hub product manifest: openvibe.download, openvibe.pics and++ * openvibe.video). The host decides which one a request is for — there is no separate deploy, and no cookie or++ * query parameter can move a request from one brand to another.++ *++ * v1 launches openvibe.download only, as a private drive. On openvibe.pics and openvibe.video the service answers++ * an honest "coming" page and nothing else: no drive, no share links, no API and no uploads, because neither brand++ * has the safety tooling its content needs yet (there is no malware or CSAM scanning on the network).++ *++ * The brand is read from the Host header nginx passes through (req.hostname; TRUST_PROXY lets a proxy name it with++ * X-Forwarded-Host). An unknown host — a bare address in development, an internal health check — is the download++ * brand, so the deployed apex never behaves differently from a test.++ */++const BRANDS = {++ 'openvibe.download': 'download',++ 'openvibe.pics': 'pics',++ 'openvibe.video': 'video',++};++++// What each unlaunched brand's page says: the product's own words, kept here so the page and the report agree.++const COMING = {++ pics: {++ domain: 'openvibe.pics',++ name: 'OpenVibe.Pics',++ tagline: 'Share pictures. Fast, open, yours.',++ what: 'OpenVibe.Pics will be the picture home of the OpenVibe network: quick uploads and share links, albums and galleries, image processing and optimisation served from a shared CDN, and AI image tools — an open and modular alternative to Imgur, usable through the OpenVibe API too.',++ pillars: ['Upload & share', 'Albums & galleries', 'Processing', 'AI image tools'],++ },++ video: {++ domain: 'openvibe.video',++ name: 'OpenVibe.Video',++ tagline: 'Your videos, everywhere.',++ what: 'OpenVibe.Video will be the home of long-form video on the OpenVibe network: watch pages, channels and playlists for uploads and past streams, a creator tool that publishes one upload to many platforms, and AI captions, highlights and editing — delivered by OpenVibe.Media.',++ pillars: ['Watch', 'Publish everywhere', 'AI tools', 'From your streams'],++ },++};++++/** The brand a request is for. Anything unrecognised is the launched one. */++function brandOf(req) {++ const host = String((req && req.hostname) || '').toLowerCase().replace(/^www\./, '');++ return BRANDS[host] || 'download';++}++++/** Set req.brand for every request, before anything reads it (the API and the pages both do). */++function middleware() {++ return (req, _res, next) => { req.brand = brandOf(req); next(); };++}++++module.exports = { brandOf, middleware, BRANDS, COMING };@@@ -9,6 +9,12 @@require('dotenv').config();const trim = (s) => String(s || '').replace(/\/+$/, '');const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++const size = (v, def) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.floor(Number(v)) : def);++++// The per-person numbers of a private drive (bytes, files, uploads, shares). Every one of them comes from the++// environment: they are the product's policy, not a constant somebody has to edit to change.++const GB = 1024 ** 3;++const MB = 1024 ** 2;function load(env = process.env) {const nodeEnv = env.NODE_ENV || 'development';@@@ -38,6 +44,38 @@ function load(env = process.env) {db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.MEDIAHUB_PGLITE_DIR || '' },valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:media-hub:' },++ // OpenVibe.Media: the only place bytes live. MediaHub talks to it server-side with its own app key++ // (Media's `:app` segment `media-hub`); no browser ever holds that key. MEDIAHUB_MEDIA_URL is the++ // host-internal address (127.0.0.1:4100 in production), never the public origin.++ media: {++ url: trim(env.MEDIAHUB_MEDIA_URL || 'http://127.0.0.1:4100'),++ app: env.MEDIAHUB_MEDIA_APP || 'media-hub',++ appKey: env.MEDIAHUB_MEDIA_APP_KEY || '',++ timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),++ },++++ // What one person may keep and do here. Media's own quotas are per tenant (this whole service is one++ // tenant), so the per-person numbers are enforced in MediaHub and nowhere else.++ quotas: {++ bytes: size(env.MEDIAHUB_QUOTA_BYTES, 2 * GB), // 2 GB stored++ maxFileBytes: size(env.MEDIAHUB_MAX_FILE_BYTES, 512 * MB), // 512 MB per file++ uploadsPerDay: size(env.MEDIAHUB_UPLOADS_PER_DAY, 50), // 50 uploads a day (UTC)++ maxShares: size(env.MEDIAHUB_MAX_SHARES, 20), // 20 live share links++ },++ // A plain <input type=file> form posts the whole file in one request; past this the page offers the++ // chunked upload (script). Both are capped by quotas.maxFileBytes.++ singleUploadBytes: size(env.MEDIAHUB_SINGLE_UPLOAD_BYTES, 256 * MB),++ // Media requires parts of at least 5 MB and at most 256 MB; ours sits between, so an operator cannot++ // configure a part size Media will refuse.++ partBytes: Math.min(256 * MB, Math.max(5 * MB, size(env.MEDIAHUB_PART_BYTES, 8 * MB))),++ uploadTtlSeconds: size(env.MEDIAHUB_UPLOAD_TTL_SECONDS, 6 * 3600),++ // Where a no-JavaScript upload is spooled before it goes to Media. Never served, never listed.++ tmpDir: env.MEDIAHUB_TMP_DIR || '',++++ // OpenVibe.Events: the ADR-033 account export/deletion events (network.account.export_requested and++ // network.account.deleted). Without the secret the route answers 503 and Network retries.++ events: { secrets: [env.MEDIAHUB_EVENTS_SECRET, env.OV_EVENTS_SECRET].filter(Boolean) },++// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.networkUrl,networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++++/**++ * What a file, a folder, a share and a report are — on both sides of the wire. The pages (http/pages.js) and the++ * API (http/api.js) share these rules, so a form and a POST can never disagree about a name, an expiry or a report.++ *++ * file fil_<ULID>, an object in OpenVibe.Media, private to the person who uploaded it++ * folder fld_<ULID>, a name and a parent++ * share /s/<slug>, 24 url-safe random characters, 1 hour to 7 days, revocable, counted++ * report malware | illegal | copyright | other, once per person per share++ *++ * Nothing here fetches anything, and nothing here decides who may see what: that is the store's and the service's++ * job. This module is the shape of the data and the sentences people read.++ */++++const ID_RE = {++ file: /^fil_[0-9A-HJKMNP-TV-Z]{26}$/,++ folder: /^fld_[0-9A-HJKMNP-TV-Z]{26}$/,++ upload: /^upl_[0-9A-HJKMNP-TV-Z]{26}$/,++ object: /^med_[0-9A-HJKMNP-TV-Z]{26}$/,++};++// 24 url-safe characters (18 random bytes): the link is the capability, so it has to be unguessable, and it is the++// only random thing in it — the share is found by slug alone.++const SLUG_RE = /^[A-Za-z0-9_-]{22,64}$/;++++const isFileId = (id) => ID_RE.file.test(String(id || ''));++const isFolderId = (id) => ID_RE.folder.test(String(id || ''));++const isUploadId = (id) => ID_RE.upload.test(String(id || ''));++const isSlug = (slug) => SLUG_RE.test(String(slug || ''));++++const NAME_MAX = 200;++const FOLDER_NAME_MAX = 80;++++const REASONS = ['malware', 'illegal', 'copyright', 'other'];++const REASON_TEXT = {++ malware: 'malware or a virus',++ illegal: 'illegal content',++ copyright: 'copyright infringement',++ other: 'something else',++};++// Three distinct people reporting, or one report of illegal content, suspends the share at once and puts it in++// front of staff. Counting is once, when the line is crossed, so a share staff restored is not suspended again by++// the same reports.++const SUSPEND_AT_REPORTS = 3;++const SUSPEND_IMMEDIATELY = ['illegal'];++++const EXPIRY_MIN_HOURS = 1;++const EXPIRY_MAX_HOURS = 7 * 24;++const EXPIRY_DEFAULT_HOURS = 7 * 24;++++/** The declared content type, normalised, or null when it is not a media type at all. */++function normalizeType(value) {++ const t = String(value == null ? '' : value).split(';')[0].trim().toLowerCase();++ return /^[\w.+-]+\/[\w.+-]+$/.test(t) ? t.slice(0, 120) : null;++}++++/**++ * The content types a download keeps when it is served: a short, safe list. Everything else — HTML, SVG, XML,++ * anything executable — is served as application/octet-stream, so a browser never treats an upload as a document++ * of its own. SVG is deliberately out: it is a script container wearing an image's type.++ */++function safeContentType(type) {++ const t = normalizeType(type);++ if (!t) return null;++ if (t === 'image/svg+xml' || t.startsWith('image/svg')) return null;++ if (t === 'text/plain' || t === 'application/pdf' || t === 'application/zip') return t;++ if (/^(image|audio|video)\//.test(t)) return t;++ return null;++}++++/**++ * Plain text somebody typed (a report note): control characters out, nothing else touched. A note is prose — the++ * slashes in "https://…" are part of it, which is why this is not cleanName.++ */++function cleanText(value, max = 500) {++ return String(value == null ? '' : value)++ .replace(/\r\n?/g, '\n')++ // eslint-disable-next-line no-control-regex++ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')++ .trim()++ .slice(0, max);++}++++/** A file name as it is stored: one line, no path, never empty. A name is not a path: its slashes become _ . */++function cleanName(value, max = NAME_MAX) {++ return cleanText(value, max).replace(/[\\/]+/g, '_');++}++++/** The name a file gets: what the person typed, or what the browser said, or a fallback. */++function fileName(value, fallback = 'file') {++ const s = cleanName(value);++ return s || cleanName(fallback) || 'file';++}++++/** A header-safe rendering of a name for Content-Disposition. */++function headerSafeName(name) {++ return String(name || 'file').replace(/["\\\r\n\t]/g, '_').slice(0, 200) || 'file';++}++++/** An expiry: whole hours, clamped to the product's own range; anything unusable is the default. */++function expiryHours(value) {++ const n = Number(value);++ if (!Number.isFinite(n) || n <= 0) return EXPIRY_DEFAULT_HOURS;++ return Math.min(EXPIRY_MAX_HOURS, Math.max(EXPIRY_MIN_HOURS, Math.floor(n)));++}++++/** A username as OpenVibe.Network writes it (lower case, 3-30 letters/digits/underscore/dash). */++const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,29}$/;++const isUsername = (value) => USERNAME_RE.test(String(value || '').trim().toLowerCase());++++/** The expiry selector the share form offers, in hours. */++const EXPIRY_CHOICES = [++ { hours: 1, label: '1 hour' },++ { hours: 24, label: '1 day' },++ { hours: 72, label: '3 days' },++ { hours: 24 * 7, label: '7 days' },++];++++/** "1.4 GB" — how a size is shown. Binary units, one decimal from MB up. */++function formatBytes(n) {++ const b = Number(n) || 0;++ if (b < 1024) return `${b} B`;++ const units = ['KB', 'MB', 'GB', 'TB'];++ let v = b / 1024;++ let i = 0;++ while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }++ return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;++}++++module.exports = {++ ID_RE, SLUG_RE, isFileId, isFolderId, isUploadId, isSlug, isUsername,++ NAME_MAX, FOLDER_NAME_MAX, REASONS, REASON_TEXT, SUSPEND_AT_REPORTS, SUSPEND_IMMEDIATELY,++ EXPIRY_MIN_HOURS, EXPIRY_MAX_HOURS, EXPIRY_DEFAULT_HOURS, EXPIRY_CHOICES,++ normalizeType, safeContentType, cleanName, cleanText, fileName, headerSafeName, expiryHours, formatBytes,++};nnew file mode 100644@@@ -0,0 +1,301 @@++'use strict';++++/**++ * The rules that need the database, shared by the pages (http/pages.js) and the API (http/api.js) so a form and a++ * POST enforce the same numbers:++ *++ * a person, never an app or a service (the guard lives in http/api.js and http/pages.js)++ * 2 GB stored, 512 MB per file, 50 uploads a day (UTC), 20 live share links (every number from the environment)++ * a share expires in 1 hour to 7 days (7 by default), can be revoked and counts its downloads++ * a share may name people: resolved to subjects, and matched on the viewer's username claim as well++ * 3 distinct reports, or one report of illegal content, suspend a share at once++ *++ * A refusal is { ok: false, code, detail }: an API problem+json, or the sentence the form re-renders with.++ *++ * Nothing here trusts a caller's identity: the caller passes the requester it already established, and this module++ * only ever compares it with what the rows say.++ */++const crypto = require('crypto');++const rules = require('./rules');++const store = require('./store');++++const DAY_MS = 86_400_000;++++/** The UTC day a moment falls in — what "50 uploads a day" is counted over. */++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);++++/** The allow-list a share carries, as stored: { subjects, usernames } or null for "any signed-in person". */++function parseAllow(value) {++ if (!value) return null;++ const v = typeof value === 'string' ? JSON.parse(value) : value;++ if (!v || typeof v !== 'object') return null;++ const list = (x) => (Array.isArray(x) ? x.filter((s) => typeof s === 'string') : []);++ const subjects = list(v.subjects);++ const usernames = list(v.usernames).map((u) => u.toLowerCase());++ if (!subjects.length && !usernames.length) return { subjects: [], usernames: [] };++ return { subjects, usernames };++}++++/**++ * May this viewer open the share? `viewer` is { subject, username } of a signed-in person — the caller has already++ * refused everyone else. An allow-list is satisfied by the person's canonical subject (resolved when the share was++ * made) or by their Network username claim (signed, so it needs no directory).++ */++function allowedFor(share, viewer) {++ const allow = parseAllow(share.allowed_subjects);++ if (!allow) return true;++ const subject = viewer && viewer.subject;++ const username = viewer && viewer.username ? String(viewer.username).toLowerCase() : null;++ if (subject && allow.subjects.includes(subject)) return true;++ if (username && allow.usernames.includes(username)) return true;++ return false;++}++++/** Where a share stands right now: what the page shows and what a download checks. */++function stateOf(share, nowIso) {++ if (!share) return 'missing';++ if (share.revoked_at) return 'revoked';++ if (share.suspended_at) return 'suspended';++ if (share.expires_at <= nowIso) return 'expired';++ return 'live';++}++++/** A slug nobody can guess by looking at another one: 18 random bytes, url-safe. */++const newSlug = () => crypto.randomBytes(18).toString('base64url');++++function createService({ s, media, identity, config, log = console }) {++ const nowIso = () => new Date(s.now()).toISOString();++++ // ── Limits ──────────────────────────────────────────────++++ /** Room for one more file of `size` bytes, and for uploads not to run past the day's cap. */++ async function uploadAllowed(owner, size) {++ if (!Number.isInteger(size) || size < 1) return { ok: false, code: 'file.invalid_size', detail: 'A file needs a size of at least one byte.' };++ if (size > config.quotas.maxFileBytes) {++ return { ok: false, code: 'file.too_large', detail: `One file may be at most ${rules.formatBytes(config.quotas.maxFileBytes)}. This one is ${rules.formatBytes(size)}.` };++ }++ const used = await store.storedBytes(s, owner);++ if (used + size > config.quotas.bytes) {++ return { ok: false, code: 'file.quota', detail: `Your drive holds ${rules.formatBytes(used)} of ${rules.formatBytes(config.quotas.bytes)}. This file needs ${rules.formatBytes(size)} more; delete something first.` };++ }++ const today = await store.uploadsToday(s, owner, dayOf(s.now()));++ if (today >= config.quotas.uploadsPerDay) {++ return { ok: false, code: 'file.daily_limit', detail: `You have uploaded ${config.quotas.uploadsPerDay} files today (the most one person may). Try again tomorrow.` };++ }++ return { ok: true };++ }++++ /** Room for one more live share link. */++ async function shareAllowed(owner) {++ const live = await store.countLiveShares(s, owner, nowIso());++ if (live >= config.quotas.maxShares) {++ return { ok: false, code: 'share.limit', detail: `You have ${config.quotas.maxShares} live share links (the most one person may have). Revoke one first — /files lists them.` };++ }++ return { ok: true };++ }++++ /** The usage bar on /files and GET /usage. */++ async function usage(owner) {++ const [used, files, today, liveShares] = await Promise.all([++ store.storedBytes(s, owner), store.liveFileCount(s, owner),++ store.uploadsToday(s, owner, dayOf(s.now())), store.countLiveShares(s, owner, nowIso()),++ ]);++ return {++ bytes_used: used, bytes_limit: config.quotas.bytes,++ max_file_bytes: config.quotas.maxFileBytes,++ files, uploads_today: today, uploads_per_day: config.quotas.uploadsPerDay,++ live_shares: liveShares, max_shares: config.quotas.maxShares,++ };++ }++++ // ── Files ───────────────────────────────────────────────++++ /**++ * Record an object that is already `ready` in Media as one of the person's files, and count the upload.++ * The quota is checked again here: the bytes exist now, which is what the number is about.++ */++ async function recordFile({ owner, objectId, name, size, contentType, sha256, folderId = null }) {++ const row = await store.insertFile(s, {++ id: s.newId('fil'), owner, object_id: objectId, name: rules.fileName(name),++ size, content_type: rules.normalizeType(contentType) || 'application/octet-stream',++ sha256: sha256 || null, folder_id: folderId, created_at: nowIso(),++ });++ await store.bumpUploads(s, owner, dayOf(s.now()));++ return row;++ }++++ /** One of the person's own files, or null. A file is private: nobody else's id resolves here. */++ const ownFile = async (owner, id) => {++ if (!rules.isFileId(id)) return null;++ const row = await store.getFile(s, id);++ return row && row.owner === owner && !row.deleted_at ? row : null;++ };++++ /**++ * Delete a file: its shares go at once, the row is soft-deleted here and the object is deleted in Media.++ * The bytes are Media's to keep or purge (Media's own retention), so this does not claim they are gone.++ */++ async function deleteFile(owner, id, { by = null } = {}) {++ const row = await ownFile(owner, id);++ if (!row) return { error: 'file.not_found' };++ await store.deleteSharesForFile(s, id);++ await store.softDeleteFile(s, id, nowIso());++ const removed = await media.remove(row.object_id);++ if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${row.object_id}: ${removed.status} ${removed.code}`);++ return { file: row, media: removed };++ }++++ // ── Folders ─────────────────────────────────────────────++++ /** A folder of this person's, or null. */++ const ownFolder = async (owner, id) => {++ if (!rules.isFolderId(id)) return null;++ const row = await store.getFolder(s, id);++ return row && row.owner === owner ? row : null;++ };++++ async function createFolder(owner, { name, parentId = null }) {++ const clean = rules.cleanName(name, rules.FOLDER_NAME_MAX);++ if (!clean) return { error: 'folder.invalid_name', detail: 'A folder needs a name.' };++ if (parentId && !await ownFolder(owner, parentId)) return { error: 'folder.no_parent', detail: 'That parent folder does not exist.' };++ const existing = await store.listFolders(s, owner);++ if (existing.some((f) => (f.parent_id || null) === (parentId || null) && f.name.toLowerCase() === clean.toLowerCase())) {++ return { error: 'folder.exists', detail: `You already have a folder called "${clean}" here.` };++ }++ return { folder: await store.insertFolder(s, { id: s.newId('fld'), owner, parent_id: parentId || null, name: clean, created_at: nowIso() }) };++ }++++ /** A folder's path from the root, for breadcrumbs and the folder picker. */++ async function folderPath(owner, folderId) {++ const all = await store.listFolders(s, owner);++ const byId = new Map(all.map((f) => [f.id, f]));++ const path = [];++ let cursor = folderId;++ let guard = 0;++ while (cursor && byId.has(cursor) && guard++ < 50) { path.unshift(byId.get(cursor)); cursor = byId.get(cursor).parent_id; }++ return path;++ }++++ // ── Shares ──────────────────────────────────────────────++++ /**++ * Make a share link for one of the person's files or folders. `names` are OpenVibe usernames the owner typed:++ * each is asked of Network's identity resolver, and the ones it cannot resolve are kept as typed and matched++ * against a viewer's username claim later. No names at all = any signed-in person with the link.++ */++ async function createShare(owner, { fileId = null, folderId = null, hours, names = [], ownerUsername = null }) {++ const room = await shareAllowed(owner);++ if (!room.ok) return room;++ if (fileId && !await ownFile(owner, fileId)) return { ok: false, code: 'file.not_found', detail: 'No such file.' };++ if (folderId && !await ownFolder(owner, folderId)) return { ok: false, code: 'folder.not_found', detail: 'No such folder.' };++ if (!fileId && !folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder.' };++ if (fileId && folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder, not both.' };++ const wanted = [...new Set(names.map((n) => String(n || '').trim().toLowerCase()).filter(Boolean))];++ for (const n of wanted) {++ if (!rules.isUsername(n)) return { ok: false, code: 'share.bad_username', detail: `"${rules.cleanName(n, 40)}" is not an OpenVibe username.` };++ }++ const resolved = wanted.length ? await identity.resolveUsernames(wanted) : { subjects: [], names: [], unresolved: [] };++ const allowed = wanted.length ? { subjects: resolved.subjects, usernames: resolved.names } : null;++ const expiresAt = new Date(s.now() + rules.expiryHours(hours) * 3_600_000).toISOString();++ const share = await store.insertShare(s, {++ slug: newSlug(), file_id: fileId || null, folder_id: folderId || null, owner,++ owner_username: ownerUsername, expires_at: expiresAt, allowed_subjects: allowed, created_at: nowIso(),++ });++ return { ok: true, share, unresolved: resolved.unresolved };++ }++++ /** The person's own share, by slug — never somebody else's. */++ async function ownShare(owner, slug) {++ if (!rules.isSlug(slug)) return null;++ const row = await store.getShare(s, slug);++ return row && row.owner === owner ? row : null;++ }++++ async function revokeShare(owner, slug) {++ const row = await ownShare(owner, slug);++ if (!row) return null;++ await store.revokeShare(s, slug, nowIso());++ return store.getShare(s, slug);++ }++++ // ── Reports ─────────────────────────────────────────────++++ /**++ * Record a report against a share. One per person per share; the third distinct person (or any report of illegal++ * content) suspends the share at once, which is what staff then look at.++ */++ async function report(slug, { reporter, reason, note }) {++ const share = rules.isSlug(slug) ? await store.getShare(s, slug) : null;++ if (!share) return { error: 'share.not_found' };++ if (share.owner === reporter) return { error: 'share.own' };++ if (share.revoked_at) return { error: 'share.revoked' };++ const row = await store.addReport(s, { slug, reporter, reason, note, createdAt: nowIso() });++ if (!row) return { error: 'report.duplicate' };++ const count = await store.reportCount(s, slug);++ // Suspension is counted once, when the line is crossed (share.report_count is the high-water mark before this++ // report): a share staff restored is not suspended again by the reports that were already there. Illegal++ // content is the exception — it suspends however many times it has to.++ const crossed = Number(share.report_count) < rules.SUSPEND_AT_REPORTS && count >= rules.SUSPEND_AT_REPORTS;++ const suspend = !share.suspended_at && (rules.SUSPEND_IMMEDIATELY.includes(reason) || crossed);++ if (suspend) await store.suspendShare(s, slug, nowIso());++ await store.bumpReportCount(s, slug, count);++ return { report: row, share: await store.getShare(s, slug), count, suspended: suspend };++ }++++ // ── Staff ───────────────────────────────────────────────++++ /** Staff: let a suspended (or reported) share work again. The reports stay on the record. */++ async function restoreShare(slug) {++ const share = rules.isSlug(slug) ? await store.getShare(s, slug) : null;++ if (!share) return { error: 'share.not_found' };++ await store.restoreShare(s, slug);++ return { share: await store.getShare(s, slug) };++ }++++ /**++ * Staff: remove what a share points at. Every share of that file or folder goes, the row is soft-deleted and++ * Media is asked to delete the object — or, for a folder, each file in it, one by one.++ */++ async function removeShared(slug) {++ const share = rules.isSlug(slug) ? await store.getShare(s, slug) : null;++ if (!share) return { error: 'share.not_found' };++ if (share.file_id) {++ const file = await store.getFile(s, share.file_id);++ if (!file) return { error: 'file.not_found' };++ await store.deleteSharesForFile(s, file.id);++ await store.softDeleteFile(s, file.id, nowIso());++ const removed = await media.remove(file.object_id);++ if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${file.object_id}: ${removed.status} ${removed.code}`);++ return { kind: 'file', file, media: removed };++ }++ const files = await store.listFiles(s, share.owner, { folderId: share.folder_id, limit: 1000 });++ await store.deleteSharesForFolder(s, share.folder_id);++ for (const file of files) {++ await store.softDeleteFile(s, file.id, nowIso());++ const removed = await media.remove(file.object_id);++ if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${file.object_id}: ${removed.status} ${removed.code}`);++ }++ await store.deleteFolder(s, share.folder_id, share.owner);++ return { kind: 'folder', files };++ }++++ // ── Reads the pages and the API both need (no SQL outside this module) ──++ const listFiles = (owner, opts) => store.listFiles(s, owner, opts);++ const listFolders = (owner) => store.listFolders(s, owner);++ const listShares = (owner) => store.listShares(s, owner);++ const usageDays = (owner) => store.usageFor(s, owner);++ const shareBySlug = (slug) => store.getShare(s, slug);++ const reportsFor = (slug) => store.reportsFor(s, slug);++++ return {++ uploadAllowed, shareAllowed, usage, dayOf,++ recordFile, ownFile, deleteFile,++ listFiles, listFolders, listShares, usageDays, shareBySlug, reportsFor,++ ownFolder, createFolder, folderPath,++ createShare, ownShare, revokeShare,++ report, restoreShare, removeShared,++ stateOf, allowedFor, parseAllow,++ };++}++++module.exports = { createService, parseAllow, allowedFor, stateOf, newSlug, dayOf, DAY_MS };nnew file mode 100644@@@ -0,0 +1,21 @@++'use strict';++++/**++ * Who is OpenVibe staff at MediaHub, exactly as at OpenVibe.Help and OpenVibe.Rent: the Network role claim on the++ * person's session (server/auth/sso.js puts `role` on req.viewer; http/principal.js puts it on req.principal). The++ * roles are OpenVibe.Network's (manifests/policy/staff-roles.json): `global_mod` and `admin` may read the report++ * queue and restore or remove what was reported.++ *++ * One place decides staff, so the pages and the API cannot drift apart. Anonymous, an app token and an unlisted++ * role are not staff.++ */++const STAFF_ROLES = ['admin', 'global_mod'];++++const roleOf = (who) => (typeof who === 'string' ? who : (who && typeof who.role === 'string' ? who.role : null));++++function isStaff(who) {++ if (who && typeof who === 'object' && who.kind && who.kind !== 'user') return false;++ return STAFF_ROLES.includes(roleOf(who));++}++++module.exports = { isStaff, STAFF_ROLES };nnew file mode 100644@@@ -0,0 +1,172 @@++'use strict';++++/**++ * Files, folders, shares, reports and upload counters in OpenVibe.MediaHub's PostgreSQL database++ * (migrations/0002_mediahub.sql). Every function takes the store (server/db.js createStore) first; the clock is the++ * store's own, so a test can move time and watch a share expire.++ *++ * Two rules live here because SQL is where they cannot be raced:++ * - a report is one per person per share (the primary key, not a read followed by a write),++ * - the upload counter is one row per person per day (upsert).++ * Everything else (who may see what, which limits apply) is server/drive/service.js.++ */++++const FILES = 'mh_files';++const FOLDERS = 'mh_folders';++const SHARES = 'mh_shares';++const REPORTS = 'mh_reports';++const USAGE = 'mh_usage';++const UPLOADS = 'mh_uploads';++++// ── Files ───────────────────────────────────────────────────────────────────────────────────────────────────────++++async function insertFile(s, row) {++ await s.db.query(++ `INSERT INTO ${FILES} (id, owner, object_id, name, size, content_type, sha256, folder_id, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,++ [row.id, row.owner, row.object_id, row.name, row.size, row.content_type, row.sha256, row.folder_id, row.created_at]);++ return getFile(s, row.id);++}++++const getFile = (s, id) => s.db.maybe(`SELECT * FROM ${FILES} WHERE id = $1`, [id]);++++/** This person's live files: newest first, a page at a time, optionally one folder (null = the root). */++async function listFiles(s, owner, { folderId = null, limit = 100, before = null } = {}) {++ const values = [owner];++ const parts = ['owner = $1', 'deleted_at IS NULL'];++ if (folderId === null) parts.push('folder_id IS NULL');++ else { values.push(folderId); parts.push(`folder_id = $${values.length}`); }++ let sql = `SELECT * FROM ${FILES} WHERE ${parts.join(' AND ')}`;++ if (before) { values.push(before); sql += ` AND id < $${values.length}`; }++ values.push(limit);++ sql += ` ORDER BY id DESC LIMIT $${values.length}`;++ return s.db.many(sql, values);++}++++/** Every size the person still holds — what the quota bar is drawn from. */++const storedBytes = (s, owner) => s.db.value(`SELECT coalesce(sum(size), 0) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL`, [owner]).then(Number);++++const liveFileCount = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL`, [owner]).then(Number);++++/** Soft delete: the row stays (it is what the Media object is written from), the person stops seeing it. */++const softDeleteFile = (s, id, at) => s.db.exec(`UPDATE ${FILES} SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`, [id, at]);++++// ── Folders ─────────────────────────────────────────────────────────────────────────────────────────────────────++++async function insertFolder(s, row) {++ await s.db.query(`INSERT INTO ${FOLDERS} (id, owner, parent_id, name, created_at) VALUES ($1, $2, $3, $4, $5)`,++ [row.id, row.owner, row.parent_id, row.name, row.created_at]);++ return getFolder(s, row.id);++}++++const getFolder = (s, id) => s.db.maybe(`SELECT * FROM ${FOLDERS} WHERE id = $1`, [id]);++const listFolders = (s, owner) => s.db.many(`SELECT * FROM ${FOLDERS} WHERE owner = $1 ORDER BY lower(name) ASC, id ASC`, [owner]);++const countFolders = (s, owner) => s.db.value(`SELECT count(*) FROM ${FOLDERS} WHERE owner = $1`, [owner]).then(Number);++const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE id = $1 AND owner = $2`, [id, owner]);++++// ── Shares ──────────────────────────────────────────────────────────────────────────────────────────────────────++++async function insertShare(s, row) {++ await s.db.query(++ `INSERT INTO ${SHARES} (slug, file_id, folder_id, owner, owner_username, expires_at, allowed_subjects, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)`,++ [row.slug, row.file_id, row.folder_id, row.owner, row.owner_username || null, row.expires_at,++ row.allowed_subjects ? JSON.stringify(row.allowed_subjects) : null, row.created_at]);++ return getShare(s, row.slug);++}++++const getShare = (s, slug) => s.db.maybe(`SELECT * FROM ${SHARES} WHERE slug = $1`, [slug]);++const listShares = (s, owner) => s.db.many(`SELECT * FROM ${SHARES} WHERE owner = $1 ORDER BY created_at DESC, slug DESC`, [owner]);++++/** Live = not revoked, not suspended and not past its expiry: what the per-person share cap counts. */++const countLiveShares = (s, owner, nowIso) => s.db.value(++ `SELECT count(*) FROM ${SHARES} WHERE owner = $1 AND revoked_at IS NULL AND suspended_at IS NULL AND expires_at > $2`,++ [owner, nowIso]).then(Number);++++const revokeShare = (s, slug, at) => s.db.exec(`UPDATE ${SHARES} SET revoked_at = $2 WHERE slug = $1 AND revoked_at IS NULL`, [slug, at]);++const suspendShare = (s, slug, at) => s.db.exec(`UPDATE ${SHARES} SET suspended_at = $2 WHERE slug = $1 AND suspended_at IS NULL`, [slug, at]);++const restoreShare = (s, slug) => s.db.exec(`UPDATE ${SHARES} SET suspended_at = NULL WHERE slug = $1`, [slug]);++++/** One more download, counted where the row is: a shared counter cannot lose one. */++const countDownload = (s, slug) => s.db.exec(`UPDATE ${SHARES} SET downloads = downloads + 1 WHERE slug = $1`, [slug]);++++const deleteSharesForFile = (s, fileId) => s.db.exec(`DELETE FROM ${SHARES} WHERE file_id = $1`, [fileId]);++const deleteSharesForFolder = (s, folderId) => s.db.exec(`DELETE FROM ${SHARES} WHERE folder_id = $1`, [folderId]);++++// ── Reports ─────────────────────────────────────────────────────────────────────────────────────────────────────++++/** Record a report. The primary key is the truth: one per person per share, raced or not. */++async function addReport(s, { slug, reporter, reason, note, createdAt }) {++ try {++ await s.db.query(`INSERT INTO ${REPORTS} (slug, reporter, reason, note, created_at) VALUES ($1, $2, $3, $4, $5)`,++ [slug, reporter, reason, note, createdAt]);++ } catch (err) {++ if (err && err.code === '23505') return null; // already reported by this person++ throw err;++ }++ return s.db.maybe(`SELECT * FROM ${REPORTS} WHERE slug = $1 AND reporter = $2`, [slug, reporter]);++}++++const reportsFor = (s, slug) => s.db.many(`SELECT * FROM ${REPORTS} WHERE slug = $1 ORDER BY created_at ASC`, [slug]);++const reportCount = (s, slug) => s.db.value(`SELECT count(*) FROM ${REPORTS} WHERE slug = $1`, [slug]).then(Number);++++/** The high-water mark of how many reports this share has had: it only ever goes up, so restoring it sticks. */++const bumpReportCount = (s, slug, count) => s.db.value(`UPDATE ${SHARES} SET report_count = greatest(report_count, $2) WHERE slug = $1 RETURNING report_count`, [slug, count]).then(Number);++++/** The staff queue: every share somebody reported, the newest report first. */++function staffQueue(s, { limit = 100 } = {}) {++ return s.db.many(++ `SELECT sh.*, max(r.created_at) AS last_report_at, count(r.reporter) AS report_count++ FROM ${SHARES} sh JOIN ${REPORTS} r ON r.slug = sh.slug++ GROUP BY sh.slug++ ORDER BY max(r.created_at) DESC++ LIMIT $1`, [limit]);++}++++async function staffCounts(s) {++ const [reported, suspended, revoked] = await Promise.all([++ s.db.value(`SELECT count(DISTINCT slug) FROM ${REPORTS}`),++ s.db.value(`SELECT count(*) FROM ${SHARES} WHERE suspended_at IS NOT NULL`),++ s.db.value(`SELECT count(*) FROM ${SHARES} WHERE revoked_at IS NOT NULL`),++ ]);++ return { reported: Number(reported), suspended: Number(suspended), revoked: Number(revoked) };++}++++// ── Usage (uploads per person per day, UTC) ─────────────────────────────────────────────────────────────────────++++/** Record one upload today, answering the new count for the day. */++async function bumpUploads(s, owner, day) {++ const row = await s.db.maybe(++ `INSERT INTO ${USAGE} (owner, day, uploads) VALUES ($1, $2, 1)++ ON CONFLICT (owner, day) DO UPDATE SET uploads = ${USAGE}.uploads + 1++ RETURNING uploads`, [owner, day]);++ return Number(row && row.uploads);++}++++const uploadsToday = (s, owner, day) => s.db.value(`SELECT uploads FROM ${USAGE} WHERE owner = $1 AND day = $2`, [owner, day]).then((v) => Number(v) || 0);++const usageFor = (s, owner) => s.db.many(`SELECT * FROM ${USAGE} WHERE owner = $1 ORDER BY day DESC LIMIT 90`, [owner]);++++// ── Upload sessions (a chunked upload in flight) ────────────────────────────────────────────────────────────────++++async function insertUpload(s, row) {++ await s.db.query(++ `INSERT INTO ${UPLOADS} (id, owner, object_id, upload_id, name, size, content_type, folder_id, part_size, parts_expected, created_at, expires_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,++ [row.id, row.owner, row.object_id, row.upload_id, row.name, row.size, row.content_type, row.folder_id,++ row.part_size, row.parts_expected, row.created_at, row.expires_at]);++ return getUpload(s, row.id);++}++++const getUpload = (s, id) => s.db.maybe(`SELECT * FROM ${UPLOADS} WHERE id = $1`, [id]);++const setUploadFile = (s, id, fileId) => s.db.exec(`UPDATE ${UPLOADS} SET file_id = $2 WHERE id = $1`, [id, fileId]);++const dropUpload = (s, id) => s.db.exec(`DELETE FROM ${UPLOADS} WHERE id = $1`, [id]);++++module.exports = {++ insertFile, getFile, listFiles, storedBytes, liveFileCount, softDeleteFile,++ insertFolder, getFolder, listFolders, countFolders, deleteFolder,++ insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,++ deleteSharesForFile, deleteSharesForFolder,++ addReport, reportsFor, reportCount, bumpReportCount, staffQueue, staffCounts,++ bumpUploads, uploadsToday, usageFor,++ insertUpload, getUpload, setUploadFile, dropUpload,++};nnew file mode 100644@@@ -0,0 +1,178 @@++'use strict';++++/**++ * Getting a file's bytes into OpenVibe.Media and a row into the drive.++ *++ * Every upload is the same three steps against Media — start a multipart session, send its parts, complete it —++ * because that is the one shape that works for a 1 KB file and a 512 MB one, and because it lets the browser send++ * a big file in pieces. What differs is who sends the bytes:++ *++ * the browser, in parts POST /api/v1/files → PUT /api/v1/uploads/:id/parts/:n → POST …/complete++ * (the page's script; each part is buffered here and forwarded, never written to disk)++ * the browser, in one POST /files/upload (a plain <input type=file> form, no JavaScript)++ * the request body is parsed and spooled to a temp file, then read back part by part++ *++ * A part is at most config.partBytes (8 MB by default) in memory at a time. Nothing is written to the database++ * until Media says the object is ready: an upload that dies halfway leaves a Media object and one mh_uploads row,++ * which is exactly what the resume needs and what the abort (or the expiry sweep) clears.++ */++const fs = require('fs');++const store = require('./store');++++/** The exact size of part n (1-based) of a session: every part but the last is part_size. */++const partSizeOf = (upload, n) => (n < upload.parts_expected ? upload.part_size : upload.size - upload.part_size * (upload.parts_expected - 1));++++/** Read [start, end) of a file into a Buffer — one part, never the whole object. */++function readSlice(file, start, length) {++ return new Promise((resolve, reject) => {++ const buf = Buffer.alloc(length);++ const fd = fs.openSync(file, 'r');++ fs.read(fd, buf, 0, length, start, (err, read) => {++ fs.closeSync(fd);++ if (err) return reject(err);++ if (read !== length) return reject(new Error(`short read: ${read} of ${length}`));++ resolve(buf);++ });++ });++}++++function createUploads({ s, media, config, service, log = console }) {++ const nowIso = () => new Date(s.now()).toISOString();++++ /** Media's object is `uploading`; the session is Media's, the row is ours. */++ async function startSession({ owner, name, size, contentType, folderId = null }) {++ const room = await service.uploadAllowed(owner, size);++ if (!room.ok) return room;++ if (folderId && !await service.ownFolder(owner, folderId)) {++ return { ok: false, code: 'folder.not_found', detail: 'That folder does not exist.' };++ }++ const started = await media.init({++ size, contentType, name, ownerSubject: owner, multipart: true, partSize: config.partBytes,++ });++ if (!started.ok) return { ok: false, code: started.code, detail: started.detail };++ const objectId = started.data.id;++ let session = started.data.upload && started.data.upload.multipart;++ if (!session || !session.upload_id) {++ const fresh = await media.multipartStart(objectId, { size, partSize: config.partBytes });++ if (!fresh.ok) {++ await media.remove(objectId);++ return { ok: false, code: fresh.code, detail: fresh.detail };++ }++ session = fresh.data;++ }++ const row = await store.insertUpload(s, {++ id: s.newId('upl'), owner, object_id: objectId, upload_id: session.upload_id,++ name, size, content_type: contentType || 'application/octet-stream', folder_id: folderId || null,++ part_size: session.part_size, parts_expected: session.parts_expected,++ created_at: nowIso(), expires_at: new Date(s.now() + config.uploadTtlSeconds * 1000).toISOString(),++ });++ return { ok: true, upload: publicUpload(row, []) };++ }++++ /** The upload as the API and the page's script see it: no Media ids beyond the object, no token. */++ const publicUpload = (row, missing) => ({++ id: row.id, name: row.name, size: row.size, content_type: row.content_type,++ folder_id: row.folder_id, part_size: row.part_size, parts_expected: row.parts_expected,++ expires_at: row.expires_at, missing,++ status: row.file_id ? 'complete' : 'open',++ });++++ /** This person's own session, still open. An expired one is abandoned here, so nothing lingers. */++ async function openSession(owner, id) {++ if (!id) return null;++ const row = await store.getUpload(s, String(id));++ if (!row || row.owner !== owner) return null;++ if (row.expires_at <= nowIso()) {++ await media.abortMultipart(row.object_id, row.upload_id);++ await store.dropUpload(s, row.id);++ return null;++ }++ return row;++ }++++ async function status(owner, id) {++ const row = await openSession(owner, id);++ if (!row) return { ok: false, code: 'upload.not_found', detail: 'No such upload.' };++ const live = await media.multipartStatus(row.object_id, row.upload_id);++ if (!live.ok) {++ // Media has forgotten the session (purged, or the process restarted with a new one): say so plainly.++ return { ok: false, code: live.code, detail: live.detail };++ }++ const missing = Array.isArray(live.data.missing) ? live.data.missing : [];++ return { ok: true, upload: publicUpload(row, missing) };++ }++++ /** One part from the client. Its length is checked here (and again by Media) before anything is stored. */++ async function putPart(owner, id, n, buffer) {++ const row = await openSession(owner, id);++ if (!row) return { ok: false, code: 'upload.not_found', detail: 'No such upload.' };++ const part = Number(n);++ if (!Number.isInteger(part) || part < 1 || part > row.parts_expected) {++ return { ok: false, code: 'upload.bad_part', detail: `Part must be 1 to ${row.parts_expected}.` };++ }++ const want = partSizeOf(row, part);++ if (buffer.length !== want) {++ return { ok: false, code: 'upload.part_size', detail: `Part ${part} must be exactly ${want} bytes; ${buffer.length} arrived.` };++ }++ const sent = await media.putPart(row.object_id, row.upload_id, part, buffer);++ if (!sent.ok) return { ok: false, code: sent.code, detail: sent.detail };++ return { ok: true, part, size: want };++ }++++ /**++ * Media assembles the parts and makes the object ready; only then does the file exist here. The size and hash++ * are read back from Media — what it stored is the truth, not what the client claimed.++ */++ async function complete(owner, id) {++ const row = await openSession(owner, id);++ if (!row) return { ok: false, code: 'upload.not_found', detail: 'No such upload.' };++ const done = await media.completeMultipart(row.object_id, row.upload_id);++ if (!done.ok) return { ok: false, code: done.code, detail: done.detail };++ const object = done.data || {};++ const file = await service.recordFile({++ owner, objectId: row.object_id, name: row.name,++ size: Number(object.size_bytes) || row.size, contentType: object.mime_type || row.content_type,++ sha256: object.content_hash || null, folderId: row.folder_id,++ });++ await store.setUploadFile(s, row.id, file.id);++ await store.dropUpload(s, row.id);++ return { ok: true, file };++ }++++ async function abort(owner, id) {++ const row = await openSession(owner, id);++ if (!row) return { ok: false, code: 'upload.not_found', detail: 'No such upload.' };++ await media.abortMultipart(row.object_id, row.upload_id);++ await store.dropUpload(s, row.id);++ return { ok: true };++ }++++ /**++ * The whole file through the session's parts, read from a spooled file. This is the no-JavaScript path: the++ * browser posted one request, we wrote it to disk, and Media gets it in parts exactly as it would from a script.++ */++ async function uploadSpooled({ owner, file, name, size, contentType, folderId = null }) {++ const started = await startSession({ owner, name, size, contentType, folderId });++ if (!started.ok) return started;++ const row = await store.getUpload(s, started.upload.id);++ try {++ let offset = 0;++ for (let n = 1; n <= row.parts_expected; n++) {++ const want = partSizeOf(row, n);++ const buffer = await readSlice(file, offset, want);++ offset += want;++ const sent = await putPart(owner, row.id, n, buffer);++ if (!sent.ok) return sent;++ }++ return await complete(owner, row.id);++ } catch (err) {++ log.warn('[MediaHub] spooled upload failed:', (err && err.message) || '');++ await abort(owner, row.id);++ return { ok: false, code: 'upload.failed', detail: 'The upload could not be finished. Nothing was kept.' };++ }++ }++++ return { startSession, status, putPart, complete, abort, uploadSpooled, partSizeOf, publicUpload };++}++++module.exports = { createUploads, partSizeOf, readSlice };@@@ -1,30 +1,268 @@'use strict';/**-- * /api/v1 — OpenVibe.MediaHub's API.++ * /api/v1 — OpenVibe.MediaHub's API. Every route here is a signed-in person's and nothing else's: no app, agent++ * or service token can hold, share or delete somebody's files, and no route names a capability (see CAPABILITIES++ * in ./principal.js). A write made with the session cookie must come from openvibe.download itself (same-origin).*-- * GET /ping public liveness: { ok: true, service }++ * GET /ping anyone liveness++ * GET /files?folder_id=&limit=&before= person your drive (one folder, or the root)++ * POST /files person start an upload: { name, size, content_type, folder_id }++ * GET /files/:id person one of your files++ * DELETE /files/:id person delete it (and ask Media to delete the object)++ * GET /folders person your folders++ * POST /folders person make one: { name, parent_id }++ * POST /shares person share a file or a folder: { file_id|folder_id, hours, usernames }++ * DELETE /shares/:slug person revoke one++ * GET /usage person what you hold and what is left++ * PUT /uploads/:id/parts/:n person one part of a chunked upload (the raw bytes)++ * GET /uploads/:id person what is still missing (resume)++ * POST /uploads/:id/complete person assemble it into a file++ * DELETE /uploads/:id person abandon it*-- * The product adds its own routes here. Every route that reads or changes a person's data names the capability it-- * needs (`principal.requireCapability('<media-hub>.thing.read')`, listed in CAPABILITIES in ./principal.js) and takes-- * its per-caller numbers (`limits.reads('<media-hub>.thing.read')` or `limits.budget('<name>')`, ./caller-limits.js).-- * A refusal is an RFC 9457 problem+json with a stable code.++ * A refusal is an RFC 9457 problem+json with a stable code. The product's own caps (2 GB, 512 MB a file, 50++ * uploads a day, 20 live shares) are `file.quota` / `file.too_large` / `file.daily_limit` / `share.limit` — told++ * apart from the rate limiter's `rate_limited`, and answered before anything is written.*/const express = require('express');++const contracts = require('openvibe-contracts');const { asyncRouter } = require('./router');++const { sameOrigin } = require('./principal');++const rules = require('../drive/rules');++++/** The largest single part body we will read, plus room for a stray byte the length check must then refuse. */++const rawCap = (config) => config.partBytes + 1024;++++/** Read a raw request body, refusing past `cap` bytes. */++function readBody(req, cap) {++ return new Promise((resolve) => {++ const chunks = [];++ let total = 0;++ let done = false;++ const settle = (v) => { if (!done) { done = true; resolve(v); } };++ req.on('data', (c) => {++ total += c.length;++ if (total > cap) { req.resume(); return settle({ error: 'too_large' }); }++ chunks.push(c);++ });++ req.on('end', () => settle({ buffer: Buffer.concat(chunks) }));++ req.on('aborted', () => settle({ error: 'aborted' }));++ req.on('error', () => settle({ error: 'aborted' }));++ });++}function createApi(ctx) {-- const { config, principal, limits } = ctx;++ const { config, s, principal, limits, drive, uploads } = ctx;const r = asyncRouter();++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });-- r.use(express.json({ limit: '64kb' }));r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ // openvibe.pics and openvibe.video have no API: no upload, no drive, no share links (server/brand.js).++ r.use((req, res, next) => (req.brand && req.brand !== 'download'++ ? contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'This brand of the hub has no API yet; OpenVibe.Download is the one that has launched.', ctx: req.ov })++ : next()));r.use(principal.middleware);-- // The one route the skeleton ships. It is public and counted per caller with the default read numbers.++ /**++ * The chunked upload's part body is raw bytes, so it is mounted before the JSON parser: express must never try++ * to read a megabyte of file as JSON. Everything below the parser is JSON in, JSON out.++ */++ r.put('/uploads/:id/parts/:n', person, limits.budget('media-hub.upload.part'), async (req, res) => {++ const got = await readBody(req, rawCap(config));++ if (got.error === 'too_large') return problem(req, res, 413, 'upload.part_too_large', `A part is at most ${config.partBytes} bytes.`);++ if (got.error) return problem(req, res, 400, 'upload.interrupted', 'The part did not arrive complete.');++ const out = await uploads.putPart(personOf(req), String(req.params.id || ''), req.params.n, got.buffer);++ if (!out.ok) return problem(req, res, out.code === 'upload.not_found' ? 404 : 422, out.code, out.detail);++ return res.json({ part: out.part, size: out.size });++ });++++ r.use(express.json({ limit: '64kb' }));++++ const personOf = (req) => (req.principal.kind === 'user' ? req.principal.requester : null);++++ /**++ * The HTTP status a product refusal is answered with. A cap (too large, quota, a day's uploads, too many live++ * shares) is 429 or 413; something that is not there is 404; everything else is a 422 the caller can fix.++ */++ function refused(code) {++ if (String(code).startsWith('media.')) return 502; // OpenVibe.Media refused or could not be reached++ if (code === 'file.not_found' || code === 'folder.not_found' || code === 'share.not_found') return 404;++ if (code === 'file.too_large') return 413;++ if (code === 'file.quota' || code === 'file.daily_limit' || code === 'share.limit') return 429;++ return 422;++ }++++ // ── Who may act ─────────────────────────────────────────++ /**++ * A person, signed in. An app, agent or service token is refused: a file is somebody's own, and no app holds++ * one here. A session write must come from openvibe.download itself.++ */++ function requirePerson(req, res) {++ const p = req.principal;++ if (p.kind === 'anonymous') {++ problem(req, res, 401, 'token.required', 'Sign in at openvibe.download, or send a person\'s Network token as a Bearer.');++ return false;++ }++ if (p.kind !== 'user') {++ problem(req, res, 403, 'file.person_required', 'Files, folders and share links belong to a person; an app, agent or service token cannot hold or share them.');++ return false;++ }++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {++ problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.download itself.');++ return false;++ }++ return true;++ }++ // Declared before the routes that use it: the raw part route is mounted above the JSON parser.++ function person(req, res, next) { return requirePerson(req, res) ? next() : undefined; }++++ // ── Wire shapes ─────────────────────────────────────────++ const wireFile = (row) => ({++ id: row.id, name: row.name, size: Number(row.size), content_type: row.content_type,++ sha256: row.sha256, folder_id: row.folder_id, created_at: row.created_at,++ object: { media_object_id: row.object_id },++ });++ const wireFolder = (row) => ({ id: row.id, name: row.name, parent_id: row.parent_id, created_at: row.created_at });++ const wireShare = (row) => ({++ slug: row.slug, url: `/s/${row.slug}`,++ file_id: row.file_id, folder_id: row.folder_id,++ expires_at: row.expires_at, downloads: Number(row.downloads) || 0, report_count: Number(row.report_count) || 0,++ state: drive.stateOf(row, s.iso()),++ revoked_at: row.revoked_at, suspended_at: row.suspended_at, created_at: row.created_at,++ allowed: drive.parseAllow(row.allowed_subjects),++ limited: Boolean(row.allowed_subjects),++ });++++ // ── Liveness ────────────────────────────────────────────r.get('/ping', limits.reads('media-hub.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));++ // ── Files ───────────────────────────────────────────────++ r.get('/files', person, limits.reads('media-hub.file.read'), async (req, res) => {++ const owner = personOf(req);++ const folderId = req.query.folder_id ? String(req.query.folder_id) : null;++ if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');++ const limit = Math.min(200, Math.max(1, Number.parseInt(req.query.limit, 10) || 100));++ const before = rules.isFileId(req.query.before) ? String(req.query.before) : null;++ const rows = await drive.listFiles(owner, { folderId, limit, before });++ const folders = await drive.listFolders(owner);++ return res.json({++ folder_id: folderId,++ folders: folders.filter((f) => (f.parent_id || null) === folderId).map(wireFolder),++ files: rows.map(wireFile),++ usage: await drive.usage(owner),++ note: 'Your files only. A file is private until you make a share link for it.',++ });++ });++++ /** Start an upload. The bytes follow through PUT /uploads/:id/parts/:n (see the page's script). */++ r.post('/files', person, limits.budget('media-hub.upload.start'), async (req, res) => {++ const owner = personOf(req);++ const b = req.body || {};++ const size = Number(b.size);++ const name = rules.fileName(b.name, 'file');++ const contentType = rules.normalizeType(b.content_type) || 'application/octet-stream';++ const folderId = b.folder_id ? String(b.folder_id) : null;++ if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');++ const out = await uploads.startSession({ owner, name, size, contentType, folderId });++ if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);++ res.set('Location', `/api/v1/uploads/${out.upload.id}`);++ return res.status(201).json({++ upload: out.upload,++ note: 'Send each part of `missing` (they are part_size bytes, the last one less) with PUT /api/v1/uploads/:id/parts/:n, then POST /api/v1/uploads/:id/complete.',++ });++ });++++ r.get('/files/:id', person, limits.reads('media-hub.file.read'), async (req, res) => {++ const row = await drive.ownFile(personOf(req), String(req.params.id || ''));++ if (!row) return problem(req, res, 404, 'file.not_found', 'No such file.');++ return res.json({ file: wireFile(row), shares: (await drive.listShares(row.owner)).filter((x) => x.file_id === row.id).map(wireShare) });++ });++++ r.delete('/files/:id', person, async (req, res) => {++ const out = await drive.deleteFile(personOf(req), String(req.params.id || ''));++ if (out.error) return problem(req, res, 404, 'file.not_found', 'No such file.');++ return res.status(204).end();++ });++++ // ── Folders ─────────────────────────────────────────────++ r.get('/folders', person, limits.reads('media-hub.folder.read'), async (req, res) => {++ const rows = await drive.listFolders(personOf(req));++ return res.json({ folders: rows.map(wireFolder) });++ });++++ r.post('/folders', person, limits.budget('media-hub.folder.create'), async (req, res) => {++ const b = req.body || {};++ const parentId = b.parent_id ? String(b.parent_id) : null;++ if (parentId && !rules.isFolderId(parentId)) return problem(req, res, 422, 'folder.invalid', 'parent_id must be a folder id.');++ const out = await drive.createFolder(personOf(req), { name: b.name, parentId });++ if (out.error) return problem(req, res, out.error === 'folder.exists' ? 409 : 422, out.error, out.detail);++ res.set('Location', `/api/v1/folders/${out.folder.id}`);++ return res.status(201).json({ folder: wireFolder(out.folder) });++ });++++ // ── Shares ──────────────────────────────────────────────++ r.post('/shares', person, limits.budget('media-hub.share.create'), async (req, res) => {++ const b = req.body || {};++ const fileId = b.file_id ? String(b.file_id) : null;++ const folderId = b.folder_id ? String(b.folder_id) : null;++ if (fileId && !rules.isFileId(fileId)) return problem(req, res, 422, 'share.invalid', 'file_id must be a file id.');++ if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'share.invalid', 'folder_id must be a folder id.');++ const usernames = Array.isArray(b.usernames) ? b.usernames++ : (typeof b.usernames === 'string' && b.usernames.trim() ? b.usernames.split(/[\s,]+/) : []);++ const out = await drive.createShare(personOf(req), {++ fileId, folderId, hours: b.hours, names: usernames, ownerUsername: req.principal.username,++ });++ if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);++ res.set('Location', `/api/v1/shares/${out.share.slug}`);++ return res.status(201).json({++ share: wireShare(out.share),++ unresolved: out.unresolved,++ note: (out.unresolved || []).length++ ? 'Network could not turn these names into subjects; the share still allows them by their username claim.'++ : undefined,++ });++ });++++ r.delete('/shares/:slug', person, async (req, res) => {++ const row = await drive.revokeShare(personOf(req), String(req.params.slug || ''));++ if (!row) return problem(req, res, 404, 'share.not_found', 'No such share.');++ return res.status(204).end();++ });++++ // ── Usage ───────────────────────────────────────────────++ r.get('/usage', person, limits.reads('media-hub.usage.read'), async (req, res) => {++ const owner = personOf(req);++ return res.json({++ usage: await drive.usage(owner),++ days: (await drive.usageDays(owner)).map((d) => ({ day: d.day, uploads: Number(d.uploads) })),++ note: 'Media\'s own quotas are per tenant (this whole service); these numbers are per person.',++ });++ });++++ // ── Chunked uploads (the part PUT is mounted above the JSON parser) ──++ r.get('/uploads/:id', person, limits.reads('media-hub.upload.read'), async (req, res) => {++ const out = await uploads.status(personOf(req), String(req.params.id || ''));++ if (!out.ok) return problem(req, res, out.code === 'upload.not_found' ? 404 : 409, out.code, out.detail);++ return res.json({ upload: out.upload });++ });++++ r.post('/uploads/:id/complete', person, limits.budget('media-hub.upload.complete'), async (req, res) => {++ const out = await uploads.complete(personOf(req), String(req.params.id || ''));++ if (!out.ok) {++ const status = out.code === 'upload.not_found' ? 404 : (out.code === 'file.quota' ? 429 : 409);++ return problem(req, res, status, out.code, out.detail);++ }++ res.set('Location', `/api/v1/files/${out.file.id}`);++ return res.status(201).json({ file: wireFile(out.file) });++ });++++ r.delete('/uploads/:id', person, async (req, res) => {++ const out = await uploads.abort(personOf(req), String(req.params.id || ''));++ if (!out.ok) return problem(req, res, 404, 'upload.not_found', 'No such upload.');++ return res.status(204).end();++ });++return r;}--module.exports = { createApi };++module.exports = { createApi, readBody };@@@ -9,7 +9,7 @@* anyone else ip:<address>** Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before it does any work; the-- * refusal is logged once and counted in media-hub_rate_limited_total{limit,window}. Reads take MEDIAHUB_LIMITS_MINUTE /++ * refusal is logged once and counted in media_hub_rate_limited_total{limit,window}. Reads take MEDIAHUB_LIMITS_MINUTE /* MEDIAHUB_LIMITS_HOUR (120 and 3000); the product's expensive routes get their own numbers in BUDGETS below.* Counters live in this process unless VALKEY_URL is set.*@@@ -31,12 +31,25 @@ function caller(req) {* not declared here. The empty object is the skeleton's starting point:** 'media-hub.thing.create': { minute: 6, hour: 60 },++ *++ * The form on a page and its API route take the same entry: the form is the same action through another door, so++ * it cannot be a way round a limit (see http/pages.js budgeted()).++ *++ * These are the per-caller rates. The product's own caps (2 GB, 512 MB a file, 50 uploads a day, 20 live shares)++ * are enforced in server/drive/service.js — both, because they mean different things.*/--const BUDGETS = {};++const BUDGETS = {++ // A 512 MB file is 64 parts of 8 MB: a part every two seconds is a fast line, not a runaway loop.++ 'media-hub.upload.start': { minute: 20, hour: 200 },++ 'media-hub.upload.part': { minute: 300, hour: 5_000 },++ 'media-hub.upload.complete': { minute: 30, hour: 300 },++ 'media-hub.folder.create': { minute: 20, hour: 120 },++ 'media-hub.share.create': { minute: 20, hour: 200 },++};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {const refused = registry-- ? registry.counter({ name: 'media-hub_rate_limited_total', help: 'Requests refused 429 by a per-caller limit, by limit name and window', labelNames: ['limit', 'window'] })++ ? registry.counter({ name: 'media_hub_rate_limited_total', help: 'Requests refused 429 by a per-caller limit, by limit name and window', labelNames: ['limit', 'window'] }): null;const limiter = createActorLimiter({limits: { minute: config.limits.minute, hour: config.limits.hour },@@@ -15,10 +15,12 @@ const { asyncRouter } = require('./router');const SITE_NAME = 'OpenVibe.MediaHub';const DESCRIPTION = 'OpenVibe.MediaHub — Your files, shared on your terms.';--const DISALLOW = ['/auth/', '/api/'];++// /s/ (with the slash) rather than /s, or the rule would also swallow /safety, which is a public page.++const DISALLOW = ['/auth/', '/api/', '/files', '/s/', '/staff'];const PAGE_TEXT = {-- '/': ['OpenVibe.MediaHub home', 'OpenVibe.MediaHub: Your files, shared on your terms.'],++ '/': ['OpenVibe.MediaHub home', 'OpenVibe.MediaHub: Your files, shared on your terms. A private drive for a signed-in OpenVibe account, with share links that expire, can be revoked and need the downloader to be signed in too.'],++ '/safety': ['Safety rules', 'What OpenVibe.Download allows and does not, who can upload and download, how reports work, and who to write to about abuse or copyright.'],'/updates': ['What shipped on OpenVibe.MediaHub', 'This site\'s update log, from the network changelog feed.'],};@@@ -39,8 +41,13 @@ function homeJsonLd(config) {];}++/**++ * The pages a crawler may read: this brand's homes, the safety rules and the update log — nothing behind sign-in.++ * /files, /s/… and /staff are a person's own, so they are disallowed above and left out here.++ */const publicPages = () => [{ path: '/', changefreq: 'weekly', priority: 1.0 },++ { path: '/safety', changefreq: 'monthly', priority: 0.6 },{ path: '/updates', changefreq: 'daily', priority: 0.5 },];@@@ -65,6 +72,7 @@ function createDiscoveryRoutes(ctx) {sections: [{ title: 'Start here', links: [{ title: 'OpenVibe.MediaHub', url: abs('/'), note: 'Your files, shared on your terms.' },++ { title: 'Safety rules', url: abs('/safety'), note: 'what is allowed, how reports work, who to write to' },{ title: 'What shipped on OpenVibe.MediaHub', url: abs('/updates') },] },{ title: 'Machine-readable', links: [nnew file mode 100644@@@ -0,0 +1,186 @@++'use strict';++++/**++ * A very small multipart/form-data reader, for the one thing this service needs it for: a plain++ * `<form enctype="multipart/form-data"><input type="file">` with no JavaScript at all.++ *++ * It streams. The file part goes straight to a temp file as it arrives (with sha256 and a byte count computed on++ * the way), so a 256 MB upload never sits in this process's memory; the text parts are small and are kept as++ * strings. It stops the moment the body passes `maxBytes` and answers { error: 'too_large' }.++ *++ * Nothing here decides what a form may contain: an unknown field comes back like any other, and the caller++ * validates what it uses. There is no dependency for this because there is none in this tree, and the one form++ * shape we accept is narrower than a general parser would be.++ */++const crypto = require('crypto');++const fs = require('fs');++++const CRLF = '\r\n';++++/** The boundary of a multipart body, or null. */++function boundaryOf(contentType) {++ const m = /;\s*boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(String(contentType || ''));++ return m ? (m[1] || m[2]) : null;++}++++/** name="x"; filename="y" from a part's Content-Disposition, or null. */++function disposition(line) {++ if (!/^content-disposition:/i.test(String(line || ''))) return null;++ const value = String(line).slice(String(line).indexOf(':') + 1);++ const get = (key) => {++ const v = new RegExp(`${key}\\s*=\\s*(?:"([^"]*)"|([^;\\s]*))`, 'i').exec(value);++ return v ? (v[1] !== undefined ? v[1] : v[2]) : null;++ };++ return { name: get('name'), filename: get('filename') };++}++++/**++ * Read a multipart body. Answers++ * { fields: { name: value }, file: { field, filename, contentType, path, size, sha256 } | null }++ * or { error, detail }. `filePath` is where the file part is written; the caller removes it.++ */++function readFormData(req, { boundary, filePath, maxBytes }) {++ return new Promise((resolve) => {++ const first = Buffer.from(`--${boundary}`);++ const delim = Buffer.from(`${CRLF}--${boundary}`);++ const fields = {};++ const hash = crypto.createHash('sha256');++ let state = 'preamble';++ let buf = Buffer.alloc(0);++ let sink = null;++ let part = null;++ let file = null;++ let settled = false;++++ const removeTemp = () => { try { fs.unlinkSync(filePath); } catch { /* never created */ } };++ const finish = (out) => {++ if (settled) return;++ settled = true;++ req.removeListener('data', onData);++ resolve(out);++ };++ const fail = (error, detail) => {++ if (settled) return;++ if (sink) sink.destroy();++ removeTemp();++ req.removeListener('data', onData);++ req.resume();++ finish({ error, detail });++ };++++ /** Everything before the boundary marker is the part's own bytes: count it, hash it, write it out. */++ const take = (data) => {++ if (!data || !data.length) return;++ if (!part || part.filename == null) {++ if (!part) part = { field: null, filename: null, contentType: null, data: [] };++ part.data.push(data);++ return;++ }++ if (!sink) sink = fs.createWriteStream(filePath, { flags: 'w' });++ sink.on('error', (err) => fail('write_failed', (err && err.message) || 'the upload could not be written'));++ file.size += data.length;++ hash.update(data);++ if (!sink.write(data)) { req.pause(); sink.once('drain', () => req.resume()); }++ };++++ const endPart = () => {++ if (part && part.filename == null) {++ fields[part.field || ''] = Buffer.concat(part.data).toString('utf8');++ } else if (part) {++ file.field = part.field;++ file.filename = part.filename;++ file.contentType = part.contentType;++ }++ part = null;++ };++++ const loop = () => {++ while (!settled) {++ if (state === 'preamble') {++ const idx = buf.indexOf(first);++ if (idx < 0) { buf = buf.subarray(Math.max(0, buf.length - first.length)); return; }++ const after = buf.subarray(idx + first.length);++ if (after.length < 2) { buf = after; return; }++ buf = after.subarray(2);++ state = after.subarray(0, 2).toString('latin1') === '--' ? 'done' : 'headers';++ continue;++ }++ if (state === 'headers') {++ const end = buf.indexOf('\r\n\r\n');++ if (end < 0) {++ if (buf.length > 16 * 1024) return fail('bad_request', 'the form part headers are too long');++ return;++ }++ const block = buf.subarray(0, end).toString('utf8').split(CRLF);++ const disp = disposition(block[0]);++ const type = block.find((l) => /^content-type:/i.test(l));++ part = {++ field: disp && disp.name ? disp.name : null,++ filename: disp && disp.filename != null ? disp.filename : null,++ contentType: type ? type.slice(type.indexOf(':') + 1).trim() : null,++ data: [],++ };++ if (part.filename != null && !file) file = { field: null, filename: null, contentType: null, path: filePath, size: 0 };++ buf = buf.subarray(end + 4);++ state = 'body';++ continue;++ }++ if (state === 'body') {++ const idx = buf.indexOf(delim);++ if (idx < 0) {++ const keep = Math.min(buf.length, delim.length - 1);++ take(buf.subarray(0, buf.length - keep));++ buf = buf.subarray(buf.length - keep);++ return;++ }++ take(buf.subarray(0, idx));++ const after = buf.subarray(idx + delim.length);++ if (after.length < 2) { buf = after; return; }++ const tail = after.subarray(0, 2).toString('latin1');++ buf = after.subarray(2);++ endPart();++ state = tail === '--' ? 'done' : 'headers';++ continue;++ }++ return;++ }++ };++++ let collected = null;++ const collect = () => {++ if (!collected) {++ collected = { fields, file: null };++ if (file) collected.file = { ...file, sha256: hash.digest('hex') };++ }++ return collected;++ };++++ // The sink is closed once, whether the last boundary arrived or the request simply ended.++ let closing = false;++ const wrapUp = () => {++ if (closing || settled) return;++ closing = true;++ endPart();++ if (sink) sink.end(() => finish(collect()));++ else finish(collect());++ };++++ let received = 0;++ function onData(chunk) {++ if (settled) return;++ received += chunk.length;++ if (received > maxBytes) return fail('too_large', 'larger than this service accepts in one request');++ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;++ loop();++ if (state === 'done') wrapUp();++ }++++ req.on('data', onData);++ // A body that ends without its last boundary is still the form we were sent: what arrived is used.++ req.on('end', wrapUp);++ req.on('aborted', () => fail('aborted', 'the upload was interrupted'));++ req.on('error', () => fail('aborted', 'the upload was interrupted'));++ });++}++++module.exports = { readFormData, boundaryOf };@@@ -1,42 +1,217 @@'use strict';/**-- * Public pages: the home page and the update log. Crawl artifacts (robots.txt, sitemap.xml, llms.txt,-- * llms-full.txt, JSON-LD) are http/discovery.js.++ * The pages of openvibe.download, plus the honest "coming" homes of openvibe.pics and openvibe.video (the host++ * decides which, see server/brand.js).*-- * Every page works without JavaScript and is server-rendered through openvibe-shared/shell (render/layout.js).-- * The product replaces the home page's sections and adds its own routes here.++ * / what this is, the safety rules and how to sign in++ * /files your drive: folders, files, the usage bar, the upload form++ * /files/:id one file: its details, a share link, delete++ * /files/:id/download its bytes++ * /s/:slug a share link: what it is, who shared it, expiry, Download and Report++ * /staff the report queue (OpenVibe staff)++ * /safety the rules, what is not allowed, how reports work, the abuse contact++ * /updates the update log++ *++ * Every page works without JavaScript and is server-rendered through openvibe-shared/shell: the upload form, the++ * share form and the report form are plain POSTs. The one script is the chunked upload's progress meter++ * (public/js/upload.js), and the plain form submits by itself when it is not there.++ *++ * A signed-in write must come from openvibe.download itself (same-origin), exactly as in the API, and the form++ * takes the same per-caller budget as the API route it stands for, so it is not a way round a limit.++ *++ * Nothing here is hand-written copy about a file: a file's name is what its owner typed, escaped by the `html`++ * template and nothing else. No file's bytes are ever rendered into a page.*/++const express = require('express');++const fs = require('fs');++const os = require('os');++const path = require('path');++const crypto = require('crypto');++const { Readable } = require('stream');++const { pipeline } = require('stream/promises');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');--const { html, raw } = require('../render/html');++const { sameOrigin } = require('./principal');++const { html, raw, table, notice, time, badge } = require('../render/html');const { send } = require('../render/layout');++const rules = require('../drive/rules');++const { isStaff } = require('../drive/staff');++const { boundaryOf, readFormData } = require('./form-upload');++const { brandOf, COMING } = require('../brand');const SITE_NAME = 'OpenVibe.MediaHub';const TAGLINE = 'Your files, shared on your terms.';++const ABUSE_EMAIL = '[email protected]';++const DMCA_EMAIL = '[email protected]';++++/** The rules, word for word: shown on / and /safety, so the two can never drift. */++const SAFETY_RULES = [++ 'Anything you would not put in a public square does not belong here. This is a private drive with share links, not a place to hide.',++ 'No malware, no phishing, no cracked software, no keygens, no "free" accounts.',++ 'No child sexual abuse material, and nothing that sexualises minors. That is reported to the authorities, not just deleted.',++ 'No content you do not have the right to share — pirated films, albums, books and software are removed when they are reported.',++ 'Every download needs a signed-in OpenVibe account, and every share carries the name of the person who made it.',++];++const REPORT_NOTE = 'Every share page has a Report form. Three different people reporting a share — or one report of illegal content — suspends it at once and puts it in front of OpenVibe staff.';function createPageRoutes(ctx) {-- const { config } = ctx;++ const { config, s, drive, uploads, media } = ctx;const r = asyncRouter();++ // The pages' forms are web forms (no JavaScript): urlencoded text, and one multipart file upload whose body the++ // upload route reads itself. The JSON parser is deliberately absent here.++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });-- const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);++ const staffViewer = (req) => isStaff(req.viewer);++ const requesterOf = (req) => `user:${req.viewer.subject}`;++ const viewerOf = (req) => ({ subject: req.viewer.subject, username: req.viewer.username || null });++++ // ── Small pieces ─────────────────────────────────────────++ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;++ const fileSize = (row) => rules.formatBytes(row.size);++++ /** The usage bar. A <progress> element: it means what it says without a stylesheet and without JavaScript. */++ const usageBar = (u) => html`<div class="usage">++<progress id="usage" max="${u.bytes_limit}" value="${u.bytes_used}" aria-describedby="usage-text">${Math.round((u.bytes_used / u.bytes_limit) * 100)}%</progress>++<p id="usage-text" class="small muted">${rules.formatBytes(u.bytes_used)} of ${rules.formatBytes(u.bytes_limit)} used · ${u.files} file${u.files === 1 ? '' : 's'} · ${u.uploads_today} of ${u.uploads_per_day} uploads today · ${u.live_shares} of ${u.max_shares} live share links</p></div>`;++++ const SHARE_STATE = { live: ['live', 'ok'], expired: ['expired', 'warn'], revoked: ['revoked', 'bad'], suspended: ['suspended — with staff', 'bad'] };++ const shareBadge = (state) => badge(SHARE_STATE[state] ? SHARE_STATE[state][0] : state, SHARE_STATE[state] ? SHARE_STATE[state][1] : '');++++ const fileRow = (row) => html`<li class="file">++<span class="file-main"><a class="file-name" href="/files/${row.id}">${row.name}</a>++<span class="file-meta small muted">${fileSize(row)} · ${row.content_type} · added ${time(row.created_at)}</span></span>++<a class="sc-btn" href="/files/${row.id}/download">Download</a></li>`;++++ /** The same per-caller budget the API applies, given an HTML face. */++ const budgeted = (name) => {++ const limit = ctx.limits.budget(name);++ return (req, res, next) => {++ let settled = false;++ const refusal = (body) => {++ let p = null;++ try { p = JSON.parse(String(body)); } catch { p = null; }++ if (p && p.retry_after_seconds) res.set('Retry-After', String(p.retry_after_seconds));++ return page(req, res, {++ title: 'Too many requests',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Too many requests' }],++ body: html`<h1>Too many requests just now</h1>++${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}++<p class="muted">Wait a moment and send it again — nothing was lost. <a href="/files">Your files</a> are still there.</p>`,++ }, 429);++ };++ limit(req, {++ statusCode: 0,++ setHeader: () => {}, getHeader: () => undefined, removeHeader: () => {},++ end: (body) => { if (!settled) { settled = true; refusal(body); } },++ }, (err) => {++ if (settled) return undefined;++ settled = true;++ return err ? next(err) : next();++ });++ return undefined;++ };++ };++++ /** A signed-in page write must come from this site, exactly as in the API. */++ function sameSite(req, res, back) {++ if (sameOrigin(req, config.baseUrl)) return true;++ page(req, res, {++ title: 'Not from this site',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Not from this site' }],++ body: html`<h1>That came from somewhere else</h1><p class="muted">A signed-in page that changes something has to come from openvibe.download itself. <a href="${back}">Go back</a> and try again.</p>`,++ }, 403);++ return false;++ }++++ /**++ * The signed-in gate. Every page that shows a file answers this to a guest — there is no anonymous anything.++ * `back` is where sign-in returns to: a fixed page, or the current URL for a share link.++ */++ const mustSignIn = (back, why = 'A file belongs to your OpenVibe account, so you can share it, download it or delete it later.') =>++ (req, res, next) => (signedIn(req) ? next()++ : page(req, res, {++ title: 'Sign in',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Sign in' }],++ body: html`<h1>Sign in first</h1>${signInPrompt(why, back === 'here' ? req.originalUrl : back)}`,++ }, 401));++++ // ── The unlaunched brands ────────────────────────────────++ /**++ * openvibe.pics and openvibe.video answer an honest "coming" page and nothing else: no drive, no share links,++ * no uploads. /updates and the crawl artifacts are the same on every brand, and everything else 404s — the++ * product has not shipped there, so there is nothing to offer but the truth.++ */++ const PASS_THROUGH = new Set(['/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt', '/release.json', '/auth/me', '/favicon.ico']);++ r.use((req, res, next) => {++ if (brandOf(req) === 'download') return next();++ if (PASS_THROUGH.has(req.path)) return next();++ if (req.path === '/') return next(); // the brand's own home, below++ if (req.path === '/updates') return next();++ const brand = COMING[brandOf(req)];++ return page(req, res, {++ title: `${brand.name} is coming`,++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Coming' }],++ body: html`<h1>${brand.name} is not open yet</h1>++${notice(`${brand.name} has no uploads and no file pages yet. The only brand of this service that has launched is OpenVibe.Download.`, 'warn')}++<p class="muted">This host serves ${brand.domain}, one of the three brands of the same OpenVibe.MediaHub service. <a href="https://openvibe.download">Go to OpenVibe.Download</a> for the part that works today.</p>`,++ }, 404);++ });// ── Home ─────────────────────────────────────────────────++ const comingBody = (brand) => html`${raw(showcase.hero({++ eyebrow: `${brand.name} · ${brand.tagline}`,++ title: brand.name,++ accent: 'coming soon',++ lede: brand.what,++ actions: [{ label: 'OpenVibe.Download is open', href: 'https://openvibe.download', primary: true }, { label: 'What shipped', href: '/updates' }],++ note: 'Nothing is uploaded here yet and there is no way to sign in on this brand.',++ }))}++${raw(showcase.features({++ title: 'What it will be',++ lede: 'The shape of it, from the product\'s own plan.',++ items: brand.pillars.map((p) => ({ icon: 'ov:page', title: p, text: '' })),++ }))}++${raw(showcase.features({++ title: 'Why it is not open yet',++ lede: 'Two things have to be true before OpenVibe runs a public place for other people\'s pictures and videos.',++ items: [++ { icon: 'ov:shield', title: 'Safety tooling first', text: 'There is no malware or CSAM scanning on the network yet, so uploads here wait for it. OpenVibe.Download opens first precisely because it is a private drive whose share links only signed-in people can open.' },++ { icon: 'ov:page', title: 'One service, three brands', text: `${brand.name} is served by the same process as openvibe.download — the same file store, the same account and the same rules. This host answers only this page until that tooling is here.` },++ ],++ }))}++${raw(showcase.cta({ title: 'In the meantime', text: 'OpenVibe.Media already stores and serves the network\'s pictures and video, and OpenVibe.Download is open for private files.', actions: [{ label: 'OpenVibe.Media', href: 'https://openvibe.media' }, { label: 'OpenVibe.Download', href: 'https://openvibe.download' }] }))}`;++++ r.get('/', (req, res, next) => {++ const brand = COMING[brandOf(req)];++ if (brand) {++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE, styles: [showcase.STYLESHEET],++ title: `${brand.name} — coming soon`,++ description: `${brand.name}: ${brand.tagline} Not open yet — uploads here wait for safety tooling. OpenVibe.Download is the brand of this service that has launched.`,++ crumbs: [{ label: 'Home', href: '/' }],++ body: comingBody(brand),++ });++ }++ return next();++ });++r.get('/', (req, res) => {const hero = showcase.hero({eyebrow: `${SITE_NAME} · ${TAGLINE}`,-- title: SITE_NAME,++ title: 'OpenVibe.Download',accent: TAGLINE,-- lede: `${SITE_NAME} is a new OpenVibe service. This is its skeleton: the platform plumbing below is in place, and the product is what goes on top of it.`,++ lede: 'A private drive for your files, with share links you control. Uploads and every file page need a signed-in OpenVibe account — there is no anonymous upload and no anonymous download here.',actions: signedIn(req)-- ? [{ label: 'Your account', href: '/auth/me' }, { label: 'What shipped', href: '/updates' }]-- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2F', primary: true }, { label: 'What shipped', href: '/updates' }],-- note: 'Open source (AGPL-3.0). Sign-in is OpenVibe.Network; a person\'s token stays in an httpOnly cookie.',++ ? [{ label: 'Your files', href: '/files', primary: true }, { label: 'The safety rules', href: '/safety' }]++ : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'The safety rules', href: '/safety' }],++ note: 'The bytes live in OpenVibe.Media. This service holds the names, the folders and the share links.',});page(req, res, {index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,@@@ -44,30 +219,525 @@ function createPageRoutes(ctx) {styles: [showcase.STYLESHEET],body: html`${raw(hero)}${raw(showcase.features({-- title: 'What is already here',-- lede: 'The parts every OpenVibe service needs, so a new product starts from them instead of rebuilding them.',++ title: 'What you get',++ lede: 'A drive, not a locker: everything here is yours, and everything you hand out can be taken back.',items: [-- { icon: 'ov:account', title: 'Sign-in with OpenVibe.Network', text: 'OAuth 2 with PKCE (S256), server-side code exchange, httpOnly session cookies, and offline token verification against the Network signing key.' },-- { icon: 'ov:page', title: 'Server-rendered pages', text: 'The OpenVibe Frame (navbar and footer), the shared theme, one stylesheet, crawl artifacts and JSON-LD. Every page is complete without JavaScript.' },-- { icon: 'ov:tools', title: 'An API mount and capabilities', text: 'GET /api/v1/ping today; the router, the problem+json errors and the app/person capability guard are wired and waiting for the product\'s routes.' },-- { icon: 'ov:gauge', title: 'Limits and readiness', text: 'Per-address and per-caller rate limits, /api/health, a truthful /api/ready and Prometheus metrics on loopback.' },-- { icon: 'ov:db', title: 'PostgreSQL and migrations', text: 'openvibe-sdk/db, migrations/NNNN_*.sql applied at boot, PGlite in development, and a store with an injectable clock.' },-- { icon: 'ov:deploy', title: 'Deploy files and tests', text: 'nginx and systemd units, an environment template, and a test suite on PGlite with a mock Network, all through npm test.' },++ { icon: 'ov:upload', title: 'Your own drive', text: 'Folders and files, private to you. 2 GB stored and 512 MB in one file, from this service\'s own configuration.' },++ { icon: 'ov:link', title: 'Share links', text: 'One link per file or folder, expiring in an hour to seven days, revocable at any moment, with its downloads counted.' },++ { icon: 'ov:account', title: 'Signed in, always', text: 'Nobody opens a share link without an OpenVibe account. No app tokens, no anonymous anything — a shared link still needs a person.' },++ { icon: 'ov:shield', title: 'Reports that act', text: REPORT_NOTE },++ { icon: 'ov:tools', title: 'A drive with an API', text: 'The same actions over /api/v1: list, upload, share, revoke, and see your usage.' },++ { icon: 'ov:db', title: 'Bytes in OpenVibe.Media', text: 'Files are objects in the network\'s own media service; deleting one here asks Media to delete it too.' },],}))}${raw(showcase.steps({-- title: 'How a new service starts',++ title: 'How it works',++ lede: 'Four steps, and the last one is the point.',items: [-- { title: 'Generate it', text: 'node scripts/new-service.js replaces the skeleton\'s tokens (service id, name, domain, port, tagline, env prefix) in every file and file name.' },-- { title: 'Install and run', text: 'npm install, then npm run dev: the service listens on its port with an embedded PGlite database, if no DATABASE_URL is set.' },-- { title: 'Make it a product', text: 'Add the product\'s tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js.' },-- { title: 'Grant and deploy', text: 'Register the service and its capabilities with OpenVibe.Contracts and OpenVibe.Services, then install the unit and the vhost.' },++ { title: 'Sign in', text: 'OpenVibe.Network signs you in. Your session stays in an httpOnly cookie on this site.' },++ { title: 'Upload', text: 'One request for a file under the single-request limit; a big file goes up in parts with a progress meter. Both need a signed-in person.' },++ { title: 'Share', text: 'Make a link that expires in an hour, a day or a week, and optionally name the OpenVibe usernames allowed to open it.' },++ { title: 'Take it back', text: 'Revoke a link and it stops working at once. Delete a file and its shares go with it, and Media is asked to drop the bytes.' },],}))}--${raw(showcase.cta({ title: 'Read the API', text: 'GET /api/v1/ping is the whole API today. The rest is what the product adds.', actions: [{ label: 'The API', href: '/api/v1/ping' }, { label: 'What shipped', href: '/updates' }] }))}`,++${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is not allowed, how reports work, and who to write to.', actions: [{ label: 'The safety rules', href: '/safety' }, { label: 'What shipped', href: '/updates' }] }))}`,++ });++ });++++ // ── The drive ────────────────────────────────────────────++ /** The folder a request is looking at, if it is the person's own. */++ async function folderOf(req) {++ const want = String(req.query.folder || req.body.folder || '');++ if (!want) return { folder: null, path: [] };++ const folder = await drive.ownFolder(requesterOf(req), want);++ if (!folder) return { folder: null, path: [] };++ return { folder, path: await drive.folderPath(requesterOf(req), folder.id) };++ }++++ const uploadForm = (folder) => html`<form class="card upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>++<h2>Upload a file</h2>++${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}++<div class="field"><label for="file">File</label><input id="file" name="file" type="file" required></div>++<div class="field"><label for="name">Name (optional)</label><input id="name" name="name" type="text" maxlength="${rules.NAME_MAX}" placeholder="leave empty to keep the file's own name"></div>++<p><button class="sc-btn sc-primary" type="submit">Upload</button></p>++<p class="small muted">Up to ${rules.formatBytes(config.quotas.maxFileBytes)} per file and ${rules.formatBytes(config.quotas.bytes)} in total, ${config.quotas.uploadsPerDay} uploads a day. Files over ${rules.formatBytes(config.singleUploadBytes)} go up in parts, which needs JavaScript; without it, the browser sends one request and files that size are refused.</p>++<progress id="upload-progress" max="100" value="0" hidden></progress>++<p id="upload-status" class="small muted" role="status"></p>++</form>`;++++ r.get('/files', mustSignIn('/files'), async (req, res) => {++ const owner = requesterOf(req);++ const { folder, path } = await folderOf(req);++ const folders = (await drive.listFolders(owner)).filter((f) => (f.parent_id || null) === (folder ? folder.id : null));++ const files = await drive.listFiles(owner, { folderId: folder ? folder.id : null, limit: 200 });++ const shares = (await drive.listShares(owner)).filter((sh) => drive.stateOf(sh, s.iso()) === 'live');++ const crumbs = [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }];++ for (const f of path) crumbs.push({ label: f.name, href: `/files?folder=${encodeURIComponent(f.id)}` });++ return page(req, res, {++ title: folder ? `${folder.name} · Your files` : 'Your files',++ crumbs,++ // The one page script: it turns the upload form into a chunked, resumable, progressive upload. The++ // form itself works without it.++ scripts: ['js/upload.js'],++ body: html`<h1>${folder ? folder.name : 'Your files'}</h1>++${usageBar(await drive.usage(owner))}++${folder ? html`<p><a href="/files">← All files</a></p>` : ''}++<section aria-labelledby="h-upload">${uploadForm(folder)}</section>++<section aria-labelledby="h-folders"><h2 id="h-folders">Folders</h2>++${folders.length ? html`<ul class="file-list">${folders.map((f) => html`<li class="file"><span class="file-main"><a class="file-name" href="/files?folder=${encodeURIComponent(f.id)}">${f.name}</a><span class="file-meta small muted">folder</span></span></li>`)}</ul>` : html`<p class="muted">No folders here yet.</p>`}++<form class="inline" method="post" action="/folders">++${folder ? html`<input type="hidden" name="parent" value="${folder.id}">` : ''}++<label for="new-folder">New folder</label> <input id="new-folder" name="name" type="text" maxlength="${rules.FOLDER_NAME_MAX}" required> <button class="sc-btn" type="submit">Create</button>++</form></section>++<section aria-labelledby="h-files"><h2 id="h-files">Files</h2>++${files.length ? html`<ul class="file-list">${files.map(fileRow)}</ul>` : html`<p class="muted">No files here yet.</p>`}</section>++<section aria-labelledby="h-shares"><h2 id="h-shares">Live share links</h2>++${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li class="file"><span class="file-main"><a class="file-name" href="/s/${sh.slug}">/s/${sh.slug}</a>++<span class="file-meta small muted">expires ${time(sh.expires_at)} · ${Number(sh.downloads)} download${Number(sh.downloads) === 1 ? '' : 's'}${sh.allowed_subjects ? ' · limited to named people' : ''}</span></span>++<form class="inline" method="post" action="/shares/${sh.slug}/revoke"><button class="sc-btn" type="submit">Revoke</button></form></li>`)}</ul>` : html`<p class="muted">No live share links. Open a file to make one.</p>`}</section>`,++ });++ });++++ r.get('/files/:id', mustSignIn('/files'), async (req, res) => {++ const owner = requesterOf(req);++ const row = await drive.ownFile(owner, String(req.params.id || ''));++ if (!row) return notFoundFile(req, res);++ const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);++ return page(req, res, {++ title: row.name,++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],++ body: html`<h1>${row.name}</h1>++${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type, row.sha256 || '—', time(row.created_at)]])}++<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a></p>++<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2>++<form method="post" action="/files/${row.id}/share">++<div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>++<div class="field"><label for="usernames">Limited to OpenVibe usernames (optional)</label><input id="usernames" name="usernames" type="text" maxlength="300" placeholder="kim, sam"><p class="small muted">Leave empty for any signed-in person with the link. Everyone who opens it still has to be signed in.</p></div>++<p><button class="sc-btn sc-primary" type="submit">Create the link</button></p>++</form>++${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh) => html`<li class="file">${shareBadge(drive.stateOf(sh, s.iso()))} <a href="/s/${sh.slug}">/s/${sh.slug}</a> <span class="small muted">expires ${time(sh.expires_at)} · ${Number(sh.downloads)} downloads</span></li>`)}</ul>` : ''}++</section>++<section aria-labelledby="h-delete"><h2 id="h-delete">Delete</h2>++<p class="muted">Deleting removes every share link to this file at once, and asks OpenVibe.Media to delete the object.</p>++<form method="post" action="/files/${row.id}/delete"><button class="sc-btn" type="submit">Delete this file</button></form></section>`,++ });++ });++++ function notFoundFile(req, res) {++ return page(req, res, {++ title: 'No such file',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],++ body: html`<h1>No such file</h1><p class="muted">It is not yours, or it is gone. <a href="/files">Your files</a>.</p>`,++ }, 404);++ }++++ // ── Uploads from the page ────────────────────────────────++ /** Where a no-JavaScript upload is spooled before it goes to Media, part by part. */++ const spoolDir = () => config.tmpDir || path.join(os.tmpdir(), 'mediahub-uploads');++++ r.post('/files/upload', mustSignIn('/files'), budgeted('media-hub.upload.start'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ const type = String(req.get('content-type') || '');++ const boundary = /^multipart\/form-data/i.test(type) ? boundaryOf(type) : null;++ if (!boundary) return uploadFailed(req, res, 'That was not a file upload. Pick a file and send the form again.');++++ // A request bigger than this is refused before it is read: nginx caps it too (deploy/nginx).++ const declared = Number(req.get('content-length'));++ if (Number.isFinite(declared) && declared > config.singleUploadBytes + 64 * 1024) {++ return uploadFailed(req, res, `A form upload is at most ${rules.formatBytes(config.singleUploadBytes)}. Larger files go up in parts — the upload form on this page does that with JavaScript on.`);++ }++++ fs.mkdirSync(spoolDir(), { recursive: true });++ const tmp = path.join(spoolDir(), `form-${crypto.randomBytes(8).toString('hex')}`);++ const got = await readFormData(req, { boundary, filePath: tmp, maxBytes: config.singleUploadBytes + 64 * 1024 });++ try {++ if (got.error === 'too_large') return uploadFailed(req, res, `A form upload is at most ${rules.formatBytes(config.singleUploadBytes)}. Larger files go up in parts — the upload form on this page does that with JavaScript on.`);++ if (got.error) return uploadFailed(req, res, 'The upload did not arrive complete. Try again.');++ if (!got.file || !got.file.size) return uploadFailed(req, res, 'No file was in that form, or it was empty.');++++ const owner = requesterOf(req);++ const { folder } = await folderOf(req);++ const out = await uploads.uploadSpooled({++ owner, file: got.file.path, size: got.file.size,++ name: rules.fileName(got.fields.name || got.file.filename, got.file.filename),++ contentType: rules.normalizeType(got.file.contentType) || 'application/octet-stream',++ folderId: folder ? folder.id : null,++ });++ // A refusal by our own rules is shown as it came; a Media outage is one sentence, not Media's own words.++ if (!out.ok) return uploadFailed(req, res, String(out.code).startsWith('media.')++ ? `OpenVibe.Media could not store that just now (${out.code}). Nothing was kept here — try again in a moment.`++ : (out.detail || 'The upload could not be stored.'));++ return res.redirect(303, `/files/${out.file.id}`);++ } finally {++ fs.promises.unlink(got.file && got.file.path ? got.file.path : tmp).catch(() => {});++ }++ });++++ const uploadFailed = (req, res, detail) => page(req, res, {++ title: 'The upload did not go through',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],++ body: html`<h1>That upload did not go through</h1>${notice(detail, 'warn')}<p><a class="sc-btn" href="/files">Back to your files</a></p>`,++ }, 422);++++ r.post('/files/:id/delete', mustSignIn('/files'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));++ if (!row) return notFoundFile(req, res);++ await drive.deleteFile(requesterOf(req), row.id);++ return res.redirect(303, '/files');++ });++++ r.post('/files/:id/share', mustSignIn('/files'), budgeted('media-hub.share.create'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ const owner = requesterOf(req);++ const row = await drive.ownFile(owner, String(req.params.id || ''));++ if (!row) return notFoundFile(req, res);++ const names = String((req.body || {}).usernames || '').split(/[\s,]+/).filter(Boolean).slice(0, 50);++ const out = await drive.createShare(owner, {++ fileId: row.id, hours: (req.body || {}).hours, names, ownerUsername: req.viewer.username || null,});++ if (!out.ok) {++ return page(req, res, {++ title: 'The link was not made',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name, href: `/files/${row.id}` }],++ body: html`<h1>The link was not made</h1>${notice(out.detail, 'warn')}<p><a class="sc-btn" href="/files/${row.id}">Back to the file</a></p>`,++ }, out.code === 'share.limit' ? 429 : 422);++ }++ return page(req, res, {++ title: 'Share link made',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name, href: `/files/${row.id}` }],++ body: html`<h1>Share link made</h1>++<p>The link is <a href="/s/${out.share.slug}"><code>/s/${out.share.slug}</code></a> — it expires ${time(out.share.expires_at)}${out.share.allowed_subjects ? ' and only the people you named can open it' : ''}.</p>++${out.unresolved && out.unresolved.length ? notice(html`Network could not turn these names into subjects: ${out.unresolved.join(', ')}. The link still allows them by username.`, 'warn') : ''}++<p class="muted">Everyone who opens it has to be signed in to OpenVibe before they can download.</p>++<p><a class="sc-btn sc-primary" href="/s/${out.share.slug}">Open the share page</a> <a class="sc-btn" href="/files/${row.id}">Back to the file</a></p>`,++ });++ });++++ r.post('/folders', mustSignIn('/files'), budgeted('media-hub.folder.create'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ const { folder } = await folderOf(req);++ const out = await drive.createFolder(requesterOf(req), { name: (req.body || {}).name, parentId: folder ? folder.id : null });++ if (out.error) {++ return page(req, res, {++ title: 'The folder was not made',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],++ body: html`<h1>The folder was not made</h1>${notice(out.detail, 'warn')}<p><a class="sc-btn" href="/files">Back to your files</a></p>`,++ }, out.error === 'folder.exists' ? 409 : 422);++ }++ return res.redirect(303, folder ? `/files?folder=${encodeURIComponent(folder.id)}` : '/files');++ });++++ r.post('/shares/:slug/revoke', mustSignIn('/files'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ await drive.revokeShare(requesterOf(req), String(req.params.slug || ''));++ return res.redirect(303, '/files');});++ // ── Downloading ──────────────────────────────────────────++ /**++ * Send a file's bytes. MediaHub is in the path on purpose: the browser-facing headers are the security policy++ * (always an attachment, never a sniffed type, the real type only for a short safe list), and this is the only++ * place that can guarantee them whatever Media does with the object. Media's own signed URL is used underneath,++ * on the configured Media host, and is never handed to a browser.++ */++ async function sendFile(req, res, row) {++ const got = await media.bytes(row.object_id, { range: req.get('range') });++ if (!got.ok) {++ // Media's own words for "there is nothing to send": the file is gone, not our problem to hide.++ const gone = got.status === 404 || got.status === 410 || got.code === 'media.object.deleted' || got.code === 'media.object.not_found';++ const status = gone ? 410 : 502;++ return res.status(status).type('text/plain').send(status === 410++ ? 'The bytes of that file are gone from OpenVibe.Media.'++ : 'OpenVibe.Media did not answer. Try again in a moment.');++ }++ const upstream = got.res;++ res.status(upstream.status === 206 ? 206 : 200);++ res.set({++ // A safe type keeps its own Content-Type so the downloaded file opens as what it is; everything else++ // (HTML, SVG, XML, scripts) is an opaque blob. It is an attachment either way: nothing here is ever++ // rendered in a page, on this site or anywhere else.++ 'Content-Type': rules.safeContentType(row.content_type) || 'application/octet-stream',++ 'Content-Disposition': `attachment; filename="${rules.headerSafeName(row.name)}"`,++ 'X-Content-Type-Options': 'nosniff',++ 'X-Robots-Tag': 'noindex',++ 'Cache-Control': 'private, no-store',++ });++ for (const [from, to] of [['content-length', 'Content-Length'], ['content-range', 'Content-Range'], ['accept-ranges', 'Accept-Ranges']]) {++ const v = upstream.headers.get(from);++ if (v) res.set(to, v);++ }++ if (!upstream.body) return res.end();++ try {++ await pipeline(Readable.fromWeb(upstream.body), res);++ } catch {++ res.destroy(); // the reader went away mid-download: nothing to say to anybody++ }++ return undefined;++ }++++ r.get('/files/:id/download', mustSignIn('/files'), async (req, res) => {++ const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));++ if (!row) return notFoundFile(req, res);++ return sendFile(req, res, row);++ });++++ // ── Share links ──────────────────────────────────────────++ /**++ * The share a visitor is looking at, and whether they may open it. A share page needs a signed-in person like++ * every other file page: the link is what makes the file reachable at all, and being signed in is what makes++ * the download attributable.++ */++ async function openShare(req, res) {++ const viewer = viewerOf(req);++ const slug = String(req.params.slug || '');++ const share = rules.isSlug(slug) ? await drive.shareBySlug(slug) : null;++ const state = drive.stateOf(share, s.iso());++ if (!share || state === 'revoked' || state === 'expired') {++ page(req, res, {++ title: 'Link not available',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link' }],++ body: html`<h1>That link is not available</h1><p class="muted">It has expired or been revoked by the person who made it. <a href="/">OpenVibe.Download</a>.</p>`,++ }, 404);++ return null;++ }++ if (state === 'suspended') {++ page(req, res, {++ title: 'Link suspended',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link' }],++ body: html`<h1>This link is suspended</h1>${notice('People reported it, so it is with OpenVibe staff until they have looked at it. Nothing can be downloaded from it in the meantime.', 'warn')}<p class="muted"><a href="/safety">What happens when you report something</a>.</p>`,++ }, 403);++ return null;++ }++ if (!drive.allowedFor(share, viewer)) {++ page(req, res, {++ title: 'Not shared with you',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link' }],++ body: html`<h1>This link is not for you</h1><p class="muted">It was shared with particular OpenVibe accounts. You are signed in as <strong>${req.viewer.username || 'you'}</strong>. <a href="/files">Your files</a>.</p>`,++ }, 403);++ return null;++ }++ return share;++ }++++ r.get('/s/:slug', mustSignIn('here', 'A share link still needs an OpenVibe account: uploads, downloads and reports here all belong to a signed-in person.'), async (req, res) => {++ const share = await openShare(req, res);++ if (!share) return undefined;++ const mine = share.owner === requesterOf(req);++ if (share.file_id) {++ const file = await fileById(share);++ if (!file) return shareGone(req, res, share);++ return page(req, res, {++ title: `${file.name} · shared by ${share.owner_username || 'an OpenVibe member'}`,++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],++ body: html`<h1>${file.name}</h1>++${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}++${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}++<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>++${mine ? '' : reportForm(share)}++<p class="small muted">${REPORT_NOTE}</p>`,++ });++ }++ const files = await drive.listFiles(share.owner, { folderId: share.folder_id, limit: 200 });++ const folder = await drive.ownFolder(share.owner, share.folder_id);++ return page(req, res, {++ title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder' }],++ body: html`<h1>${folder ? folder.name : 'A folder'}</h1>++${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}++<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)}</p>++${files.length ? html`<ul class="file-list">${files.map((f) => html`<li class="file"><span class="file-main"><span class="file-name">${f.name}</span><span class="file-meta small muted">${fileSize(f)} · ${f.content_type}</span></span><a class="sc-btn" href="/s/${share.slug}/f/${f.id}/download">Download</a></li>`)}</ul>` : html`<p class="muted">The folder is empty.</p>`}++${mine ? '' : reportForm(share)}`,++ });++ });++++ /** The file row behind a file share, by its id — the owner's row, or null once it was deleted. */++ async function fileById(share) {++ const row = share.file_id ? await ctx.store.getFile(s, share.file_id) : null;++ return row && !row.deleted_at ? row : null;++ }++++ const shareGone = (req, res, share) => page(req, res, {++ title: 'The file is gone',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${share.slug}` }],++ body: html`<h1>The file behind this link is gone</h1><p class="muted">Its owner deleted it. <a href="/">OpenVibe.Download</a>.</p>`,++ }, 410);++++ const reportForm = (share) => html`<section class="card" aria-labelledby="h-report"><h2 id="h-report">Report this</h2>++<form method="post" action="/s/${share.slug}/report">++<div class="field"><label for="reason">Why?</label><select id="reason" name="reason" required>${rules.REASONS.map((x) => html`<option value="${x}">${rules.REASON_TEXT[x]}</option>`)}</select></div>++<div class="field"><label for="note">Anything to add? (optional)</label><textarea id="note" name="note" maxlength="500" rows="3"></textarea></div>++<p><button class="sc-btn" type="submit">Report it</button></p>++</form>++<p class="small muted">${REPORT_NOTE}</p></section>`;++++ r.get('/s/:slug/download', mustSignIn('here', 'A share link still needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {++ const share = await openShare(req, res);++ if (!share) return undefined;++ const file = share.file_id ? await fileById(share) : null;++ if (!file) return shareGone(req, res, share);++ await ctx.store.countDownload(s, share.slug);++ return sendFile(req, res, file);++ });++++ r.get('/s/:slug/f/:fileId/download', mustSignIn('here', 'A share link still needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {++ const share = await openShare(req, res);++ if (!share || !share.folder_id) return undefined;++ const files = await drive.listFiles(share.owner, { folderId: share.folder_id, limit: 1000 });++ const file = files.find((f) => f.id === String(req.params.fileId || ''));++ if (!file) {++ return page(req, res, {++ title: 'No such file',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder', href: `/s/${share.slug}` }],++ body: html`<h1>No such file in this share</h1><p class="muted"><a href="/s/${share.slug}">The share page</a>.</p>`,++ }, 404);++ }++ await ctx.store.countDownload(s, share.slug);++ return sendFile(req, res, file);++ });++++ r.post('/s/:slug/report', mustSignIn('here'), async (req, res) => {++ if (!sameSite(req, res, '/s')) return undefined;++ const slug = String(req.params.slug || '');++ const reason = String((req.body || {}).reason || '');++ const note = rules.cleanText((req.body || {}).note, 500);++ if (!rules.REASONS.includes(reason)) {++ return page(req, res, {++ title: 'That report was not sent',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],++ body: html`<h1>That report was not sent</h1>${notice('Pick one of the reasons on the list.', 'warn')}<p><a class="sc-btn" href="/s/${slug}">Back to the share</a></p>`,++ }, 422);++ }++ const out = await drive.report(slug, { reporter: requesterOf(req), reason, note });++ if (out.error === 'share.own') {++ return page(req, res, {++ title: 'That is your share',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],++ body: html`<h1>That is your own link</h1><p class="muted">You cannot report your own share. <a href="/files">Your files</a>.</p>`,++ }, 403);++ }++ if (out.error === 'report.duplicate') {++ return page(req, res, {++ title: 'Already reported',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],++ body: html`<h1>You have already reported this</h1><p class="muted">One report per person per link. Thank you. <a href="/s/${slug}">Back to the share</a>.</p>`,++ }, 409);++ }++ if (out.error === 'share.revoked') {++ return page(req, res, {++ title: 'The link was taken down',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],++ body: html`<h1>That link was already taken down</h1><p class="muted">Its owner revoked it. <a href="/">OpenVibe.Download</a>.</p>`,++ }, 409);++ }++ if (out.error) {++ return page(req, res, {++ title: 'No such share',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],++ body: html`<h1>No such share</h1><p class="muted"><a href="/">OpenVibe.Download</a>.</p>`,++ }, 404);++ }++ return page(req, res, {++ title: 'Report received',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report received' }],++ body: html`<h1>Thank you — that is with us</h1>++${notice(out.suspended++ ? 'This share is suspended now and OpenVibe staff have been given it. Nothing can be downloaded from it until they have looked.'++ : `That was report ${out.count}. A share is suspended at ${rules.SUSPEND_AT_REPORTS} distinct reports, or at once for illegal content.`, 'warn')}++<p class="muted">Read <a href="/safety">how reports work</a>, or write to <a href="mailto:${ABUSE_EMAIL}">${ABUSE_EMAIL}</a>.</p>`,++ });++ });++++ // ── The report queue (staff) ─────────────────────────────++ r.get('/staff', async (req, res) => {++ if (!signedIn(req)) return page(req, res, {++ title: 'Report queue',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],++ body: html`<h1>The report queue</h1>${signInPrompt('The queue is for OpenVibe staff.', '/staff')}`,++ }, 401);++ if (!staffViewer(req)) return page(req, res, {++ title: 'Staff only',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],++ body: html`<h1>Staff only</h1><p class="muted">The report queue is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Yours is <code>${req.viewer.role || 'user'}</code>. <a href="/files">Your files</a>.</p>`,++ }, 403);++ const rows = await ctx.store.staffQueue(s, { limit: 100 });++ const counts = await ctx.store.staffCounts(s);++ const items = [];++ for (const row of rows) items.push({ row, reports: await drive.reportsFor(row.slug), file: row.file_id ? await fileById(row) : null });++ return page(req, res, {++ title: 'Report queue',++ description: 'Share links people reported, for OpenVibe staff to restore or remove.',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],++ body: html`<h1>The report queue</h1>++<p class="sc-lede">${counts.reported} share link${counts.reported === 1 ? '' : 's'} reported, ${counts.suspended} suspended, ${counts.revoked} revoked by their owner. A share is suspended at ${rules.SUSPEND_AT_REPORTS} distinct reports, or at once for illegal content.</p>++${items.length ? items.map((it) => html`<section class="card staff-item">++<h2><a href="/s/${it.row.slug}">/s/${it.row.slug}</a> ${shareBadge(drive.stateOf(it.row, s.iso()))}</h2>++<p class="small muted">Shared by <code>${it.row.owner_username ? `@${it.row.owner_username}` : it.row.owner}</code> · expires ${time(it.row.expires_at)} · ${Number(it.row.downloads)} downloads · ${Number(it.row.report_count)} report${Number(it.row.report_count) === 1 ? '' : 's'}</p>++${it.file ? html`<p><b>${it.file.name}</b> · ${rules.formatBytes(it.file.size)} · ${it.file.content_type} · object <code>${it.file.object_id}</code></p>` : html`<p class="muted">The file behind this link is gone, or it is a folder share.</p>`}++<ul class="report-list small">${it.reports.map((rp) => html`<li><b>${rp.reason}</b> · <code>${rp.reporter}</code> · ${time(rp.created_at)}${rp.note ? html` — ${rp.note}` : ''}</li>`)}</ul>++<form class="inline" method="post" action="/staff/${it.row.slug}/restore"><button class="sc-btn" type="submit">Restore it</button></form>++<form class="inline" method="post" action="/staff/${it.row.slug}/remove"><button class="sc-btn" type="submit">Remove the file</button></form>++</section>`) : html`<p class="muted">Nothing has been reported.</p>`}`,++ });++ });++++ r.post('/staff/:slug/restore', async (req, res) => {++ if (!signedIn(req) || !staffViewer(req)) return staffOnly(req, res);++ if (!sameSite(req, res, '/staff')) return undefined;++ await drive.restoreShare(String(req.params.slug || ''));++ return res.redirect(303, '/staff');++ });++++ r.post('/staff/:slug/remove', async (req, res) => {++ if (!signedIn(req) || !staffViewer(req)) return staffOnly(req, res);++ if (!sameSite(req, res, '/staff')) return undefined;++ await drive.removeShared(String(req.params.slug || ''));++ return res.redirect(303, '/staff');++ });++++ const staffOnly = (req, res) => page(req, res, {++ title: 'Staff only',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],++ body: html`<h1>Staff only</h1><p class="muted">Restoring or removing a share is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>.</p>`,++ }, signedIn(req) ? 403 : 401);++++ // ── Safety ───────────────────────────────────────────────++ r.get('/safety', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'Safety rules',++ description: 'What OpenVibe.Download allows, what it does not, how reports work, and who to write to.',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Safety' }],++ styles: [showcase.STYLESHEET],++ body: html`<h1>The rules of OpenVibe.Download</h1>++${notice('This is a private drive with share links. It is not a place to hide, and nothing here is anonymous.', 'warn')}++<section class="sc-sec" aria-labelledby="h-rules"><h2 id="h-rules">What is not allowed</h2>++<ul class="prose">${SAFETY_RULES.map((x) => html`<li>${x}</li>`)}</ul></section>++<section class="sc-sec" aria-labelledby="h-uploads"><h2 id="h-uploads">Who can do what</h2>++<ul class="prose">++<li><b>Every upload needs a signed-in OpenVibe account.</b> There is no anonymous upload and no app or service token that can post a file here.</li>++<li><b>Every file is private to the person who uploaded it.</b> Nobody else sees it in a list, and nobody else can download it.</li>++<li><b>Every download needs a signed-in OpenVibe account too.</b> A share link does not open a file on its own: the person opening it must be signed in, and the share page says who shared it.</li>++<li><b>A share can be limited to named OpenVibe usernames</b>, expire in an hour to seven days, and be revoked at any moment — after which it stops working at once.</li>++<li><b>Downloads never happen in a page.</b> A file is always sent as an attachment with a no-sniff header, and a type that is not on a short safe list is sent as an opaque blob. Uploaded HTML and SVG are never rendered.</li>++</ul></section>++<section class="sc-sec" aria-labelledby="h-reports"><h2 id="h-reports">How reports work</h2>++<p class="sc-prose">${REPORT_NOTE} A report says why: malware, illegal content, copyright, or something else, with room to explain in up to 500 characters. You can report a share once, and reporting your own share is refused.</p>++<p class="sc-prose">Reports are read by OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Staff can restore a share (the report stays on the record) or remove the file, which deletes it for its owner as well and asks OpenVibe.Media to delete the object.</p></section>++<section class="sc-sec" aria-labelledby="h-limits"><h2 id="h-limits">The limits</h2>++<ul class="prose">++<li>${rules.formatBytes(config.quotas.bytes)} stored per person.</li>++<li>${rules.formatBytes(config.quotas.maxFileBytes)} in one file.</li>++<li>${config.quotas.uploadsPerDay} uploads a day, from midnight UTC.</li>++<li>${config.quotas.maxShares} live share links at a time.</li>++<li>A share expires in ${rules.EXPIRY_MIN_HOURS} hour${rules.EXPIRY_MIN_HOURS === 1 ? '' : 's'} to ${rules.EXPIRY_MAX_HOURS / 24} days, seven by default.</li>++</ul>++<p class="sc-prose">The bytes live in <a href="https://openvibe.media">OpenVibe.Media</a>, the network's own media service. Deleting a file here asks Media to delete its object; Media keeps its own retention period before the bytes are purged.</p></section>++<section class="sc-sec" aria-labelledby="h-contact"><h2 id="h-contact">Report abuse, or a copyright problem</h2>++<p class="sc-prose">Write to <a href="mailto:${ABUSE_EMAIL}">${ABUSE_EMAIL}</a> for anything that needs a human now — illegal content, malware, a threat. Copyright complaints go to <a href="mailto:${DMCA_EMAIL}">${DMCA_EMAIL}</a>; say which link, and what right you hold. The Report form on any share page is faster for everything else.</p></section>++<section class="sc-sec" aria-labelledby="h-account"><h2 id="h-account">Your account, your data</h2>++<p class="sc-prose">Your files, folders, share links, reports and upload counts are yours to export or delete with your OpenVibe account (ADR-033): deleting the account deletes them here and asks OpenVibe.Media to delete the objects. Read more in the <a href="https://openvibe.network">account settings on OpenVibe.Network</a>.</p></section>`,++ }));++// ── The update log ───────────────────────────────────────r.get('/updates', (req, res) => page(req, res, {index: true, cache: PUBLIC_CACHE,@@@ -80,4 +750,4 @@ ${raw(showcase.cta({ title: 'Read the API', text: 'GET /api/v1/ping is the wholereturn r;}--module.exports = { createPageRoutes };++module.exports = { createPageRoutes, SAFETY_RULES, ABUSE_EMAIL };@@@ -3,8 +3,12 @@/*** Who is calling /api/v1 — req.principal:*-- * { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,-- * or this site's session cookie++ * { kind: 'user', requester: 'user:usr_…', project: null, viaSession, username, role }++ * a person: their Network token as a Bearer,++ * or this site's session cookie. `username`++ * and `role` are the token's own claims, so++ * a share's allow-list and the staff check++ * read the same thing whichever door was used.* { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }* a Network app, agent or service token for* audience openvibe.download; each route names@@@ -31,6 +35,12 @@ function decodePayload(token) {try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }}++/** The person's Network username claim, as a plain string — what a share's allow-list is matched against. */++function usernameClaim(claims) {++ return claims && typeof claims.username === 'string' && claims.username ? claims.username.slice(0, 64) : null;++}++const roleClaim = (claims) => (claims && typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user');++function requesterOfSub(sub) {if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;return sub; // app:app_…, agent:agt_…, mod:mod_…@@@ -63,11 +73,21 @@ function createPrincipal({ config, keys }) {if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };-- return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };++ return {++ principal: {++ kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false,++ username: usernameClaim(v.claims), role: roleClaim(v.claims),++ },++ };}const viewer = req.viewer;if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {-- return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, viaSession: true } };++ return {++ principal: {++ kind: 'user', requester: `user:${viewer.subject}`, project: null, viaSession: true,++ username: usernameClaim(viewer), role: roleClaim(viewer),++ },++ };}return { principal: { kind: 'anonymous' } };}@@@ -98,4 +118,4 @@ function createPrincipal({ config, keys }) {return { middleware, resolve, requireCapability: require_ };}--module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin, usernameClaim, roleClaim };@@@ -1,14 +1,16 @@'use strict';/**-- * express.Router() whose get/post handlers may be async: a rejected promise goes to next(err) (the-- * app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.++ * express.Router() whose get/post/put/patch/delete handlers may be async: a rejected promise goes to next(err)++ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.*/const express = require('express');++const METHODS = ['get', 'post', 'put', 'patch', 'delete'];++function asyncRouter() {const r = express.Router();-- for (const method of ['get', 'post']) {++ for (const method of METHODS) {const orig = r[method].bind(r);r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {try {nnew file mode 100644@@@ -0,0 +1,149 @@++'use strict';++++/**++ * OpenVibe.Media: where the bytes live. MediaHub calls Media's object API v2 **server-side only**, with its own++ * app key (audience `media-hub`), and never hands that key, an upload token or a signed URL to a browser.++ *++ * init({ size, contentType, name, ownerSubject, multipart }) POST /api/v2/media-hub/objects++ * putContent(objectId, buffer) PUT …/objects/:id/content++ * complete(objectId) POST …/objects/:id/complete++ * multipartStatus(objectId, uploadId) GET …/objects/:id/multipart/:uploadId++ * putPart(objectId, uploadId, n, buffer) PUT …/objects/:id/multipart/:uploadId/parts/:n++ * completeMultipart(objectId, uploadId) POST …/objects/:id/multipart/:uploadId/complete++ * abortMultipart(objectId, uploadId) DELETE …/objects/:id/multipart/:uploadId++ * get(objectId) GET …/objects/:id++ * downloadUrl(objectId) GET …/objects/:id/download++ * bytes(objectId, { range }) GET <signed URL on the Media host>++ * remove(objectId) DELETE …/objects/:id (a soft delete in Media)++ *++ * Two details worth knowing. Media answers a private object's download with a signed URL on its *public* origin;++ * this client rewrites that URL onto the configured Media host before fetching it, so the bytes are read from the++ * one address MEDIAHUB_MEDIA_URL names and never from a URL a caller supplied. And a Media call never throws for++ * a refusal: it answers { ok: false, status, code, detail } so the caller can show the problem as it came.++ */++const { ids } = require('openvibe-contracts');++++/** A user subject as Media wants it in X-OV-Subject: the bare usr_…, or null when it cannot be one. */++function subjectHeader(requester) {++ const parsed = ids.parseSubject(String(requester || ''));++ if (parsed && parsed.type === 'user') return parsed.id;++ const bare = String(requester || '').replace(/^user:/, '');++ return ids.isSubjectId('user', bare) ? bare : null;++}++++function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console }) {++ const media = config.media;++ const base = () => `${media.url}/api/v2/${encodeURIComponent(media.app)}/objects`;++ const enabled = Boolean(media.appKey);++++ /** One JSON call. Never throws for a Media refusal; throws only when Media cannot be reached at all. */++ async function call(path, { method = 'GET', body, json, headers = {}, raw = false } = {}) {++ if (!enabled) return { ok: false, status: 503, code: 'media.not_configured', detail: 'MEDIAHUB_MEDIA_APP_KEY is not set' };++ const h = { Accept: 'application/json', Authorization: `Bearer ${media.appKey}`, ...headers };++ let payload = body;++ if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }++ let res;++ try {++ res = await fetchImpl(`${base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(media.timeoutMs) });++ } catch (err) {++ log.warn('[MediaHub] Media did not answer:', (err && err.name) || '', (err && err.message) || '');++ return { ok: false, status: 502, code: 'media.unreachable', detail: 'OpenVibe.Media did not answer' };++ }++ if (raw) return { ok: res.ok, status: res.status, res };++ const data = await res.json().catch(() => null);++ if (!res.ok) {++ const p = (data && (data.problem || data)) || {};++ return { ok: false, status: res.status, code: p.code || `media.http_${res.status}`, detail: p.detail || 'OpenVibe.Media refused the request' };++ }++ return { ok: true, status: res.status, data };++ }++++ return {++ enabled,++++ /** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */++ async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null }) {++ const subject = subjectHeader(ownerSubject);++ if (!subject) return { ok: false, status: 400, code: 'file.bad_owner', detail: 'a file belongs to a person with a canonical subject' };++ return await call('', {++ method: 'POST',++ headers: { 'X-OV-Subject': subject },++ json: {++ kind: 'file', visibility: 'private', size_bytes: size,++ mime_type: contentType || null, filename: name, multipart: multipart === true,++ ...(multipart && partSize ? { part_size: partSize } : {}),++ },++ });++ },++++ /** The bytes of a single-part upload (exactly `size` bytes). */++ async putContent(objectId, buffer) {++ return await call(`/${encodeURIComponent(objectId)}/content`, {++ method: 'PUT', body: buffer,++ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.length) },++ });++ },++++ complete: (objectId) => call(`/${encodeURIComponent(objectId)}/complete`, { method: 'POST', json: {} }),++++ /** A fresh multipart session for an object created without one (or whose session expired). */++ multipartStart: (objectId, { size, partSize }) => call(`/${encodeURIComponent(objectId)}/multipart`, { method: 'POST', json: { size_bytes: size, part_size: partSize } }),++++ multipartStatus: (objectId, uploadId) => call(`/${encodeURIComponent(objectId)}/multipart/${encodeURIComponent(uploadId)}`),++++ putPart: (objectId, uploadId, n, buffer) => call(`/${encodeURIComponent(objectId)}/multipart/${encodeURIComponent(uploadId)}/parts/${Number(n)}`, {++ method: 'PUT', body: buffer,++ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.length) },++ }),++++ completeMultipart: (objectId, uploadId) => call(`/${encodeURIComponent(objectId)}/multipart/${encodeURIComponent(uploadId)}/complete`, { method: 'POST', json: {} }),++++ abortMultipart: (objectId, uploadId) => call(`/${encodeURIComponent(objectId)}/multipart/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }),++++ get: (objectId) => call(`/${encodeURIComponent(objectId)}`),++++ remove: (objectId) => call(`/${encodeURIComponent(objectId)}`, { method: 'DELETE' }),++++ /**++ * Media's short-lived signed URL for an object's bytes, rewritten onto the configured Media host.++ * The signature covers the object id and the expiry, not the host, so the bytes are read from++ * MEDIAHUB_MEDIA_URL — the only host this service fetches bytes from.++ */++ async downloadUrl(objectId) {++ const r = await call(`/${encodeURIComponent(objectId)}/download?format=json`);++ if (!r.ok) return r;++ const url = r.data && r.data.url;++ if (!url) return { ok: false, status: 502, code: 'media.no_url', detail: 'OpenVibe.Media gave no download URL' };++ let rewritten;++ try {++ const u = new URL(url);++ rewritten = `${media.url}/o/${encodeURIComponent(objectId)}${u.search}`;++ } catch {++ return { ok: false, status: 502, code: 'media.no_url', detail: 'OpenVibe.Media gave an unusable download URL' };++ }++ return { ok: true, status: 200, url: rewritten, expires_at: (r.data && r.data.expires_at) || null };++ },++++ /**++ * The object's bytes, streamed. `range` (a Range header value) is passed through, so a browser can++ * resume a download; the caller answers 206 from the upstream status and Content-Range.++ */++ async bytes(objectId, { range = null } = {}) {++ const signed = await this.downloadUrl(objectId);++ if (!signed.ok) return signed;++ try {++ const res = await fetchImpl(signed.url, {++ headers: { ...(range ? { Range: String(range) } : {}) },++ redirect: 'follow', // Media may hand the read to its own storage provider; that is Media's choice++ signal: AbortSignal.timeout(media.timeoutMs),++ });++ return { ok: res.ok || res.status === 206, status: res.status, res };++ } catch (err) {++ log.warn('[MediaHub] Media bytes fetch failed:', (err && err.message) || '');++ return { ok: false, status: 502, code: 'media.unreachable', detail: 'OpenVibe.Media did not answer' };++ }++ },++ };++}++++module.exports = { createMediaClient, subjectHeader };nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++++/**++ * Resolving the OpenVibe usernames a person names on a share link to canonical subjects, through++ * OpenVibe.Network's identity.subject.resolve (network/identity/internal-routes.js):++ *++ * GET {OV_NETWORK_INTERNAL_URL}/internal/identity/resolve?system=&type=&id=++ *++ * A service token with audience openvibe.network and scope identity.subject.resolve is required, so it only runs++ * where the OAuth client is configured. Network keys a subject by its id or by a service-local (system, type, id)++ * pair; a username is asked for as type=username, and when Network does not know that key the answer is 404 and the++ * name stays unresolved.++ *++ * That is not a dead end: a share's allow-list holds both the subjects we could resolve and the names as typed, and++ * a download is allowed when the signed-in person's own *subject* is in the resolved list or their Network++ * *username claim* is in the typed one. The claim is signed by Network, so matching on it needs no directory at++ * all — the resolve call only sharpens the list (it survives a rename and reaches guests, who have no username).++ */++const { serviceAuth } = require('openvibe-contracts');++++function createIdentity({ config, fetchImpl = globalThis.fetch, log = console }) {++ const configured = Boolean(config.oauth.clientSecret);++ const tokens = configured ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network',++ scope: 'identity.subject.resolve',++ fetchImpl,++ }) : null;++++ /** One username → its subject, or null when Network does not know it (or cannot be asked). */++ async function resolveUsername(username) {++ if (!tokens) return null;++ try {++ const res = await fetchImpl(++ `${config.networkInternalUrl}/internal/identity/resolve?system=network&type=username&id=${encodeURIComponent(username)}`,++ { headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(5000) });++ if (res.status === 401 && tokens.invalidate) tokens.invalidate();++ if (res.status === 404) return null;++ if (!res.ok) {++ log.warn(`[MediaHub] identity.resolve answered ${res.status} for @${username.slice(0, 40)}`);++ return null;++ }++ const data = await res.json().catch(() => null);++ const id = data && data.subject && data.subject.id;++ return typeof id === 'string' && id.startsWith('usr_') ? id : null;++ } catch (err) {++ log.warn('[MediaHub] identity.resolve failed:', (err && err.message) || '');++ return null;++ }++ }++++ /**++ * names → { subjects: ['usr_…'], names: ['kim'], unresolved: ['kim'] }.++ * `names` keeps every typed username (matched against the viewer's claim); `unresolved` is what Network could++ * not turn into a subject, which the caller reports back to the owner without refusing the share.++ */++ async function resolveUsernames(list) {++ const names = [];++ const subjects = [];++ const unresolved = [];++ for (const name of list) {++ // Sequential on purpose: a share names a handful of people, and one call each keeps the token client simple.++ const subject = await resolveUsername(name);++ if (subject) subjects.push(subject);++ else unresolved.push(name);++ names.push(name);++ }++ return { subjects: [...new Set(subjects)], names: [...new Set(names)], unresolved };++ }++++ return { configured, resolveUsername, resolveUsernames };++}++++module.exports = { createIdentity };nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++/**++ * Account export and deletion (ADR-033), through openvibe-sdk/account-data: Network asks for a person's rows, and++ * later tells this service they are gone. Deleting also asks OpenVibe.Media to delete each object the person held.++ *++ * The events arrive signed on POST /internal/events — a route that refuses anything that came through a proxy, so++ * only loopback can reach it even though nginx proxies the path. The stand-in Network records the part it was given++ * and the confirmation it was sent (test/helpers/mocks.js).++ */++const assert = require('assert');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, upload, share } = require('./helpers/drive');++++const SECRET = 'm'.repeat(48);++const EXPORT = 'exp_01JABCDEFGHJKMNPQRSTVWXYZ0';++const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';++++(async () => {++ const t = await boot({ env: { MEDIAHUB_EVENTS_SECRET: SECRET } });++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++++ const file = (await upload(t, kim, bytes(300, 'kim'), { name: 'kim.bin' })).json().file;++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++ await t.get(`/s/${slug}/report`, { as: sam, form: { reason: 'malware', note: 'nope' }, headers: SAME });++ const sams = (await upload(t, sam, bytes(300, 'sam'), { name: 'sam.bin' })).json().file;++ // And kim files one against sam's file, so each has a report of their own in the export.++ const samSlug = (await share(t, sam, { file_id: sams.id, hours: 24 })).json().share.slug;++ await t.get(`/s/${samSlug}/report`, { as: kim, form: { reason: 'other', note: 'kim says' }, headers: SAME });++++ const deliver = (event) => {++ const body = Buffer.from(JSON.stringify({ event }));++ return t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ };++ const envelope = (type, payload) => ({ event_id: `evt_${Math.random().toString(16).slice(2)}`, event_type: type, source: 'network', at: new Date().toISOString(), payload });++++ await check('the events route takes a signed delivery, and nothing else', async () => {++ const event = envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject });++ const body = Buffer.from(JSON.stringify({ event }));++ const unsigned = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json' } });++ assert.strictEqual(unsigned.status, 401);++ const wrongKey = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'w'.repeat(48)) } });++ assert.strictEqual(wrongKey.status, 401);++ const proxied = await t.get('/internal/events', {++ method: 'POST', body,++ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET), 'x-forwarded-for': '203.0.113.9' },++ });++ assert.strictEqual(proxied.status, 403, 'a request through a proxy is not Network');++ assert.strictEqual((await t.get('/internal/events', { method: 'GET' })).status, 405);++ });++++ await check('the export carries the person\'s files, folders, shares, reports and upload counts', async () => {++ const r = await deliver(envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject }));++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().outcome, 'exported');++ assert.strictEqual(t.network.accountParts.length, 1, 'Network was given the part');++ const part = t.network.accountParts[0].body;++ assert.strictEqual(part.subject, kim.subject);++ const files = part.files.find((f) => f.name === 'files.json');++ assert.ok(files, 'a files.json part');++ assert.strictEqual(files.content.length, 1);++ assert.strictEqual(files.content[0].id, file.id);++ assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'reports.json', 'shares.json', 'usage.json'], 'and the rest of the person\'s own data');++ assert.ok(part.files.find((f) => f.name === 'shares.json').content.some((s) => s.slug === slug));++ assert.strictEqual(part.files.find((f) => f.name === 'reports.json').content.length, 1, 'the report kim filed');++ assert.ok(!JSON.stringify(part).includes(sams.id), 'nothing of sam\'s');++++ // The same delivery again changes nothing: it is idempotent per export id.++ assert.strictEqual((await deliver(envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject }))).json().outcome, 'unchanged');++ assert.strictEqual(t.network.accountParts.length, 1);++ });++++ await check('a deletion erases the rows and asks Media to delete each object', async () => {++ const before = t.media.deleted.length;++ const r = await deliver(envelope('network.account.deleted', { deletion_id: DELETION, subject: kim.subject }));++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().outcome, 'erased');++++ assert.strictEqual((await t.get('/api/v1/files', { as: kim })).json().files.length, 0, 'kim has no files');++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404, 'and no share links');++ assert.deepStrictEqual(t.media.deleted.slice(before), [file.object.media_object_id], 'Media was asked for exactly kim\'s object');++++ assert.strictEqual(t.network.accountConfirmations.length, 1, 'Network was confirmed');++ const confirmation = t.network.accountConfirmations[0].body;++ assert.strictEqual(confirmation.subject, kim.subject);++ assert.ok(confirmation.erased.mh_files >= 1, JSON.stringify(confirmation));++ assert.ok(confirmation.erased.mh_shares >= 1, JSON.stringify(confirmation));++++ // Sam's drive is untouched.++ assert.strictEqual((await t.get(`/api/v1/files/${sams.id}`, { as: sam })).status, 200);++ assert.deepStrictEqual(t.media.bytesOf(sams.object.media_object_id), bytes(300, 'sam'), 'and his bytes are still in Media');++ });++++ await check('a redelivered deletion erases nothing twice', async () => {++ const before = t.media.deleted.length;++ const again = await deliver(envelope('network.account.deleted', { deletion_id: DELETION, subject: kim.subject }));++ assert.strictEqual(again.status, 200);++ assert.strictEqual(t.media.deleted.length, before, 'Media is not asked again');++ assert.strictEqual(t.network.accountConfirmations.length, 1, 'and no second confirmation is sent');++ });++++ await check('an event this service does not handle is acknowledged and ignored', async () => {++ const r = await deliver(envelope('network.something.else', {}));++ assert.strictEqual(r.status, 200);++ assert.match(r.json().outcome, /^ignored/);++ });++++ await check('another service\'s event source is not accepted as Network\'s', async () => {++ const event = { ...envelope('network.account.deleted', { deletion_id: 'del_01JABCDEFGHJKMNPQRSTVWXYZ1', subject: sam.subject }), source: 'live' };++ const r = await deliver(event);++ assert.strictEqual(r.status, 200);++ assert.match(r.json().outcome, /^ignored/);++ assert.strictEqual((await t.get(`/api/v1/files/${sams.id}`, { as: sam })).status, 200, 'and sam still has his file');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * One process, three brand surfaces (the media-hub product manifest). The Host header decides which one a request++ * is for, and v1 has launched only openvibe.download: openvibe.pics and openvibe.video answer an honest "coming"++ * page and nothing else — no drive, no share links, no API and above all no uploads, because neither brand has the++ * safety tooling its content needs yet.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, upload, share } = require('./helpers/drive');++++const PICS = { 'x-forwarded-host': 'openvibe.pics' };++const VIDEO = { 'x-forwarded-host': 'openvibe.video' };++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const other = t.network.addUser('sam');++ const file = (await upload(t, kim, bytes(120, 'brand'), { name: 'brand.bin' })).json().file;++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++++ await check('openvibe.pics and openvibe.video answer a coming home that says what they will be', async () => {++ const pics = await t.get('/', { headers: PICS });++ assert.strictEqual(pics.status, 200);++ assert.match(pics.headers.get('content-type'), /^text\/html/);++ assert.ok(pics.text.includes('OpenVibe.Pics'), 'the brand');++ assert.ok(pics.text.includes('Share pictures. Fast, open, yours.'), 'its own tagline');++ assert.ok(pics.text.includes('Albums & galleries') || pics.text.includes('Albums & galleries'), 'what it will be');++ assert.ok(/safety tooling/i.test(pics.text), 'why it is not open yet');++ assert.ok(/no malware or CSAM scanning/i.test(pics.text), 'said plainly');++ assert.ok(pics.text.includes('https://openvibe.download'), 'a link to the brand that has launched');++++ const video = await t.get('/', { headers: VIDEO });++ assert.strictEqual(video.status, 200);++ assert.ok(video.text.includes('OpenVibe.Video'));++ assert.ok(video.text.includes('Your videos, everywhere.'));++ assert.ok(/safety tooling/i.test(video.text));++ assert.ok(video.text.includes('https://openvibe.download'));++ });++++ await check('nothing else exists on those brands: no drive, no share, no API, no upload', async () => {++ const before = t.media.requests.length;++ for (const headers of [PICS, VIDEO]) {++ const brand = headers['x-forwarded-host'];++ for (const p of ['/files', `/files/${file.id}`, `/files/${file.id}/download`, `/s/${slug}`, `/s/${slug}/download`, `/staff`, '/api/v1/ping']) {++ const r = await t.get(p, { as: kim, headers });++ assert.strictEqual(r.status, 404, `${brand} ${p} answered ${r.status}`);++ }++ // The write routes too — with a real file behind them.++ const up = await t.get('/api/v1/files', { as: kim, headers: { ...headers, ...SAME }, json: { name: 'x.bin', size: 10, content_type: 'text/plain' } });++ assert.strictEqual(up.status, 404, `${brand}: the API is not there`);++ const form = await t.get('/files/upload', { as: kim, method: 'POST', headers: { ...headers, ...SAME, 'content-type': 'application/x-www-form-urlencoded' }, body: 'name=x' });++ assert.strictEqual(form.status, 404, `${brand}: no form upload`);++ }++ assert.strictEqual(t.media.requests.length, before, 'and OpenVibe.Media was never called');++ });++++ await check('the crawl artifacts and the update log are the same on every brand', async () => {++ for (const headers of [{}, PICS, VIDEO]) {++ assert.strictEqual((await t.get('/updates', { headers })).status, 200);++ assert.strictEqual((await t.get('/robots.txt', { headers })).status, 200);++ assert.strictEqual((await t.get('/sitemap.xml', { headers })).status, 200);++ assert.strictEqual((await t.get('/llms.txt', { headers })).status, 200);++ }++ });++++ await check('the launched brand is untouched, and an unknown host is the launched brand', async () => {++ assert.ok((await t.get('/')).text.includes('OpenVibe.Download'));++ assert.ok((await t.get('/')).text.includes('private drive'));++ for (const headers of [{ 'x-forwarded-host': 'openvibe.download' }, { 'x-forwarded-host': 'www.openvibe.download' }, { 'x-forwarded-host': '127.0.0.1' }, { 'x-forwarded-host': 'openvibe.host' }]) {++ const r = await t.get('/', { headers });++ assert.ok(r.text.includes('OpenVibe.Download'), JSON.stringify(headers));++ }++ assert.strictEqual((await t.get('/files', { as: kim })).status, 200);++ assert.strictEqual((await t.get(`/s/${slug}`, { as: other })).status, 200, 'and its share links work');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -5,7 +5,7 @@* window reopens on the clock. A person counts as themselves, anyone else by address. Reads take* MEDIAHUB_LIMITS_MINUTE/MEDIAHUB_LIMITS_HOUR; the product's expensive routes get their own numbers in BUDGETS.* Health, ready, release.json and metrics are never limited; refusals are logged (no token) and counted in-- * media-hub_rate_limited_total.++ * media_hub_rate_limited_total.*/const assert = require('assert');const { boot, check, done } = require('./helpers/boot');@@@ -20,8 +20,14 @@ const READ = 'media-hub.api.read';const rosa = t.network.addUser('rosa');const sam = t.network.addUser('sam');-- await check('BUDGETS starts empty: the product declares its own expensive routes', () => {-- assert.deepStrictEqual(BUDGETS, {});++ await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {++ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [++ 'media-hub.folder.create', 'media-hub.share.create',++ 'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',++ ]);++ for (const [name, own] of Object.entries(BUDGETS)) {++ assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);++ }});await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {@@@ -62,10 +68,10 @@ const READ = 'media-hub.api.read';}});-- await check('refusals are counted in media-hub_rate_limited_total and logged without a token', async () => {++ await check('refusals are counted in media_hub_rate_limited_total and logged without a token', async () => {const m = (await t.get('/metrics')).text;-- const counted = m.split('\n').filter((l) => l.includes('media-hub_rate_limited_total')).join('\n');-- assert.ok(/media-hub_rate_limited_total\{limit="media-hub\.api\.read",window="minute"\} 2/.test(m), counted);++ const counted = m.split('\n').filter((l) => l.includes('media_hub_rate_limited_total')).join('\n');++ assert.ok(/media_hub_rate_limited_total\{limit="media-hub\.api\.read",window="minute"\} 2/.test(m), counted);const logs = t.logs();assert.ok(logs.includes(`[Limits] ${READ}: user:${rosa.subject} refused`), 'one log line per refusal');assert.ok(logs.includes(`[Limits] ${READ}: ip:203.0.113.7 refused`), 'the address refusal is logged');nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++/**++ * Downloading. This is the highest-risk surface of the service: an upload must never be handed to a browser as if++ * it were a document. Every download is an attachment, with X-Content-Type-Options: nosniff and the real type only++ * for a short safe list — HTML, SVG and anything executable come back as an opaque blob. Nothing is ever inline,++ * and no upload is ever rendered in a page.++ *++ * The bytes are read from OpenVibe.Media through the app, so MediaHub's own headers are what the browser sees++ * whatever Media answers.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, sha256, upload, share } = require('./helpers/drive');++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++++ const put = async (name, data, contentType) => (await upload(t, kim, data, { name, contentType })).json().file;++++ await check('a safe type keeps its own Content-Type; everything else is an opaque blob', async () => {++ const cases = [++ ['photo.png', 'image/png', 'image/png'],++ ['paper.pdf', 'application/pdf', 'application/pdf'],++ ['notes.txt', 'text/plain', 'text/plain'],++ ['bundle.zip', 'application/zip', 'application/zip'],++ ['song.mp3', 'audio/mpeg', 'audio/mpeg'],++ ['clip.mp4', 'video/mp4', 'video/mp4'],++ ['page.html', 'text/html', 'application/octet-stream'],++ ['picture.svg', 'image/svg+xml', 'application/octet-stream'],++ ['feed.xml', 'application/xml', 'application/octet-stream'],++ ['run.sh', 'application/x-sh', 'application/octet-stream'],++ ['no-type', null, 'application/octet-stream'],++ ];++ for (const [name, declared, expected] of cases) {++ const file = await put(name, bytes(64, name), declared);++ const dl = await t.get(`/files/${file.id}/download`, { as: kim });++ assert.strictEqual(dl.status, 200, `${name}: ${dl.text.slice(0, 120)}`);++ assert.ok(dl.headers.get('content-type').startsWith(expected), `${name}: ${dl.headers.get('content-type')} should start with ${expected}`);++ assert.match(dl.headers.get('content-disposition'), /^attachment; filename=/, name);++ assert.ok(!/inline/i.test(dl.headers.get('content-disposition')), `${name} must never be inline`);++ assert.strictEqual(dl.headers.get('x-content-type-options'), 'nosniff', name);++ assert.strictEqual(dl.headers.get('x-robots-tag'), 'noindex', name);++ assert.match(dl.headers.get('cache-control'), /private/, name);++ assert.strictEqual(sha256(dl.buffer), sha256(bytes(64, name)), `${name}: the bytes are the ones uploaded`);++ }++ });++++ await check('an uploaded HTML page is never rendered: it comes back as octet-stream, as an attachment', async () => {++ const nasty = Buffer.from('<!doctype html><html><body><script>window.top.location="https://evil.test"</script></body></html>');++ const file = await put('shared.html', nasty, 'text/html');++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++++ const page = await t.get(`/s/${slug}`, { as: sam });++ assert.strictEqual(page.status, 200);++ assert.ok(!page.text.includes('window.top.location'), 'the bytes are not in the page');++ assert.ok(!/<script>window\.top/.test(page.text));++++ const dl = await t.get(`/s/${slug}/download`, { as: sam });++ assert.strictEqual(dl.status, 200);++ assert.strictEqual(dl.headers.get('content-type').split(';')[0], 'application/octet-stream');++ assert.strictEqual(dl.headers.get('x-content-type-options'), 'nosniff');++ assert.match(dl.headers.get('content-disposition'), /^attachment/);++ assert.strictEqual(sha256(dl.buffer), sha256(nasty), 'and the bytes are exactly what was uploaded');++ });++++ await check('a name full of quotes and newlines cannot break the header', async () => {++ const file = await put('a"; attachment; filename="evil.exe\r\nX-Injected: 1.txt', bytes(20, 'name'), 'text/plain');++ const dl = await t.get(`/files/${file.id}/download`, { as: kim });++ assert.strictEqual(dl.status, 200);++ const dispo = dl.headers.get('content-disposition');++ assert.match(dispo, /^attachment; filename="[^"]*"$/, dispo);++ assert.ok(!dispo.includes('\n') && !dispo.includes('\r'), dispo);++ assert.strictEqual(dl.headers.get('x-injected'), null, 'no header was injected');++ });++++ await check('a download resumes: a Range is passed through and answered 206', async () => {++ const data = bytes(4096, 'range');++ const file = await put('slice.bin', data, 'application/octet-stream');++ const part = await t.get(`/files/${file.id}/download`, { as: kim, headers: { range: 'bytes=100-199' } });++ assert.strictEqual(part.status, 206, part.text.slice(0, 120));++ assert.strictEqual(part.buffer.length, 100);++ assert.strictEqual(sha256(part.buffer), sha256(data.subarray(100, 200)));++ assert.strictEqual(part.headers.get('content-range'), 'bytes 100-199/4096');++ assert.strictEqual(part.headers.get('content-disposition'), 'attachment; filename="slice.bin"');++ });++++ await check('nothing is served to anybody who is not signed in', async () => {++ const file = await put('guarded.bin', bytes(30, 'guarded'), 'application/octet-stream');++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++ for (const p of [`/files/${file.id}/download`, `/s/${slug}/download`]) {++ const r = await t.get(p);++ assert.strictEqual(r.status, 401, p);++ assert.ok(!r.buffer.includes(Buffer.from('guarded')), `${p}: no bytes to a guest`);++ }++ });++++ await check('what Media no longer has is a plain 410, not a broken page', async () => {++ const file = await put('gone.bin', bytes(30, 'gone'), 'application/octet-stream');++ t.media.objectOf(file.object.media_object_id).lifecycle_status = 'deleted';++ const r = await t.get(`/files/${file.id}/download`, { as: kim });++ assert.strictEqual(r.status, 410, r.text.slice(0, 120));++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.ok(!r.text.includes('<html'), 'a sentence, not a page');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++/**++ * The drive's pages: what a guest, a person and staff see, folder navigation, the usage bar, the upload form and++ * the one script on any page — and that everything a person typed is escaped everywhere it is shown.++ *++ * Nothing here needs JavaScript: the pages are server-rendered and the forms are plain POSTs.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, upload, share } = require('./helpers/drive');++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++++ await check('a guest gets a sign-in page for every file page, never a file', async () => {++ for (const p of ['/files', '/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {++ const r = await t.get(p);++ assert.strictEqual(r.status, 401, p);++ assert.ok(r.text.includes('Sign in'), `${p}: a sign-in page`);++ assert.ok(r.text.includes('/auth/login?next='), `${p}: with a way to sign in`);++ assert.ok(!r.text.includes('<progress'), `${p}: and nothing of the drive`);++ }++ // The home page and the safety rules are public: they are how somebody decides to sign up at all.++ assert.strictEqual((await t.get('/')).status, 200);++ assert.strictEqual((await t.get('/safety')).status, 200);++ });++++ await check('the home page says what this is, the rules, and that everything needs an account', async () => {++ const home = await t.get('/');++ assert.ok(home.text.includes('private drive'), 'it says what it is');++ assert.ok(home.text.includes('no anonymous upload'), 'and that nothing is anonymous');++ assert.ok(home.text.includes('Sign in with OpenVibe'), 'a way in');++ assert.ok(home.text.includes('/safety'), 'a link to the rules');++ const safety = await t.get('/safety');++ assert.ok(safety.text.includes('[email protected]'), 'the abuse contact');++ assert.ok(safety.text.includes('[email protected]'), 'the copyright contact');++ assert.ok(safety.text.includes('child sexual'), 'what is not allowed, said plainly');++ assert.ok(safety.text.includes('malware'), 'and the rest of it');++ assert.ok(/3 distinct reports|three/i.test(safety.text), 'how reports work');++ });++++ await check('the drive shows the usage bar, the upload form and the one page script', async () => {++ const r = await t.get('/files', { as: kim });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('<progress id="usage"'), 'the usage bar is a progress element');++ assert.ok(r.text.includes('2.0 GB'), 'the quota from the environment');++ assert.ok(r.text.includes('action="/files/upload"'), 'the upload form');++ assert.ok(r.text.includes('enctype="multipart/form-data"'), 'which is a plain file form');++ assert.ok(r.text.includes('data-upload'), 'that the script upgrades');++ assert.ok(r.text.includes('/js/upload.js?v='), 'and the script itself');++ assert.ok(r.text.includes('<noscript>'), 'the frame\'s no-JavaScript navigation is still there');++ // Signed-in pages are never cached by anything shared.++ assert.match(r.headers.get('cache-control'), /private/);++ });++++ await check('folders: made from the form, browsed, and never two of the same name in one place', async () => {++ const made = await t.get('/folders', { as: kim, form: { name: 'Taxes 2026' }, headers: SAME });++ assert.strictEqual(made.status, 303);++ const folder = (await t.get('/api/v1/folders', { as: kim })).json().folders.find((f) => f.name === 'Taxes 2026');++ assert.ok(folder, 'the folder exists');++++ const twice = await t.get('/folders', { as: kim, form: { name: 'taxes 2026' }, headers: SAME });++ assert.strictEqual(twice.status, 409);++ assert.ok(twice.text.includes('already have a folder called'), twice.text.slice(0, 300));++++ const file = (await upload(t, kim, bytes(80, 'taxes'), { name: 'return.pdf', contentType: 'application/pdf', folder: folder.id })).json().file;++ const inside = await t.get(`/files?folder=${folder.id}`, { as: kim });++ assert.ok(inside.text.includes('return.pdf'), 'the file is in the folder');++ assert.ok(inside.text.includes('Taxes 2026'), 'and the page is the folder');++ const root = await t.get('/files', { as: kim });++ assert.ok(!root.text.includes('return.pdf'), 'and not in the root');++ assert.ok(root.text.includes('Taxes 2026'), 'where the folder is listed');++ assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 200);++ });++++ await check('everything a person typed is escaped, everywhere it is shown', async () => {++ const nastyName = '<script>alert("x")</script>.txt';++ const nastyFolder = '<img src=x onerror="alert(1)">';++ const folder = (await t.get('/api/v1/folders', { as: kim, headers: SAME, json: { name: nastyFolder } })).json().folder;++ const file = (await upload(t, kim, bytes(50, 'nasty'), { name: nastyName, contentType: 'text/plain', folder: folder.id })).json().file;++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++ // Reported once, so the share (and with it the typed name) is in the staff queue too.++ const reporter = t.network.addUser('nia');++ await t.get(`/s/${slug}/report`, { as: reporter, form: { reason: 'malware', note: '<b>not bold</b>' }, headers: SAME });++++ for (const [where, path, as] of [++ ['the drive', `/files?folder=${folder.id}`, kim],++ ['the file page', `/files/${file.id}`, kim],++ ['the share page', `/s/${slug}`, sam],++ ['the report queue', '/staff', t.network.addUser('root', { role: 'admin' })],++ ]) {++ const r = await t.get(path, { as });++ assert.strictEqual(r.status, 200, `${where}: ${r.text.slice(0, 200)}`);++ assert.ok(!r.text.includes(nastyName), `${where} renders the file name raw`);++ assert.ok(!r.text.includes(nastyFolder), `${where} renders the folder name raw`);++ assert.ok(r.text.includes('<script>') || r.text.includes('<img src=x'), `${where} does not show it at all`);++ }++++ // A report note is text somebody typed, and it is escaped where staff read it.++ const queue = await t.get('/staff', { as: t.network.addUser('mod', { role: 'global_mod' }) });++ assert.ok(queue.text.includes('<b>not bold</b>'), 'the note is escaped in the queue');++ assert.ok(!queue.text.includes('<b>not bold</b>'), 'and not rendered as markup');++ });++++ await check('a person\'s own share link is listed with its state, and staff see who shared it', async () => {++ const file = (await upload(t, kim, bytes(40, 'listed'), { name: 'listed.bin' })).json().file;++ const slug = (await share(t, kim, { file_id: file.id, hours: 1 })).json().share.slug;++ const page = await t.get(`/files/${file.id}`, { as: kim });++ assert.ok(page.text.includes(`/s/${slug}`), 'the file page lists its link');++ assert.ok(/expires <time/.test(page.text), 'with when it expires');++ const drive = await t.get('/files', { as: kim });++ assert.ok(drive.text.includes(`/s/${slug}`), 'and so does the drive');++ });++++ await check('the update log and the crawl artifacts still work, and a guest can read the rules', async () => {++ assert.strictEqual((await t.get('/updates')).status, 200);++ const robots = await t.get('/robots.txt');++ for (const d of ['/auth/', '/api/', '/files', '/s/', '/staff']) assert.ok(robots.text.includes(`Disallow: ${d}`), `robots disallows ${d}`);++ assert.ok(!robots.text.includes('Disallow: /safety'), '/safety is not swallowed by the /s/ rule');++ const sitemap = await t.get('/sitemap.xml');++ assert.ok(sitemap.text.includes('https://openvibe.download/safety'), 'the rules are in the sitemap');++ assert.ok(!sitemap.text.includes('/files'), 'and the drive is not');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++/**++ * Reporting a share and what staff do about it: one report per person per link, three distinct people (or one++ * report of illegal content) suspending it at once, and OpenVibe staff restoring it or removing the file.++ *++ * The reasons, the notes and everything else somebody typed are text: they are escaped wherever the queue shows++ * them (drive-pages.test.js has the escaping; this file has the rules).++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, upload, share } = require('./helpers/drive');++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++ const jo = t.network.addUser('jo');++ const ada = t.network.addUser('ada', { role: 'admin' });++ const gil = t.network.addUser('gil', { role: 'global_mod' });++ const pat = t.network.addUser('pat', { role: 'streamer' });++++ const file = (await upload(t, kim, bytes(200, 'reported'), { name: 'suspect.bin' })).json().file;++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++ const report = (body, as, o = {}) => t.get(`/s/${slug}/report`, { as, form: body, headers: { ...SAME, ...(o.headers || {}) } });++ const state = async () => (await t.get(`/api/v1/files/${file.id}`, { as: kim })).json().shares.find((x) => x.slug === slug).state;++++ await check('a signed-in person reports a share once; their own is not theirs to report', async () => {++ const r = await report({ reason: 'malware', note: 'It asked me to run it.' }, sam);++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.ok(r.text.includes('report 1'), 'the page says which report it was');++ assert.strictEqual(await state(), 'live', 'one report suspends nothing');++ assert.strictEqual((await report({ reason: 'other' }, sam)).status, 409, 'once per person');++ assert.strictEqual((await report({ reason: 'other' }, kim)).status, 403, 'not your own share');++ assert.strictEqual((await report({ reason: 'nonsense' }, jo)).status, 422, 'the reason is from the list');++ assert.strictEqual((await t.get(`/s/${slug}/report`, { form: { reason: 'malware' }, headers: SAME })).status, 401, 'a guest reports nothing');++ assert.strictEqual((await t.get(`/s/${slug}/report`, { as: jo, form: { reason: 'malware' } })).status, 403, 'a cross-site form cannot report as somebody');++ assert.strictEqual((await report({ reason: 'copyright', note: 'x'.repeat(600) }, jo)).status, 200);++ assert.strictEqual(await state(), 'live', 'two reports still do not suspend it');++ });++++ await check('the third distinct report suspends the share at once, everywhere', async () => {++ const nia = t.network.addUser('nia');++ await report({ reason: 'copyright' }, nia);++ assert.strictEqual(await state(), 'suspended');++++ const page = await t.get(`/s/${slug}`, { as: sam });++ assert.strictEqual(page.status, 403);++ assert.ok(page.text.includes('suspended'), page.text.slice(0, 300));++ assert.ok(!page.text.includes('suspect.bin'), 'and nothing about the file');++ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 403, 'nothing can be downloaded from it');++ // The owner sees it suspended too, and can still revoke it.++ assert.strictEqual((await t.get(`/s/${slug}`, { as: kim })).status, 403);++ });++++ await check('one report of illegal content suspends at once', async () => {++ const other = (await upload(t, kim, bytes(50, 'illegal'), { name: 'bad.bin' })).json().file;++ const s2 = (await share(t, kim, { file_id: other.id, hours: 24 })).json().share.slug;++ const r = await t.get(`/s/${s2}/report`, { as: jo, form: { reason: 'illegal', note: 'I think this is abuse material.' }, headers: SAME });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('suspended now'), r.text.slice(0, 400));++ assert.strictEqual((await t.get(`/s/${s2}`, { as: sam })).status, 403);++ assert.strictEqual((await t.get(`/s/${s2}/download`, { as: sam })).status, 403);++ });++++ await check('the queue is for staff only, and shows who shared it, why it was reported and what was said', async () => {++ for (const [who, status] of [[null, 401], [sam, 403], [pat, 403]]) {++ const r = await t.get('/staff', who ? { as: who } : {});++ assert.strictEqual(r.status, status, String(who && who.username));++ }++ const queue = await t.get('/staff', { as: ada });++ assert.strictEqual(queue.status, 200);++ assert.ok(queue.text.includes(slug), 'the reported share');++ assert.ok(queue.text.includes('suspect.bin'), 'the file behind it');++ assert.ok(queue.text.includes('@kim'), 'who shared it, by username');++ assert.ok(queue.text.includes('malware') && queue.text.includes('copyright'), 'the reasons');++ assert.ok(queue.text.includes('It asked me to run it.'), 'what the first reporter said');++ assert.ok(queue.text.includes('suspect.bin'));++ assert.ok((await t.get('/staff', { as: gil })).text.includes(slug), 'a global mod reads it too');++ assert.ok(!(await t.get('/staff', { as: ada })).text.includes('member of the public'), 'no');++ });++++ await check('staff restore it: the link works again and the reports stay on the record', async () => {++ const r = await t.get(`/staff/${slug}/restore`, { as: ada, form: {}, headers: SAME });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(await state(), 'live');++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200, 'and it opens again');++ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 200);++ const queue = await t.get('/staff', { as: ada });++ assert.ok(queue.text.includes(slug), 'the reports are still there for staff');++ assert.ok(queue.text.includes('live'), 'and it says the link is live again');++ // A restored share is not immediately suspended again by the reports it already has.++ assert.strictEqual((await report({ reason: 'malware' }, t.network.addUser('dea'))).status, 200);++ assert.strictEqual(await state(), 'live', 'the count is what suspends, and it does not count twice');++ });++++ await check('staff remove the file: the link, the file and Media\'s object all go', async () => {++ const before = t.media.deleted.length;++ const r = await t.get(`/staff/${slug}/remove`, { as: gil, form: {}, headers: SAME });++ assert.strictEqual(r.status, 303);++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404);++ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).status, 404, 'gone for its owner too');++ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 404);++ assert.strictEqual(t.media.deleted.length, before + 1, 'Media was asked to delete the object');++ assert.strictEqual(t.media.deleted[t.media.deleted.length - 1], file.object.media_object_id);++ });++++ await check('removing or restoring is staff\'s, and comes from this site', async () => {++ const f = (await upload(t, kim, bytes(60, 'again'), { name: 'again.bin' })).json().file;++ const s = (await share(t, kim, { file_id: f.id, hours: 24 })).json().share.slug;++ assert.strictEqual((await t.get(`/staff/${s}/remove`, { as: sam, form: {}, headers: SAME })).status, 403);++ assert.strictEqual((await t.get(`/staff/${s}/remove`, { form: {}, headers: SAME })).status, 401);++ assert.strictEqual((await t.get(`/staff/${s}/remove`, { as: ada, form: {} })).status, 403, 'a cross-site form is refused');++ assert.strictEqual((await t.get(`/staff/${s}/restore`, { as: ada, form: {}, headers: SAME })).status, 303);++ });++++ await check('a share its owner already revoked is not a report, and a report on a folder share is a report', async () => {++ const f = (await upload(t, kim, bytes(60, 'gone'), { name: 'gone.bin' })).json().file;++ const s = (await share(t, kim, { file_id: f.id, hours: 24 })).json().share.slug;++ await t.get(`/api/v1/shares/${s}`, { as: kim, method: 'DELETE', headers: SAME });++ const r = await t.get(`/s/${s}/report`, { as: sam, form: { reason: 'malware' }, headers: SAME });++ assert.strictEqual(r.status, 409);++ assert.match(r.text, /already taken down/i);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++/**++ * Share links: 24 random characters, an expiry of an hour to seven days, revocable, counted, and never open to++ * anybody who is not signed in. A share may name OpenVibe usernames; everybody else with the link is refused.++ *++ * The clock is the store's own (boot({ now })), so a share can be watched expiring.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, upload, share } = require('./helpers/drive');++++const HOUR = 3_600_000;++++(async () => {++ // The injected clock drives the store and session verification, so it must not sit before the moment the++ // Network mock mints its tokens (an iat in the future is an invalid token).++ let clock = Date.now() + 1000;++ const t = await boot({ now: () => clock });++ // The injected clock also judges session tokens, so a test that moves it past a share's expiry would expire++ // the session too. Every request here carries a token minted for a day instead of an hour.++ const rawGet = t.get.bind(t);++ t.get = (path, o = {}) => (o.as++ ? rawGet(path, { ...o, cookie: `media-hub_at=${t.network.userToken(o.as, { ttl: 24 * 3600 })}` })++ : rawGet(path, o));++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++ const ada = t.network.addUser('ada');++++ const put = async (as, name, data = bytes(400, name)) => (await upload(t, as, data, { name })).json().file;++ const file = await put(kim, 'holiday.bin');++++ await check('a share link is 24 random characters and expires when it was told to', async () => {++ const r = await share(t, kim, { file_id: file.id, hours: 24 });++ assert.strictEqual(r.status, 201, r.text);++ const s = r.json().share;++ assert.match(s.slug, /^[A-Za-z0-9_-]{24}$/);++ assert.strictEqual(s.state, 'live');++ assert.strictEqual(s.url, `/s/${s.slug}`);++ assert.strictEqual(s.expires_at, new Date(clock + 24 * HOUR).toISOString());++ assert.strictEqual((await share(t, kim, { file_id: file.id })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString(), 'seven days by default');++++ // The clamp: an hour is the shortest, a week the longest.++ assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 0 })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString());++ assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 999 })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString());++++ // A file that is not yours has no share.++ const sams = await put(sam, 'sams.bin');++ assert.strictEqual((await share(t, kim, { file_id: sams.id })).status, 404);++ assert.strictEqual((await share(t, kim, {})).status, 422);++ });++++ await check('the share page and its download need a signed-in person', async () => {++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++ const anon = await t.get(`/s/${slug}`);++ assert.strictEqual(anon.status, 401);++ assert.ok(anon.text.includes('Sign in first'), 'a sign-in page, not the file');++ assert.ok(!anon.text.includes('holiday.bin'), 'the name of the file is not shown to a guest');++ assert.strictEqual((await t.get(`/s/${slug}/download`)).status, 401);++++ const asSam = await t.get(`/s/${slug}`, { as: sam });++ assert.strictEqual(asSam.status, 200);++ assert.ok(asSam.text.includes('holiday.bin'));++ assert.ok(asSam.text.includes('@kim'), 'who shared it: their OpenVibe username');++ assert.ok(!asSam.text.includes(kim.subject), 'and never their subject id');++ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).json().shares.find((x) => x.slug === slug).downloads, 0);++ const dl = await t.get(`/s/${slug}/download`, { as: sam });++ assert.strictEqual(dl.status, 200);++ assert.strictEqual(dl.headers.get('content-disposition'), 'attachment; filename="holiday.bin"');++ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).json().shares.find((x) => x.slug === slug).downloads, 1, 'the download is counted');++ });++++ await check('a share can be revoked, and stops working at once', async () => {++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);++ assert.strictEqual((await t.get(`/api/v1/shares/${slug}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204);++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404);++ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/shares/${slug}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204, 'revoking it twice is the same as revoking it once');++ assert.strictEqual((await t.get(`/api/v1/shares/not-a-slug-at-all-here`, { as: kim, method: 'DELETE', headers: SAME })).status, 404);++ // Somebody else's share is not theirs to revoke.++ const s2 = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++ assert.strictEqual((await t.get(`/api/v1/shares/${s2}`, { as: sam, method: 'DELETE', headers: SAME })).status, 404);++ });++++ await check('a share expires on its own', async () => {++ const slug = (await share(t, kim, { file_id: file.id, hours: 1 })).json().share.slug;++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);++ clock += 59 * 60 * 1000;++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200, 'still inside the hour');++ clock += 2 * 60 * 1000;++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404, 'past it');++ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).json().shares.find((x) => x.slug === slug).state, 'expired');++ });++++ await check('a share named for usernames is refused to everybody else', async () => {++ const r = await share(t, kim, { file_id: file.id, hours: 24, usernames: ['sam'] });++ assert.strictEqual(r.status, 201, r.text);++ const slug = r.json().share.slug;++ assert.deepStrictEqual(r.json().share.allowed, { subjects: [], usernames: ['sam'] }, 'Network could not resolve the name, so it is kept as typed');++ assert.deepStrictEqual(r.json().unresolved, ['sam']);++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200, 'sam is named');++ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 200);++ const adaPage = await t.get(`/s/${slug}`, { as: ada });++ assert.strictEqual(adaPage.status, 403);++ assert.ok(adaPage.text.includes('not for you'), adaPage.text.slice(0, 300));++ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: ada })).status, 403);++ assert.ok(!adaPage.text.includes('holiday.bin'), 'not even the name');++ // The owner is named too, when they name themselves.++ assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 24, usernames: ['not a name!'] })).status, 422);++ });++++ await check('a folder can be shared, one file at a time', async () => {++ const folder = (await t.get('/api/v1/folders', { as: kim, headers: SAME, json: { name: 'photos' } })).json().folder;++ const inside = (await upload(t, kim, bytes(120, 'in-folder'), { name: 'in.png', folder: folder.id })).json().file;++ const outside = (await upload(t, kim, bytes(120, 'outside'), { name: 'out.png' })).json().file;++ const slug = (await share(t, kim, { folder_id: folder.id, hours: 24 })).json().share.slug;++++ const page = await t.get(`/s/${slug}`, { as: sam });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('photos') && page.text.includes('in.png'));++ assert.ok(!page.text.includes('out.png'), 'a file outside the folder is not in the share');++++ const dl = await t.get(`/s/${slug}/f/${inside.id}/download`, { as: sam });++ assert.strictEqual(dl.status, 200, dl.text.slice(0, 200));++ assert.strictEqual((await t.get(`/s/${slug}/f/${outside.id}/download`, { as: sam })).status, 404);++ assert.strictEqual((await t.get(`/s/${slug}/f/fil_01JABCDEFGHJKMNPQRSTVWXYZ0/download`, { as: sam })).status, 404);++ });++++ await check('20 live share links is the most one person may have; revoked and expired ones do not count', async () => {++ const nia = t.network.addUser('nia');++ const f = await put(nia, 'spread.bin');++ const made = [];++ for (let i = 0; i < 20; i++) {++ const r = await share(t, nia, { file_id: f.id, hours: 1 });++ assert.strictEqual(r.status, 201, `share ${i}: ${r.text}`);++ made.push(r.json().share.slug);++ }++ const refused = await share(t, nia, { file_id: f.id, hours: 1 });++ assert.strictEqual(refused.status, 429);++ assert.strictEqual(refused.json().code, 'share.limit');++ assert.strictEqual((await t.get('/api/v1/usage', { as: nia })).json().usage.live_shares, 20);++++ // Revoking one frees a slot...++ assert.strictEqual((await t.get(`/api/v1/shares/${made[0]}`, { as: nia, method: 'DELETE', headers: SAME })).status, 204);++ assert.strictEqual((await share(t, nia, { file_id: f.id, hours: 1 })).status, 201, 'a revoked one frees a slot');++ assert.strictEqual((await t.get('/api/v1/usage', { as: nia })).json().usage.live_shares, 20);++ // ...and so does the hour passing.++ clock += 2 * HOUR;++ assert.strictEqual((await t.get('/api/v1/usage', { as: nia })).json().usage.live_shares, 0, 'the old ones expired');++ assert.strictEqual((await share(t, nia, { file_id: f.id, hours: 1 })).status, 201);++ });++++ await check('deleting a file takes its share links with it', async () => {++ const ola = t.network.addUser('ola');++ const f = await put(ola, 'doomed.bin');++ const slug = (await share(t, ola, { file_id: f.id, hours: 24 })).json().share.slug;++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);++ assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola, method: 'DELETE', headers: SAME })).status, 204);++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola })).status, 404, 'and the file is gone for its owner too');++ assert.ok(t.media.deleted.length > 0, 'Media was asked to delete the object');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,156 @@++'use strict';++/**++ * Uploading: the plain form (no JavaScript, one request), the chunked path the page's script uses, resume after a++ * dropped part, and the per-person numbers — 512 MB a file, 2 GB stored, 50 uploads a day.++ *++ * The bytes end up in the stand-in OpenVibe.Media (test/helpers/media.js), which is the only place they live: the++ * assertions here are about what MediaHub told Media and what it recorded, not about bytes it kept itself.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, sha256, upload, postForm } = require('./helpers/drive');++++const MB = 1024 * 1024;++++(async () => {++ // Small parts (5 MB, Media's own minimum) so a two-part upload is a few megabytes, not a few hundred.++ const t = await boot({++ env: {++ MEDIAHUB_PART_BYTES: String(5 * MB),++ MEDIAHUB_MAX_FILE_BYTES: String(20 * MB),++ MEDIAHUB_QUOTA_BYTES: String(40 * MB),++ MEDIAHUB_UPLOADS_PER_DAY: '3',++ },++ });++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++++ await check('the plain form uploads a file in one request, without JavaScript', async () => {++ const data = bytes(5000, 'plain-form');++ const r = await postForm(t, kim, { name: 'quarterly.bin' }, { filename: 'orig.bin', type: 'application/octet-stream', data });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ const id = r.headers.get('location').replace('/files/', '');++ assert.match(id, /^fil_[0-9A-HJKMNP-TV-Z]{26}$/);++++ const meta = await t.get(`/api/v1/files/${id}`, { as: kim });++ assert.strictEqual(meta.status, 200);++ assert.strictEqual(meta.json().file.name, 'quarterly.bin', 'the typed name wins over the file name');++ assert.strictEqual(meta.json().file.size, data.length);++ assert.strictEqual(meta.json().file.sha256, sha256(data), 'Media computed the hash and MediaHub kept it');++++ const dl = await t.get(`/files/${id}/download`, { as: kim });++ assert.strictEqual(dl.status, 200);++ assert.strictEqual(sha256(dl.buffer), sha256(data), 'the bytes round-trip');++ });++++ await check('the chunked upload the page script uses: start, parts, complete', async () => {++ const data = bytes(6 * MB, 'chunked');++ const started = await t.get('/api/v1/files', { as: kim, headers: SAME, json: { name: 'video.bin', size: data.length, content_type: 'application/octet-stream' } });++ assert.strictEqual(started.status, 201, started.text);++ const up = started.json().upload;++ assert.strictEqual(up.parts_expected, 2, 'six megabytes at five megabytes a part');++ assert.strictEqual(up.part_size, 5 * MB);++ assert.deepStrictEqual(up.missing, []);++++ const first = await t.get(`/api/v1/uploads/${up.id}/parts/1`, { as: kim, method: 'PUT', body: data.subarray(0, up.part_size), headers: SAME });++ assert.strictEqual(first.status, 200, first.text);++++ // The resume answer: part 1 is in, part 2 is what is left.++ const resume = await t.get(`/api/v1/uploads/${up.id}`, { as: kim });++ assert.strictEqual(resume.status, 200);++ assert.deepStrictEqual(resume.json().upload.missing, [2]);++ assert.strictEqual(resume.json().upload.status, 'open');++++ const second = await t.get(`/api/v1/uploads/${up.id}/parts/2`, { as: kim, method: 'PUT', body: data.subarray(up.part_size), headers: SAME });++ assert.strictEqual(second.status, 200, second.text);++ const done_ = await t.get(`/api/v1/uploads/${up.id}/complete`, { as: kim, json: {}, headers: SAME });++ assert.strictEqual(done_.status, 201, done_.text);++ assert.strictEqual(done_.json().file.size, data.length);++ assert.strictEqual(done_.json().file.sha256, sha256(data));++++ // The session is gone once it is a file, and so is the resume route for it.++ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}`, { as: kim })).status, 404);++ });++++ await check('a part of the wrong size is refused, and one person cannot touch another\'s upload', async () => {++ const data = bytes(6 * MB, 'parts');++ const up = (await t.get('/api/v1/files', { as: kim, headers: SAME, json: { name: 'p.bin', size: data.length, content_type: 'application/octet-stream' } })).json().upload;++++ const short = await t.get(`/api/v1/uploads/${up.id}/parts/1`, { as: kim, method: 'PUT', body: data.subarray(0, 10), headers: SAME });++ assert.strictEqual(short.status, 422);++ assert.strictEqual(short.json().code, 'upload.part_size');++ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}/parts/9`, { as: kim, method: 'PUT', body: data.subarray(0, up.part_size), headers: SAME })).status, 422);++++ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}`, { as: sam })).status, 404, 'not sam\'s upload');++ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}/parts/1`, { as: sam, method: 'PUT', body: data.subarray(0, up.part_size), headers: SAME })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204, 'the owner can abandon it');++ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}`, { as: kim })).status, 404);++ });++++ await check('a file over the per-file cap is refused before anything is created', async () => {++ const r = await t.get('/api/v1/files', { as: kim, headers: SAME, json: { name: 'huge.bin', size: 25 * MB, content_type: 'application/octet-stream' } });++ assert.strictEqual(r.status, 413);++ assert.strictEqual(r.json().code, 'file.too_large');++ assert.ok(r.json().detail.includes('20'), r.json().detail);++ });++++ await check('the day\'s uploads and the stored quota are enforced, from the environment', async () => {++ const usage = (await t.get('/api/v1/usage', { as: kim })).json().usage;++ assert.strictEqual(usage.uploads_per_day, 3);++ assert.strictEqual(usage.max_file_bytes, 20 * MB);++ assert.strictEqual(usage.bytes_limit, 40 * MB);++ // kim has uploaded twice (the plain form and the chunked upload). One more, then the day is full.++ assert.strictEqual(usage.uploads_today, 2, JSON.stringify(usage));++ const third = await upload(t, kim, bytes(1000, 'third'), { name: 'third.bin' });++ assert.strictEqual(third.status, 201, third.text);++ const fourth = await upload(t, kim, bytes(1000, 'fourth'), { name: 'fourth.bin' });++ assert.strictEqual(fourth.status, 429);++ assert.strictEqual(fourth.json().code, 'file.daily_limit');++ // The quota: sam has room, kim does not have 40 MB any more either.++ const stored = (await t.get('/api/v1/usage', { as: kim })).json().usage.bytes_used;++ const over = await t.get('/api/v1/files', { as: sam, headers: SAME, json: { name: 'ok.bin', size: 1000, content_type: 'text/plain' } });++ assert.strictEqual(over.status, 201, 'another person has their own numbers');++ assert.ok(stored > 6 * MB);++ });++++ await check('an upload is private to its owner until it is shared', async () => {++ // Somebody with their own numbers: kim's day is full by now, which is a different rule.++ const nia = t.network.addUser('nia');++ const up = await upload(t, nia, bytes(200, 'private'), { name: 'mine.bin' });++ assert.strictEqual(up.status, 201, up.text);++ const id = up.json().file.id;++ assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: sam })).status, 404);++ // A session-cookie DELETE is only accepted from this site, and it is still not sam's file.++ assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: sam, method: 'DELETE' })).status, 403);++ assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: sam, method: 'DELETE', headers: SAME })).status, 404);++ assert.strictEqual((await t.get(`/files/${id}`, { as: sam })).status, 404);++ assert.strictEqual((await t.get(`/files/${id}/download`, { as: sam })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: nia })).status, 200, 'its owner sees it');++ assert.ok((await t.get('/api/v1/files', { as: sam })).json().files.every((f) => f.name !== 'mine.bin'));++ });++++ await check('an empty or nameless form is refused with a page, not a crash', async () => {++ const empty = await postForm(t, kim, {}, { filename: 'empty.bin', type: 'application/octet-stream', data: Buffer.alloc(0) });++ assert.strictEqual(empty.status, 422);++ assert.ok(empty.text.includes('No file was in that form'), empty.text.slice(0, 200));++ const none = await postForm(t, kim, {}, null);++ assert.strictEqual(none.status, 422);++ assert.strictEqual((await t.get('/files/upload', { as: kim, headers: SAME, body: Buffer.from('not a form'), })).status, 422);++ });++++ await check('Media being down is a refusal the person can read, never a broken page', async () => {++ const ola = t.network.addUser('ola');++ t.media.setDown(true);++ const r = await upload(t, ola, bytes(100), { name: 'down.bin' });++ assert.strictEqual(r.status, 502);++ assert.match(r.json().code, /^media\./, r.json().code);++ const form = await postForm(t, ola, { name: 'down.bin' }, { filename: 'down.bin', type: 'text/plain', data: bytes(100, 'down') });++ assert.strictEqual(form.status, 422);++ assert.ok(form.text.includes('could not store that just now'), form.text.slice(0, 400));++ t.media.setDown(false);++ assert.strictEqual((await upload(t, ola, bytes(100), { name: 'back.bin' })).status, 201, 'and it works again once Media is back');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -14,6 +14,7 @@ process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN = process.env.MEDIAHUB_API_RATE_LIMI*/const http = require('http');const { startNetwork } = require('./mocks');++const { startMedia, KEY } = require('./media');const captured = [];for (const m of ['log', 'info', 'warn', 'error']) {@@@ -23,10 +24,13 @@ for (const m of ['log', 'info', 'warn', 'error']) {async function boot(opts = {}) {const network = await startNetwork(opts.network || {});++ // The stand-in for OpenVibe.Media (test/helpers/media.js): the bytes' real home, in this process.++ const media = opts.media === false ? null : await startMedia(opts.media || {});const env = {NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.download', TRUST_PROXY: '1',OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,OV_OAUTH_CLIENT_ID: 'media-hub', OV_OAUTH_CLIENT_SECRET: 'media-hub-secret', COOKIE_SECURE: 'false',++ ...(media ? { MEDIAHUB_MEDIA_URL: media.url, MEDIAHUB_MEDIA_APP_KEY: KEY } : {}),...(opts.env || {}),};for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];@@@ -52,8 +56,10 @@ async function boot(opts = {}) {if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;let body = o.body;if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }-- if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }-- const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });++ // An empty form is still a POST with a body (urlencoded ""), not a GET: the route is a write either way.++ if (o.form !== undefined) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const sendsBody = body !== undefined && body !== null;++ const res = await fetch(base + p, { method: o.method || (sendsBody ? 'POST' : 'GET'), headers, body: sendsBody ? body : undefined, redirect: 'manual' });const buf = Buffer.from(await res.arrayBuffer());const text = buf.toString('utf8');return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };@@@ -66,13 +72,14 @@ async function boot(opts = {}) {}const t = {-- base, network, config, ctx: built.ctx, get, signIn, dbDump,++ base, network, media, config, ctx: built.ctx, get, signIn, dbDump,logs: () => captured.join('\n'),async close() {await new Promise((r) => server.close(r));built.ctx.keys.client.stop();await testdb.close();await network.close();++ if (media) await media.close();},};return t;nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * What the drive tests share: the same-origin header every signed-in write needs (a session-cookie write is only++ * accepted from openvibe.download itself), a chunked upload in one call, and small builders for a file and a share.++ */++const crypto = require('crypto');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++/** Deterministic bytes of `n`: a pattern long enough to notice a wrong byte, and hashable. */++function bytes(n, seed = 'OpenVibe.MediaHub') {++ const out = Buffer.alloc(n);++ const block = Buffer.from(seed, 'utf8');++ for (let i = 0; i < n; i++) out[i] = block[i % block.length] ^ (i & 0xff);++ return out;++}++++const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');++++/**++ * Upload through the API the way the page's script does: POST /files, one PUT per part, then complete.++ * Answers the final response (t.get's shape), so a caller can assert on it directly.++ */++async function upload(t, as, data, { name = 'file.bin', contentType = 'application/octet-stream', folder = null, onPart = null } = {}) {++ const started = await t.get('/api/v1/files', {++ as, headers: SAME,++ json: { name, size: data.length, content_type: contentType, folder_id: folder },++ });++ if (started.status !== 201 || !started.json().upload) return started;++ const up = started.json().upload;++ for (let n = 1; n <= up.parts_expected; n++) {++ const start = (n - 1) * up.part_size;++ const slice = data.subarray(start, Math.min(start + up.part_size, data.length));++ if (onPart) await onPart(n, up);++ const sent = await t.get(`/api/v1/uploads/${up.id}/parts/${n}`, {++ as, method: 'PUT', body: slice, headers: { ...SAME, 'content-type': 'application/octet-stream' },++ });++ if (sent.status !== 200) return sent;++ }++ return await t.get(`/api/v1/uploads/${up.id}/complete`, { as, json: {}, headers: SAME });++}++++/** POST /api/v1/shares as somebody, same-origin by default. */++const share = (t, as, body, o = {}) => t.get('/api/v1/shares', { as, json: body, headers: { ...SAME, ...(o.headers || {}) } });++++/**++ * The body a browser sends for `<form method=post enctype="multipart/form-data">` with a few text fields and one++ * file — what the no-JavaScript upload is. Returns { body, contentType } for t.get.++ */++function formBody(fields, file) {++ const boundary = `----ovtest${crypto.randomBytes(8).toString('hex')}`;++ const head = [];++ for (const [name, value] of Object.entries(fields || {})) {++ head.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`));++ }++ const fileHead = file ? [Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: ${file.type || 'application/octet-stream'}\r\n\r\n`)] : [];++ const fileBytes = file ? [file.data, Buffer.from('\r\n')] : [];++ return {++ body: Buffer.concat([...head, ...fileHead, ...fileBytes, Buffer.from(`--${boundary}--\r\n`)]),++ contentType: `multipart/form-data; boundary=${boundary}`,++ };++}++++/** POST the plain upload form (no JavaScript). */++const postForm = (t, as, fields, file, o = {}) => {++ const form = formBody(fields, file);++ return t.get('/files/upload', { as, body: form.body, headers: { ...SAME, 'content-type': form.contentType, ...(o.headers || {}) } });++};++++module.exports = { SAME, bytes, sha256, upload, share, formBody, postForm };nnew file mode 100644@@@ -0,0 +1,267 @@++'use strict';++/**++ * A stand-in for OpenVibe.Media: a small local HTTP server that implements exactly the object-API v2 endpoints++ * MediaHub calls, with Media's own behaviour where it matters to this service:++ *++ * POST /api/v2/:app/objects init (single-part or a multipart session)++ * PUT /api/v2/:app/objects/:id/content the bytes, exactly size_bytes of them++ * POST /api/v2/:app/objects/:id/complete hash, size and lifecycle → ready++ * POST /api/v2/:app/objects/:id/multipart (re)start a session++ * GET /api/v2/:app/objects/:id/multipart/:uid parts received and what is missing (resume)++ * PUT /api/v2/:app/objects/:id/multipart/:uid/parts/:n one part, exactly part_size bytes (the last less)++ * POST /api/v2/:app/objects/:id/multipart/:uid/complete assemble into the object++ * DELETE /api/v2/:app/objects/:id/multipart/:uid abort++ * GET /api/v2/:app/objects/:id metadata++ * GET /api/v2/:app/objects/:id/download a short-lived signed URL (host-independent signature)++ * DELETE /api/v2/:app/objects/:id soft delete++ * GET /o/:id?exp&sig the bytes, with the signature checked++ *++ * The app key is required on everything under /api/v2 (x-ov-subject names the owner at init), exactly as Media++ * requires it, so a test can prove MediaHub never asks Media for anything with the wrong credential. `.requests`++ * records every call ({ method, path, query, authorization, subject, bytes }) for the assertions that need it.++ *++ * Behaviour knobs: `setDown(true)` makes every call answer 503; `setKeyRejects(true)` answers 401 as a bad key++ * would; `objects`/`bytesOf(id)` are what the store holds; `deleted` lists the ids Media was asked to delete.++ */++const http = require('http');++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++++const KEY = 'media-hub-test-key';++const SIGN_SECRET = 'stand-in-media-signing-secret';++++const sign = (objectId, exp) => crypto.createHmac('sha256', SIGN_SECRET).update(`get\n${objectId}\n${exp}`).digest('base64url');++++async function startMedia({ key = KEY, maxSingle = 256 * 1024 * 1024 } = {}) {++ const objects = new Map(); // id → { id, size_bytes, mime_type, content_hash, lifecycle_status, visibility, owner_subject, filename, bytes }++ const sessions = new Map(); // id → { id, object_id, part_size, total_size, parts_expected, status, parts: Map }++ const requests = [];++ const deleted = [];++ const state = { down: false, keyRejects: false };++ let seq = 0;++++ const json = (res, status, body) => {++ const text = JSON.stringify(body);++ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });++ res.end(text);++ };++ const problem = (res, status, code, detail) => json(res, status, { type: `https://openvibe.media/problems/${code}`, title: 'Error', status, code, detail });++++ const readRaw = (req) => new Promise((resolve, reject) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => resolve(Buffer.concat(chunks)));++ req.on('error', reject);++ });++++ const publicObject = (o) => ({++ id: o.id, kind: 'file', visibility: o.visibility, lifecycle_status: o.lifecycle_status,++ mime_type: o.mime_type, size_bytes: o.size_bytes, content_hash: o.content_hash,++ owner: { subject: o.owner_subject }, metadata: { filename: o.filename },++ created_at: o.created_at, updated_at: o.created_at, deleted_at: o.deleted_at || null,++ });++++ const sessionPublic = (sess, { parts = true } = {}) => {++ const out = {++ upload_id: sess.id, object_id: sess.object_id, status: sess.status,++ part_size: sess.part_size, total_size: sess.total_size, parts_expected: sess.parts_expected,++ expires_at: new Date(Date.now() + 3600_000).toISOString(), created_at: sess.created_at,++ };++ if (parts) {++ const have = [...sess.parts.keys()].sort((a, b) => a - b);++ out.parts = have.map((n) => ({ part_number: n, size_bytes: sess.parts.get(n).length, sha256: sha256(sess.parts.get(n)) }));++ out.received_bytes = have.reduce((a, n) => a + sess.parts.get(n).length, 0);++ out.missing = [];++ for (let n = 1; n <= sess.parts_expected; n++) if (!sess.parts.has(n)) out.missing.push(n);++ }++ return out;++ };++++ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');++ const partSize = (sess, n) => (n < sess.parts_expected ? sess.part_size : sess.total_size - sess.part_size * (sess.parts_expected - 1));++++ const server = http.createServer(async (req, res) => {++ const url = new URL(req.url, 'http://x');++ const raw = req.method === 'PUT' || req.method === 'POST' ? await readRaw(req) : Buffer.alloc(0);++ const path = url.pathname;++ const entry = { method: req.method, path, query: url.search, authorization: req.headers.authorization || null, subject: req.headers['x-ov-subject'] || null, bytes: raw.length };++ requests.push(entry);++ if (state.down) return problem(res, 503, 'media.down', 'Media is restarting');++ if (!path.startsWith('/api/v2/') && !path.startsWith('/o/')) return problem(res, 404, 'route.not_found', 'no such route');++++ // ── The public bytes of an object (/o/:id), signature-checked like Media checks it ──++ if (path.startsWith('/o/')) {++ const id = decodeURIComponent(path.slice(3));++ const obj = objects.get(id);++ const exp = Number(url.searchParams.get('exp'));++ const sig = url.searchParams.get('sig') || '';++ const signed = Number.isInteger(exp) && exp * 1000 > Date.now() && sig === sign(id, exp);++ if (!obj || obj.visibility === 'private' && !signed) return problem(res, 404, 'media.object.not_found', 'No such object');++ if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Gone');++ if (obj.lifecycle_status !== 'ready') return problem(res, 404, 'media.object.not_found', 'No such object');++ const name = String(obj.filename || obj.id).replace(/["\\\r\n]/g, '_');++ const headers = {++ 'Content-Type': obj.mime_type || 'application/octet-stream',++ 'Content-Disposition': `attachment; filename="${name}"`,++ 'X-Content-Type-Options': 'nosniff',++ 'Accept-Ranges': 'bytes',++ };++ // A Range read, as Media serves one for a packed or large object.++ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));++ if (range && (range[1] || range[2])) {++ const start = range[1] ? Number(range[1]) : Math.max(0, obj.bytes.length - Number(range[2]));++ const end = range[1] && range[2] ? Math.min(Number(range[2]), obj.bytes.length - 1) : obj.bytes.length - 1;++ const slice = obj.bytes.subarray(start, end + 1);++ res.writeHead(206, { ...headers, 'Content-Range': `bytes ${start}-${end}/${obj.bytes.length}`, 'Content-Length': String(slice.length) });++ return res.end(slice);++ }++ res.writeHead(200, { ...headers, 'Content-Length': String(obj.bytes.length) });++ return res.end(obj.bytes);++ }++++ // ── Everything else is the tenant API, and needs the app key ──++ const keyed = String(req.headers.authorization || '').startsWith('Bearer ') ? String(req.headers.authorization).slice(7) : null;++ if (state.keyRejects || keyed !== key) return problem(res, 401, 'auth.required', 'Authentication required');++++ const rest = path.replace(/^\/api\/v2\//, '').split('/').filter(Boolean).map(decodeURIComponent);++ if (rest.length < 2 || rest[1] !== 'objects') return problem(res, 404, 'route.not_found', 'no such route');++ const tail = rest.slice(2);++ const parseJson = () => { try { return raw.length ? JSON.parse(raw.toString('utf8')) : {}; } catch { return null; } };++++ // POST /objects — init++ if (!tail.length && req.method === 'POST') {++ const body = parseJson();++ if (!body) return problem(res, 400, 'media.object.invalid', 'body is not JSON');++ const size = Number(body.size_bytes);++ if (!Number.isInteger(size) || size < 1) return problem(res, 400, 'media.object.invalid', 'size_bytes must be a positive integer');++ const subject = req.headers['x-ov-subject'];++ if (!subject || !ids.isSubjectId('user', subject)) return problem(res, 400, 'media.object.invalid', 'X-OV-Subject must be a user subject');++ const wantParts = body.multipart === true;++ if (!wantParts && size > maxSingle) return problem(res, 413, 'media.object.too_large', 'Single-part uploads are limited; send multipart: true');++ seq += 1;++ const obj = {++ id: ids.newId('media', 1_700_000_000_000 + seq), visibility: body.visibility || 'private',++ lifecycle_status: 'uploading', mime_type: body.mime_type || null, size_bytes: size,++ content_hash: null, owner_subject: subject, filename: body.filename || null, bytes: null, x: true,++ created_at: new Date().toISOString(),++ };++ objects.set(obj.id, obj);++ const base = `/api/v2/${rest[0]}/objects/${obj.id}`;++ if (!wantParts) {++ const exp = Math.floor(Date.now() / 1000) + 3600;++ return json(res, 201, {++ id: obj.id, object: publicObject(obj),++ upload: { method: 'PUT', url: `${base}/content?token=t`, token: 't', expires_at: null, max_bytes: size, content_type: obj.mime_type, complete_url: `${base}/complete`, multipart_url: `${base}/multipart` },++ });++ }++ const sess = newSession(obj, body.part_size);++ return json(res, 201, {++ id: obj.id, object: publicObject(obj),++ upload: { method: 'multipart', url: null, token: null, expires_at: null, max_bytes: size, content_type: obj.mime_type, complete_url: `${base}/complete`, multipart_url: `${base}/multipart`, multipart: sessionPublic(sess, { parts: false }) },++ });++ }++++ const obj = objects.get(tail[0]);++ if (!obj) return problem(res, 404, 'media.object.not_found', 'No such object in this namespace');++ const sub = tail.slice(1);++++ // PUT /objects/:id/content++ if (!sub.length && req.method === 'PUT') {++ if (obj.lifecycle_status !== 'uploading') return problem(res, 409, 'media.object.not_uploading', `Object is ${obj.lifecycle_status}`);++ if (raw.length !== Number(obj.size_bytes)) return problem(res, 400, 'media.object.size_mismatch', `Declared ${obj.size_bytes} bytes, received ${raw.length}`);++ obj.bytes = raw;++ obj.content_hash = sha256(raw);++ return json(res, 200, { id: obj.id, size_bytes: raw.length, content_hash: obj.content_hash });++ }++ // POST /objects/:id/complete++ if (sub.length === 1 && sub[0] === 'complete' && req.method === 'POST') {++ if (!obj.bytes) return problem(res, 409, 'media.object.no_content', 'PUT the content first');++ obj.lifecycle_status = 'ready';++ return json(res, 200, publicObject(obj));++ }++ // POST /objects/:id/multipart++ if (sub.length === 1 && sub[0] === 'multipart' && req.method === 'POST') {++ const body = parseJson() || {};++ const sess = newSession(obj, body.part_size, Number(body.size_bytes) || Number(obj.size_bytes));++ return json(res, 201, sessionPublic(sess, { parts: false }));++ }++ if (sub[0] === 'multipart' && sub.length >= 2) {++ const sess = sessions.get(sub[1]);++ if (!sess || sess.object_id !== obj.id) return problem(res, 404, 'media.upload.not_found', 'No such upload for this object');++ // GET …/multipart/:uid — the resume answer++ if (sub.length === 2 && req.method === 'GET') return json(res, 200, sessionPublic(sess));++ // DELETE …/multipart/:uid — abort++ if (sub.length === 2 && req.method === 'DELETE') { sess.status = 'aborted'; sess.parts.clear(); return json(res, 200, sessionPublic(sess, { parts: false })); }++ // PUT …/multipart/:uid/parts/:n++ if (sub.length === 4 && sub[2] === 'parts' && req.method === 'PUT') {++ if (sess.status !== 'active') return problem(res, 409, 'media.upload.not_active', `The upload is ${sess.status}`);++ const n = Number(sub[3]);++ if (!Number.isInteger(n) || n < 1 || n > sess.parts_expected) return problem(res, 400, 'media.upload.invalid_part', `part_number must be 1-${sess.parts_expected}`);++ const want = partSize(sess, n);++ if (raw.length !== want) return problem(res, raw.length > want ? 413 : 400, 'media.upload.part_size_mismatch', `Part ${n} must be ${want} bytes`);++ sess.parts.set(n, raw);++ return json(res, 200, { part_number: n, size_bytes: raw.length, sha256: sha256(raw) });++ }++ // POST …/multipart/:uid/complete — assemble++ if (sub.length === 3 && sub[2] === 'complete' && req.method === 'POST') {++ if (sess.status !== 'active') return problem(res, 409, 'media.upload.not_active', `The upload is ${sess.status}`);++ const missing = [];++ for (let n = 1; n <= sess.parts_expected; n++) if (!sess.parts.has(n)) missing.push(n);++ if (missing.length) return problem(res, 409, 'media.upload.incomplete', `parts missing: ${missing.join(', ')}`);++ const parts = [];++ for (let n = 1; n <= sess.parts_expected; n++) parts.push(sess.parts.get(n));++ obj.bytes = Buffer.concat(parts);++ obj.content_hash = sha256(obj.bytes);++ obj.size_bytes = obj.bytes.length;++ obj.lifecycle_status = 'ready';++ sess.status = 'completed';++ return json(res, 200, publicObject(obj));++ }++ }++ // GET /objects/:id++ if (!sub.length && req.method === 'GET') return json(res, 200, publicObject(obj));++ // GET /objects/:id/download++ if (sub.length === 1 && sub[0] === 'download' && req.method === 'GET') {++ if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Object was deleted');++ if (obj.lifecycle_status !== 'ready') return problem(res, 409, 'media.object.not_ready', `Object is ${obj.lifecycle_status}`);++ const exp = Math.floor(Date.now() / 1000) + 300;++ return json(res, 200, { url: `https://media.test/o/${encodeURIComponent(obj.id)}?exp=${exp}&sig=${sign(obj.id, exp)}`, expires_at: new Date(exp * 1000).toISOString(), public: false });++ }++ // DELETE /objects/:id++ if (!sub.length && req.method === 'DELETE') {++ obj.lifecycle_status = 'deleted';++ obj.deleted_at = new Date().toISOString();++ deleted.push(obj.id);++ return json(res, 200, publicObject(obj));++ }++ return problem(res, 404, 'route.not_found', 'no such route');++ });++++ function newSession(obj, requestedPartSize, total = Number(obj.size_bytes)) {++ const size = Number(requestedPartSize) || 8 * 1024 * 1024;++ seq += 1;++ const sess = {++ id: `up_${seq}${crypto.randomBytes(6).toString('hex')}`, object_id: obj.id, part_size: size,++ total_size: total, parts_expected: Math.max(1, Math.ceil(total / size)), status: 'active',++ parts: new Map(), created_at: new Date().toISOString(),++ };++ // An active session for the object is replaced, exactly as Media replaces it.++ for (const other of sessions.values()) if (other.object_id === obj.id && other.status === 'active') other.status = 'replaced';++ sessions.set(sess.id, sess);++ return sess;++ }++++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++ const url = `http://127.0.0.1:${server.address().port}`;++ return {++ url, key, objects, sessions, requests, deleted, state,++ bytesOf: (id) => (objects.get(id) && objects.get(id).bytes) || null,++ objectOf: (id) => objects.get(id) || null,++ setDown: (v) => { state.down = v; },++ setKeyRejects: (v) => { state.keyRejects = v; },++ reset: () => { requests.length = 0; },++ close: () => new Promise((r) => server.close(r)),++ };++}++++module.exports = { startMedia, KEY, sign };@@@ -56,6 +56,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.musers: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,++ accountParts: [], accountConfirmations: [],};let issuer = null;let nextUserId = 1;@@@ -256,6 +257,17 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mreturn json(200, { services: [{ id: 'network', name: 'OpenVibe.Network', status: 'stable', domains: ['openvibe.network'], capabilities: [], runtime: { status: 'up', basis: 'ready', checked_at: new Date().toISOString() } }] });}if (url.pathname === '/oauth/revoke') return json(200, {});++ // ADR-033: the internal routes a service's account export and deletion answers come back to++ // (openvibe-sdk/account-data). Recorded in `.accountParts` and `.accountConfirmations`, as Events' side is++ // what a service test needs to see.++ if (/^\/internal\/account-exports\/exp_[0-9A-HJKMNP-TV-Z]{26}\/parts$/.test(url.pathname)) {++ st.accountParts.push({ path: url.pathname, body: JSON.parse(raw.toString('utf8') || '{}') });++ return json(200, { ok: true });++ }++ if (/^\/internal\/account-deletions\/del_[0-9A-HJKMNP-TV-Z]{26}\/confirmations$/.test(url.pathname)) {++ st.accountConfirmations.push({ path: url.pathname, body: JSON.parse(raw.toString('utf8') || '{}') });++ return json(200, { ok: true });++ }if (url.pathname === '/oauth/token' && req.method === 'POST') {const ct = String(req.headers['content-type'] || '');const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));@@@ -312,6 +324,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mreturn {url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,++ accountParts: st.accountParts, accountConfirmations: st.accountConfirmations,addUser, userToken,/** An authorization code the mock will exchange for this user if the verifier matches. */issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },@@@ -43,7 +43,8 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');-- for (const tag of ['<meta property="og:title"', 'data-ov-icon="media-hub"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {++ // openvibe-shared/app-icon slugs the site id to lower-case letters (media-hub → mediahub) in data-ov-icon.++ for (const tag of ['<meta property="og:title"', 'data-ov-icon="mediahub"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {assert.ok(head.includes(tag), `head has ${tag}`);}assert.ok(r.text.includes('id="ov-footer"'), 'the server-rendered footer');
@@ -24,5 +24,32 @@ OV_OAUTH_CLIENT_SECRET=
@@ -24,5 +24,32 @@ OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.download/auth/callback
# OV_OAUTH_REDIRECT_URI=https://openvibe.download/auth/callback
# MEDIAHUB_AUDIENCE=openvibe.download
# MEDIAHUB_AUDIENCE=openvibe.download
+# OpenVibe.Events: the ADR-033 account export/deletion deliveries (POST /internal/events, v2 signature, loopback
+# only). Without it the route answers 503 and Events retries.
+MEDIAHUB_EVENTS_SECRET=
+
+# ── OpenVibe.Media: where the bytes live ────────────────────
+# The host-internal address of the Media object API, and the app key of Media's `media-hub` tenant. The key never
+# reaches a browser: MediaHub is the only client. Add `media-hub` to Media's MEDIA_APPS_SEED before deploying.
+MEDIAHUB_MEDIA_URL=http://127.0.0.1:4100
+MEDIAHUB_MEDIA_APP=media-hub
+MEDIAHUB_MEDIA_APP_KEY=
+#MEDIAHUB_MEDIA_TIMEOUT_MS=30000
+
+# ── Per person (Media's quotas are per tenant, so these are enforced here) ──
+#MEDIAHUB_QUOTA_BYTES=2147483648
+#MEDIAHUB_MAX_FILE_BYTES=536870912
+#MEDIAHUB_UPLOADS_PER_DAY=50
+#MEDIAHUB_MAX_SHARES=20
+# The most a plain <input type=file> form may post in one request; keep it in step with client_max_body_size for
+# `location = /files/upload` in deploy/nginx/openvibe.download.conf. Larger files go up in parts.
+#MEDIAHUB_SINGLE_UPLOAD_BYTES=268435456
+# One part of a chunked upload. Media requires at least 5 MB and at most 256 MB; MediaHub clamps to that range.
+#MEDIAHUB_PART_BYTES=8388608
+# How long an unfinished chunked upload may be resumed.
+#MEDIAHUB_UPLOAD_TTL_SECONDS=21600
+# Where a no-JavaScript upload is spooled before it goes to Media (default: the system temp directory).
+#MEDIAHUB_TMP_DIR=
+
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# COOKIE_SECURE=true
@@ -89,8 +89,42 @@ server {
@@ -89,8 +89,42 @@ server {
proxy_pass http://127.0.0.1:4990;
proxy_pass http://127.0.0.1:4990;
}
}
- # ── Forms and other writes ──────────────────────────────
- location ~ ^/forms {
+ # ── Uploads: bytes, and they stream through ─────────────
+ # A plain <input type=file> form posts the whole file here in one request, up to MEDIAHUB_SINGLE_UPLOAD_BYTES
+ # (256 MB by default) — keep this number in step with that variable. A larger file goes up in parts under
+ # /api/v1/uploads/ below. Neither is buffered to disk first: a 256 MB body must not be a 256 MB nginx temp file.
+ location = /files/upload {
+ limit_req zone=media-hub_forms burst=5 nodelay;
+ limit_req_status 429;
+ client_max_body_size 256m;
+ proxy_request_buffering off;
+ proxy_buffering off;
+ proxy_read_timeout 900s;
+ proxy_send_timeout 900s;
+ proxy_pass http://127.0.0.1:4990;
+ }
+
+ # One part of a chunked upload (at most MEDIAHUB_PART_BYTES, 8 MB) and the session's status and complete calls.
+ location ~ ^/api/v1/uploads/ {
+ limit_req zone=media-hub_api burst=20 nodelay;
+ limit_req_status 429;
+ client_max_body_size 16m;
+ proxy_request_buffering off;
+ proxy_read_timeout 900s;
+ proxy_pass http://127.0.0.1:4990;
+ }
+
+ # ── Downloads: streamed from OpenVibe.Media through the app ──
+ # The app answers every download itself (Content-Disposition: attachment, nosniff, an opaque type for anything
+ # not on the safe list), so these must not be buffered: nginx would hold the whole file before sending a byte.
+ location ~ ^/(files/[^/]+/download|s/[^/]+/download|s/[^/]+/f/[^/]+/download)$ {
+ proxy_buffering off;
+ proxy_read_timeout 900s;
+ proxy_pass http://127.0.0.1:4990;
+ }
+
+ # ── Form writes (the upload form is the exact match above) ──
+ location ~ ^/(files|folders|shares|staff)(/|$) {
limit_req zone=media-hub_forms burst=10 nodelay;
limit_req zone=media-hub_forms burst=10 nodelay;
limit_req_status 429;
limit_req_status 429;
proxy_pass http://127.0.0.1:4990;
proxy_pass http://127.0.0.1:4990;
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+-- phase: expand
+-- OpenVibe.MediaHub: the private drive (files and folders), the share links people hand out, the reports against
+-- those shares and the per-person upload counter. Applied at boot by openvibe-sdk/db (one file per phase,
+-- NNNN_name.sql, in order). Nothing written here is ever a credential, a key or a token.
+--
+-- v1 is deliberately narrow: openvibe.download only, signed in only, private by default. The bytes never live here:
+-- every file row names an object in OpenVibe.Media (object_id), and the bytes are served from there.
+
+CREATE TABLE mh_files (
+ id text COLLATE "C" PRIMARY KEY, -- fil_<ULID>
+ owner text COLLATE "C" NOT NULL, -- user:usr_… (a file belongs to a person, never to an app)
+ object_id text COLLATE "C" NOT NULL, -- med_… : the object in OpenVibe.Media holding the bytes
+ name text NOT NULL, -- what the person called it (never a path)
+ size bigint NOT NULL,
+ content_type text COLLATE "C" NOT NULL,
+ sha256 text COLLATE "C", -- what Media computed, when it gave one
+ folder_id text COLLATE "C", -- fld_<ULID>, or NULL for the drive's root
+ created_at text COLLATE "C" NOT NULL,
+ deleted_at text COLLATE "C" -- soft delete: the row stays until the bytes are gone
+);
+
+-- The drive is read as "this person's files, newest first"; a folder view narrows it to one folder.
+CREATE INDEX mh_files_owner ON mh_files (owner, id DESC) WHERE deleted_at IS NULL;
+CREATE INDEX mh_files_folder ON mh_files (folder_id) WHERE deleted_at IS NULL;
+CREATE INDEX mh_files_object ON mh_files (object_id);
+
+CREATE TABLE mh_folders (
+ id text COLLATE "C" PRIMARY KEY, -- fld_<ULID>
+ owner text COLLATE "C" NOT NULL, -- user:usr_…
+ parent_id text COLLATE "C", -- fld_<ULID>, or NULL for the drive's root
+ name text NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);
+
+-- One folder per name per parent per person: two folders called "work" in the same place confuse everybody.
+CREATE UNIQUE INDEX mh_folders_once ON mh_folders (owner, coalesce(parent_id, ''), lower(name));
+CREATE INDEX mh_folders_owner ON mh_folders (owner, id DESC);
+
+CREATE TABLE mh_shares (
+ slug text COLLATE "C" PRIMARY KEY, -- 24 url-safe random characters (/s/<slug>)
+ file_id text COLLATE "C" REFERENCES mh_files (id) ON DELETE CASCADE,
+ folder_id text COLLATE "C" REFERENCES mh_folders (id) ON DELETE CASCADE,
+ owner text COLLATE "C" NOT NULL, -- user:usr_…
+ owner_username text, -- the owner's OpenVibe username: what a share page shows,
+ -- and nothing else about them (no subject, no id)
+ expires_at text COLLATE "C" NOT NULL, -- 1 hour to 7 days after creation
+ allowed_subjects jsonb, -- NULL: any signed-in person; else {"subjects":[usr_…],"usernames":[…]}
+ downloads integer NOT NULL DEFAULT 0,
+ report_count integer NOT NULL DEFAULT 0, -- the most reports this share has ever had: a high-water
+ -- mark, so a share staff restored is not suspended again
+ -- by the reports that were already there
+ revoked_at text COLLATE "C",
+ suspended_at text COLLATE "C", -- set by the report threshold; only staff clear it
+ created_at text COLLATE "C" NOT NULL,
+ CHECK ((file_id IS NULL) <> (folder_id IS NULL))
+);
+
+CREATE INDEX mh_shares_owner ON mh_shares (owner, created_at DESC);
+CREATE INDEX mh_shares_file ON mh_shares (file_id);
+CREATE INDEX mh_shares_folder ON mh_shares (folder_id);
+
+CREATE TABLE mh_reports (
+ slug text COLLATE "C" NOT NULL REFERENCES mh_shares (slug) ON DELETE CASCADE,
+ reporter text COLLATE "C" NOT NULL, -- user:usr_…
+ reason text COLLATE "C" NOT NULL, -- malware | illegal | copyright | other
+ note text, -- <= 500 characters
+ created_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (slug, reporter)
+);
+
+CREATE INDEX mh_reports_recent ON mh_reports (created_at DESC);
+-- The staff queue reads "the shares somebody reported, newest report first"; this index is the join's own order.
+CREATE INDEX mh_reports_by_slug ON mh_reports (slug, created_at DESC);
+
+CREATE TABLE mh_usage (
+ owner text COLLATE "C" NOT NULL, -- user:usr_…
+ day text COLLATE "C" NOT NULL, -- YYYY-MM-DD, UTC
+ uploads integer NOT NULL DEFAULT 0,
+ PRIMARY KEY (owner, day)
+);
+
+-- An in-flight chunked upload (the browser sends a big file in parts; /api/v1/uploads/*). It is MediaHub's own
+-- state because Media issues the multipart session but knows nothing about whose file it is: this row is what
+-- makes a resume (and an abort) belong to one person. Deleted when the upload completes or is abandoned.
+CREATE TABLE mh_uploads (
+ id text COLLATE "C" PRIMARY KEY, -- upl_<ULID>
+ owner text COLLATE "C" NOT NULL,
+ object_id text COLLATE "C" NOT NULL, -- med_… : the object being uploaded in Media
+ upload_id text COLLATE "C" NOT NULL, -- Media's multipart session id
+ name text NOT NULL,
+ size bigint NOT NULL,
+ content_type text COLLATE "C" NOT NULL,
+ folder_id text COLLATE "C",
+ part_size integer NOT NULL,
+ parts_expected integer NOT NULL,
+ file_id text COLLATE "C", -- set once the upload completed into a file
+ created_at text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C" NOT NULL -- after this the session is abandoned and its parts dropped
+);
+
+CREATE INDEX mh_uploads_owner ON mh_uploads (owner, id DESC);
+
+-- openvibe-sdk/account-data (ADR-033): one row per export or deletion event this service has applied, so a
+-- redelivered event never erases twice and a confirmation that failed is sent again. ACCOUNT_DATA_SCHEMA,
+-- written here rather than at boot so the schema is the migrations' business like every other table.
+CREATE TABLE IF NOT EXISTS account_data_events (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ outcome JSONB,
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ sent_at TIMESTAMPTZ
+);
@@ -42,3 +42,20 @@ form.inline { display: inline; }
@@ -42,3 +42,20 @@ form.inline { display: inline; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
button.sc-btn { background: transparent; }
button.sc-btn { background: transparent; }
button.sc-btn.sc-primary { border: 0; }
button.sc-btn.sc-primary { border: 0; }
+
+/* The drive: the usage bar, one folder or file per row, and the upload form. */
+.card { background: var(--bg-card, #131c2e); border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 14px 16px; margin: 14px 0; }
+.usage { margin: 10px 0 16px; }
+.usage progress { width: 100%; max-width: 420px; height: 12px; display: block; }
+.file-list, .report-list { list-style: none; margin: 0; padding: 0; }
+.file-list .file { display: flex; align-items: center; gap: 12px; padding: 9px 2px; border-bottom: 1px solid var(--border, #1f2d47); }
+.file-list .file:last-child { border-bottom: 0; }
+.file-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }
+.file-name { font-weight: 600; overflow-wrap: anywhere; }
+.file-meta { margin-top: 2px; }
+.upload progress { max-width: 420px; }
+.staff-item h2 { margin-top: 0; }
+.report-list li { padding: 3px 0; }
+.field { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
+.field input[type=text], .field input[type=search], .field select, .field textarea, form.inline input[type=text] { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: inherit; }
+.field-row { display: flex; flex-wrap: wrap; gap: 12px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+/* OpenVibe.MediaHub — the chunked upload, and the only JavaScript on any page.
+ *
+ * The form it upgrades works without it: submit posts the whole file to POST /files/upload in one request. With
+ * this script the file goes up in parts instead (POST /api/v1/files, one PUT per part, then complete), so a file
+ * larger than the single-request limit still uploads, a dropped connection can resume, and the person can watch
+ * the progress.
+ *
+ * Every request is same-origin, so the browser sends Sec-Fetch-Site: same-origin by itself — which is exactly what
+ * a session-cookie write on this site requires. No token, no key and no file name is ever put in a URL.
+ */
+(function () {
+ 'use strict';
+ var form = document.querySelector('form[data-upload]');
+ if (!form || !window.fetch) return;
+ var input = form.querySelector('input[type=file]');
+ var nameField = form.querySelector('input[name=name]');
+ var folderField = form.querySelector('input[name=folder]');
+ var bar = form.querySelector('progress');
+ var status = form.querySelector('[role=status]');
+ var button = form.querySelector('button[type=submit]');
+ if (!input) return;
+
+ function say(text) { if (status) status.textContent = text; }
+ function progress(done, total) {
+ if (!bar) return;
+ bar.hidden = false;
+ bar.max = total;
+ bar.value = done;
+ }
+ function problem(res) {
+ return res.json().then(function (p) {
+ return (p && p.detail) || 'The upload was refused.';
+ }, function () { return 'The upload was refused (' + res.status + ').'; });
+ }
+ function busy(on) {
+ if (button) button.disabled = on;
+ if (input) input.disabled = on;
+ }
+
+ /** One request, answering a promise of its JSON (or rejecting with the problem's detail). */
+ function request(url, options) {
+ return fetch(url, options).then(function (res) {
+ if (res.ok) return res.status === 204 ? null : res.json().catch(function () { return null; });
+ return problem(res).then(function (detail) { throw new Error(detail); });
+ });
+ }
+
+ function upload(file) {
+ var sent = 0;
+ var uploadId = null;
+ busy(true);
+ progress(0, file.size);
+ say('Starting…');
+ return request('/api/v1/files', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: (nameField && nameField.value) || file.name,
+ size: file.size,
+ content_type: file.type || 'application/octet-stream',
+ folder_id: (folderField && folderField.value) || null,
+ }),
+ }).then(function (started) {
+ var up = started.upload;
+ uploadId = up.id;
+ // Parts go up one after another: one connection, in order, and the meter moves.
+ var chain = Promise.resolve();
+ for (var n = 1; n <= up.parts_expected; n++) {
+ chain = chain.then(function (part) {
+ return function () {
+ var start = (part - 1) * up.part_size;
+ var slice = file.slice(start, Math.min(start + up.part_size, file.size));
+ return request('/api/v1/uploads/' + up.id + '/parts/' + part, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/octet-stream' },
+ body: slice,
+ }).then(function () {
+ sent += slice.size;
+ progress(sent, file.size);
+ say('Uploading… ' + Math.round((sent / file.size) * 100) + '%');
+ });
+ };
+ }(n));
+ }
+ return chain;
+ }).then(function () {
+ say('Finishing…');
+ return request('/api/v1/uploads/' + uploadId + '/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ }).then(function (done) {
+ say('Uploaded. Opening it…');
+ window.location.assign('/files/' + done.file.id);
+ }).catch(function (err) {
+ busy(false);
+ say((err && err.message) || 'The upload did not go through.');
+ });
+ }
+
+ form.addEventListener('submit', function (event) {
+ var file = input.files && input.files[0];
+ if (!file) return; // no file chosen: let the server answer the empty form
+ event.preventDefault();
+ upload(file);
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+
+/**
+ * This service's share of a person's account export and deletion (ADR-033), through openvibe-sdk/account-data.
+ *
+ * network.account.export_requested the person's files, folders, shares, reports and upload counts, as JSON
+ * network.account.deleted the same rows erased, and every Media object they held deleted
+ *
+ * The events arrive signed on POST /internal/events (loopback only). `apply` is wrapped here for one reason: an
+ * erasure has to tell OpenVibe.Media to delete each object, and the object ids only exist *before* the rows are
+ * gone — extraErase runs after the tables are erased, so the ids are read on the way in and handed to it.
+ *
+ * A Media deletion that fails does not fail the erasure: the person's rows are gone here either way, and Media's
+ * own retention decides when the bytes go. It is logged, not swallowed silently.
+ */
+const { createAccountData, createNetworkSender } = require('openvibe-sdk/account-data');
+const { parseDelivery } = require('openvibe-sdk/events');
+
+/**
+ * The tables that hold a person's own data. `file` names the export file; null leaves a table out of it. Every
+ * subject column here stores the requester form (`user:usr_…`), so `value` maps the plain subject Network names.
+ */
+const AS_REQUESTER = (usr) => `user:${usr}`;
+// The order matters: mh_shares and mh_reports cascade from mh_files (the foreign keys), so they are erased first —
+// otherwise the rows would be gone by the cascade and the counts would read as nothing was erased.
+const TABLES = [
+ { table: 'mh_reports', subject: 'reporter', value: AS_REQUESTER, file: 'reports.json' },
+ { table: 'mh_shares', subject: 'owner', value: AS_REQUESTER, file: 'shares.json' },
+ { table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
+ { table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },
+ { table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },
+ // An upload that never finished is this service's own state, not something the person made: it is erased with
+ // the account but is not part of the export.
+ { table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
+];
+
+function createAccount({ db, config, media, log = console }) {
+ /** The object ids of a deletion, read before the rows go (extraErase runs after them). */
+ let pendingObjects = [];
+
+ const accountData = createAccountData({
+ db,
+ service: 'media-hub',
+ tables: TABLES,
+ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed and your upload counts. The bytes of your files live in OpenVibe.Media.',
+ extraErase: async () => {
+ const ids = pendingObjects;
+ pendingObjects = [];
+ for (const id of ids) {
+ const out = await media.remove(id);
+ if (!out.ok && out.status !== 404 && out.status !== 410) {
+ log.warn(`[MediaHub] account deletion: Media refused to delete ${id}: ${out.status} ${out.code}`);
+ }
+ }
+ },
+ log,
+ });
+
+ /** The subjects a deletion names, as this service stores them: the account, and any account merged into it. */
+ const subjectsOf = (payload) => [payload.subject, ...(Array.isArray(payload.aliases) ? payload.aliases : [])]
+ .filter((x) => typeof x === 'string' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(x))
+ .map((x) => `user:${x}`);
+
+ /** One envelope → an outcome (see openvibe-sdk/account-data); throws so Events redelivers. */
+ async function apply(ev, { send }) {
+ if (ev && ev.event_type === 'network.account.deleted' && ev.source === 'network') {
+ const payload = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
+ // Read the media objects first: the erase deletes the rows that name them.
+ pendingObjects = (await db.many('SELECT object_id FROM mh_files WHERE owner = ANY($1::text[])', [subjectsOf(payload)]))
+ .map((r) => r.object_id).filter(Boolean);
+ }
+ return await accountData.apply(ev, { send });
+ }
+
+ /**
+ * POST /internal/events — signed with MEDIAHUB_EVENTS_SECRET, loopback only. It is mounted before any body
+ * parser because the signature covers the raw bytes.
+ */
+ function consumer({ send }) {
+ const reply = (res, status, body) => {
+ res.statusCode = status;
+ res.setHeader('Content-Type', status < 300 ? 'application/json' : 'application/problem+json');
+ res.setHeader('Cache-Control', 'no-store');
+ res.end(JSON.stringify(body));
+ };
+ const problem = (res, status, code, detail) => reply(res, status, { type: 'about:blank', title: code, status, code, detail });
+ const secrets = (config.events.secrets || []).filter((k) => typeof k === 'string' && k.length >= 32);
+ return async function handle(req, res) {
+ if (req.method !== 'POST') return problem(res, 405, 'method_not_allowed', 'POST only');
+ // Loopback only: a request that came through a proxy is not Network's.
+ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {
+ return problem(res, 403, 'media-hub.internal_only', 'internal route');
+ }
+ if (!secrets.length) return problem(res, 503, 'media-hub.events_disabled', 'no events secret is configured');
+ const raw = await new Promise((resolve, reject) => {
+ const chunks = [];
+ let size = 0;
+ req.on('data', (c) => { size += c.length; if (size > 256 * 1024) { reject(Object.assign(new Error('too large'), { status: 413 })); req.destroy(); } else chunks.push(c); });
+ req.on('end', () => resolve(Buffer.concat(chunks)));
+ req.on('error', reject);
+ }).catch(() => null);
+ if (!raw) return problem(res, 413, 'request.too_large', 'unreadable or oversized body');
+ let delivery = null;
+ for (const secret of secrets) { delivery = parseDelivery(raw, req.headers, secret, { requireV2: true }); if (delivery) break; }
+ if (!delivery || !delivery.event) return problem(res, 401, 'media-hub.bad_signature', 'X-OpenVibe-Signature-V2 does not verify');
+ try {
+ const outcome = await apply(delivery.event, { send });
+ return reply(res, 200, { event_id: delivery.event.event_id, outcome });
+ } catch (err) {
+ log.warn(`[MediaHub] ${delivery.event.event_type} failed: ${(err && err.message) || err}`);
+ return problem(res, 503, 'media-hub.retry', 'not applied; Events will retry');
+ }
+ };
+ }
+
+ return { accountData, apply, consumer, TABLES };
+}
+
+/**
+ * The Network sender the account events are answered with (our own client-credentials token). Without the OAuth
+ * client there is nothing to sign with: every send answers 503, which the consumer turns into "Events will retry"
+ * rather than a crash at boot (a development process has no client secret).
+ */
+function createSender({ config, fetchImpl }) {
+ if (!config.oauth.clientSecret) {
+ return async () => ({ ok: false, status: 503, json: async () => ({}) });
+ }
+ return createNetworkSender({
+ networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ fetch: fetchImpl || globalThis.fetch,
+ });
+}
+
+module.exports = { createAccount, createSender, TABLES };
@@ -32,6 +32,13 @@ const { createServiceReadiness } = require('./observability');
@@ -32,6 +32,13 @@ const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
+const brand = require('./brand');
+const store = require('./drive/store');
+const { createMediaClient } = require('./media/client');
+const { createIdentity } = require('./network/identity');
+const { createService } = require('./drive/service');
+const { createUploads } = require('./drive/upload');
+const { createAccount, createSender } = require('./account');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
@@ -49,7 +56,13 @@ async function createApp(opts = {}) {
@@ -49,7 +56,13 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
- const ctx = { config, s, keys, sso, principal, log };
+ // The product: bytes in OpenVibe.Media, the drive's own rules, and the two upload flows.
+ const media = createMediaClient({ config, fetchImpl, log });
+ const identity = createIdentity({ config, fetchImpl, log });
+ const drive = createService({ s, media, identity, config, log });
+ const uploads = createUploads({ s, media, config, service: drive, log });
+ const account = createAccount({ db: s.db, config, media, log });
+ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, log };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -88,12 +101,20 @@ async function createApp(opts = {}) {
@@ -88,12 +101,20 @@ async function createApp(opts = {}) {
}));
}));
app.use(cookieParser());
app.use(cookieParser());
+ // ── Which brand this request is for (the Host header decides; server/brand.js) ──
+ app.use(brand.middleware());
+
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-media-hub', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-media-hub', version: VERSION }));
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
+ // ── Account export and deletion (ADR-033) ───────────────
+ // Mounted before every body parser: the Events signature covers the raw bytes. It refuses anything that came
+ // through a proxy, so only loopback can reach it even though nginx proxies this path.
+ app.use('/internal/events', account.consumer({ send: createSender({ config, fetchImpl }) }));
+
// ── Who is asking (verified offline; refreshed when expired) ──
// ── Who is asking (verified offline; refreshed when expired) ──
app.use(sso.middleware());
app.use(sso.middleware());
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+
+/**
+ * One process, three brand surfaces (the media-hub product manifest: openvibe.download, openvibe.pics and
+ * openvibe.video). The host decides which one a request is for — there is no separate deploy, and no cookie or
+ * query parameter can move a request from one brand to another.
+ *
+ * v1 launches openvibe.download only, as a private drive. On openvibe.pics and openvibe.video the service answers
+ * an honest "coming" page and nothing else: no drive, no share links, no API and no uploads, because neither brand
+ * has the safety tooling its content needs yet (there is no malware or CSAM scanning on the network).
+ *
+ * The brand is read from the Host header nginx passes through (req.hostname; TRUST_PROXY lets a proxy name it with
+ * X-Forwarded-Host). An unknown host — a bare address in development, an internal health check — is the download
+ * brand, so the deployed apex never behaves differently from a test.
+ */
+const BRANDS = {
+ 'openvibe.download': 'download',
+ 'openvibe.pics': 'pics',
+ 'openvibe.video': 'video',
+};
+
+// What each unlaunched brand's page says: the product's own words, kept here so the page and the report agree.
+const COMING = {
+ pics: {
+ domain: 'openvibe.pics',
+ name: 'OpenVibe.Pics',
+ tagline: 'Share pictures. Fast, open, yours.',
+ what: 'OpenVibe.Pics will be the picture home of the OpenVibe network: quick uploads and share links, albums and galleries, image processing and optimisation served from a shared CDN, and AI image tools — an open and modular alternative to Imgur, usable through the OpenVibe API too.',
+ pillars: ['Upload & share', 'Albums & galleries', 'Processing', 'AI image tools'],
+ },
+ video: {
+ domain: 'openvibe.video',
+ name: 'OpenVibe.Video',
+ tagline: 'Your videos, everywhere.',
+ what: 'OpenVibe.Video will be the home of long-form video on the OpenVibe network: watch pages, channels and playlists for uploads and past streams, a creator tool that publishes one upload to many platforms, and AI captions, highlights and editing — delivered by OpenVibe.Media.',
+ pillars: ['Watch', 'Publish everywhere', 'AI tools', 'From your streams'],
+ },
+};
+
+/** The brand a request is for. Anything unrecognised is the launched one. */
+function brandOf(req) {
+ const host = String((req && req.hostname) || '').toLowerCase().replace(/^www\./, '');
+ return BRANDS[host] || 'download';
+}
+
+/** Set req.brand for every request, before anything reads it (the API and the pages both do). */
+function middleware() {
+ return (req, _res, next) => { req.brand = brandOf(req); next(); };
+}
+
+module.exports = { brandOf, middleware, BRANDS, COMING };
@@ -9,6 +9,12 @@
@@ -9,6 +9,12 @@
require('dotenv').config();
require('dotenv').config();
const trim = (s) => String(s || '').replace(/\/+$/, '');
const trim = (s) => String(s || '').replace(/\/+$/, '');
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+const size = (v, def) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.floor(Number(v)) : def);
+
+// The per-person numbers of a private drive (bytes, files, uploads, shares). Every one of them comes from the
+// environment: they are the product's policy, not a constant somebody has to edit to change.
+const GB = 1024 ** 3;
+const MB = 1024 ** 2;
function load(env = process.env) {
function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
@@ -38,6 +44,38 @@ function load(env = process.env) {
@@ -38,6 +44,38 @@ function load(env = process.env) {
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.MEDIAHUB_PGLITE_DIR || '' },
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.MEDIAHUB_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:media-hub:' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:media-hub:' },
+ // OpenVibe.Media: the only place bytes live. MediaHub talks to it server-side with its own app key
+ // (Media's `:app` segment `media-hub`); no browser ever holds that key. MEDIAHUB_MEDIA_URL is the
+ // host-internal address (127.0.0.1:4100 in production), never the public origin.
+ media: {
+ url: trim(env.MEDIAHUB_MEDIA_URL || 'http://127.0.0.1:4100'),
+ app: env.MEDIAHUB_MEDIA_APP || 'media-hub',
+ appKey: env.MEDIAHUB_MEDIA_APP_KEY || '',
+ timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),
+ },
+
+ // What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
+ // tenant), so the per-person numbers are enforced in MediaHub and nowhere else.
+ quotas: {
+ bytes: size(env.MEDIAHUB_QUOTA_BYTES, 2 * GB), // 2 GB stored
+ maxFileBytes: size(env.MEDIAHUB_MAX_FILE_BYTES, 512 * MB), // 512 MB per file
+ uploadsPerDay: size(env.MEDIAHUB_UPLOADS_PER_DAY, 50), // 50 uploads a day (UTC)
+ maxShares: size(env.MEDIAHUB_MAX_SHARES, 20), // 20 live share links
+ },
+ // A plain <input type=file> form posts the whole file in one request; past this the page offers the
+ // chunked upload (script). Both are capped by quotas.maxFileBytes.
+ singleUploadBytes: size(env.MEDIAHUB_SINGLE_UPLOAD_BYTES, 256 * MB),
+ // Media requires parts of at least 5 MB and at most 256 MB; ours sits between, so an operator cannot
+ // configure a part size Media will refuse.
+ partBytes: Math.min(256 * MB, Math.max(5 * MB, size(env.MEDIAHUB_PART_BYTES, 8 * MB))),
+ uploadTtlSeconds: size(env.MEDIAHUB_UPLOAD_TTL_SECONDS, 6 * 3600),
+ // Where a no-JavaScript upload is spooled before it goes to Media. Never served, never listed.
+ tmpDir: env.MEDIAHUB_TMP_DIR || '',
+
+ // OpenVibe.Events: the ADR-033 account export/deletion events (network.account.export_requested and
+ // network.account.deleted). Without the secret the route answers 503 and Network retries.
+ events: { secrets: [env.MEDIAHUB_EVENTS_SECRET, env.OV_EVENTS_SECRET].filter(Boolean) },
+
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+
+/**
+ * What a file, a folder, a share and a report are — on both sides of the wire. The pages (http/pages.js) and the
+ * API (http/api.js) share these rules, so a form and a POST can never disagree about a name, an expiry or a report.
+ *
+ * file fil_<ULID>, an object in OpenVibe.Media, private to the person who uploaded it
+ * folder fld_<ULID>, a name and a parent
+ * share /s/<slug>, 24 url-safe random characters, 1 hour to 7 days, revocable, counted
+ * report malware | illegal | copyright | other, once per person per share
+ *
+ * Nothing here fetches anything, and nothing here decides who may see what: that is the store's and the service's
+ * job. This module is the shape of the data and the sentences people read.
+ */
+
+const ID_RE = {
+ file: /^fil_[0-9A-HJKMNP-TV-Z]{26}$/,
+ folder: /^fld_[0-9A-HJKMNP-TV-Z]{26}$/,
+ upload: /^upl_[0-9A-HJKMNP-TV-Z]{26}$/,
+ object: /^med_[0-9A-HJKMNP-TV-Z]{26}$/,
+};
+// 24 url-safe characters (18 random bytes): the link is the capability, so it has to be unguessable, and it is the
+// only random thing in it — the share is found by slug alone.
+const SLUG_RE = /^[A-Za-z0-9_-]{22,64}$/;
+
+const isFileId = (id) => ID_RE.file.test(String(id || ''));
+const isFolderId = (id) => ID_RE.folder.test(String(id || ''));
+const isUploadId = (id) => ID_RE.upload.test(String(id || ''));
+const isSlug = (slug) => SLUG_RE.test(String(slug || ''));
+
+const NAME_MAX = 200;
+const FOLDER_NAME_MAX = 80;
+
+const REASONS = ['malware', 'illegal', 'copyright', 'other'];
+const REASON_TEXT = {
+ malware: 'malware or a virus',
+ illegal: 'illegal content',
+ copyright: 'copyright infringement',
+ other: 'something else',
+};
+// Three distinct people reporting, or one report of illegal content, suspends the share at once and puts it in
+// front of staff. Counting is once, when the line is crossed, so a share staff restored is not suspended again by
+// the same reports.
+const SUSPEND_AT_REPORTS = 3;
+const SUSPEND_IMMEDIATELY = ['illegal'];
+
+const EXPIRY_MIN_HOURS = 1;
+const EXPIRY_MAX_HOURS = 7 * 24;
+const EXPIRY_DEFAULT_HOURS = 7 * 24;
+
+/** The declared content type, normalised, or null when it is not a media type at all. */
+function normalizeType(value) {
+ const t = String(value == null ? '' : value).split(';')[0].trim().toLowerCase();
+ return /^[\w.+-]+\/[\w.+-]+$/.test(t) ? t.slice(0, 120) : null;
+}
+
+/**
+ * The content types a download keeps when it is served: a short, safe list. Everything else — HTML, SVG, XML,
+ * anything executable — is served as application/octet-stream, so a browser never treats an upload as a document
+ * of its own. SVG is deliberately out: it is a script container wearing an image's type.
+ */
+function safeContentType(type) {
+ const t = normalizeType(type);
+ if (!t) return null;
+ if (t === 'image/svg+xml' || t.startsWith('image/svg')) return null;
+ if (t === 'text/plain' || t === 'application/pdf' || t === 'application/zip') return t;
+ if (/^(image|audio|video)\//.test(t)) return t;
+ return null;
+}
+
+/**
+ * Plain text somebody typed (a report note): control characters out, nothing else touched. A note is prose — the
+ * slashes in "https://…" are part of it, which is why this is not cleanName.
+ */
+function cleanText(value, max = 500) {
+ return String(value == null ? '' : value)
+ .replace(/\r\n?/g, '\n')
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
+ .trim()
+ .slice(0, max);
+}
+
+/** A file name as it is stored: one line, no path, never empty. A name is not a path: its slashes become _ . */
+function cleanName(value, max = NAME_MAX) {
+ return cleanText(value, max).replace(/[\\/]+/g, '_');
+}
+
+/** The name a file gets: what the person typed, or what the browser said, or a fallback. */
+function fileName(value, fallback = 'file') {
+ const s = cleanName(value);
+ return s || cleanName(fallback) || 'file';
+}
+
+/** A header-safe rendering of a name for Content-Disposition. */
+function headerSafeName(name) {
+ return String(name || 'file').replace(/["\\\r\n\t]/g, '_').slice(0, 200) || 'file';
+}
+
+/** An expiry: whole hours, clamped to the product's own range; anything unusable is the default. */
+function expiryHours(value) {
+ const n = Number(value);
+ if (!Number.isFinite(n) || n <= 0) return EXPIRY_DEFAULT_HOURS;
+ return Math.min(EXPIRY_MAX_HOURS, Math.max(EXPIRY_MIN_HOURS, Math.floor(n)));
+}
+
+/** A username as OpenVibe.Network writes it (lower case, 3-30 letters/digits/underscore/dash). */
+const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,29}$/;
+const isUsername = (value) => USERNAME_RE.test(String(value || '').trim().toLowerCase());
+
+/** The expiry selector the share form offers, in hours. */
+const EXPIRY_CHOICES = [
+ { hours: 1, label: '1 hour' },
+ { hours: 24, label: '1 day' },
+ { hours: 72, label: '3 days' },
+ { hours: 24 * 7, label: '7 days' },
+];
+
+/** "1.4 GB" — how a size is shown. Binary units, one decimal from MB up. */
+function formatBytes(n) {
+ const b = Number(n) || 0;
+ if (b < 1024) return `${b} B`;
+ const units = ['KB', 'MB', 'GB', 'TB'];
+ let v = b / 1024;
+ let i = 0;
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
+ return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
+}
+
+module.exports = {
+ ID_RE, SLUG_RE, isFileId, isFolderId, isUploadId, isSlug, isUsername,
+ NAME_MAX, FOLDER_NAME_MAX, REASONS, REASON_TEXT, SUSPEND_AT_REPORTS, SUSPEND_IMMEDIATELY,
+ EXPIRY_MIN_HOURS, EXPIRY_MAX_HOURS, EXPIRY_DEFAULT_HOURS, EXPIRY_CHOICES,
+ normalizeType, safeContentType, cleanName, cleanText, fileName, headerSafeName, expiryHours, formatBytes,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,301 @@
@@ -0,0 +1,301 @@
+'use strict';
+
+/**
+ * The rules that need the database, shared by the pages (http/pages.js) and the API (http/api.js) so a form and a
+ * POST enforce the same numbers:
+ *
+ * a person, never an app or a service (the guard lives in http/api.js and http/pages.js)
+ * 2 GB stored, 512 MB per file, 50 uploads a day (UTC), 20 live share links (every number from the environment)
+ * a share expires in 1 hour to 7 days (7 by default), can be revoked and counts its downloads
+ * a share may name people: resolved to subjects, and matched on the viewer's username claim as well
+ * 3 distinct reports, or one report of illegal content, suspend a share at once
+ *
+ * A refusal is { ok: false, code, detail }: an API problem+json, or the sentence the form re-renders with.
+ *
+ * Nothing here trusts a caller's identity: the caller passes the requester it already established, and this module
+ * only ever compares it with what the rows say.
+ */
+const crypto = require('crypto');
+const rules = require('./rules');
+const store = require('./store');
+
+const DAY_MS = 86_400_000;
+
+/** The UTC day a moment falls in — what "50 uploads a day" is counted over. */
+const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
+
+/** The allow-list a share carries, as stored: { subjects, usernames } or null for "any signed-in person". */
+function parseAllow(value) {
+ if (!value) return null;
+ const v = typeof value === 'string' ? JSON.parse(value) : value;
+ if (!v || typeof v !== 'object') return null;
+ const list = (x) => (Array.isArray(x) ? x.filter((s) => typeof s === 'string') : []);
+ const subjects = list(v.subjects);
+ const usernames = list(v.usernames).map((u) => u.toLowerCase());
+ if (!subjects.length && !usernames.length) return { subjects: [], usernames: [] };
+ return { subjects, usernames };
+}
+
+/**
+ * May this viewer open the share? `viewer` is { subject, username } of a signed-in person — the caller has already
+ * refused everyone else. An allow-list is satisfied by the person's canonical subject (resolved when the share was
+ * made) or by their Network username claim (signed, so it needs no directory).
+ */
+function allowedFor(share, viewer) {
+ const allow = parseAllow(share.allowed_subjects);
+ if (!allow) return true;
+ const subject = viewer && viewer.subject;
+ const username = viewer && viewer.username ? String(viewer.username).toLowerCase() : null;
+ if (subject && allow.subjects.includes(subject)) return true;
+ if (username && allow.usernames.includes(username)) return true;
+ return false;
+}
+
+/** Where a share stands right now: what the page shows and what a download checks. */
+function stateOf(share, nowIso) {
+ if (!share) return 'missing';
+ if (share.revoked_at) return 'revoked';
+ if (share.suspended_at) return 'suspended';
+ if (share.expires_at <= nowIso) return 'expired';
+ return 'live';
+}
+
+/** A slug nobody can guess by looking at another one: 18 random bytes, url-safe. */
+const newSlug = () => crypto.randomBytes(18).toString('base64url');
+
+function createService({ s, media, identity, config, log = console }) {
+ const nowIso = () => new Date(s.now()).toISOString();
+
+ // ── Limits ──────────────────────────────────────────────
+
+ /** Room for one more file of `size` bytes, and for uploads not to run past the day's cap. */
+ async function uploadAllowed(owner, size) {
+ if (!Number.isInteger(size) || size < 1) return { ok: false, code: 'file.invalid_size', detail: 'A file needs a size of at least one byte.' };
+ if (size > config.quotas.maxFileBytes) {
+ return { ok: false, code: 'file.too_large', detail: `One file may be at most ${rules.formatBytes(config.quotas.maxFileBytes)}. This one is ${rules.formatBytes(size)}.` };
+ }
+ const used = await store.storedBytes(s, owner);
+ if (used + size > config.quotas.bytes) {
+ return { ok: false, code: 'file.quota', detail: `Your drive holds ${rules.formatBytes(used)} of ${rules.formatBytes(config.quotas.bytes)}. This file needs ${rules.formatBytes(size)} more; delete something first.` };
+ }
+ const today = await store.uploadsToday(s, owner, dayOf(s.now()));
+ if (today >= config.quotas.uploadsPerDay) {
+ return { ok: false, code: 'file.daily_limit', detail: `You have uploaded ${config.quotas.uploadsPerDay} files today (the most one person may). Try again tomorrow.` };
+ }
+ return { ok: true };
+ }
+
+ /** Room for one more live share link. */
+ async function shareAllowed(owner) {
+ const live = await store.countLiveShares(s, owner, nowIso());
+ if (live >= config.quotas.maxShares) {
+ return { ok: false, code: 'share.limit', detail: `You have ${config.quotas.maxShares} live share links (the most one person may have). Revoke one first — /files lists them.` };
+ }
+ return { ok: true };
+ }
+
+ /** The usage bar on /files and GET /usage. */
+ async function usage(owner) {
+ const [used, files, today, liveShares] = await Promise.all([
+ store.storedBytes(s, owner), store.liveFileCount(s, owner),
+ store.uploadsToday(s, owner, dayOf(s.now())), store.countLiveShares(s, owner, nowIso()),
+ ]);
+ return {
+ bytes_used: used, bytes_limit: config.quotas.bytes,
+ max_file_bytes: config.quotas.maxFileBytes,
+ files, uploads_today: today, uploads_per_day: config.quotas.uploadsPerDay,
+ live_shares: liveShares, max_shares: config.quotas.maxShares,
+ };
+ }
+
+ // ── Files ───────────────────────────────────────────────
+
+ /**
+ * Record an object that is already `ready` in Media as one of the person's files, and count the upload.
+ * The quota is checked again here: the bytes exist now, which is what the number is about.
+ */
+ async function recordFile({ owner, objectId, name, size, contentType, sha256, folderId = null }) {
+ const row = await store.insertFile(s, {
+ id: s.newId('fil'), owner, object_id: objectId, name: rules.fileName(name),
+ size, content_type: rules.normalizeType(contentType) || 'application/octet-stream',
+ sha256: sha256 || null, folder_id: folderId, created_at: nowIso(),
+ });
+ await store.bumpUploads(s, owner, dayOf(s.now()));
+ return row;
+ }
+
+ /** One of the person's own files, or null. A file is private: nobody else's id resolves here. */
+ const ownFile = async (owner, id) => {
+ if (!rules.isFileId(id)) return null;
+ const row = await store.getFile(s, id);
+ return row && row.owner === owner && !row.deleted_at ? row : null;
+ };
+
+ /**
+ * Delete a file: its shares go at once, the row is soft-deleted here and the object is deleted in Media.
+ * The bytes are Media's to keep or purge (Media's own retention), so this does not claim they are gone.
+ */
+ async function deleteFile(owner, id, { by = null } = {}) {
+ const row = await ownFile(owner, id);
+ if (!row) return { error: 'file.not_found' };
+ await store.deleteSharesForFile(s, id);
+ await store.softDeleteFile(s, id, nowIso());
+ const removed = await media.remove(row.object_id);
+ if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${row.object_id}: ${removed.status} ${removed.code}`);
+ return { file: row, media: removed };
+ }
+
+ // ── Folders ─────────────────────────────────────────────
+
+ /** A folder of this person's, or null. */
+ const ownFolder = async (owner, id) => {
+ if (!rules.isFolderId(id)) return null;
+ const row = await store.getFolder(s, id);
+ return row && row.owner === owner ? row : null;
+ };
+
+ async function createFolder(owner, { name, parentId = null }) {
+ const clean = rules.cleanName(name, rules.FOLDER_NAME_MAX);
+ if (!clean) return { error: 'folder.invalid_name', detail: 'A folder needs a name.' };
+ if (parentId && !await ownFolder(owner, parentId)) return { error: 'folder.no_parent', detail: 'That parent folder does not exist.' };
+ const existing = await store.listFolders(s, owner);
+ if (existing.some((f) => (f.parent_id || null) === (parentId || null) && f.name.toLowerCase() === clean.toLowerCase())) {
+ return { error: 'folder.exists', detail: `You already have a folder called "${clean}" here.` };
+ }
+ return { folder: await store.insertFolder(s, { id: s.newId('fld'), owner, parent_id: parentId || null, name: clean, created_at: nowIso() }) };
+ }
+
+ /** A folder's path from the root, for breadcrumbs and the folder picker. */
+ async function folderPath(owner, folderId) {
+ const all = await store.listFolders(s, owner);
+ const byId = new Map(all.map((f) => [f.id, f]));
+ const path = [];
+ let cursor = folderId;
+ let guard = 0;
+ while (cursor && byId.has(cursor) && guard++ < 50) { path.unshift(byId.get(cursor)); cursor = byId.get(cursor).parent_id; }
+ return path;
+ }
+
+ // ── Shares ──────────────────────────────────────────────
+
+ /**
+ * Make a share link for one of the person's files or folders. `names` are OpenVibe usernames the owner typed:
+ * each is asked of Network's identity resolver, and the ones it cannot resolve are kept as typed and matched
+ * against a viewer's username claim later. No names at all = any signed-in person with the link.
+ */
+ async function createShare(owner, { fileId = null, folderId = null, hours, names = [], ownerUsername = null }) {
+ const room = await shareAllowed(owner);
+ if (!room.ok) return room;
+ if (fileId && !await ownFile(owner, fileId)) return { ok: false, code: 'file.not_found', detail: 'No such file.' };
+ if (folderId && !await ownFolder(owner, folderId)) return { ok: false, code: 'folder.not_found', detail: 'No such folder.' };
+ if (!fileId && !folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder.' };
+ if (fileId && folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder, not both.' };
+ const wanted = [...new Set(names.map((n) => String(n || '').trim().toLowerCase()).filter(Boolean))];
+ for (const n of wanted) {
+ if (!rules.isUsername(n)) return { ok: false, code: 'share.bad_username', detail: `"${rules.cleanName(n, 40)}" is not an OpenVibe username.` };
+ }
+ const resolved = wanted.length ? await identity.resolveUsernames(wanted) : { subjects: [], names: [], unresolved: [] };
+ const allowed = wanted.length ? { subjects: resolved.subjects, usernames: resolved.names } : null;
+ const expiresAt = new Date(s.now() + rules.expiryHours(hours) * 3_600_000).toISOString();
+ const share = await store.insertShare(s, {
+ slug: newSlug(), file_id: fileId || null, folder_id: folderId || null, owner,
+ owner_username: ownerUsername, expires_at: expiresAt, allowed_subjects: allowed, created_at: nowIso(),
+ });
+ return { ok: true, share, unresolved: resolved.unresolved };
+ }
+
+ /** The person's own share, by slug — never somebody else's. */
+ async function ownShare(owner, slug) {
+ if (!rules.isSlug(slug)) return null;
+ const row = await store.getShare(s, slug);
+ return row && row.owner === owner ? row : null;
+ }
+
+ async function revokeShare(owner, slug) {
+ const row = await ownShare(owner, slug);
+ if (!row) return null;
+ await store.revokeShare(s, slug, nowIso());
+ return store.getShare(s, slug);
+ }
+
+ // ── Reports ─────────────────────────────────────────────
+
+ /**
+ * Record a report against a share. One per person per share; the third distinct person (or any report of illegal
+ * content) suspends the share at once, which is what staff then look at.
+ */
+ async function report(slug, { reporter, reason, note }) {
+ const share = rules.isSlug(slug) ? await store.getShare(s, slug) : null;
+ if (!share) return { error: 'share.not_found' };
+ if (share.owner === reporter) return { error: 'share.own' };
+ if (share.revoked_at) return { error: 'share.revoked' };
+ const row = await store.addReport(s, { slug, reporter, reason, note, createdAt: nowIso() });
+ if (!row) return { error: 'report.duplicate' };
+ const count = await store.reportCount(s, slug);
+ // Suspension is counted once, when the line is crossed (share.report_count is the high-water mark before this
+ // report): a share staff restored is not suspended again by the reports that were already there. Illegal
+ // content is the exception — it suspends however many times it has to.
+ const crossed = Number(share.report_count) < rules.SUSPEND_AT_REPORTS && count >= rules.SUSPEND_AT_REPORTS;
+ const suspend = !share.suspended_at && (rules.SUSPEND_IMMEDIATELY.includes(reason) || crossed);
+ if (suspend) await store.suspendShare(s, slug, nowIso());
+ await store.bumpReportCount(s, slug, count);
+ return { report: row, share: await store.getShare(s, slug), count, suspended: suspend };
+ }
+
+ // ── Staff ───────────────────────────────────────────────
+
+ /** Staff: let a suspended (or reported) share work again. The reports stay on the record. */
+ async function restoreShare(slug) {
+ const share = rules.isSlug(slug) ? await store.getShare(s, slug) : null;
+ if (!share) return { error: 'share.not_found' };
+ await store.restoreShare(s, slug);
+ return { share: await store.getShare(s, slug) };
+ }
+
+ /**
+ * Staff: remove what a share points at. Every share of that file or folder goes, the row is soft-deleted and
+ * Media is asked to delete the object — or, for a folder, each file in it, one by one.
+ */
+ async function removeShared(slug) {
+ const share = rules.isSlug(slug) ? await store.getShare(s, slug) : null;
+ if (!share) return { error: 'share.not_found' };
+ if (share.file_id) {
+ const file = await store.getFile(s, share.file_id);
+ if (!file) return { error: 'file.not_found' };
+ await store.deleteSharesForFile(s, file.id);
+ await store.softDeleteFile(s, file.id, nowIso());
+ const removed = await media.remove(file.object_id);
+ if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${file.object_id}: ${removed.status} ${removed.code}`);
+ return { kind: 'file', file, media: removed };
+ }
+ const files = await store.listFiles(s, share.owner, { folderId: share.folder_id, limit: 1000 });
+ await store.deleteSharesForFolder(s, share.folder_id);
+ for (const file of files) {
+ await store.softDeleteFile(s, file.id, nowIso());
+ const removed = await media.remove(file.object_id);
+ if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${file.object_id}: ${removed.status} ${removed.code}`);
+ }
+ await store.deleteFolder(s, share.folder_id, share.owner);
+ return { kind: 'folder', files };
+ }
+
+ // ── Reads the pages and the API both need (no SQL outside this module) ──
+ const listFiles = (owner, opts) => store.listFiles(s, owner, opts);
+ const listFolders = (owner) => store.listFolders(s, owner);
+ const listShares = (owner) => store.listShares(s, owner);
+ const usageDays = (owner) => store.usageFor(s, owner);
+ const shareBySlug = (slug) => store.getShare(s, slug);
+ const reportsFor = (slug) => store.reportsFor(s, slug);
+
+ return {
+ uploadAllowed, shareAllowed, usage, dayOf,
+ recordFile, ownFile, deleteFile,
+ listFiles, listFolders, listShares, usageDays, shareBySlug, reportsFor,
+ ownFolder, createFolder, folderPath,
+ createShare, ownShare, revokeShare,
+ report, restoreShare, removeShared,
+ stateOf, allowedFor, parseAllow,
+ };
+}
+
+module.exports = { createService, parseAllow, allowedFor, stateOf, newSlug, dayOf, DAY_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+'use strict';
+
+/**
+ * Who is OpenVibe staff at MediaHub, exactly as at OpenVibe.Help and OpenVibe.Rent: the Network role claim on the
+ * person's session (server/auth/sso.js puts `role` on req.viewer; http/principal.js puts it on req.principal). The
+ * roles are OpenVibe.Network's (manifests/policy/staff-roles.json): `global_mod` and `admin` may read the report
+ * queue and restore or remove what was reported.
+ *
+ * One place decides staff, so the pages and the API cannot drift apart. Anonymous, an app token and an unlisted
+ * role are not staff.
+ */
+const STAFF_ROLES = ['admin', 'global_mod'];
+
+const roleOf = (who) => (typeof who === 'string' ? who : (who && typeof who.role === 'string' ? who.role : null));
+
+function isStaff(who) {
+ if (who && typeof who === 'object' && who.kind && who.kind !== 'user') return false;
+ return STAFF_ROLES.includes(roleOf(who));
+}
+
+module.exports = { isStaff, STAFF_ROLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+'use strict';
+
+/**
+ * Files, folders, shares, reports and upload counters in OpenVibe.MediaHub's PostgreSQL database
+ * (migrations/0002_mediahub.sql). Every function takes the store (server/db.js createStore) first; the clock is the
+ * store's own, so a test can move time and watch a share expire.
+ *
+ * Two rules live here because SQL is where they cannot be raced:
+ * - a report is one per person per share (the primary key, not a read followed by a write),
+ * - the upload counter is one row per person per day (upsert).
+ * Everything else (who may see what, which limits apply) is server/drive/service.js.
+ */
+
+const FILES = 'mh_files';
+const FOLDERS = 'mh_folders';
+const SHARES = 'mh_shares';
+const REPORTS = 'mh_reports';
+const USAGE = 'mh_usage';
+const UPLOADS = 'mh_uploads';
+
+// ── Files ───────────────────────────────────────────────────────────────────────────────────────────────────────
+
+async function insertFile(s, row) {
+ await s.db.query(
+ `INSERT INTO ${FILES} (id, owner, object_id, name, size, content_type, sha256, folder_id, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+ [row.id, row.owner, row.object_id, row.name, row.size, row.content_type, row.sha256, row.folder_id, row.created_at]);
+ return getFile(s, row.id);
+}
+
+const getFile = (s, id) => s.db.maybe(`SELECT * FROM ${FILES} WHERE id = $1`, [id]);
+
+/** This person's live files: newest first, a page at a time, optionally one folder (null = the root). */
+async function listFiles(s, owner, { folderId = null, limit = 100, before = null } = {}) {
+ const values = [owner];
+ const parts = ['owner = $1', 'deleted_at IS NULL'];
+ if (folderId === null) parts.push('folder_id IS NULL');
+ else { values.push(folderId); parts.push(`folder_id = $${values.length}`); }
+ let sql = `SELECT * FROM ${FILES} WHERE ${parts.join(' AND ')}`;
+ if (before) { values.push(before); sql += ` AND id < $${values.length}`; }
+ values.push(limit);
+ sql += ` ORDER BY id DESC LIMIT $${values.length}`;
+ return s.db.many(sql, values);
+}
+
+/** Every size the person still holds — what the quota bar is drawn from. */
+const storedBytes = (s, owner) => s.db.value(`SELECT coalesce(sum(size), 0) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL`, [owner]).then(Number);
+
+const liveFileCount = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL`, [owner]).then(Number);
+
+/** Soft delete: the row stays (it is what the Media object is written from), the person stops seeing it. */
+const softDeleteFile = (s, id, at) => s.db.exec(`UPDATE ${FILES} SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`, [id, at]);
+
+// ── Folders ─────────────────────────────────────────────────────────────────────────────────────────────────────
+
+async function insertFolder(s, row) {
+ await s.db.query(`INSERT INTO ${FOLDERS} (id, owner, parent_id, name, created_at) VALUES ($1, $2, $3, $4, $5)`,
+ [row.id, row.owner, row.parent_id, row.name, row.created_at]);
+ return getFolder(s, row.id);
+}
+
+const getFolder = (s, id) => s.db.maybe(`SELECT * FROM ${FOLDERS} WHERE id = $1`, [id]);
+const listFolders = (s, owner) => s.db.many(`SELECT * FROM ${FOLDERS} WHERE owner = $1 ORDER BY lower(name) ASC, id ASC`, [owner]);
+const countFolders = (s, owner) => s.db.value(`SELECT count(*) FROM ${FOLDERS} WHERE owner = $1`, [owner]).then(Number);
+const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE id = $1 AND owner = $2`, [id, owner]);
+
+// ── Shares ──────────────────────────────────────────────────────────────────────────────────────────────────────
+
+async function insertShare(s, row) {
+ await s.db.query(
+ `INSERT INTO ${SHARES} (slug, file_id, folder_id, owner, owner_username, expires_at, allowed_subjects, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)`,
+ [row.slug, row.file_id, row.folder_id, row.owner, row.owner_username || null, row.expires_at,
+ row.allowed_subjects ? JSON.stringify(row.allowed_subjects) : null, row.created_at]);
+ return getShare(s, row.slug);
+}
+
+const getShare = (s, slug) => s.db.maybe(`SELECT * FROM ${SHARES} WHERE slug = $1`, [slug]);
+const listShares = (s, owner) => s.db.many(`SELECT * FROM ${SHARES} WHERE owner = $1 ORDER BY created_at DESC, slug DESC`, [owner]);
+
+/** Live = not revoked, not suspended and not past its expiry: what the per-person share cap counts. */
+const countLiveShares = (s, owner, nowIso) => s.db.value(
+ `SELECT count(*) FROM ${SHARES} WHERE owner = $1 AND revoked_at IS NULL AND suspended_at IS NULL AND expires_at > $2`,
+ [owner, nowIso]).then(Number);
+
+const revokeShare = (s, slug, at) => s.db.exec(`UPDATE ${SHARES} SET revoked_at = $2 WHERE slug = $1 AND revoked_at IS NULL`, [slug, at]);
+const suspendShare = (s, slug, at) => s.db.exec(`UPDATE ${SHARES} SET suspended_at = $2 WHERE slug = $1 AND suspended_at IS NULL`, [slug, at]);
+const restoreShare = (s, slug) => s.db.exec(`UPDATE ${SHARES} SET suspended_at = NULL WHERE slug = $1`, [slug]);
+
+/** One more download, counted where the row is: a shared counter cannot lose one. */
+const countDownload = (s, slug) => s.db.exec(`UPDATE ${SHARES} SET downloads = downloads + 1 WHERE slug = $1`, [slug]);
+
+const deleteSharesForFile = (s, fileId) => s.db.exec(`DELETE FROM ${SHARES} WHERE file_id = $1`, [fileId]);
+const deleteSharesForFolder = (s, folderId) => s.db.exec(`DELETE FROM ${SHARES} WHERE folder_id = $1`, [folderId]);
+
+// ── Reports ─────────────────────────────────────────────────────────────────────────────────────────────────────
+
+/** Record a report. The primary key is the truth: one per person per share, raced or not. */
+async function addReport(s, { slug, reporter, reason, note, createdAt }) {
+ try {
+ await s.db.query(`INSERT INTO ${REPORTS} (slug, reporter, reason, note, created_at) VALUES ($1, $2, $3, $4, $5)`,
+ [slug, reporter, reason, note, createdAt]);
+ } catch (err) {
+ if (err && err.code === '23505') return null; // already reported by this person
+ throw err;
+ }
+ return s.db.maybe(`SELECT * FROM ${REPORTS} WHERE slug = $1 AND reporter = $2`, [slug, reporter]);
+}
+
+const reportsFor = (s, slug) => s.db.many(`SELECT * FROM ${REPORTS} WHERE slug = $1 ORDER BY created_at ASC`, [slug]);
+const reportCount = (s, slug) => s.db.value(`SELECT count(*) FROM ${REPORTS} WHERE slug = $1`, [slug]).then(Number);
+
+/** The high-water mark of how many reports this share has had: it only ever goes up, so restoring it sticks. */
+const bumpReportCount = (s, slug, count) => s.db.value(`UPDATE ${SHARES} SET report_count = greatest(report_count, $2) WHERE slug = $1 RETURNING report_count`, [slug, count]).then(Number);
+
+/** The staff queue: every share somebody reported, the newest report first. */
+function staffQueue(s, { limit = 100 } = {}) {
+ return s.db.many(
+ `SELECT sh.*, max(r.created_at) AS last_report_at, count(r.reporter) AS report_count
+ FROM ${SHARES} sh JOIN ${REPORTS} r ON r.slug = sh.slug
+ GROUP BY sh.slug
+ ORDER BY max(r.created_at) DESC
+ LIMIT $1`, [limit]);
+}
+
+async function staffCounts(s) {
+ const [reported, suspended, revoked] = await Promise.all([
+ s.db.value(`SELECT count(DISTINCT slug) FROM ${REPORTS}`),
+ s.db.value(`SELECT count(*) FROM ${SHARES} WHERE suspended_at IS NOT NULL`),
+ s.db.value(`SELECT count(*) FROM ${SHARES} WHERE revoked_at IS NOT NULL`),
+ ]);
+ return { reported: Number(reported), suspended: Number(suspended), revoked: Number(revoked) };
+}
+
+// ── Usage (uploads per person per day, UTC) ─────────────────────────────────────────────────────────────────────
+
+/** Record one upload today, answering the new count for the day. */
+async function bumpUploads(s, owner, day) {
+ const row = await s.db.maybe(
+ `INSERT INTO ${USAGE} (owner, day, uploads) VALUES ($1, $2, 1)
+ ON CONFLICT (owner, day) DO UPDATE SET uploads = ${USAGE}.uploads + 1
+ RETURNING uploads`, [owner, day]);
+ return Number(row && row.uploads);
+}
+
+const uploadsToday = (s, owner, day) => s.db.value(`SELECT uploads FROM ${USAGE} WHERE owner = $1 AND day = $2`, [owner, day]).then((v) => Number(v) || 0);
+const usageFor = (s, owner) => s.db.many(`SELECT * FROM ${USAGE} WHERE owner = $1 ORDER BY day DESC LIMIT 90`, [owner]);
+
+// ── Upload sessions (a chunked upload in flight) ────────────────────────────────────────────────────────────────
+
+async function insertUpload(s, row) {
+ await s.db.query(
+ `INSERT INTO ${UPLOADS} (id, owner, object_id, upload_id, name, size, content_type, folder_id, part_size, parts_expected, created_at, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
+ [row.id, row.owner, row.object_id, row.upload_id, row.name, row.size, row.content_type, row.folder_id,
+ row.part_size, row.parts_expected, row.created_at, row.expires_at]);
+ return getUpload(s, row.id);
+}
+
+const getUpload = (s, id) => s.db.maybe(`SELECT * FROM ${UPLOADS} WHERE id = $1`, [id]);
+const setUploadFile = (s, id, fileId) => s.db.exec(`UPDATE ${UPLOADS} SET file_id = $2 WHERE id = $1`, [id, fileId]);
+const dropUpload = (s, id) => s.db.exec(`DELETE FROM ${UPLOADS} WHERE id = $1`, [id]);
+
+module.exports = {
+ insertFile, getFile, listFiles, storedBytes, liveFileCount, softDeleteFile,
+ insertFolder, getFolder, listFolders, countFolders, deleteFolder,
+ insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,
+ deleteSharesForFile, deleteSharesForFolder,
+ addReport, reportsFor, reportCount, bumpReportCount, staffQueue, staffCounts,
+ bumpUploads, uploadsToday, usageFor,
+ insertUpload, getUpload, setUploadFile, dropUpload,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+'use strict';
+
+/**
+ * Getting a file's bytes into OpenVibe.Media and a row into the drive.
+ *
+ * Every upload is the same three steps against Media — start a multipart session, send its parts, complete it —
+ * because that is the one shape that works for a 1 KB file and a 512 MB one, and because it lets the browser send
+ * a big file in pieces. What differs is who sends the bytes:
+ *
+ * the browser, in parts POST /api/v1/files → PUT /api/v1/uploads/:id/parts/:n → POST …/complete
+ * (the page's script; each part is buffered here and forwarded, never written to disk)
+ * the browser, in one POST /files/upload (a plain <input type=file> form, no JavaScript)
+ * the request body is parsed and spooled to a temp file, then read back part by part
+ *
+ * A part is at most config.partBytes (8 MB by default) in memory at a time. Nothing is written to the database
+ * until Media says the object is ready: an upload that dies halfway leaves a Media object and one mh_uploads row,
+ * which is exactly what the resume needs and what the abort (or the expiry sweep) clears.
+ */
+const fs = require('fs');
+const store = require('./store');
+
+/** The exact size of part n (1-based) of a session: every part but the last is part_size. */
+const partSizeOf = (upload, n) => (n < upload.parts_expected ? upload.part_size : upload.size - upload.part_size * (upload.parts_expected - 1));
+
+/** Read [start, end) of a file into a Buffer — one part, never the whole object. */
+function readSlice(file, start, length) {
+ return new Promise((resolve, reject) => {
+ const buf = Buffer.alloc(length);
+ const fd = fs.openSync(file, 'r');
+ fs.read(fd, buf, 0, length, start, (err, read) => {
+ fs.closeSync(fd);
+ if (err) return reject(err);
+ if (read !== length) return reject(new Error(`short read: ${read} of ${length}`));
+ resolve(buf);
+ });
+ });
+}
+
+function createUploads({ s, media, config, service, log = console }) {
+ const nowIso = () => new Date(s.now()).toISOString();
+
+ /** Media's object is `uploading`; the session is Media's, the row is ours. */
+ async function startSession({ owner, name, size, contentType, folderId = null }) {
+ const room = await service.uploadAllowed(owner, size);
+ if (!room.ok) return room;
+ if (folderId && !await service.ownFolder(owner, folderId)) {
+ return { ok: false, code: 'folder.not_found', detail: 'That folder does not exist.' };
+ }
+ const started = await media.init({
+ size, contentType, name, ownerSubject: owner, multipart: true, partSize: config.partBytes,
+ });
+ if (!started.ok) return { ok: false, code: started.code, detail: started.detail };
+ const objectId = started.data.id;
+ let session = started.data.upload && started.data.upload.multipart;
+ if (!session || !session.upload_id) {
+ const fresh = await media.multipartStart(objectId, { size, partSize: config.partBytes });
+ if (!fresh.ok) {
+ await media.remove(objectId);
+ return { ok: false, code: fresh.code, detail: fresh.detail };
+ }
+ session = fresh.data;
+ }
+ const row = await store.insertUpload(s, {
+ id: s.newId('upl'), owner, object_id: objectId, upload_id: session.upload_id,
+ name, size, content_type: contentType || 'application/octet-stream', folder_id: folderId || null,
+ part_size: session.part_size, parts_expected: session.parts_expected,
+ created_at: nowIso(), expires_at: new Date(s.now() + config.uploadTtlSeconds * 1000).toISOString(),
+ });
+ return { ok: true, upload: publicUpload(row, []) };
+ }
+
+ /** The upload as the API and the page's script see it: no Media ids beyond the object, no token. */
+ const publicUpload = (row, missing) => ({
+ id: row.id, name: row.name, size: row.size, content_type: row.content_type,
+ folder_id: row.folder_id, part_size: row.part_size, parts_expected: row.parts_expected,
+ expires_at: row.expires_at, missing,
+ status: row.file_id ? 'complete' : 'open',
+ });
+
+ /** This person's own session, still open. An expired one is abandoned here, so nothing lingers. */
+ async function openSession(owner, id) {
+ if (!id) return null;
+ const row = await store.getUpload(s, String(id));
+ if (!row || row.owner !== owner) return null;
+ if (row.expires_at <= nowIso()) {
+ await media.abortMultipart(row.object_id, row.upload_id);
+ await store.dropUpload(s, row.id);
+ return null;
+ }
+ return row;
+ }
+
+ async function status(owner, id) {
+ const row = await openSession(owner, id);
+ if (!row) return { ok: false, code: 'upload.not_found', detail: 'No such upload.' };
+ const live = await media.multipartStatus(row.object_id, row.upload_id);
+ if (!live.ok) {
+ // Media has forgotten the session (purged, or the process restarted with a new one): say so plainly.
+ return { ok: false, code: live.code, detail: live.detail };
+ }
+ const missing = Array.isArray(live.data.missing) ? live.data.missing : [];
+ return { ok: true, upload: publicUpload(row, missing) };
+ }
+
+ /** One part from the client. Its length is checked here (and again by Media) before anything is stored. */
+ async function putPart(owner, id, n, buffer) {
+ const row = await openSession(owner, id);
+ if (!row) return { ok: false, code: 'upload.not_found', detail: 'No such upload.' };
+ const part = Number(n);
+ if (!Number.isInteger(part) || part < 1 || part > row.parts_expected) {
+ return { ok: false, code: 'upload.bad_part', detail: `Part must be 1 to ${row.parts_expected}.` };
+ }
+ const want = partSizeOf(row, part);
+ if (buffer.length !== want) {
+ return { ok: false, code: 'upload.part_size', detail: `Part ${part} must be exactly ${want} bytes; ${buffer.length} arrived.` };
+ }
+ const sent = await media.putPart(row.object_id, row.upload_id, part, buffer);
+ if (!sent.ok) return { ok: false, code: sent.code, detail: sent.detail };
+ return { ok: true, part, size: want };
+ }
+
+ /**
+ * Media assembles the parts and makes the object ready; only then does the file exist here. The size and hash
+ * are read back from Media — what it stored is the truth, not what the client claimed.
+ */
+ async function complete(owner, id) {
+ const row = await openSession(owner, id);
+ if (!row) return { ok: false, code: 'upload.not_found', detail: 'No such upload.' };
+ const done = await media.completeMultipart(row.object_id, row.upload_id);
+ if (!done.ok) return { ok: false, code: done.code, detail: done.detail };
+ const object = done.data || {};
+ const file = await service.recordFile({
+ owner, objectId: row.object_id, name: row.name,
+ size: Number(object.size_bytes) || row.size, contentType: object.mime_type || row.content_type,
+ sha256: object.content_hash || null, folderId: row.folder_id,
+ });
+ await store.setUploadFile(s, row.id, file.id);
+ await store.dropUpload(s, row.id);
+ return { ok: true, file };
+ }
+
+ async function abort(owner, id) {
+ const row = await openSession(owner, id);
+ if (!row) return { ok: false, code: 'upload.not_found', detail: 'No such upload.' };
+ await media.abortMultipart(row.object_id, row.upload_id);
+ await store.dropUpload(s, row.id);
+ return { ok: true };
+ }
+
+ /**
+ * The whole file through the session's parts, read from a spooled file. This is the no-JavaScript path: the
+ * browser posted one request, we wrote it to disk, and Media gets it in parts exactly as it would from a script.
+ */
+ async function uploadSpooled({ owner, file, name, size, contentType, folderId = null }) {
+ const started = await startSession({ owner, name, size, contentType, folderId });
+ if (!started.ok) return started;
+ const row = await store.getUpload(s, started.upload.id);
+ try {
+ let offset = 0;
+ for (let n = 1; n <= row.parts_expected; n++) {
+ const want = partSizeOf(row, n);
+ const buffer = await readSlice(file, offset, want);
+ offset += want;
+ const sent = await putPart(owner, row.id, n, buffer);
+ if (!sent.ok) return sent;
+ }
+ return await complete(owner, row.id);
+ } catch (err) {
+ log.warn('[MediaHub] spooled upload failed:', (err && err.message) || '');
+ await abort(owner, row.id);
+ return { ok: false, code: 'upload.failed', detail: 'The upload could not be finished. Nothing was kept.' };
+ }
+ }
+
+ return { startSession, status, putPart, complete, abort, uploadSpooled, partSizeOf, publicUpload };
+}
+
+module.exports = { createUploads, partSizeOf, readSlice };
@@ -1,30 +1,268 @@
@@ -1,30 +1,268 @@
'use strict';
'use strict';
/**
/**
- * /api/v1 — OpenVibe.MediaHub's API.
+ * /api/v1 — OpenVibe.MediaHub's API. Every route here is a signed-in person's and nothing else's: no app, agent
+ * or service token can hold, share or delete somebody's files, and no route names a capability (see CAPABILITIES
+ * in ./principal.js). A write made with the session cookie must come from openvibe.download itself (same-origin).
*
*
- * GET /ping public liveness: { ok: true, service }
+ * GET /ping anyone liveness
+ * GET /files?folder_id=&limit=&before= person your drive (one folder, or the root)
+ * POST /files person start an upload: { name, size, content_type, folder_id }
+ * GET /files/:id person one of your files
+ * DELETE /files/:id person delete it (and ask Media to delete the object)
+ * GET /folders person your folders
+ * POST /folders person make one: { name, parent_id }
+ * POST /shares person share a file or a folder: { file_id|folder_id, hours, usernames }
+ * DELETE /shares/:slug person revoke one
+ * GET /usage person what you hold and what is left
+ * PUT /uploads/:id/parts/:n person one part of a chunked upload (the raw bytes)
+ * GET /uploads/:id person what is still missing (resume)
+ * POST /uploads/:id/complete person assemble it into a file
+ * DELETE /uploads/:id person abandon it
*
*
- * The product adds its own routes here. Every route that reads or changes a person's data names the capability it
- * needs (`principal.requireCapability('<media-hub>.thing.read')`, listed in CAPABILITIES in ./principal.js) and takes
- * its per-caller numbers (`limits.reads('<media-hub>.thing.read')` or `limits.budget('<name>')`, ./caller-limits.js).
- * A refusal is an RFC 9457 problem+json with a stable code.
+ * A refusal is an RFC 9457 problem+json with a stable code. The product's own caps (2 GB, 512 MB a file, 50
+ * uploads a day, 20 live shares) are `file.quota` / `file.too_large` / `file.daily_limit` / `share.limit` — told
+ * apart from the rate limiter's `rate_limited`, and answered before anything is written.
*/
*/
const express = require('express');
const express = require('express');
+const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./principal');
+const rules = require('../drive/rules');
+
+/** The largest single part body we will read, plus room for a stray byte the length check must then refuse. */
+const rawCap = (config) => config.partBytes + 1024;
+
+/** Read a raw request body, refusing past `cap` bytes. */
+function readBody(req, cap) {
+ return new Promise((resolve) => {
+ const chunks = [];
+ let total = 0;
+ let done = false;
+ const settle = (v) => { if (!done) { done = true; resolve(v); } };
+ req.on('data', (c) => {
+ total += c.length;
+ if (total > cap) { req.resume(); return settle({ error: 'too_large' }); }
+ chunks.push(c);
+ });
+ req.on('end', () => settle({ buffer: Buffer.concat(chunks) }));
+ req.on('aborted', () => settle({ error: 'aborted' }));
+ req.on('error', () => settle({ error: 'aborted' }));
+ });
+}
function createApi(ctx) {
function createApi(ctx) {
- const { config, principal, limits } = ctx;
+ const { config, s, principal, limits, drive, uploads } = ctx;
const r = asyncRouter();
const r = asyncRouter();
+ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
- r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ // openvibe.pics and openvibe.video have no API: no upload, no drive, no share links (server/brand.js).
+ r.use((req, res, next) => (req.brand && req.brand !== 'download'
+ ? contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'This brand of the hub has no API yet; OpenVibe.Download is the one that has launched.', ctx: req.ov })
+ : next()));
r.use(principal.middleware);
r.use(principal.middleware);
- // The one route the skeleton ships. It is public and counted per caller with the default read numbers.
+ /**
+ * The chunked upload's part body is raw bytes, so it is mounted before the JSON parser: express must never try
+ * to read a megabyte of file as JSON. Everything below the parser is JSON in, JSON out.
+ */
+ r.put('/uploads/:id/parts/:n', person, limits.budget('media-hub.upload.part'), async (req, res) => {
+ const got = await readBody(req, rawCap(config));
+ if (got.error === 'too_large') return problem(req, res, 413, 'upload.part_too_large', `A part is at most ${config.partBytes} bytes.`);
+ if (got.error) return problem(req, res, 400, 'upload.interrupted', 'The part did not arrive complete.');
+ const out = await uploads.putPart(personOf(req), String(req.params.id || ''), req.params.n, got.buffer);
+ if (!out.ok) return problem(req, res, out.code === 'upload.not_found' ? 404 : 422, out.code, out.detail);
+ return res.json({ part: out.part, size: out.size });
+ });
+
+ r.use(express.json({ limit: '64kb' }));
+
+ const personOf = (req) => (req.principal.kind === 'user' ? req.principal.requester : null);
+
+ /**
+ * The HTTP status a product refusal is answered with. A cap (too large, quota, a day's uploads, too many live
+ * shares) is 429 or 413; something that is not there is 404; everything else is a 422 the caller can fix.
+ */
+ function refused(code) {
+ if (String(code).startsWith('media.')) return 502; // OpenVibe.Media refused or could not be reached
+ if (code === 'file.not_found' || code === 'folder.not_found' || code === 'share.not_found') return 404;
+ if (code === 'file.too_large') return 413;
+ if (code === 'file.quota' || code === 'file.daily_limit' || code === 'share.limit') return 429;
+ return 422;
+ }
+
+ // ── Who may act ─────────────────────────────────────────
+ /**
+ * A person, signed in. An app, agent or service token is refused: a file is somebody's own, and no app holds
+ * one here. A session write must come from openvibe.download itself.
+ */
+ function requirePerson(req, res) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') {
+ problem(req, res, 401, 'token.required', 'Sign in at openvibe.download, or send a person\'s Network token as a Bearer.');
+ return false;
+ }
+ if (p.kind !== 'user') {
+ problem(req, res, 403, 'file.person_required', 'Files, folders and share links belong to a person; an app, agent or service token cannot hold or share them.');
+ return false;
+ }
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {
+ problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.download itself.');
+ return false;
+ }
+ return true;
+ }
+ // Declared before the routes that use it: the raw part route is mounted above the JSON parser.
+ function person(req, res, next) { return requirePerson(req, res) ? next() : undefined; }
+
+ // ── Wire shapes ─────────────────────────────────────────
+ const wireFile = (row) => ({
+ id: row.id, name: row.name, size: Number(row.size), content_type: row.content_type,
+ sha256: row.sha256, folder_id: row.folder_id, created_at: row.created_at,
+ object: { media_object_id: row.object_id },
+ });
+ const wireFolder = (row) => ({ id: row.id, name: row.name, parent_id: row.parent_id, created_at: row.created_at });
+ const wireShare = (row) => ({
+ slug: row.slug, url: `/s/${row.slug}`,
+ file_id: row.file_id, folder_id: row.folder_id,
+ expires_at: row.expires_at, downloads: Number(row.downloads) || 0, report_count: Number(row.report_count) || 0,
+ state: drive.stateOf(row, s.iso()),
+ revoked_at: row.revoked_at, suspended_at: row.suspended_at, created_at: row.created_at,
+ allowed: drive.parseAllow(row.allowed_subjects),
+ limited: Boolean(row.allowed_subjects),
+ });
+
+ // ── Liveness ────────────────────────────────────────────
r.get('/ping', limits.reads('media-hub.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
r.get('/ping', limits.reads('media-hub.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
+ // ── Files ───────────────────────────────────────────────
+ r.get('/files', person, limits.reads('media-hub.file.read'), async (req, res) => {
+ const owner = personOf(req);
+ const folderId = req.query.folder_id ? String(req.query.folder_id) : null;
+ if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');
+ const limit = Math.min(200, Math.max(1, Number.parseInt(req.query.limit, 10) || 100));
+ const before = rules.isFileId(req.query.before) ? String(req.query.before) : null;
+ const rows = await drive.listFiles(owner, { folderId, limit, before });
+ const folders = await drive.listFolders(owner);
+ return res.json({
+ folder_id: folderId,
+ folders: folders.filter((f) => (f.parent_id || null) === folderId).map(wireFolder),
+ files: rows.map(wireFile),
+ usage: await drive.usage(owner),
+ note: 'Your files only. A file is private until you make a share link for it.',
+ });
+ });
+
+ /** Start an upload. The bytes follow through PUT /uploads/:id/parts/:n (see the page's script). */
+ r.post('/files', person, limits.budget('media-hub.upload.start'), async (req, res) => {
+ const owner = personOf(req);
+ const b = req.body || {};
+ const size = Number(b.size);
+ const name = rules.fileName(b.name, 'file');
+ const contentType = rules.normalizeType(b.content_type) || 'application/octet-stream';
+ const folderId = b.folder_id ? String(b.folder_id) : null;
+ if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');
+ const out = await uploads.startSession({ owner, name, size, contentType, folderId });
+ if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
+ res.set('Location', `/api/v1/uploads/${out.upload.id}`);
+ return res.status(201).json({
+ upload: out.upload,
+ note: 'Send each part of `missing` (they are part_size bytes, the last one less) with PUT /api/v1/uploads/:id/parts/:n, then POST /api/v1/uploads/:id/complete.',
+ });
+ });
+
+ r.get('/files/:id', person, limits.reads('media-hub.file.read'), async (req, res) => {
+ const row = await drive.ownFile(personOf(req), String(req.params.id || ''));
+ if (!row) return problem(req, res, 404, 'file.not_found', 'No such file.');
+ return res.json({ file: wireFile(row), shares: (await drive.listShares(row.owner)).filter((x) => x.file_id === row.id).map(wireShare) });
+ });
+
+ r.delete('/files/:id', person, async (req, res) => {
+ const out = await drive.deleteFile(personOf(req), String(req.params.id || ''));
+ if (out.error) return problem(req, res, 404, 'file.not_found', 'No such file.');
+ return res.status(204).end();
+ });
+
+ // ── Folders ─────────────────────────────────────────────
+ r.get('/folders', person, limits.reads('media-hub.folder.read'), async (req, res) => {
+ const rows = await drive.listFolders(personOf(req));
+ return res.json({ folders: rows.map(wireFolder) });
+ });
+
+ r.post('/folders', person, limits.budget('media-hub.folder.create'), async (req, res) => {
+ const b = req.body || {};
+ const parentId = b.parent_id ? String(b.parent_id) : null;
+ if (parentId && !rules.isFolderId(parentId)) return problem(req, res, 422, 'folder.invalid', 'parent_id must be a folder id.');
+ const out = await drive.createFolder(personOf(req), { name: b.name, parentId });
+ if (out.error) return problem(req, res, out.error === 'folder.exists' ? 409 : 422, out.error, out.detail);
+ res.set('Location', `/api/v1/folders/${out.folder.id}`);
+ return res.status(201).json({ folder: wireFolder(out.folder) });
+ });
+
+ // ── Shares ──────────────────────────────────────────────
+ r.post('/shares', person, limits.budget('media-hub.share.create'), async (req, res) => {
+ const b = req.body || {};
+ const fileId = b.file_id ? String(b.file_id) : null;
+ const folderId = b.folder_id ? String(b.folder_id) : null;
+ if (fileId && !rules.isFileId(fileId)) return problem(req, res, 422, 'share.invalid', 'file_id must be a file id.');
+ if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'share.invalid', 'folder_id must be a folder id.');
+ const usernames = Array.isArray(b.usernames) ? b.usernames
+ : (typeof b.usernames === 'string' && b.usernames.trim() ? b.usernames.split(/[\s,]+/) : []);
+ const out = await drive.createShare(personOf(req), {
+ fileId, folderId, hours: b.hours, names: usernames, ownerUsername: req.principal.username,
+ });
+ if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
+ res.set('Location', `/api/v1/shares/${out.share.slug}`);
+ return res.status(201).json({
+ share: wireShare(out.share),
+ unresolved: out.unresolved,
+ note: (out.unresolved || []).length
+ ? 'Network could not turn these names into subjects; the share still allows them by their username claim.'
+ : undefined,
+ });
+ });
+
+ r.delete('/shares/:slug', person, async (req, res) => {
+ const row = await drive.revokeShare(personOf(req), String(req.params.slug || ''));
+ if (!row) return problem(req, res, 404, 'share.not_found', 'No such share.');
+ return res.status(204).end();
+ });
+
+ // ── Usage ───────────────────────────────────────────────
+ r.get('/usage', person, limits.reads('media-hub.usage.read'), async (req, res) => {
+ const owner = personOf(req);
+ return res.json({
+ usage: await drive.usage(owner),
+ days: (await drive.usageDays(owner)).map((d) => ({ day: d.day, uploads: Number(d.uploads) })),
+ note: 'Media\'s own quotas are per tenant (this whole service); these numbers are per person.',
+ });
+ });
+
+ // ── Chunked uploads (the part PUT is mounted above the JSON parser) ──
+ r.get('/uploads/:id', person, limits.reads('media-hub.upload.read'), async (req, res) => {
+ const out = await uploads.status(personOf(req), String(req.params.id || ''));
+ if (!out.ok) return problem(req, res, out.code === 'upload.not_found' ? 404 : 409, out.code, out.detail);
+ return res.json({ upload: out.upload });
+ });
+
+ r.post('/uploads/:id/complete', person, limits.budget('media-hub.upload.complete'), async (req, res) => {
+ const out = await uploads.complete(personOf(req), String(req.params.id || ''));
+ if (!out.ok) {
+ const status = out.code === 'upload.not_found' ? 404 : (out.code === 'file.quota' ? 429 : 409);
+ return problem(req, res, status, out.code, out.detail);
+ }
+ res.set('Location', `/api/v1/files/${out.file.id}`);
+ return res.status(201).json({ file: wireFile(out.file) });
+ });
+
+ r.delete('/uploads/:id', person, async (req, res) => {
+ const out = await uploads.abort(personOf(req), String(req.params.id || ''));
+ if (!out.ok) return problem(req, res, 404, 'upload.not_found', 'No such upload.');
+ return res.status(204).end();
+ });
+
return r;
return r;
}
}
-module.exports = { createApi };
+module.exports = { createApi, readBody };
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* anyone else ip:<address>
* anyone else ip:<address>
*
*
* Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before it does any work; the
* Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before it does any work; the
- * refusal is logged once and counted in media-hub_rate_limited_total{limit,window}. Reads take MEDIAHUB_LIMITS_MINUTE /
+ * refusal is logged once and counted in media_hub_rate_limited_total{limit,window}. Reads take MEDIAHUB_LIMITS_MINUTE /
* MEDIAHUB_LIMITS_HOUR (120 and 3000); the product's expensive routes get their own numbers in BUDGETS below.
* MEDIAHUB_LIMITS_HOUR (120 and 3000); the product's expensive routes get their own numbers in BUDGETS below.
* Counters live in this process unless VALKEY_URL is set.
* Counters live in this process unless VALKEY_URL is set.
*
*
@@ -31,12 +31,25 @@ function caller(req) {
@@ -31,12 +31,25 @@ function caller(req) {
* not declared here. The empty object is the skeleton's starting point:
* not declared here. The empty object is the skeleton's starting point:
*
*
* 'media-hub.thing.create': { minute: 6, hour: 60 },
* 'media-hub.thing.create': { minute: 6, hour: 60 },
+ *
+ * The form on a page and its API route take the same entry: the form is the same action through another door, so
+ * it cannot be a way round a limit (see http/pages.js budgeted()).
+ *
+ * These are the per-caller rates. The product's own caps (2 GB, 512 MB a file, 50 uploads a day, 20 live shares)
+ * are enforced in server/drive/service.js — both, because they mean different things.
*/
*/
-const BUDGETS = {};
+const BUDGETS = {
+ // A 512 MB file is 64 parts of 8 MB: a part every two seconds is a fast line, not a runaway loop.
+ 'media-hub.upload.start': { minute: 20, hour: 200 },
+ 'media-hub.upload.part': { minute: 300, hour: 5_000 },
+ 'media-hub.upload.complete': { minute: 30, hour: 300 },
+ 'media-hub.folder.create': { minute: 20, hour: 120 },
+ 'media-hub.share.create': { minute: 20, hour: 200 },
+};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
const refused = registry
const refused = registry
- ? registry.counter({ name: 'media-hub_rate_limited_total', help: 'Requests refused 429 by a per-caller limit, by limit name and window', labelNames: ['limit', 'window'] })
+ ? registry.counter({ name: 'media_hub_rate_limited_total', help: 'Requests refused 429 by a per-caller limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
: null;
const limiter = createActorLimiter({
const limiter = createActorLimiter({
limits: { minute: config.limits.minute, hour: config.limits.hour },
limits: { minute: config.limits.minute, hour: config.limits.hour },
@@ -15,10 +15,12 @@ const { asyncRouter } = require('./router');
@@ -15,10 +15,12 @@ const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.MediaHub';
const SITE_NAME = 'OpenVibe.MediaHub';
const DESCRIPTION = 'OpenVibe.MediaHub — Your files, shared on your terms.';
const DESCRIPTION = 'OpenVibe.MediaHub — Your files, shared on your terms.';
-const DISALLOW = ['/auth/', '/api/'];
+// /s/ (with the slash) rather than /s, or the rule would also swallow /safety, which is a public page.
+const DISALLOW = ['/auth/', '/api/', '/files', '/s/', '/staff'];
const PAGE_TEXT = {
const PAGE_TEXT = {
- '/': ['OpenVibe.MediaHub home', 'OpenVibe.MediaHub: Your files, shared on your terms.'],
+ '/': ['OpenVibe.MediaHub home', 'OpenVibe.MediaHub: Your files, shared on your terms. A private drive for a signed-in OpenVibe account, with share links that expire, can be revoked and need the downloader to be signed in too.'],
+ '/safety': ['Safety rules', 'What OpenVibe.Download allows and does not, who can upload and download, how reports work, and who to write to about abuse or copyright.'],
'/updates': ['What shipped on OpenVibe.MediaHub', 'This site\'s update log, from the network changelog feed.'],
'/updates': ['What shipped on OpenVibe.MediaHub', 'This site\'s update log, from the network changelog feed.'],
};
};
@@ -39,8 +41,13 @@ function homeJsonLd(config) {
@@ -39,8 +41,13 @@ function homeJsonLd(config) {
];
];
}
}
+/**
+ * The pages a crawler may read: this brand's homes, the safety rules and the update log — nothing behind sign-in.
+ * /files, /s/… and /staff are a person's own, so they are disallowed above and left out here.
+ */
const publicPages = () => [
const publicPages = () => [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/', changefreq: 'weekly', priority: 1.0 },
+ { path: '/safety', changefreq: 'monthly', priority: 0.6 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
];
];
@@ -65,6 +72,7 @@ function createDiscoveryRoutes(ctx) {
@@ -65,6 +72,7 @@ function createDiscoveryRoutes(ctx) {
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.MediaHub', url: abs('/'), note: 'Your files, shared on your terms.' },
{ title: 'OpenVibe.MediaHub', url: abs('/'), note: 'Your files, shared on your terms.' },
+ { title: 'Safety rules', url: abs('/safety'), note: 'what is allowed, how reports work, who to write to' },
{ title: 'What shipped on OpenVibe.MediaHub', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.MediaHub', url: abs('/updates') },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+'use strict';
+
+/**
+ * A very small multipart/form-data reader, for the one thing this service needs it for: a plain
+ * `<form enctype="multipart/form-data"><input type="file">` with no JavaScript at all.
+ *
+ * It streams. The file part goes straight to a temp file as it arrives (with sha256 and a byte count computed on
+ * the way), so a 256 MB upload never sits in this process's memory; the text parts are small and are kept as
+ * strings. It stops the moment the body passes `maxBytes` and answers { error: 'too_large' }.
+ *
+ * Nothing here decides what a form may contain: an unknown field comes back like any other, and the caller
+ * validates what it uses. There is no dependency for this because there is none in this tree, and the one form
+ * shape we accept is narrower than a general parser would be.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+
+const CRLF = '\r\n';
+
+/** The boundary of a multipart body, or null. */
+function boundaryOf(contentType) {
+ const m = /;\s*boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(String(contentType || ''));
+ return m ? (m[1] || m[2]) : null;
+}
+
+/** name="x"; filename="y" from a part's Content-Disposition, or null. */
+function disposition(line) {
+ if (!/^content-disposition:/i.test(String(line || ''))) return null;
+ const value = String(line).slice(String(line).indexOf(':') + 1);
+ const get = (key) => {
+ const v = new RegExp(`${key}\\s*=\\s*(?:"([^"]*)"|([^;\\s]*))`, 'i').exec(value);
+ return v ? (v[1] !== undefined ? v[1] : v[2]) : null;
+ };
+ return { name: get('name'), filename: get('filename') };
+}
+
+/**
+ * Read a multipart body. Answers
+ * { fields: { name: value }, file: { field, filename, contentType, path, size, sha256 } | null }
+ * or { error, detail }. `filePath` is where the file part is written; the caller removes it.
+ */
+function readFormData(req, { boundary, filePath, maxBytes }) {
+ return new Promise((resolve) => {
+ const first = Buffer.from(`--${boundary}`);
+ const delim = Buffer.from(`${CRLF}--${boundary}`);
+ const fields = {};
+ const hash = crypto.createHash('sha256');
+ let state = 'preamble';
+ let buf = Buffer.alloc(0);
+ let sink = null;
+ let part = null;
+ let file = null;
+ let settled = false;
+
+ const removeTemp = () => { try { fs.unlinkSync(filePath); } catch { /* never created */ } };
+ const finish = (out) => {
+ if (settled) return;
+ settled = true;
+ req.removeListener('data', onData);
+ resolve(out);
+ };
+ const fail = (error, detail) => {
+ if (settled) return;
+ if (sink) sink.destroy();
+ removeTemp();
+ req.removeListener('data', onData);
+ req.resume();
+ finish({ error, detail });
+ };
+
+ /** Everything before the boundary marker is the part's own bytes: count it, hash it, write it out. */
+ const take = (data) => {
+ if (!data || !data.length) return;
+ if (!part || part.filename == null) {
+ if (!part) part = { field: null, filename: null, contentType: null, data: [] };
+ part.data.push(data);
+ return;
+ }
+ if (!sink) sink = fs.createWriteStream(filePath, { flags: 'w' });
+ sink.on('error', (err) => fail('write_failed', (err && err.message) || 'the upload could not be written'));
+ file.size += data.length;
+ hash.update(data);
+ if (!sink.write(data)) { req.pause(); sink.once('drain', () => req.resume()); }
+ };
+
+ const endPart = () => {
+ if (part && part.filename == null) {
+ fields[part.field || ''] = Buffer.concat(part.data).toString('utf8');
+ } else if (part) {
+ file.field = part.field;
+ file.filename = part.filename;
+ file.contentType = part.contentType;
+ }
+ part = null;
+ };
+
+ const loop = () => {
+ while (!settled) {
+ if (state === 'preamble') {
+ const idx = buf.indexOf(first);
+ if (idx < 0) { buf = buf.subarray(Math.max(0, buf.length - first.length)); return; }
+ const after = buf.subarray(idx + first.length);
+ if (after.length < 2) { buf = after; return; }
+ buf = after.subarray(2);
+ state = after.subarray(0, 2).toString('latin1') === '--' ? 'done' : 'headers';
+ continue;
+ }
+ if (state === 'headers') {
+ const end = buf.indexOf('\r\n\r\n');
+ if (end < 0) {
+ if (buf.length > 16 * 1024) return fail('bad_request', 'the form part headers are too long');
+ return;
+ }
+ const block = buf.subarray(0, end).toString('utf8').split(CRLF);
+ const disp = disposition(block[0]);
+ const type = block.find((l) => /^content-type:/i.test(l));
+ part = {
+ field: disp && disp.name ? disp.name : null,
+ filename: disp && disp.filename != null ? disp.filename : null,
+ contentType: type ? type.slice(type.indexOf(':') + 1).trim() : null,
+ data: [],
+ };
+ if (part.filename != null && !file) file = { field: null, filename: null, contentType: null, path: filePath, size: 0 };
+ buf = buf.subarray(end + 4);
+ state = 'body';
+ continue;
+ }
+ if (state === 'body') {
+ const idx = buf.indexOf(delim);
+ if (idx < 0) {
+ const keep = Math.min(buf.length, delim.length - 1);
+ take(buf.subarray(0, buf.length - keep));
+ buf = buf.subarray(buf.length - keep);
+ return;
+ }
+ take(buf.subarray(0, idx));
+ const after = buf.subarray(idx + delim.length);
+ if (after.length < 2) { buf = after; return; }
+ const tail = after.subarray(0, 2).toString('latin1');
+ buf = after.subarray(2);
+ endPart();
+ state = tail === '--' ? 'done' : 'headers';
+ continue;
+ }
+ return;
+ }
+ };
+
+ let collected = null;
+ const collect = () => {
+ if (!collected) {
+ collected = { fields, file: null };
+ if (file) collected.file = { ...file, sha256: hash.digest('hex') };
+ }
+ return collected;
+ };
+
+ // The sink is closed once, whether the last boundary arrived or the request simply ended.
+ let closing = false;
+ const wrapUp = () => {
+ if (closing || settled) return;
+ closing = true;
+ endPart();
+ if (sink) sink.end(() => finish(collect()));
+ else finish(collect());
+ };
+
+ let received = 0;
+ function onData(chunk) {
+ if (settled) return;
+ received += chunk.length;
+ if (received > maxBytes) return fail('too_large', 'larger than this service accepts in one request');
+ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
+ loop();
+ if (state === 'done') wrapUp();
+ }
+
+ req.on('data', onData);
+ // A body that ends without its last boundary is still the form we were sent: what arrived is used.
+ req.on('end', wrapUp);
+ req.on('aborted', () => fail('aborted', 'the upload was interrupted'));
+ req.on('error', () => fail('aborted', 'the upload was interrupted'));
+ });
+}
+
+module.exports = { readFormData, boundaryOf };
@@ -1,42 +1,217 @@
@@ -1,42 +1,217 @@
'use strict';
'use strict';
/**
/**
- * Public pages: the home page and the update log. Crawl artifacts (robots.txt, sitemap.xml, llms.txt,
- * llms-full.txt, JSON-LD) are http/discovery.js.
+ * The pages of openvibe.download, plus the honest "coming" homes of openvibe.pics and openvibe.video (the host
+ * decides which, see server/brand.js).
*
*
- * Every page works without JavaScript and is server-rendered through openvibe-shared/shell (render/layout.js).
- * The product replaces the home page's sections and adds its own routes here.
+ * / what this is, the safety rules and how to sign in
+ * /files your drive: folders, files, the usage bar, the upload form
+ * /files/:id one file: its details, a share link, delete
+ * /files/:id/download its bytes
+ * /s/:slug a share link: what it is, who shared it, expiry, Download and Report
+ * /staff the report queue (OpenVibe staff)
+ * /safety the rules, what is not allowed, how reports work, the abuse contact
+ * /updates the update log
+ *
+ * Every page works without JavaScript and is server-rendered through openvibe-shared/shell: the upload form, the
+ * share form and the report form are plain POSTs. The one script is the chunked upload's progress meter
+ * (public/js/upload.js), and the plain form submits by itself when it is not there.
+ *
+ * A signed-in write must come from openvibe.download itself (same-origin), exactly as in the API, and the form
+ * takes the same per-caller budget as the API route it stands for, so it is not a way round a limit.
+ *
+ * Nothing here is hand-written copy about a file: a file's name is what its owner typed, escaped by the `html`
+ * template and nothing else. No file's bytes are ever rendered into a page.
*/
*/
+const express = require('express');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const crypto = require('crypto');
+const { Readable } = require('stream');
+const { pipeline } = require('stream/promises');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
-const { html, raw } = require('../render/html');
+const { sameOrigin } = require('./principal');
+const { html, raw, table, notice, time, badge } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
+const rules = require('../drive/rules');
+const { isStaff } = require('../drive/staff');
+const { boundaryOf, readFormData } = require('./form-upload');
+const { brandOf, COMING } = require('../brand');
const SITE_NAME = 'OpenVibe.MediaHub';
const SITE_NAME = 'OpenVibe.MediaHub';
const TAGLINE = 'Your files, shared on your terms.';
const TAGLINE = 'Your files, shared on your terms.';
+const ABUSE_EMAIL = '[email protected]';
+const DMCA_EMAIL = '[email protected]';
+
+/** The rules, word for word: shown on / and /safety, so the two can never drift. */
+const SAFETY_RULES = [
+ 'Anything you would not put in a public square does not belong here. This is a private drive with share links, not a place to hide.',
+ 'No malware, no phishing, no cracked software, no keygens, no "free" accounts.',
+ 'No child sexual abuse material, and nothing that sexualises minors. That is reported to the authorities, not just deleted.',
+ 'No content you do not have the right to share — pirated films, albums, books and software are removed when they are reported.',
+ 'Every download needs a signed-in OpenVibe account, and every share carries the name of the person who made it.',
+];
+const REPORT_NOTE = 'Every share page has a Report form. Three different people reporting a share — or one report of illegal content — suspends it at once and puts it in front of OpenVibe staff.';
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config } = ctx;
+ const { config, s, drive, uploads, media } = ctx;
const r = asyncRouter();
const r = asyncRouter();
+ // The pages' forms are web forms (no JavaScript): urlencoded text, and one multipart file upload whose body the
+ // upload route reads itself. The JSON parser is deliberately absent here.
+ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
- const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
+ const staffViewer = (req) => isStaff(req.viewer);
+ const requesterOf = (req) => `user:${req.viewer.subject}`;
+ const viewerOf = (req) => ({ subject: req.viewer.subject, username: req.viewer.username || null });
+
+ // ── Small pieces ─────────────────────────────────────────
+ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
+ const fileSize = (row) => rules.formatBytes(row.size);
+
+ /** The usage bar. A <progress> element: it means what it says without a stylesheet and without JavaScript. */
+ const usageBar = (u) => html`<div class="usage">
+<progress id="usage" max="${u.bytes_limit}" value="${u.bytes_used}" aria-describedby="usage-text">${Math.round((u.bytes_used / u.bytes_limit) * 100)}%</progress>
+<p id="usage-text" class="small muted">${rules.formatBytes(u.bytes_used)} of ${rules.formatBytes(u.bytes_limit)} used · ${u.files} file${u.files === 1 ? '' : 's'} · ${u.uploads_today} of ${u.uploads_per_day} uploads today · ${u.live_shares} of ${u.max_shares} live share links</p></div>`;
+
+ const SHARE_STATE = { live: ['live', 'ok'], expired: ['expired', 'warn'], revoked: ['revoked', 'bad'], suspended: ['suspended — with staff', 'bad'] };
+ const shareBadge = (state) => badge(SHARE_STATE[state] ? SHARE_STATE[state][0] : state, SHARE_STATE[state] ? SHARE_STATE[state][1] : '');
+
+ const fileRow = (row) => html`<li class="file">
+<span class="file-main"><a class="file-name" href="/files/${row.id}">${row.name}</a>
+<span class="file-meta small muted">${fileSize(row)} · ${row.content_type} · added ${time(row.created_at)}</span></span>
+<a class="sc-btn" href="/files/${row.id}/download">Download</a></li>`;
+
+ /** The same per-caller budget the API applies, given an HTML face. */
+ const budgeted = (name) => {
+ const limit = ctx.limits.budget(name);
+ return (req, res, next) => {
+ let settled = false;
+ const refusal = (body) => {
+ let p = null;
+ try { p = JSON.parse(String(body)); } catch { p = null; }
+ if (p && p.retry_after_seconds) res.set('Retry-After', String(p.retry_after_seconds));
+ return page(req, res, {
+ title: 'Too many requests',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Too many requests' }],
+ body: html`<h1>Too many requests just now</h1>
+${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
+<p class="muted">Wait a moment and send it again — nothing was lost. <a href="/files">Your files</a> are still there.</p>`,
+ }, 429);
+ };
+ limit(req, {
+ statusCode: 0,
+ setHeader: () => {}, getHeader: () => undefined, removeHeader: () => {},
+ end: (body) => { if (!settled) { settled = true; refusal(body); } },
+ }, (err) => {
+ if (settled) return undefined;
+ settled = true;
+ return err ? next(err) : next();
+ });
+ return undefined;
+ };
+ };
+
+ /** A signed-in page write must come from this site, exactly as in the API. */
+ function sameSite(req, res, back) {
+ if (sameOrigin(req, config.baseUrl)) return true;
+ page(req, res, {
+ title: 'Not from this site',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Not from this site' }],
+ body: html`<h1>That came from somewhere else</h1><p class="muted">A signed-in page that changes something has to come from openvibe.download itself. <a href="${back}">Go back</a> and try again.</p>`,
+ }, 403);
+ return false;
+ }
+
+ /**
+ * The signed-in gate. Every page that shows a file answers this to a guest — there is no anonymous anything.
+ * `back` is where sign-in returns to: a fixed page, or the current URL for a share link.
+ */
+ const mustSignIn = (back, why = 'A file belongs to your OpenVibe account, so you can share it, download it or delete it later.') =>
+ (req, res, next) => (signedIn(req) ? next()
+ : page(req, res, {
+ title: 'Sign in',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Sign in' }],
+ body: html`<h1>Sign in first</h1>${signInPrompt(why, back === 'here' ? req.originalUrl : back)}`,
+ }, 401));
+
+ // ── The unlaunched brands ────────────────────────────────
+ /**
+ * openvibe.pics and openvibe.video answer an honest "coming" page and nothing else: no drive, no share links,
+ * no uploads. /updates and the crawl artifacts are the same on every brand, and everything else 404s — the
+ * product has not shipped there, so there is nothing to offer but the truth.
+ */
+ const PASS_THROUGH = new Set(['/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt', '/release.json', '/auth/me', '/favicon.ico']);
+ r.use((req, res, next) => {
+ if (brandOf(req) === 'download') return next();
+ if (PASS_THROUGH.has(req.path)) return next();
+ if (req.path === '/') return next(); // the brand's own home, below
+ if (req.path === '/updates') return next();
+ const brand = COMING[brandOf(req)];
+ return page(req, res, {
+ title: `${brand.name} is coming`,
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Coming' }],
+ body: html`<h1>${brand.name} is not open yet</h1>
+${notice(`${brand.name} has no uploads and no file pages yet. The only brand of this service that has launched is OpenVibe.Download.`, 'warn')}
+<p class="muted">This host serves ${brand.domain}, one of the three brands of the same OpenVibe.MediaHub service. <a href="https://openvibe.download">Go to OpenVibe.Download</a> for the part that works today.</p>`,
+ }, 404);
+ });
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
+ const comingBody = (brand) => html`${raw(showcase.hero({
+ eyebrow: `${brand.name} · ${brand.tagline}`,
+ title: brand.name,
+ accent: 'coming soon',
+ lede: brand.what,
+ actions: [{ label: 'OpenVibe.Download is open', href: 'https://openvibe.download', primary: true }, { label: 'What shipped', href: '/updates' }],
+ note: 'Nothing is uploaded here yet and there is no way to sign in on this brand.',
+ }))}
+${raw(showcase.features({
+ title: 'What it will be',
+ lede: 'The shape of it, from the product\'s own plan.',
+ items: brand.pillars.map((p) => ({ icon: 'ov:page', title: p, text: '' })),
+ }))}
+${raw(showcase.features({
+ title: 'Why it is not open yet',
+ lede: 'Two things have to be true before OpenVibe runs a public place for other people\'s pictures and videos.',
+ items: [
+ { icon: 'ov:shield', title: 'Safety tooling first', text: 'There is no malware or CSAM scanning on the network yet, so uploads here wait for it. OpenVibe.Download opens first precisely because it is a private drive whose share links only signed-in people can open.' },
+ { icon: 'ov:page', title: 'One service, three brands', text: `${brand.name} is served by the same process as openvibe.download — the same file store, the same account and the same rules. This host answers only this page until that tooling is here.` },
+ ],
+ }))}
+${raw(showcase.cta({ title: 'In the meantime', text: 'OpenVibe.Media already stores and serves the network\'s pictures and video, and OpenVibe.Download is open for private files.', actions: [{ label: 'OpenVibe.Media', href: 'https://openvibe.media' }, { label: 'OpenVibe.Download', href: 'https://openvibe.download' }] }))}`;
+
+ r.get('/', (req, res, next) => {
+ const brand = COMING[brandOf(req)];
+ if (brand) {
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE, styles: [showcase.STYLESHEET],
+ title: `${brand.name} — coming soon`,
+ description: `${brand.name}: ${brand.tagline} Not open yet — uploads here wait for safety tooling. OpenVibe.Download is the brand of this service that has launched.`,
+ crumbs: [{ label: 'Home', href: '/' }],
+ body: comingBody(brand),
+ });
+ }
+ return next();
+ });
+
r.get('/', (req, res) => {
r.get('/', (req, res) => {
const hero = showcase.hero({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
- title: SITE_NAME,
+ title: 'OpenVibe.Download',
accent: TAGLINE,
accent: TAGLINE,
- lede: `${SITE_NAME} is a new OpenVibe service. This is its skeleton: the platform plumbing below is in place, and the product is what goes on top of it.`,
+ lede: 'A private drive for your files, with share links you control. Uploads and every file page need a signed-in OpenVibe account — there is no anonymous upload and no anonymous download here.',
actions: signedIn(req)
actions: signedIn(req)
- ? [{ label: 'Your account', href: '/auth/me' }, { label: 'What shipped', href: '/updates' }]
- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2F', primary: true }, { label: 'What shipped', href: '/updates' }],
- note: 'Open source (AGPL-3.0). Sign-in is OpenVibe.Network; a person\'s token stays in an httpOnly cookie.',
+ ? [{ label: 'Your files', href: '/files', primary: true }, { label: 'The safety rules', href: '/safety' }]
+ : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'The safety rules', href: '/safety' }],
+ note: 'The bytes live in OpenVibe.Media. This service holds the names, the folders and the share links.',
});
});
page(req, res, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
@@ -44,30 +219,525 @@ function createPageRoutes(ctx) {
@@ -44,30 +219,525 @@ function createPageRoutes(ctx) {
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
body: html`${raw(hero)}
${raw(showcase.features({
${raw(showcase.features({
- title: 'What is already here',
- lede: 'The parts every OpenVibe service needs, so a new product starts from them instead of rebuilding them.',
+ title: 'What you get',
+ lede: 'A drive, not a locker: everything here is yours, and everything you hand out can be taken back.',
items: [
items: [
- { icon: 'ov:account', title: 'Sign-in with OpenVibe.Network', text: 'OAuth 2 with PKCE (S256), server-side code exchange, httpOnly session cookies, and offline token verification against the Network signing key.' },
- { icon: 'ov:page', title: 'Server-rendered pages', text: 'The OpenVibe Frame (navbar and footer), the shared theme, one stylesheet, crawl artifacts and JSON-LD. Every page is complete without JavaScript.' },
- { icon: 'ov:tools', title: 'An API mount and capabilities', text: 'GET /api/v1/ping today; the router, the problem+json errors and the app/person capability guard are wired and waiting for the product\'s routes.' },
- { icon: 'ov:gauge', title: 'Limits and readiness', text: 'Per-address and per-caller rate limits, /api/health, a truthful /api/ready and Prometheus metrics on loopback.' },
- { icon: 'ov:db', title: 'PostgreSQL and migrations', text: 'openvibe-sdk/db, migrations/NNNN_*.sql applied at boot, PGlite in development, and a store with an injectable clock.' },
- { icon: 'ov:deploy', title: 'Deploy files and tests', text: 'nginx and systemd units, an environment template, and a test suite on PGlite with a mock Network, all through npm test.' },
+ { icon: 'ov:upload', title: 'Your own drive', text: 'Folders and files, private to you. 2 GB stored and 512 MB in one file, from this service\'s own configuration.' },
+ { icon: 'ov:link', title: 'Share links', text: 'One link per file or folder, expiring in an hour to seven days, revocable at any moment, with its downloads counted.' },
+ { icon: 'ov:account', title: 'Signed in, always', text: 'Nobody opens a share link without an OpenVibe account. No app tokens, no anonymous anything — a shared link still needs a person.' },
+ { icon: 'ov:shield', title: 'Reports that act', text: REPORT_NOTE },
+ { icon: 'ov:tools', title: 'A drive with an API', text: 'The same actions over /api/v1: list, upload, share, revoke, and see your usage.' },
+ { icon: 'ov:db', title: 'Bytes in OpenVibe.Media', text: 'Files are objects in the network\'s own media service; deleting one here asks Media to delete it too.' },
],
],
}))}
}))}
${raw(showcase.steps({
${raw(showcase.steps({
- title: 'How a new service starts',
+ title: 'How it works',
+ lede: 'Four steps, and the last one is the point.',
items: [
items: [
- { title: 'Generate it', text: 'node scripts/new-service.js replaces the skeleton\'s tokens (service id, name, domain, port, tagline, env prefix) in every file and file name.' },
- { title: 'Install and run', text: 'npm install, then npm run dev: the service listens on its port with an embedded PGlite database, if no DATABASE_URL is set.' },
- { title: 'Make it a product', text: 'Add the product\'s tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js.' },
- { title: 'Grant and deploy', text: 'Register the service and its capabilities with OpenVibe.Contracts and OpenVibe.Services, then install the unit and the vhost.' },
+ { title: 'Sign in', text: 'OpenVibe.Network signs you in. Your session stays in an httpOnly cookie on this site.' },
+ { title: 'Upload', text: 'One request for a file under the single-request limit; a big file goes up in parts with a progress meter. Both need a signed-in person.' },
+ { title: 'Share', text: 'Make a link that expires in an hour, a day or a week, and optionally name the OpenVibe usernames allowed to open it.' },
+ { title: 'Take it back', text: 'Revoke a link and it stops working at once. Delete a file and its shares go with it, and Media is asked to drop the bytes.' },
],
],
}))}
}))}
-${raw(showcase.cta({ title: 'Read the API', text: 'GET /api/v1/ping is the whole API today. The rest is what the product adds.', actions: [{ label: 'The API', href: '/api/v1/ping' }, { label: 'What shipped', href: '/updates' }] }))}`,
+${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is not allowed, how reports work, and who to write to.', actions: [{ label: 'The safety rules', href: '/safety' }, { label: 'What shipped', href: '/updates' }] }))}`,
+ });
+ });
+
+ // ── The drive ────────────────────────────────────────────
+ /** The folder a request is looking at, if it is the person's own. */
+ async function folderOf(req) {
+ const want = String(req.query.folder || req.body.folder || '');
+ if (!want) return { folder: null, path: [] };
+ const folder = await drive.ownFolder(requesterOf(req), want);
+ if (!folder) return { folder: null, path: [] };
+ return { folder, path: await drive.folderPath(requesterOf(req), folder.id) };
+ }
+
+ const uploadForm = (folder) => html`<form class="card upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>
+<h2>Upload a file</h2>
+${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
+<div class="field"><label for="file">File</label><input id="file" name="file" type="file" required></div>
+<div class="field"><label for="name">Name (optional)</label><input id="name" name="name" type="text" maxlength="${rules.NAME_MAX}" placeholder="leave empty to keep the file's own name"></div>
+<p><button class="sc-btn sc-primary" type="submit">Upload</button></p>
+<p class="small muted">Up to ${rules.formatBytes(config.quotas.maxFileBytes)} per file and ${rules.formatBytes(config.quotas.bytes)} in total, ${config.quotas.uploadsPerDay} uploads a day. Files over ${rules.formatBytes(config.singleUploadBytes)} go up in parts, which needs JavaScript; without it, the browser sends one request and files that size are refused.</p>
+<progress id="upload-progress" max="100" value="0" hidden></progress>
+<p id="upload-status" class="small muted" role="status"></p>
+</form>`;
+
+ r.get('/files', mustSignIn('/files'), async (req, res) => {
+ const owner = requesterOf(req);
+ const { folder, path } = await folderOf(req);
+ const folders = (await drive.listFolders(owner)).filter((f) => (f.parent_id || null) === (folder ? folder.id : null));
+ const files = await drive.listFiles(owner, { folderId: folder ? folder.id : null, limit: 200 });
+ const shares = (await drive.listShares(owner)).filter((sh) => drive.stateOf(sh, s.iso()) === 'live');
+ const crumbs = [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }];
+ for (const f of path) crumbs.push({ label: f.name, href: `/files?folder=${encodeURIComponent(f.id)}` });
+ return page(req, res, {
+ title: folder ? `${folder.name} · Your files` : 'Your files',
+ crumbs,
+ // The one page script: it turns the upload form into a chunked, resumable, progressive upload. The
+ // form itself works without it.
+ scripts: ['js/upload.js'],
+ body: html`<h1>${folder ? folder.name : 'Your files'}</h1>
+${usageBar(await drive.usage(owner))}
+${folder ? html`<p><a href="/files">← All files</a></p>` : ''}
+<section aria-labelledby="h-upload">${uploadForm(folder)}</section>
+<section aria-labelledby="h-folders"><h2 id="h-folders">Folders</h2>
+${folders.length ? html`<ul class="file-list">${folders.map((f) => html`<li class="file"><span class="file-main"><a class="file-name" href="/files?folder=${encodeURIComponent(f.id)}">${f.name}</a><span class="file-meta small muted">folder</span></span></li>`)}</ul>` : html`<p class="muted">No folders here yet.</p>`}
+<form class="inline" method="post" action="/folders">
+${folder ? html`<input type="hidden" name="parent" value="${folder.id}">` : ''}
+<label for="new-folder">New folder</label> <input id="new-folder" name="name" type="text" maxlength="${rules.FOLDER_NAME_MAX}" required> <button class="sc-btn" type="submit">Create</button>
+</form></section>
+<section aria-labelledby="h-files"><h2 id="h-files">Files</h2>
+${files.length ? html`<ul class="file-list">${files.map(fileRow)}</ul>` : html`<p class="muted">No files here yet.</p>`}</section>
+<section aria-labelledby="h-shares"><h2 id="h-shares">Live share links</h2>
+${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li class="file"><span class="file-main"><a class="file-name" href="/s/${sh.slug}">/s/${sh.slug}</a>
+<span class="file-meta small muted">expires ${time(sh.expires_at)} · ${Number(sh.downloads)} download${Number(sh.downloads) === 1 ? '' : 's'}${sh.allowed_subjects ? ' · limited to named people' : ''}</span></span>
+<form class="inline" method="post" action="/shares/${sh.slug}/revoke"><button class="sc-btn" type="submit">Revoke</button></form></li>`)}</ul>` : html`<p class="muted">No live share links. Open a file to make one.</p>`}</section>`,
+ });
+ });
+
+ r.get('/files/:id', mustSignIn('/files'), async (req, res) => {
+ const owner = requesterOf(req);
+ const row = await drive.ownFile(owner, String(req.params.id || ''));
+ if (!row) return notFoundFile(req, res);
+ const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);
+ return page(req, res, {
+ title: row.name,
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],
+ body: html`<h1>${row.name}</h1>
+${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type, row.sha256 || '—', time(row.created_at)]])}
+<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a></p>
+<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2>
+<form method="post" action="/files/${row.id}/share">
+<div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>
+<div class="field"><label for="usernames">Limited to OpenVibe usernames (optional)</label><input id="usernames" name="usernames" type="text" maxlength="300" placeholder="kim, sam"><p class="small muted">Leave empty for any signed-in person with the link. Everyone who opens it still has to be signed in.</p></div>
+<p><button class="sc-btn sc-primary" type="submit">Create the link</button></p>
+</form>
+${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh) => html`<li class="file">${shareBadge(drive.stateOf(sh, s.iso()))} <a href="/s/${sh.slug}">/s/${sh.slug}</a> <span class="small muted">expires ${time(sh.expires_at)} · ${Number(sh.downloads)} downloads</span></li>`)}</ul>` : ''}
+</section>
+<section aria-labelledby="h-delete"><h2 id="h-delete">Delete</h2>
+<p class="muted">Deleting removes every share link to this file at once, and asks OpenVibe.Media to delete the object.</p>
+<form method="post" action="/files/${row.id}/delete"><button class="sc-btn" type="submit">Delete this file</button></form></section>`,
+ });
+ });
+
+ function notFoundFile(req, res) {
+ return page(req, res, {
+ title: 'No such file',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],
+ body: html`<h1>No such file</h1><p class="muted">It is not yours, or it is gone. <a href="/files">Your files</a>.</p>`,
+ }, 404);
+ }
+
+ // ── Uploads from the page ────────────────────────────────
+ /** Where a no-JavaScript upload is spooled before it goes to Media, part by part. */
+ const spoolDir = () => config.tmpDir || path.join(os.tmpdir(), 'mediahub-uploads');
+
+ r.post('/files/upload', mustSignIn('/files'), budgeted('media-hub.upload.start'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ const type = String(req.get('content-type') || '');
+ const boundary = /^multipart\/form-data/i.test(type) ? boundaryOf(type) : null;
+ if (!boundary) return uploadFailed(req, res, 'That was not a file upload. Pick a file and send the form again.');
+
+ // A request bigger than this is refused before it is read: nginx caps it too (deploy/nginx).
+ const declared = Number(req.get('content-length'));
+ if (Number.isFinite(declared) && declared > config.singleUploadBytes + 64 * 1024) {
+ return uploadFailed(req, res, `A form upload is at most ${rules.formatBytes(config.singleUploadBytes)}. Larger files go up in parts — the upload form on this page does that with JavaScript on.`);
+ }
+
+ fs.mkdirSync(spoolDir(), { recursive: true });
+ const tmp = path.join(spoolDir(), `form-${crypto.randomBytes(8).toString('hex')}`);
+ const got = await readFormData(req, { boundary, filePath: tmp, maxBytes: config.singleUploadBytes + 64 * 1024 });
+ try {
+ if (got.error === 'too_large') return uploadFailed(req, res, `A form upload is at most ${rules.formatBytes(config.singleUploadBytes)}. Larger files go up in parts — the upload form on this page does that with JavaScript on.`);
+ if (got.error) return uploadFailed(req, res, 'The upload did not arrive complete. Try again.');
+ if (!got.file || !got.file.size) return uploadFailed(req, res, 'No file was in that form, or it was empty.');
+
+ const owner = requesterOf(req);
+ const { folder } = await folderOf(req);
+ const out = await uploads.uploadSpooled({
+ owner, file: got.file.path, size: got.file.size,
+ name: rules.fileName(got.fields.name || got.file.filename, got.file.filename),
+ contentType: rules.normalizeType(got.file.contentType) || 'application/octet-stream',
+ folderId: folder ? folder.id : null,
+ });
+ // A refusal by our own rules is shown as it came; a Media outage is one sentence, not Media's own words.
+ if (!out.ok) return uploadFailed(req, res, String(out.code).startsWith('media.')
+ ? `OpenVibe.Media could not store that just now (${out.code}). Nothing was kept here — try again in a moment.`
+ : (out.detail || 'The upload could not be stored.'));
+ return res.redirect(303, `/files/${out.file.id}`);
+ } finally {
+ fs.promises.unlink(got.file && got.file.path ? got.file.path : tmp).catch(() => {});
+ }
+ });
+
+ const uploadFailed = (req, res, detail) => page(req, res, {
+ title: 'The upload did not go through',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],
+ body: html`<h1>That upload did not go through</h1>${notice(detail, 'warn')}<p><a class="sc-btn" href="/files">Back to your files</a></p>`,
+ }, 422);
+
+ r.post('/files/:id/delete', mustSignIn('/files'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
+ if (!row) return notFoundFile(req, res);
+ await drive.deleteFile(requesterOf(req), row.id);
+ return res.redirect(303, '/files');
+ });
+
+ r.post('/files/:id/share', mustSignIn('/files'), budgeted('media-hub.share.create'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ const owner = requesterOf(req);
+ const row = await drive.ownFile(owner, String(req.params.id || ''));
+ if (!row) return notFoundFile(req, res);
+ const names = String((req.body || {}).usernames || '').split(/[\s,]+/).filter(Boolean).slice(0, 50);
+ const out = await drive.createShare(owner, {
+ fileId: row.id, hours: (req.body || {}).hours, names, ownerUsername: req.viewer.username || null,
});
});
+ if (!out.ok) {
+ return page(req, res, {
+ title: 'The link was not made',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name, href: `/files/${row.id}` }],
+ body: html`<h1>The link was not made</h1>${notice(out.detail, 'warn')}<p><a class="sc-btn" href="/files/${row.id}">Back to the file</a></p>`,
+ }, out.code === 'share.limit' ? 429 : 422);
+ }
+ return page(req, res, {
+ title: 'Share link made',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name, href: `/files/${row.id}` }],
+ body: html`<h1>Share link made</h1>
+<p>The link is <a href="/s/${out.share.slug}"><code>/s/${out.share.slug}</code></a> — it expires ${time(out.share.expires_at)}${out.share.allowed_subjects ? ' and only the people you named can open it' : ''}.</p>
+${out.unresolved && out.unresolved.length ? notice(html`Network could not turn these names into subjects: ${out.unresolved.join(', ')}. The link still allows them by username.`, 'warn') : ''}
+<p class="muted">Everyone who opens it has to be signed in to OpenVibe before they can download.</p>
+<p><a class="sc-btn sc-primary" href="/s/${out.share.slug}">Open the share page</a> <a class="sc-btn" href="/files/${row.id}">Back to the file</a></p>`,
+ });
+ });
+
+ r.post('/folders', mustSignIn('/files'), budgeted('media-hub.folder.create'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ const { folder } = await folderOf(req);
+ const out = await drive.createFolder(requesterOf(req), { name: (req.body || {}).name, parentId: folder ? folder.id : null });
+ if (out.error) {
+ return page(req, res, {
+ title: 'The folder was not made',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],
+ body: html`<h1>The folder was not made</h1>${notice(out.detail, 'warn')}<p><a class="sc-btn" href="/files">Back to your files</a></p>`,
+ }, out.error === 'folder.exists' ? 409 : 422);
+ }
+ return res.redirect(303, folder ? `/files?folder=${encodeURIComponent(folder.id)}` : '/files');
+ });
+
+ r.post('/shares/:slug/revoke', mustSignIn('/files'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ await drive.revokeShare(requesterOf(req), String(req.params.slug || ''));
+ return res.redirect(303, '/files');
});
});
+ // ── Downloading ──────────────────────────────────────────
+ /**
+ * Send a file's bytes. MediaHub is in the path on purpose: the browser-facing headers are the security policy
+ * (always an attachment, never a sniffed type, the real type only for a short safe list), and this is the only
+ * place that can guarantee them whatever Media does with the object. Media's own signed URL is used underneath,
+ * on the configured Media host, and is never handed to a browser.
+ */
+ async function sendFile(req, res, row) {
+ const got = await media.bytes(row.object_id, { range: req.get('range') });
+ if (!got.ok) {
+ // Media's own words for "there is nothing to send": the file is gone, not our problem to hide.
+ const gone = got.status === 404 || got.status === 410 || got.code === 'media.object.deleted' || got.code === 'media.object.not_found';
+ const status = gone ? 410 : 502;
+ return res.status(status).type('text/plain').send(status === 410
+ ? 'The bytes of that file are gone from OpenVibe.Media.'
+ : 'OpenVibe.Media did not answer. Try again in a moment.');
+ }
+ const upstream = got.res;
+ res.status(upstream.status === 206 ? 206 : 200);
+ res.set({
+ // A safe type keeps its own Content-Type so the downloaded file opens as what it is; everything else
+ // (HTML, SVG, XML, scripts) is an opaque blob. It is an attachment either way: nothing here is ever
+ // rendered in a page, on this site or anywhere else.
+ 'Content-Type': rules.safeContentType(row.content_type) || 'application/octet-stream',
+ 'Content-Disposition': `attachment; filename="${rules.headerSafeName(row.name)}"`,
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Robots-Tag': 'noindex',
+ 'Cache-Control': 'private, no-store',
+ });
+ for (const [from, to] of [['content-length', 'Content-Length'], ['content-range', 'Content-Range'], ['accept-ranges', 'Accept-Ranges']]) {
+ const v = upstream.headers.get(from);
+ if (v) res.set(to, v);
+ }
+ if (!upstream.body) return res.end();
+ try {
+ await pipeline(Readable.fromWeb(upstream.body), res);
+ } catch {
+ res.destroy(); // the reader went away mid-download: nothing to say to anybody
+ }
+ return undefined;
+ }
+
+ r.get('/files/:id/download', mustSignIn('/files'), async (req, res) => {
+ const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
+ if (!row) return notFoundFile(req, res);
+ return sendFile(req, res, row);
+ });
+
+ // ── Share links ──────────────────────────────────────────
+ /**
+ * The share a visitor is looking at, and whether they may open it. A share page needs a signed-in person like
+ * every other file page: the link is what makes the file reachable at all, and being signed in is what makes
+ * the download attributable.
+ */
+ async function openShare(req, res) {
+ const viewer = viewerOf(req);
+ const slug = String(req.params.slug || '');
+ const share = rules.isSlug(slug) ? await drive.shareBySlug(slug) : null;
+ const state = drive.stateOf(share, s.iso());
+ if (!share || state === 'revoked' || state === 'expired') {
+ page(req, res, {
+ title: 'Link not available',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link' }],
+ body: html`<h1>That link is not available</h1><p class="muted">It has expired or been revoked by the person who made it. <a href="/">OpenVibe.Download</a>.</p>`,
+ }, 404);
+ return null;
+ }
+ if (state === 'suspended') {
+ page(req, res, {
+ title: 'Link suspended',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link' }],
+ body: html`<h1>This link is suspended</h1>${notice('People reported it, so it is with OpenVibe staff until they have looked at it. Nothing can be downloaded from it in the meantime.', 'warn')}<p class="muted"><a href="/safety">What happens when you report something</a>.</p>`,
+ }, 403);
+ return null;
+ }
+ if (!drive.allowedFor(share, viewer)) {
+ page(req, res, {
+ title: 'Not shared with you',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link' }],
+ body: html`<h1>This link is not for you</h1><p class="muted">It was shared with particular OpenVibe accounts. You are signed in as <strong>${req.viewer.username || 'you'}</strong>. <a href="/files">Your files</a>.</p>`,
+ }, 403);
+ return null;
+ }
+ return share;
+ }
+
+ r.get('/s/:slug', mustSignIn('here', 'A share link still needs an OpenVibe account: uploads, downloads and reports here all belong to a signed-in person.'), async (req, res) => {
+ const share = await openShare(req, res);
+ if (!share) return undefined;
+ const mine = share.owner === requesterOf(req);
+ if (share.file_id) {
+ const file = await fileById(share);
+ if (!file) return shareGone(req, res, share);
+ return page(req, res, {
+ title: `${file.name} · shared by ${share.owner_username || 'an OpenVibe member'}`,
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],
+ body: html`<h1>${file.name}</h1>
+${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
+${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}
+<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>
+${mine ? '' : reportForm(share)}
+<p class="small muted">${REPORT_NOTE}</p>`,
+ });
+ }
+ const files = await drive.listFiles(share.owner, { folderId: share.folder_id, limit: 200 });
+ const folder = await drive.ownFolder(share.owner, share.folder_id);
+ return page(req, res, {
+ title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder' }],
+ body: html`<h1>${folder ? folder.name : 'A folder'}</h1>
+${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
+<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)}</p>
+${files.length ? html`<ul class="file-list">${files.map((f) => html`<li class="file"><span class="file-main"><span class="file-name">${f.name}</span><span class="file-meta small muted">${fileSize(f)} · ${f.content_type}</span></span><a class="sc-btn" href="/s/${share.slug}/f/${f.id}/download">Download</a></li>`)}</ul>` : html`<p class="muted">The folder is empty.</p>`}
+${mine ? '' : reportForm(share)}`,
+ });
+ });
+
+ /** The file row behind a file share, by its id — the owner's row, or null once it was deleted. */
+ async function fileById(share) {
+ const row = share.file_id ? await ctx.store.getFile(s, share.file_id) : null;
+ return row && !row.deleted_at ? row : null;
+ }
+
+ const shareGone = (req, res, share) => page(req, res, {
+ title: 'The file is gone',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${share.slug}` }],
+ body: html`<h1>The file behind this link is gone</h1><p class="muted">Its owner deleted it. <a href="/">OpenVibe.Download</a>.</p>`,
+ }, 410);
+
+ const reportForm = (share) => html`<section class="card" aria-labelledby="h-report"><h2 id="h-report">Report this</h2>
+<form method="post" action="/s/${share.slug}/report">
+<div class="field"><label for="reason">Why?</label><select id="reason" name="reason" required>${rules.REASONS.map((x) => html`<option value="${x}">${rules.REASON_TEXT[x]}</option>`)}</select></div>
+<div class="field"><label for="note">Anything to add? (optional)</label><textarea id="note" name="note" maxlength="500" rows="3"></textarea></div>
+<p><button class="sc-btn" type="submit">Report it</button></p>
+</form>
+<p class="small muted">${REPORT_NOTE}</p></section>`;
+
+ r.get('/s/:slug/download', mustSignIn('here', 'A share link still needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {
+ const share = await openShare(req, res);
+ if (!share) return undefined;
+ const file = share.file_id ? await fileById(share) : null;
+ if (!file) return shareGone(req, res, share);
+ await ctx.store.countDownload(s, share.slug);
+ return sendFile(req, res, file);
+ });
+
+ r.get('/s/:slug/f/:fileId/download', mustSignIn('here', 'A share link still needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {
+ const share = await openShare(req, res);
+ if (!share || !share.folder_id) return undefined;
+ const files = await drive.listFiles(share.owner, { folderId: share.folder_id, limit: 1000 });
+ const file = files.find((f) => f.id === String(req.params.fileId || ''));
+ if (!file) {
+ return page(req, res, {
+ title: 'No such file',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder', href: `/s/${share.slug}` }],
+ body: html`<h1>No such file in this share</h1><p class="muted"><a href="/s/${share.slug}">The share page</a>.</p>`,
+ }, 404);
+ }
+ await ctx.store.countDownload(s, share.slug);
+ return sendFile(req, res, file);
+ });
+
+ r.post('/s/:slug/report', mustSignIn('here'), async (req, res) => {
+ if (!sameSite(req, res, '/s')) return undefined;
+ const slug = String(req.params.slug || '');
+ const reason = String((req.body || {}).reason || '');
+ const note = rules.cleanText((req.body || {}).note, 500);
+ if (!rules.REASONS.includes(reason)) {
+ return page(req, res, {
+ title: 'That report was not sent',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],
+ body: html`<h1>That report was not sent</h1>${notice('Pick one of the reasons on the list.', 'warn')}<p><a class="sc-btn" href="/s/${slug}">Back to the share</a></p>`,
+ }, 422);
+ }
+ const out = await drive.report(slug, { reporter: requesterOf(req), reason, note });
+ if (out.error === 'share.own') {
+ return page(req, res, {
+ title: 'That is your share',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],
+ body: html`<h1>That is your own link</h1><p class="muted">You cannot report your own share. <a href="/files">Your files</a>.</p>`,
+ }, 403);
+ }
+ if (out.error === 'report.duplicate') {
+ return page(req, res, {
+ title: 'Already reported',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],
+ body: html`<h1>You have already reported this</h1><p class="muted">One report per person per link. Thank you. <a href="/s/${slug}">Back to the share</a>.</p>`,
+ }, 409);
+ }
+ if (out.error === 'share.revoked') {
+ return page(req, res, {
+ title: 'The link was taken down',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],
+ body: html`<h1>That link was already taken down</h1><p class="muted">Its owner revoked it. <a href="/">OpenVibe.Download</a>.</p>`,
+ }, 409);
+ }
+ if (out.error) {
+ return page(req, res, {
+ title: 'No such share',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Share link', href: `/s/${slug}` }],
+ body: html`<h1>No such share</h1><p class="muted"><a href="/">OpenVibe.Download</a>.</p>`,
+ }, 404);
+ }
+ return page(req, res, {
+ title: 'Report received',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report received' }],
+ body: html`<h1>Thank you — that is with us</h1>
+${notice(out.suspended
+ ? 'This share is suspended now and OpenVibe staff have been given it. Nothing can be downloaded from it until they have looked.'
+ : `That was report ${out.count}. A share is suspended at ${rules.SUSPEND_AT_REPORTS} distinct reports, or at once for illegal content.`, 'warn')}
+<p class="muted">Read <a href="/safety">how reports work</a>, or write to <a href="mailto:${ABUSE_EMAIL}">${ABUSE_EMAIL}</a>.</p>`,
+ });
+ });
+
+ // ── The report queue (staff) ─────────────────────────────
+ r.get('/staff', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, {
+ title: 'Report queue',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
+ body: html`<h1>The report queue</h1>${signInPrompt('The queue is for OpenVibe staff.', '/staff')}`,
+ }, 401);
+ if (!staffViewer(req)) return page(req, res, {
+ title: 'Staff only',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
+ body: html`<h1>Staff only</h1><p class="muted">The report queue is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Yours is <code>${req.viewer.role || 'user'}</code>. <a href="/files">Your files</a>.</p>`,
+ }, 403);
+ const rows = await ctx.store.staffQueue(s, { limit: 100 });
+ const counts = await ctx.store.staffCounts(s);
+ const items = [];
+ for (const row of rows) items.push({ row, reports: await drive.reportsFor(row.slug), file: row.file_id ? await fileById(row) : null });
+ return page(req, res, {
+ title: 'Report queue',
+ description: 'Share links people reported, for OpenVibe staff to restore or remove.',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
+ body: html`<h1>The report queue</h1>
+<p class="sc-lede">${counts.reported} share link${counts.reported === 1 ? '' : 's'} reported, ${counts.suspended} suspended, ${counts.revoked} revoked by their owner. A share is suspended at ${rules.SUSPEND_AT_REPORTS} distinct reports, or at once for illegal content.</p>
+${items.length ? items.map((it) => html`<section class="card staff-item">
+<h2><a href="/s/${it.row.slug}">/s/${it.row.slug}</a> ${shareBadge(drive.stateOf(it.row, s.iso()))}</h2>
+<p class="small muted">Shared by <code>${it.row.owner_username ? `@${it.row.owner_username}` : it.row.owner}</code> · expires ${time(it.row.expires_at)} · ${Number(it.row.downloads)} downloads · ${Number(it.row.report_count)} report${Number(it.row.report_count) === 1 ? '' : 's'}</p>
+${it.file ? html`<p><b>${it.file.name}</b> · ${rules.formatBytes(it.file.size)} · ${it.file.content_type} · object <code>${it.file.object_id}</code></p>` : html`<p class="muted">The file behind this link is gone, or it is a folder share.</p>`}
+<ul class="report-list small">${it.reports.map((rp) => html`<li><b>${rp.reason}</b> · <code>${rp.reporter}</code> · ${time(rp.created_at)}${rp.note ? html` — ${rp.note}` : ''}</li>`)}</ul>
+<form class="inline" method="post" action="/staff/${it.row.slug}/restore"><button class="sc-btn" type="submit">Restore it</button></form>
+<form class="inline" method="post" action="/staff/${it.row.slug}/remove"><button class="sc-btn" type="submit">Remove the file</button></form>
+</section>`) : html`<p class="muted">Nothing has been reported.</p>`}`,
+ });
+ });
+
+ r.post('/staff/:slug/restore', async (req, res) => {
+ if (!signedIn(req) || !staffViewer(req)) return staffOnly(req, res);
+ if (!sameSite(req, res, '/staff')) return undefined;
+ await drive.restoreShare(String(req.params.slug || ''));
+ return res.redirect(303, '/staff');
+ });
+
+ r.post('/staff/:slug/remove', async (req, res) => {
+ if (!signedIn(req) || !staffViewer(req)) return staffOnly(req, res);
+ if (!sameSite(req, res, '/staff')) return undefined;
+ await drive.removeShared(String(req.params.slug || ''));
+ return res.redirect(303, '/staff');
+ });
+
+ const staffOnly = (req, res) => page(req, res, {
+ title: 'Staff only',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
+ body: html`<h1>Staff only</h1><p class="muted">Restoring or removing a share is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>.</p>`,
+ }, signedIn(req) ? 403 : 401);
+
+ // ── Safety ───────────────────────────────────────────────
+ r.get('/safety', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'Safety rules',
+ description: 'What OpenVibe.Download allows, what it does not, how reports work, and who to write to.',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Safety' }],
+ styles: [showcase.STYLESHEET],
+ body: html`<h1>The rules of OpenVibe.Download</h1>
+${notice('This is a private drive with share links. It is not a place to hide, and nothing here is anonymous.', 'warn')}
+<section class="sc-sec" aria-labelledby="h-rules"><h2 id="h-rules">What is not allowed</h2>
+<ul class="prose">${SAFETY_RULES.map((x) => html`<li>${x}</li>`)}</ul></section>
+<section class="sc-sec" aria-labelledby="h-uploads"><h2 id="h-uploads">Who can do what</h2>
+<ul class="prose">
+<li><b>Every upload needs a signed-in OpenVibe account.</b> There is no anonymous upload and no app or service token that can post a file here.</li>
+<li><b>Every file is private to the person who uploaded it.</b> Nobody else sees it in a list, and nobody else can download it.</li>
+<li><b>Every download needs a signed-in OpenVibe account too.</b> A share link does not open a file on its own: the person opening it must be signed in, and the share page says who shared it.</li>
+<li><b>A share can be limited to named OpenVibe usernames</b>, expire in an hour to seven days, and be revoked at any moment — after which it stops working at once.</li>
+<li><b>Downloads never happen in a page.</b> A file is always sent as an attachment with a no-sniff header, and a type that is not on a short safe list is sent as an opaque blob. Uploaded HTML and SVG are never rendered.</li>
+</ul></section>
+<section class="sc-sec" aria-labelledby="h-reports"><h2 id="h-reports">How reports work</h2>
+<p class="sc-prose">${REPORT_NOTE} A report says why: malware, illegal content, copyright, or something else, with room to explain in up to 500 characters. You can report a share once, and reporting your own share is refused.</p>
+<p class="sc-prose">Reports are read by OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Staff can restore a share (the report stays on the record) or remove the file, which deletes it for its owner as well and asks OpenVibe.Media to delete the object.</p></section>
+<section class="sc-sec" aria-labelledby="h-limits"><h2 id="h-limits">The limits</h2>
+<ul class="prose">
+<li>${rules.formatBytes(config.quotas.bytes)} stored per person.</li>
+<li>${rules.formatBytes(config.quotas.maxFileBytes)} in one file.</li>
+<li>${config.quotas.uploadsPerDay} uploads a day, from midnight UTC.</li>
+<li>${config.quotas.maxShares} live share links at a time.</li>
+<li>A share expires in ${rules.EXPIRY_MIN_HOURS} hour${rules.EXPIRY_MIN_HOURS === 1 ? '' : 's'} to ${rules.EXPIRY_MAX_HOURS / 24} days, seven by default.</li>
+</ul>
+<p class="sc-prose">The bytes live in <a href="https://openvibe.media">OpenVibe.Media</a>, the network's own media service. Deleting a file here asks Media to delete its object; Media keeps its own retention period before the bytes are purged.</p></section>
+<section class="sc-sec" aria-labelledby="h-contact"><h2 id="h-contact">Report abuse, or a copyright problem</h2>
+<p class="sc-prose">Write to <a href="mailto:${ABUSE_EMAIL}">${ABUSE_EMAIL}</a> for anything that needs a human now — illegal content, malware, a threat. Copyright complaints go to <a href="mailto:${DMCA_EMAIL}">${DMCA_EMAIL}</a>; say which link, and what right you hold. The Report form on any share page is faster for everything else.</p></section>
+<section class="sc-sec" aria-labelledby="h-account"><h2 id="h-account">Your account, your data</h2>
+<p class="sc-prose">Your files, folders, share links, reports and upload counts are yours to export or delete with your OpenVibe account (ADR-033): deleting the account deletes them here and asks OpenVibe.Media to delete the objects. Read more in the <a href="https://openvibe.network">account settings on OpenVibe.Network</a>.</p></section>`,
+ }));
+
// ── The update log ───────────────────────────────────────
// ── The update log ───────────────────────────────────────
r.get('/updates', (req, res) => page(req, res, {
r.get('/updates', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
@@ -80,4 +750,4 @@ ${raw(showcase.cta({ title: 'Read the API', text: 'GET /api/v1/ping is the whole
@@ -80,4 +750,4 @@ ${raw(showcase.cta({ title: 'Read the API', text: 'GET /api/v1/ping is the whole
return r;
return r;
}
}
-module.exports = { createPageRoutes };
+module.exports = { createPageRoutes, SAFETY_RULES, ABUSE_EMAIL };
@@ -3,8 +3,12 @@
@@ -3,8 +3,12 @@
/**
/**
* Who is calling /api/v1 — req.principal:
* Who is calling /api/v1 — req.principal:
*
*
- * { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,
- * or this site's session cookie
+ * { kind: 'user', requester: 'user:usr_…', project: null, viaSession, username, role }
+ * a person: their Network token as a Bearer,
+ * or this site's session cookie. `username`
+ * and `role` are the token's own claims, so
+ * a share's allow-list and the staff check
+ * read the same thing whichever door was used.
* { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }
* { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }
* a Network app, agent or service token for
* a Network app, agent or service token for
* audience openvibe.download; each route names
* audience openvibe.download; each route names
@@ -31,6 +35,12 @@ function decodePayload(token) {
@@ -31,6 +35,12 @@ function decodePayload(token) {
try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
}
}
+/** The person's Network username claim, as a plain string — what a share's allow-list is matched against. */
+function usernameClaim(claims) {
+ return claims && typeof claims.username === 'string' && claims.username ? claims.username.slice(0, 64) : null;
+}
+const roleClaim = (claims) => (claims && typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user');
+
function requesterOfSub(sub) {
function requesterOfSub(sub) {
if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
return sub; // app:app_…, agent:agt_…, mod:mod_…
return sub; // app:app_…, agent:agt_…, mod:mod_…
@@ -63,11 +73,21 @@ function createPrincipal({ config, keys }) {
@@ -63,11 +73,21 @@ function createPrincipal({ config, keys }) {
if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
- return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };
+ return {
+ principal: {
+ kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false,
+ username: usernameClaim(v.claims), role: roleClaim(v.claims),
+ },
+ };
}
}
const viewer = req.viewer;
const viewer = req.viewer;
if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
- return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, viaSession: true } };
+ return {
+ principal: {
+ kind: 'user', requester: `user:${viewer.subject}`, project: null, viaSession: true,
+ username: usernameClaim(viewer), role: roleClaim(viewer),
+ },
+ };
}
}
return { principal: { kind: 'anonymous' } };
return { principal: { kind: 'anonymous' } };
}
}
@@ -98,4 +118,4 @@ function createPrincipal({ config, keys }) {
@@ -98,4 +118,4 @@ function createPrincipal({ config, keys }) {
return { middleware, resolve, requireCapability: require_ };
return { middleware, resolve, requireCapability: require_ };
}
}
-module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
+module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin, usernameClaim, roleClaim };
@@ -1,14 +1,16 @@
@@ -1,14 +1,16 @@
'use strict';
'use strict';
/**
/**
- * express.Router() whose get/post handlers may be async: a rejected promise goes to next(err) (the
- * app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
+ * express.Router() whose get/post/put/patch/delete handlers may be async: a rejected promise goes to next(err)
+ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
*/
*/
const express = require('express');
const express = require('express');
+const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
+
function asyncRouter() {
function asyncRouter() {
const r = express.Router();
const r = express.Router();
- for (const method of ['get', 'post']) {
+ for (const method of METHODS) {
const orig = r[method].bind(r);
const orig = r[method].bind(r);
r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+'use strict';
+
+/**
+ * OpenVibe.Media: where the bytes live. MediaHub calls Media's object API v2 **server-side only**, with its own
+ * app key (audience `media-hub`), and never hands that key, an upload token or a signed URL to a browser.
+ *
+ * init({ size, contentType, name, ownerSubject, multipart }) POST /api/v2/media-hub/objects
+ * putContent(objectId, buffer) PUT …/objects/:id/content
+ * complete(objectId) POST …/objects/:id/complete
+ * multipartStatus(objectId, uploadId) GET …/objects/:id/multipart/:uploadId
+ * putPart(objectId, uploadId, n, buffer) PUT …/objects/:id/multipart/:uploadId/parts/:n
+ * completeMultipart(objectId, uploadId) POST …/objects/:id/multipart/:uploadId/complete
+ * abortMultipart(objectId, uploadId) DELETE …/objects/:id/multipart/:uploadId
+ * get(objectId) GET …/objects/:id
+ * downloadUrl(objectId) GET …/objects/:id/download
+ * bytes(objectId, { range }) GET <signed URL on the Media host>
+ * remove(objectId) DELETE …/objects/:id (a soft delete in Media)
+ *
+ * Two details worth knowing. Media answers a private object's download with a signed URL on its *public* origin;
+ * this client rewrites that URL onto the configured Media host before fetching it, so the bytes are read from the
+ * one address MEDIAHUB_MEDIA_URL names and never from a URL a caller supplied. And a Media call never throws for
+ * a refusal: it answers { ok: false, status, code, detail } so the caller can show the problem as it came.
+ */
+const { ids } = require('openvibe-contracts');
+
+/** A user subject as Media wants it in X-OV-Subject: the bare usr_…, or null when it cannot be one. */
+function subjectHeader(requester) {
+ const parsed = ids.parseSubject(String(requester || ''));
+ if (parsed && parsed.type === 'user') return parsed.id;
+ const bare = String(requester || '').replace(/^user:/, '');
+ return ids.isSubjectId('user', bare) ? bare : null;
+}
+
+function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const media = config.media;
+ const base = () => `${media.url}/api/v2/${encodeURIComponent(media.app)}/objects`;
+ const enabled = Boolean(media.appKey);
+
+ /** One JSON call. Never throws for a Media refusal; throws only when Media cannot be reached at all. */
+ async function call(path, { method = 'GET', body, json, headers = {}, raw = false } = {}) {
+ if (!enabled) return { ok: false, status: 503, code: 'media.not_configured', detail: 'MEDIAHUB_MEDIA_APP_KEY is not set' };
+ const h = { Accept: 'application/json', Authorization: `Bearer ${media.appKey}`, ...headers };
+ let payload = body;
+ if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }
+ let res;
+ try {
+ res = await fetchImpl(`${base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(media.timeoutMs) });
+ } catch (err) {
+ log.warn('[MediaHub] Media did not answer:', (err && err.name) || '', (err && err.message) || '');
+ return { ok: false, status: 502, code: 'media.unreachable', detail: 'OpenVibe.Media did not answer' };
+ }
+ if (raw) return { ok: res.ok, status: res.status, res };
+ const data = await res.json().catch(() => null);
+ if (!res.ok) {
+ const p = (data && (data.problem || data)) || {};
+ return { ok: false, status: res.status, code: p.code || `media.http_${res.status}`, detail: p.detail || 'OpenVibe.Media refused the request' };
+ }
+ return { ok: true, status: res.status, data };
+ }
+
+ return {
+ enabled,
+
+ /** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */
+ async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null }) {
+ const subject = subjectHeader(ownerSubject);
+ if (!subject) return { ok: false, status: 400, code: 'file.bad_owner', detail: 'a file belongs to a person with a canonical subject' };
+ return await call('', {
+ method: 'POST',
+ headers: { 'X-OV-Subject': subject },
+ json: {
+ kind: 'file', visibility: 'private', size_bytes: size,
+ mime_type: contentType || null, filename: name, multipart: multipart === true,
+ ...(multipart && partSize ? { part_size: partSize } : {}),
+ },
+ });
+ },
+
+ /** The bytes of a single-part upload (exactly `size` bytes). */
+ async putContent(objectId, buffer) {
+ return await call(`/${encodeURIComponent(objectId)}/content`, {
+ method: 'PUT', body: buffer,
+ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.length) },
+ });
+ },
+
+ complete: (objectId) => call(`/${encodeURIComponent(objectId)}/complete`, { method: 'POST', json: {} }),
+
+ /** A fresh multipart session for an object created without one (or whose session expired). */
+ multipartStart: (objectId, { size, partSize }) => call(`/${encodeURIComponent(objectId)}/multipart`, { method: 'POST', json: { size_bytes: size, part_size: partSize } }),
+
+ multipartStatus: (objectId, uploadId) => call(`/${encodeURIComponent(objectId)}/multipart/${encodeURIComponent(uploadId)}`),
+
+ putPart: (objectId, uploadId, n, buffer) => call(`/${encodeURIComponent(objectId)}/multipart/${encodeURIComponent(uploadId)}/parts/${Number(n)}`, {
+ method: 'PUT', body: buffer,
+ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.length) },
+ }),
+
+ completeMultipart: (objectId, uploadId) => call(`/${encodeURIComponent(objectId)}/multipart/${encodeURIComponent(uploadId)}/complete`, { method: 'POST', json: {} }),
+
+ abortMultipart: (objectId, uploadId) => call(`/${encodeURIComponent(objectId)}/multipart/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }),
+
+ get: (objectId) => call(`/${encodeURIComponent(objectId)}`),
+
+ remove: (objectId) => call(`/${encodeURIComponent(objectId)}`, { method: 'DELETE' }),
+
+ /**
+ * Media's short-lived signed URL for an object's bytes, rewritten onto the configured Media host.
+ * The signature covers the object id and the expiry, not the host, so the bytes are read from
+ * MEDIAHUB_MEDIA_URL — the only host this service fetches bytes from.
+ */
+ async downloadUrl(objectId) {
+ const r = await call(`/${encodeURIComponent(objectId)}/download?format=json`);
+ if (!r.ok) return r;
+ const url = r.data && r.data.url;
+ if (!url) return { ok: false, status: 502, code: 'media.no_url', detail: 'OpenVibe.Media gave no download URL' };
+ let rewritten;
+ try {
+ const u = new URL(url);
+ rewritten = `${media.url}/o/${encodeURIComponent(objectId)}${u.search}`;
+ } catch {
+ return { ok: false, status: 502, code: 'media.no_url', detail: 'OpenVibe.Media gave an unusable download URL' };
+ }
+ return { ok: true, status: 200, url: rewritten, expires_at: (r.data && r.data.expires_at) || null };
+ },
+
+ /**
+ * The object's bytes, streamed. `range` (a Range header value) is passed through, so a browser can
+ * resume a download; the caller answers 206 from the upstream status and Content-Range.
+ */
+ async bytes(objectId, { range = null } = {}) {
+ const signed = await this.downloadUrl(objectId);
+ if (!signed.ok) return signed;
+ try {
+ const res = await fetchImpl(signed.url, {
+ headers: { ...(range ? { Range: String(range) } : {}) },
+ redirect: 'follow', // Media may hand the read to its own storage provider; that is Media's choice
+ signal: AbortSignal.timeout(media.timeoutMs),
+ });
+ return { ok: res.ok || res.status === 206, status: res.status, res };
+ } catch (err) {
+ log.warn('[MediaHub] Media bytes fetch failed:', (err && err.message) || '');
+ return { ok: false, status: 502, code: 'media.unreachable', detail: 'OpenVibe.Media did not answer' };
+ }
+ },
+ };
+}
+
+module.exports = { createMediaClient, subjectHeader };
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+
+/**
+ * Resolving the OpenVibe usernames a person names on a share link to canonical subjects, through
+ * OpenVibe.Network's identity.subject.resolve (network/identity/internal-routes.js):
+ *
+ * GET {OV_NETWORK_INTERNAL_URL}/internal/identity/resolve?system=&type=&id=
+ *
+ * A service token with audience openvibe.network and scope identity.subject.resolve is required, so it only runs
+ * where the OAuth client is configured. Network keys a subject by its id or by a service-local (system, type, id)
+ * pair; a username is asked for as type=username, and when Network does not know that key the answer is 404 and the
+ * name stays unresolved.
+ *
+ * That is not a dead end: a share's allow-list holds both the subjects we could resolve and the names as typed, and
+ * a download is allowed when the signed-in person's own *subject* is in the resolved list or their Network
+ * *username claim* is in the typed one. The claim is signed by Network, so matching on it needs no directory at
+ * all — the resolve call only sharpens the list (it survives a rename and reaches guests, who have no username).
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+function createIdentity({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const configured = Boolean(config.oauth.clientSecret);
+ const tokens = configured ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network',
+ scope: 'identity.subject.resolve',
+ fetchImpl,
+ }) : null;
+
+ /** One username → its subject, or null when Network does not know it (or cannot be asked). */
+ async function resolveUsername(username) {
+ if (!tokens) return null;
+ try {
+ const res = await fetchImpl(
+ `${config.networkInternalUrl}/internal/identity/resolve?system=network&type=username&id=${encodeURIComponent(username)}`,
+ { headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(5000) });
+ if (res.status === 401 && tokens.invalidate) tokens.invalidate();
+ if (res.status === 404) return null;
+ if (!res.ok) {
+ log.warn(`[MediaHub] identity.resolve answered ${res.status} for @${username.slice(0, 40)}`);
+ return null;
+ }
+ const data = await res.json().catch(() => null);
+ const id = data && data.subject && data.subject.id;
+ return typeof id === 'string' && id.startsWith('usr_') ? id : null;
+ } catch (err) {
+ log.warn('[MediaHub] identity.resolve failed:', (err && err.message) || '');
+ return null;
+ }
+ }
+
+ /**
+ * names → { subjects: ['usr_…'], names: ['kim'], unresolved: ['kim'] }.
+ * `names` keeps every typed username (matched against the viewer's claim); `unresolved` is what Network could
+ * not turn into a subject, which the caller reports back to the owner without refusing the share.
+ */
+ async function resolveUsernames(list) {
+ const names = [];
+ const subjects = [];
+ const unresolved = [];
+ for (const name of list) {
+ // Sequential on purpose: a share names a handful of people, and one call each keeps the token client simple.
+ const subject = await resolveUsername(name);
+ if (subject) subjects.push(subject);
+ else unresolved.push(name);
+ names.push(name);
+ }
+ return { subjects: [...new Set(subjects)], names: [...new Set(names)], unresolved };
+ }
+
+ return { configured, resolveUsername, resolveUsernames };
+}
+
+module.exports = { createIdentity };
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+/**
+ * Account export and deletion (ADR-033), through openvibe-sdk/account-data: Network asks for a person's rows, and
+ * later tells this service they are gone. Deleting also asks OpenVibe.Media to delete each object the person held.
+ *
+ * The events arrive signed on POST /internal/events — a route that refuses anything that came through a proxy, so
+ * only loopback can reach it even though nginx proxies the path. The stand-in Network records the part it was given
+ * and the confirmation it was sent (test/helpers/mocks.js).
+ */
+const assert = require('assert');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, upload, share } = require('./helpers/drive');
+
+const SECRET = 'm'.repeat(48);
+const EXPORT = 'exp_01JABCDEFGHJKMNPQRSTVWXYZ0';
+const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
+
+(async () => {
+ const t = await boot({ env: { MEDIAHUB_EVENTS_SECRET: SECRET } });
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+
+ const file = (await upload(t, kim, bytes(300, 'kim'), { name: 'kim.bin' })).json().file;
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+ await t.get(`/s/${slug}/report`, { as: sam, form: { reason: 'malware', note: 'nope' }, headers: SAME });
+ const sams = (await upload(t, sam, bytes(300, 'sam'), { name: 'sam.bin' })).json().file;
+ // And kim files one against sam's file, so each has a report of their own in the export.
+ const samSlug = (await share(t, sam, { file_id: sams.id, hours: 24 })).json().share.slug;
+ await t.get(`/s/${samSlug}/report`, { as: kim, form: { reason: 'other', note: 'kim says' }, headers: SAME });
+
+ const deliver = (event) => {
+ const body = Buffer.from(JSON.stringify({ event }));
+ return t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ };
+ const envelope = (type, payload) => ({ event_id: `evt_${Math.random().toString(16).slice(2)}`, event_type: type, source: 'network', at: new Date().toISOString(), payload });
+
+ await check('the events route takes a signed delivery, and nothing else', async () => {
+ const event = envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject });
+ const body = Buffer.from(JSON.stringify({ event }));
+ const unsigned = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(unsigned.status, 401);
+ const wrongKey = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'w'.repeat(48)) } });
+ assert.strictEqual(wrongKey.status, 401);
+ const proxied = await t.get('/internal/events', {
+ method: 'POST', body,
+ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET), 'x-forwarded-for': '203.0.113.9' },
+ });
+ assert.strictEqual(proxied.status, 403, 'a request through a proxy is not Network');
+ assert.strictEqual((await t.get('/internal/events', { method: 'GET' })).status, 405);
+ });
+
+ await check('the export carries the person\'s files, folders, shares, reports and upload counts', async () => {
+ const r = await deliver(envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject }));
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().outcome, 'exported');
+ assert.strictEqual(t.network.accountParts.length, 1, 'Network was given the part');
+ const part = t.network.accountParts[0].body;
+ assert.strictEqual(part.subject, kim.subject);
+ const files = part.files.find((f) => f.name === 'files.json');
+ assert.ok(files, 'a files.json part');
+ assert.strictEqual(files.content.length, 1);
+ assert.strictEqual(files.content[0].id, file.id);
+ assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'reports.json', 'shares.json', 'usage.json'], 'and the rest of the person\'s own data');
+ assert.ok(part.files.find((f) => f.name === 'shares.json').content.some((s) => s.slug === slug));
+ assert.strictEqual(part.files.find((f) => f.name === 'reports.json').content.length, 1, 'the report kim filed');
+ assert.ok(!JSON.stringify(part).includes(sams.id), 'nothing of sam\'s');
+
+ // The same delivery again changes nothing: it is idempotent per export id.
+ assert.strictEqual((await deliver(envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject }))).json().outcome, 'unchanged');
+ assert.strictEqual(t.network.accountParts.length, 1);
+ });
+
+ await check('a deletion erases the rows and asks Media to delete each object', async () => {
+ const before = t.media.deleted.length;
+ const r = await deliver(envelope('network.account.deleted', { deletion_id: DELETION, subject: kim.subject }));
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().outcome, 'erased');
+
+ assert.strictEqual((await t.get('/api/v1/files', { as: kim })).json().files.length, 0, 'kim has no files');
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404, 'and no share links');
+ assert.deepStrictEqual(t.media.deleted.slice(before), [file.object.media_object_id], 'Media was asked for exactly kim\'s object');
+
+ assert.strictEqual(t.network.accountConfirmations.length, 1, 'Network was confirmed');
+ const confirmation = t.network.accountConfirmations[0].body;
+ assert.strictEqual(confirmation.subject, kim.subject);
+ assert.ok(confirmation.erased.mh_files >= 1, JSON.stringify(confirmation));
+ assert.ok(confirmation.erased.mh_shares >= 1, JSON.stringify(confirmation));
+
+ // Sam's drive is untouched.
+ assert.strictEqual((await t.get(`/api/v1/files/${sams.id}`, { as: sam })).status, 200);
+ assert.deepStrictEqual(t.media.bytesOf(sams.object.media_object_id), bytes(300, 'sam'), 'and his bytes are still in Media');
+ });
+
+ await check('a redelivered deletion erases nothing twice', async () => {
+ const before = t.media.deleted.length;
+ const again = await deliver(envelope('network.account.deleted', { deletion_id: DELETION, subject: kim.subject }));
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(t.media.deleted.length, before, 'Media is not asked again');
+ assert.strictEqual(t.network.accountConfirmations.length, 1, 'and no second confirmation is sent');
+ });
+
+ await check('an event this service does not handle is acknowledged and ignored', async () => {
+ const r = await deliver(envelope('network.something.else', {}));
+ assert.strictEqual(r.status, 200);
+ assert.match(r.json().outcome, /^ignored/);
+ });
+
+ await check('another service\'s event source is not accepted as Network\'s', async () => {
+ const event = { ...envelope('network.account.deleted', { deletion_id: 'del_01JABCDEFGHJKMNPQRSTVWXYZ1', subject: sam.subject }), source: 'live' };
+ const r = await deliver(event);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.json().outcome, /^ignored/);
+ assert.strictEqual((await t.get(`/api/v1/files/${sams.id}`, { as: sam })).status, 200, 'and sam still has his file');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * One process, three brand surfaces (the media-hub product manifest). The Host header decides which one a request
+ * is for, and v1 has launched only openvibe.download: openvibe.pics and openvibe.video answer an honest "coming"
+ * page and nothing else — no drive, no share links, no API and above all no uploads, because neither brand has the
+ * safety tooling its content needs yet.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, upload, share } = require('./helpers/drive');
+
+const PICS = { 'x-forwarded-host': 'openvibe.pics' };
+const VIDEO = { 'x-forwarded-host': 'openvibe.video' };
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const other = t.network.addUser('sam');
+ const file = (await upload(t, kim, bytes(120, 'brand'), { name: 'brand.bin' })).json().file;
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+
+ await check('openvibe.pics and openvibe.video answer a coming home that says what they will be', async () => {
+ const pics = await t.get('/', { headers: PICS });
+ assert.strictEqual(pics.status, 200);
+ assert.match(pics.headers.get('content-type'), /^text\/html/);
+ assert.ok(pics.text.includes('OpenVibe.Pics'), 'the brand');
+ assert.ok(pics.text.includes('Share pictures. Fast, open, yours.'), 'its own tagline');
+ assert.ok(pics.text.includes('Albums & galleries') || pics.text.includes('Albums & galleries'), 'what it will be');
+ assert.ok(/safety tooling/i.test(pics.text), 'why it is not open yet');
+ assert.ok(/no malware or CSAM scanning/i.test(pics.text), 'said plainly');
+ assert.ok(pics.text.includes('https://openvibe.download'), 'a link to the brand that has launched');
+
+ const video = await t.get('/', { headers: VIDEO });
+ assert.strictEqual(video.status, 200);
+ assert.ok(video.text.includes('OpenVibe.Video'));
+ assert.ok(video.text.includes('Your videos, everywhere.'));
+ assert.ok(/safety tooling/i.test(video.text));
+ assert.ok(video.text.includes('https://openvibe.download'));
+ });
+
+ await check('nothing else exists on those brands: no drive, no share, no API, no upload', async () => {
+ const before = t.media.requests.length;
+ for (const headers of [PICS, VIDEO]) {
+ const brand = headers['x-forwarded-host'];
+ for (const p of ['/files', `/files/${file.id}`, `/files/${file.id}/download`, `/s/${slug}`, `/s/${slug}/download`, `/staff`, '/api/v1/ping']) {
+ const r = await t.get(p, { as: kim, headers });
+ assert.strictEqual(r.status, 404, `${brand} ${p} answered ${r.status}`);
+ }
+ // The write routes too — with a real file behind them.
+ const up = await t.get('/api/v1/files', { as: kim, headers: { ...headers, ...SAME }, json: { name: 'x.bin', size: 10, content_type: 'text/plain' } });
+ assert.strictEqual(up.status, 404, `${brand}: the API is not there`);
+ const form = await t.get('/files/upload', { as: kim, method: 'POST', headers: { ...headers, ...SAME, 'content-type': 'application/x-www-form-urlencoded' }, body: 'name=x' });
+ assert.strictEqual(form.status, 404, `${brand}: no form upload`);
+ }
+ assert.strictEqual(t.media.requests.length, before, 'and OpenVibe.Media was never called');
+ });
+
+ await check('the crawl artifacts and the update log are the same on every brand', async () => {
+ for (const headers of [{}, PICS, VIDEO]) {
+ assert.strictEqual((await t.get('/updates', { headers })).status, 200);
+ assert.strictEqual((await t.get('/robots.txt', { headers })).status, 200);
+ assert.strictEqual((await t.get('/sitemap.xml', { headers })).status, 200);
+ assert.strictEqual((await t.get('/llms.txt', { headers })).status, 200);
+ }
+ });
+
+ await check('the launched brand is untouched, and an unknown host is the launched brand', async () => {
+ assert.ok((await t.get('/')).text.includes('OpenVibe.Download'));
+ assert.ok((await t.get('/')).text.includes('private drive'));
+ for (const headers of [{ 'x-forwarded-host': 'openvibe.download' }, { 'x-forwarded-host': 'www.openvibe.download' }, { 'x-forwarded-host': '127.0.0.1' }, { 'x-forwarded-host': 'openvibe.host' }]) {
+ const r = await t.get('/', { headers });
+ assert.ok(r.text.includes('OpenVibe.Download'), JSON.stringify(headers));
+ }
+ assert.strictEqual((await t.get('/files', { as: kim })).status, 200);
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: other })).status, 200, 'and its share links work');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* window reopens on the clock. A person counts as themselves, anyone else by address. Reads take
* window reopens on the clock. A person counts as themselves, anyone else by address. Reads take
* MEDIAHUB_LIMITS_MINUTE/MEDIAHUB_LIMITS_HOUR; the product's expensive routes get their own numbers in BUDGETS.
* MEDIAHUB_LIMITS_MINUTE/MEDIAHUB_LIMITS_HOUR; the product's expensive routes get their own numbers in BUDGETS.
* Health, ready, release.json and metrics are never limited; refusals are logged (no token) and counted in
* Health, ready, release.json and metrics are never limited; refusals are logged (no token) and counted in
- * media-hub_rate_limited_total.
+ * media_hub_rate_limited_total.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
@@ -20,8 +20,14 @@ const READ = 'media-hub.api.read';
@@ -20,8 +20,14 @@ const READ = 'media-hub.api.read';
const rosa = t.network.addUser('rosa');
const rosa = t.network.addUser('rosa');
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
- await check('BUDGETS starts empty: the product declares its own expensive routes', () => {
- assert.deepStrictEqual(BUDGETS, {});
+ await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
+ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
+ 'media-hub.folder.create', 'media-hub.share.create',
+ 'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
+ ]);
+ for (const [name, own] of Object.entries(BUDGETS)) {
+ assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
+ }
});
});
await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
@@ -62,10 +68,10 @@ const READ = 'media-hub.api.read';
@@ -62,10 +68,10 @@ const READ = 'media-hub.api.read';
}
}
});
});
- await check('refusals are counted in media-hub_rate_limited_total and logged without a token', async () => {
+ await check('refusals are counted in media_hub_rate_limited_total and logged without a token', async () => {
const m = (await t.get('/metrics')).text;
const m = (await t.get('/metrics')).text;
- const counted = m.split('\n').filter((l) => l.includes('media-hub_rate_limited_total')).join('\n');
- assert.ok(/media-hub_rate_limited_total\{limit="media-hub\.api\.read",window="minute"\} 2/.test(m), counted);
+ const counted = m.split('\n').filter((l) => l.includes('media_hub_rate_limited_total')).join('\n');
+ assert.ok(/media_hub_rate_limited_total\{limit="media-hub\.api\.read",window="minute"\} 2/.test(m), counted);
const logs = t.logs();
const logs = t.logs();
assert.ok(logs.includes(`[Limits] ${READ}: user:${rosa.subject} refused`), 'one log line per refusal');
assert.ok(logs.includes(`[Limits] ${READ}: user:${rosa.subject} refused`), 'one log line per refusal');
assert.ok(logs.includes(`[Limits] ${READ}: ip:203.0.113.7 refused`), 'the address refusal is logged');
assert.ok(logs.includes(`[Limits] ${READ}: ip:203.0.113.7 refused`), 'the address refusal is logged');
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+/**
+ * Downloading. This is the highest-risk surface of the service: an upload must never be handed to a browser as if
+ * it were a document. Every download is an attachment, with X-Content-Type-Options: nosniff and the real type only
+ * for a short safe list — HTML, SVG and anything executable come back as an opaque blob. Nothing is ever inline,
+ * and no upload is ever rendered in a page.
+ *
+ * The bytes are read from OpenVibe.Media through the app, so MediaHub's own headers are what the browser sees
+ * whatever Media answers.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, sha256, upload, share } = require('./helpers/drive');
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+
+ const put = async (name, data, contentType) => (await upload(t, kim, data, { name, contentType })).json().file;
+
+ await check('a safe type keeps its own Content-Type; everything else is an opaque blob', async () => {
+ const cases = [
+ ['photo.png', 'image/png', 'image/png'],
+ ['paper.pdf', 'application/pdf', 'application/pdf'],
+ ['notes.txt', 'text/plain', 'text/plain'],
+ ['bundle.zip', 'application/zip', 'application/zip'],
+ ['song.mp3', 'audio/mpeg', 'audio/mpeg'],
+ ['clip.mp4', 'video/mp4', 'video/mp4'],
+ ['page.html', 'text/html', 'application/octet-stream'],
+ ['picture.svg', 'image/svg+xml', 'application/octet-stream'],
+ ['feed.xml', 'application/xml', 'application/octet-stream'],
+ ['run.sh', 'application/x-sh', 'application/octet-stream'],
+ ['no-type', null, 'application/octet-stream'],
+ ];
+ for (const [name, declared, expected] of cases) {
+ const file = await put(name, bytes(64, name), declared);
+ const dl = await t.get(`/files/${file.id}/download`, { as: kim });
+ assert.strictEqual(dl.status, 200, `${name}: ${dl.text.slice(0, 120)}`);
+ assert.ok(dl.headers.get('content-type').startsWith(expected), `${name}: ${dl.headers.get('content-type')} should start with ${expected}`);
+ assert.match(dl.headers.get('content-disposition'), /^attachment; filename=/, name);
+ assert.ok(!/inline/i.test(dl.headers.get('content-disposition')), `${name} must never be inline`);
+ assert.strictEqual(dl.headers.get('x-content-type-options'), 'nosniff', name);
+ assert.strictEqual(dl.headers.get('x-robots-tag'), 'noindex', name);
+ assert.match(dl.headers.get('cache-control'), /private/, name);
+ assert.strictEqual(sha256(dl.buffer), sha256(bytes(64, name)), `${name}: the bytes are the ones uploaded`);
+ }
+ });
+
+ await check('an uploaded HTML page is never rendered: it comes back as octet-stream, as an attachment', async () => {
+ const nasty = Buffer.from('<!doctype html><html><body><script>window.top.location="https://evil.test"</script></body></html>');
+ const file = await put('shared.html', nasty, 'text/html');
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+
+ const page = await t.get(`/s/${slug}`, { as: sam });
+ assert.strictEqual(page.status, 200);
+ assert.ok(!page.text.includes('window.top.location'), 'the bytes are not in the page');
+ assert.ok(!/<script>window\.top/.test(page.text));
+
+ const dl = await t.get(`/s/${slug}/download`, { as: sam });
+ assert.strictEqual(dl.status, 200);
+ assert.strictEqual(dl.headers.get('content-type').split(';')[0], 'application/octet-stream');
+ assert.strictEqual(dl.headers.get('x-content-type-options'), 'nosniff');
+ assert.match(dl.headers.get('content-disposition'), /^attachment/);
+ assert.strictEqual(sha256(dl.buffer), sha256(nasty), 'and the bytes are exactly what was uploaded');
+ });
+
+ await check('a name full of quotes and newlines cannot break the header', async () => {
+ const file = await put('a"; attachment; filename="evil.exe\r\nX-Injected: 1.txt', bytes(20, 'name'), 'text/plain');
+ const dl = await t.get(`/files/${file.id}/download`, { as: kim });
+ assert.strictEqual(dl.status, 200);
+ const dispo = dl.headers.get('content-disposition');
+ assert.match(dispo, /^attachment; filename="[^"]*"$/, dispo);
+ assert.ok(!dispo.includes('\n') && !dispo.includes('\r'), dispo);
+ assert.strictEqual(dl.headers.get('x-injected'), null, 'no header was injected');
+ });
+
+ await check('a download resumes: a Range is passed through and answered 206', async () => {
+ const data = bytes(4096, 'range');
+ const file = await put('slice.bin', data, 'application/octet-stream');
+ const part = await t.get(`/files/${file.id}/download`, { as: kim, headers: { range: 'bytes=100-199' } });
+ assert.strictEqual(part.status, 206, part.text.slice(0, 120));
+ assert.strictEqual(part.buffer.length, 100);
+ assert.strictEqual(sha256(part.buffer), sha256(data.subarray(100, 200)));
+ assert.strictEqual(part.headers.get('content-range'), 'bytes 100-199/4096');
+ assert.strictEqual(part.headers.get('content-disposition'), 'attachment; filename="slice.bin"');
+ });
+
+ await check('nothing is served to anybody who is not signed in', async () => {
+ const file = await put('guarded.bin', bytes(30, 'guarded'), 'application/octet-stream');
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+ for (const p of [`/files/${file.id}/download`, `/s/${slug}/download`]) {
+ const r = await t.get(p);
+ assert.strictEqual(r.status, 401, p);
+ assert.ok(!r.buffer.includes(Buffer.from('guarded')), `${p}: no bytes to a guest`);
+ }
+ });
+
+ await check('what Media no longer has is a plain 410, not a broken page', async () => {
+ const file = await put('gone.bin', bytes(30, 'gone'), 'application/octet-stream');
+ t.media.objectOf(file.object.media_object_id).lifecycle_status = 'deleted';
+ const r = await t.get(`/files/${file.id}/download`, { as: kim });
+ assert.strictEqual(r.status, 410, r.text.slice(0, 120));
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.ok(!r.text.includes('<html'), 'a sentence, not a page');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+/**
+ * The drive's pages: what a guest, a person and staff see, folder navigation, the usage bar, the upload form and
+ * the one script on any page — and that everything a person typed is escaped everywhere it is shown.
+ *
+ * Nothing here needs JavaScript: the pages are server-rendered and the forms are plain POSTs.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, upload, share } = require('./helpers/drive');
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+
+ await check('a guest gets a sign-in page for every file page, never a file', async () => {
+ for (const p of ['/files', '/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {
+ const r = await t.get(p);
+ assert.strictEqual(r.status, 401, p);
+ assert.ok(r.text.includes('Sign in'), `${p}: a sign-in page`);
+ assert.ok(r.text.includes('/auth/login?next='), `${p}: with a way to sign in`);
+ assert.ok(!r.text.includes('<progress'), `${p}: and nothing of the drive`);
+ }
+ // The home page and the safety rules are public: they are how somebody decides to sign up at all.
+ assert.strictEqual((await t.get('/')).status, 200);
+ assert.strictEqual((await t.get('/safety')).status, 200);
+ });
+
+ await check('the home page says what this is, the rules, and that everything needs an account', async () => {
+ const home = await t.get('/');
+ assert.ok(home.text.includes('private drive'), 'it says what it is');
+ assert.ok(home.text.includes('no anonymous upload'), 'and that nothing is anonymous');
+ assert.ok(home.text.includes('Sign in with OpenVibe'), 'a way in');
+ assert.ok(home.text.includes('/safety'), 'a link to the rules');
+ const safety = await t.get('/safety');
+ assert.ok(safety.text.includes('[email protected]'), 'the abuse contact');
+ assert.ok(safety.text.includes('[email protected]'), 'the copyright contact');
+ assert.ok(safety.text.includes('child sexual'), 'what is not allowed, said plainly');
+ assert.ok(safety.text.includes('malware'), 'and the rest of it');
+ assert.ok(/3 distinct reports|three/i.test(safety.text), 'how reports work');
+ });
+
+ await check('the drive shows the usage bar, the upload form and the one page script', async () => {
+ const r = await t.get('/files', { as: kim });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('<progress id="usage"'), 'the usage bar is a progress element');
+ assert.ok(r.text.includes('2.0 GB'), 'the quota from the environment');
+ assert.ok(r.text.includes('action="/files/upload"'), 'the upload form');
+ assert.ok(r.text.includes('enctype="multipart/form-data"'), 'which is a plain file form');
+ assert.ok(r.text.includes('data-upload'), 'that the script upgrades');
+ assert.ok(r.text.includes('/js/upload.js?v='), 'and the script itself');
+ assert.ok(r.text.includes('<noscript>'), 'the frame\'s no-JavaScript navigation is still there');
+ // Signed-in pages are never cached by anything shared.
+ assert.match(r.headers.get('cache-control'), /private/);
+ });
+
+ await check('folders: made from the form, browsed, and never two of the same name in one place', async () => {
+ const made = await t.get('/folders', { as: kim, form: { name: 'Taxes 2026' }, headers: SAME });
+ assert.strictEqual(made.status, 303);
+ const folder = (await t.get('/api/v1/folders', { as: kim })).json().folders.find((f) => f.name === 'Taxes 2026');
+ assert.ok(folder, 'the folder exists');
+
+ const twice = await t.get('/folders', { as: kim, form: { name: 'taxes 2026' }, headers: SAME });
+ assert.strictEqual(twice.status, 409);
+ assert.ok(twice.text.includes('already have a folder called'), twice.text.slice(0, 300));
+
+ const file = (await upload(t, kim, bytes(80, 'taxes'), { name: 'return.pdf', contentType: 'application/pdf', folder: folder.id })).json().file;
+ const inside = await t.get(`/files?folder=${folder.id}`, { as: kim });
+ assert.ok(inside.text.includes('return.pdf'), 'the file is in the folder');
+ assert.ok(inside.text.includes('Taxes 2026'), 'and the page is the folder');
+ const root = await t.get('/files', { as: kim });
+ assert.ok(!root.text.includes('return.pdf'), 'and not in the root');
+ assert.ok(root.text.includes('Taxes 2026'), 'where the folder is listed');
+ assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 200);
+ });
+
+ await check('everything a person typed is escaped, everywhere it is shown', async () => {
+ const nastyName = '<script>alert("x")</script>.txt';
+ const nastyFolder = '<img src=x onerror="alert(1)">';
+ const folder = (await t.get('/api/v1/folders', { as: kim, headers: SAME, json: { name: nastyFolder } })).json().folder;
+ const file = (await upload(t, kim, bytes(50, 'nasty'), { name: nastyName, contentType: 'text/plain', folder: folder.id })).json().file;
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+ // Reported once, so the share (and with it the typed name) is in the staff queue too.
+ const reporter = t.network.addUser('nia');
+ await t.get(`/s/${slug}/report`, { as: reporter, form: { reason: 'malware', note: '<b>not bold</b>' }, headers: SAME });
+
+ for (const [where, path, as] of [
+ ['the drive', `/files?folder=${folder.id}`, kim],
+ ['the file page', `/files/${file.id}`, kim],
+ ['the share page', `/s/${slug}`, sam],
+ ['the report queue', '/staff', t.network.addUser('root', { role: 'admin' })],
+ ]) {
+ const r = await t.get(path, { as });
+ assert.strictEqual(r.status, 200, `${where}: ${r.text.slice(0, 200)}`);
+ assert.ok(!r.text.includes(nastyName), `${where} renders the file name raw`);
+ assert.ok(!r.text.includes(nastyFolder), `${where} renders the folder name raw`);
+ assert.ok(r.text.includes('<script>') || r.text.includes('<img src=x'), `${where} does not show it at all`);
+ }
+
+ // A report note is text somebody typed, and it is escaped where staff read it.
+ const queue = await t.get('/staff', { as: t.network.addUser('mod', { role: 'global_mod' }) });
+ assert.ok(queue.text.includes('<b>not bold</b>'), 'the note is escaped in the queue');
+ assert.ok(!queue.text.includes('<b>not bold</b>'), 'and not rendered as markup');
+ });
+
+ await check('a person\'s own share link is listed with its state, and staff see who shared it', async () => {
+ const file = (await upload(t, kim, bytes(40, 'listed'), { name: 'listed.bin' })).json().file;
+ const slug = (await share(t, kim, { file_id: file.id, hours: 1 })).json().share.slug;
+ const page = await t.get(`/files/${file.id}`, { as: kim });
+ assert.ok(page.text.includes(`/s/${slug}`), 'the file page lists its link');
+ assert.ok(/expires <time/.test(page.text), 'with when it expires');
+ const drive = await t.get('/files', { as: kim });
+ assert.ok(drive.text.includes(`/s/${slug}`), 'and so does the drive');
+ });
+
+ await check('the update log and the crawl artifacts still work, and a guest can read the rules', async () => {
+ assert.strictEqual((await t.get('/updates')).status, 200);
+ const robots = await t.get('/robots.txt');
+ for (const d of ['/auth/', '/api/', '/files', '/s/', '/staff']) assert.ok(robots.text.includes(`Disallow: ${d}`), `robots disallows ${d}`);
+ assert.ok(!robots.text.includes('Disallow: /safety'), '/safety is not swallowed by the /s/ rule');
+ const sitemap = await t.get('/sitemap.xml');
+ assert.ok(sitemap.text.includes('https://openvibe.download/safety'), 'the rules are in the sitemap');
+ assert.ok(!sitemap.text.includes('/files'), 'and the drive is not');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * Reporting a share and what staff do about it: one report per person per link, three distinct people (or one
+ * report of illegal content) suspending it at once, and OpenVibe staff restoring it or removing the file.
+ *
+ * The reasons, the notes and everything else somebody typed are text: they are escaped wherever the queue shows
+ * them (drive-pages.test.js has the escaping; this file has the rules).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, upload, share } = require('./helpers/drive');
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+ const jo = t.network.addUser('jo');
+ const ada = t.network.addUser('ada', { role: 'admin' });
+ const gil = t.network.addUser('gil', { role: 'global_mod' });
+ const pat = t.network.addUser('pat', { role: 'streamer' });
+
+ const file = (await upload(t, kim, bytes(200, 'reported'), { name: 'suspect.bin' })).json().file;
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+ const report = (body, as, o = {}) => t.get(`/s/${slug}/report`, { as, form: body, headers: { ...SAME, ...(o.headers || {}) } });
+ const state = async () => (await t.get(`/api/v1/files/${file.id}`, { as: kim })).json().shares.find((x) => x.slug === slug).state;
+
+ await check('a signed-in person reports a share once; their own is not theirs to report', async () => {
+ const r = await report({ reason: 'malware', note: 'It asked me to run it.' }, sam);
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.ok(r.text.includes('report 1'), 'the page says which report it was');
+ assert.strictEqual(await state(), 'live', 'one report suspends nothing');
+ assert.strictEqual((await report({ reason: 'other' }, sam)).status, 409, 'once per person');
+ assert.strictEqual((await report({ reason: 'other' }, kim)).status, 403, 'not your own share');
+ assert.strictEqual((await report({ reason: 'nonsense' }, jo)).status, 422, 'the reason is from the list');
+ assert.strictEqual((await t.get(`/s/${slug}/report`, { form: { reason: 'malware' }, headers: SAME })).status, 401, 'a guest reports nothing');
+ assert.strictEqual((await t.get(`/s/${slug}/report`, { as: jo, form: { reason: 'malware' } })).status, 403, 'a cross-site form cannot report as somebody');
+ assert.strictEqual((await report({ reason: 'copyright', note: 'x'.repeat(600) }, jo)).status, 200);
+ assert.strictEqual(await state(), 'live', 'two reports still do not suspend it');
+ });
+
+ await check('the third distinct report suspends the share at once, everywhere', async () => {
+ const nia = t.network.addUser('nia');
+ await report({ reason: 'copyright' }, nia);
+ assert.strictEqual(await state(), 'suspended');
+
+ const page = await t.get(`/s/${slug}`, { as: sam });
+ assert.strictEqual(page.status, 403);
+ assert.ok(page.text.includes('suspended'), page.text.slice(0, 300));
+ assert.ok(!page.text.includes('suspect.bin'), 'and nothing about the file');
+ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 403, 'nothing can be downloaded from it');
+ // The owner sees it suspended too, and can still revoke it.
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: kim })).status, 403);
+ });
+
+ await check('one report of illegal content suspends at once', async () => {
+ const other = (await upload(t, kim, bytes(50, 'illegal'), { name: 'bad.bin' })).json().file;
+ const s2 = (await share(t, kim, { file_id: other.id, hours: 24 })).json().share.slug;
+ const r = await t.get(`/s/${s2}/report`, { as: jo, form: { reason: 'illegal', note: 'I think this is abuse material.' }, headers: SAME });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('suspended now'), r.text.slice(0, 400));
+ assert.strictEqual((await t.get(`/s/${s2}`, { as: sam })).status, 403);
+ assert.strictEqual((await t.get(`/s/${s2}/download`, { as: sam })).status, 403);
+ });
+
+ await check('the queue is for staff only, and shows who shared it, why it was reported and what was said', async () => {
+ for (const [who, status] of [[null, 401], [sam, 403], [pat, 403]]) {
+ const r = await t.get('/staff', who ? { as: who } : {});
+ assert.strictEqual(r.status, status, String(who && who.username));
+ }
+ const queue = await t.get('/staff', { as: ada });
+ assert.strictEqual(queue.status, 200);
+ assert.ok(queue.text.includes(slug), 'the reported share');
+ assert.ok(queue.text.includes('suspect.bin'), 'the file behind it');
+ assert.ok(queue.text.includes('@kim'), 'who shared it, by username');
+ assert.ok(queue.text.includes('malware') && queue.text.includes('copyright'), 'the reasons');
+ assert.ok(queue.text.includes('It asked me to run it.'), 'what the first reporter said');
+ assert.ok(queue.text.includes('suspect.bin'));
+ assert.ok((await t.get('/staff', { as: gil })).text.includes(slug), 'a global mod reads it too');
+ assert.ok(!(await t.get('/staff', { as: ada })).text.includes('member of the public'), 'no');
+ });
+
+ await check('staff restore it: the link works again and the reports stay on the record', async () => {
+ const r = await t.get(`/staff/${slug}/restore`, { as: ada, form: {}, headers: SAME });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(await state(), 'live');
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200, 'and it opens again');
+ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 200);
+ const queue = await t.get('/staff', { as: ada });
+ assert.ok(queue.text.includes(slug), 'the reports are still there for staff');
+ assert.ok(queue.text.includes('live'), 'and it says the link is live again');
+ // A restored share is not immediately suspended again by the reports it already has.
+ assert.strictEqual((await report({ reason: 'malware' }, t.network.addUser('dea'))).status, 200);
+ assert.strictEqual(await state(), 'live', 'the count is what suspends, and it does not count twice');
+ });
+
+ await check('staff remove the file: the link, the file and Media\'s object all go', async () => {
+ const before = t.media.deleted.length;
+ const r = await t.get(`/staff/${slug}/remove`, { as: gil, form: {}, headers: SAME });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404);
+ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).status, 404, 'gone for its owner too');
+ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 404);
+ assert.strictEqual(t.media.deleted.length, before + 1, 'Media was asked to delete the object');
+ assert.strictEqual(t.media.deleted[t.media.deleted.length - 1], file.object.media_object_id);
+ });
+
+ await check('removing or restoring is staff\'s, and comes from this site', async () => {
+ const f = (await upload(t, kim, bytes(60, 'again'), { name: 'again.bin' })).json().file;
+ const s = (await share(t, kim, { file_id: f.id, hours: 24 })).json().share.slug;
+ assert.strictEqual((await t.get(`/staff/${s}/remove`, { as: sam, form: {}, headers: SAME })).status, 403);
+ assert.strictEqual((await t.get(`/staff/${s}/remove`, { form: {}, headers: SAME })).status, 401);
+ assert.strictEqual((await t.get(`/staff/${s}/remove`, { as: ada, form: {} })).status, 403, 'a cross-site form is refused');
+ assert.strictEqual((await t.get(`/staff/${s}/restore`, { as: ada, form: {}, headers: SAME })).status, 303);
+ });
+
+ await check('a share its owner already revoked is not a report, and a report on a folder share is a report', async () => {
+ const f = (await upload(t, kim, bytes(60, 'gone'), { name: 'gone.bin' })).json().file;
+ const s = (await share(t, kim, { file_id: f.id, hours: 24 })).json().share.slug;
+ await t.get(`/api/v1/shares/${s}`, { as: kim, method: 'DELETE', headers: SAME });
+ const r = await t.get(`/s/${s}/report`, { as: sam, form: { reason: 'malware' }, headers: SAME });
+ assert.strictEqual(r.status, 409);
+ assert.match(r.text, /already taken down/i);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+/**
+ * Share links: 24 random characters, an expiry of an hour to seven days, revocable, counted, and never open to
+ * anybody who is not signed in. A share may name OpenVibe usernames; everybody else with the link is refused.
+ *
+ * The clock is the store's own (boot({ now })), so a share can be watched expiring.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, upload, share } = require('./helpers/drive');
+
+const HOUR = 3_600_000;
+
+(async () => {
+ // The injected clock drives the store and session verification, so it must not sit before the moment the
+ // Network mock mints its tokens (an iat in the future is an invalid token).
+ let clock = Date.now() + 1000;
+ const t = await boot({ now: () => clock });
+ // The injected clock also judges session tokens, so a test that moves it past a share's expiry would expire
+ // the session too. Every request here carries a token minted for a day instead of an hour.
+ const rawGet = t.get.bind(t);
+ t.get = (path, o = {}) => (o.as
+ ? rawGet(path, { ...o, cookie: `media-hub_at=${t.network.userToken(o.as, { ttl: 24 * 3600 })}` })
+ : rawGet(path, o));
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+ const ada = t.network.addUser('ada');
+
+ const put = async (as, name, data = bytes(400, name)) => (await upload(t, as, data, { name })).json().file;
+ const file = await put(kim, 'holiday.bin');
+
+ await check('a share link is 24 random characters and expires when it was told to', async () => {
+ const r = await share(t, kim, { file_id: file.id, hours: 24 });
+ assert.strictEqual(r.status, 201, r.text);
+ const s = r.json().share;
+ assert.match(s.slug, /^[A-Za-z0-9_-]{24}$/);
+ assert.strictEqual(s.state, 'live');
+ assert.strictEqual(s.url, `/s/${s.slug}`);
+ assert.strictEqual(s.expires_at, new Date(clock + 24 * HOUR).toISOString());
+ assert.strictEqual((await share(t, kim, { file_id: file.id })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString(), 'seven days by default');
+
+ // The clamp: an hour is the shortest, a week the longest.
+ assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 0 })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString());
+ assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 999 })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString());
+
+ // A file that is not yours has no share.
+ const sams = await put(sam, 'sams.bin');
+ assert.strictEqual((await share(t, kim, { file_id: sams.id })).status, 404);
+ assert.strictEqual((await share(t, kim, {})).status, 422);
+ });
+
+ await check('the share page and its download need a signed-in person', async () => {
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+ const anon = await t.get(`/s/${slug}`);
+ assert.strictEqual(anon.status, 401);
+ assert.ok(anon.text.includes('Sign in first'), 'a sign-in page, not the file');
+ assert.ok(!anon.text.includes('holiday.bin'), 'the name of the file is not shown to a guest');
+ assert.strictEqual((await t.get(`/s/${slug}/download`)).status, 401);
+
+ const asSam = await t.get(`/s/${slug}`, { as: sam });
+ assert.strictEqual(asSam.status, 200);
+ assert.ok(asSam.text.includes('holiday.bin'));
+ assert.ok(asSam.text.includes('@kim'), 'who shared it: their OpenVibe username');
+ assert.ok(!asSam.text.includes(kim.subject), 'and never their subject id');
+ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).json().shares.find((x) => x.slug === slug).downloads, 0);
+ const dl = await t.get(`/s/${slug}/download`, { as: sam });
+ assert.strictEqual(dl.status, 200);
+ assert.strictEqual(dl.headers.get('content-disposition'), 'attachment; filename="holiday.bin"');
+ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).json().shares.find((x) => x.slug === slug).downloads, 1, 'the download is counted');
+ });
+
+ await check('a share can be revoked, and stops working at once', async () => {
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);
+ assert.strictEqual((await t.get(`/api/v1/shares/${slug}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204);
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404);
+ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/shares/${slug}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204, 'revoking it twice is the same as revoking it once');
+ assert.strictEqual((await t.get(`/api/v1/shares/not-a-slug-at-all-here`, { as: kim, method: 'DELETE', headers: SAME })).status, 404);
+ // Somebody else's share is not theirs to revoke.
+ const s2 = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+ assert.strictEqual((await t.get(`/api/v1/shares/${s2}`, { as: sam, method: 'DELETE', headers: SAME })).status, 404);
+ });
+
+ await check('a share expires on its own', async () => {
+ const slug = (await share(t, kim, { file_id: file.id, hours: 1 })).json().share.slug;
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);
+ clock += 59 * 60 * 1000;
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200, 'still inside the hour');
+ clock += 2 * 60 * 1000;
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404, 'past it');
+ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).json().shares.find((x) => x.slug === slug).state, 'expired');
+ });
+
+ await check('a share named for usernames is refused to everybody else', async () => {
+ const r = await share(t, kim, { file_id: file.id, hours: 24, usernames: ['sam'] });
+ assert.strictEqual(r.status, 201, r.text);
+ const slug = r.json().share.slug;
+ assert.deepStrictEqual(r.json().share.allowed, { subjects: [], usernames: ['sam'] }, 'Network could not resolve the name, so it is kept as typed');
+ assert.deepStrictEqual(r.json().unresolved, ['sam']);
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200, 'sam is named');
+ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: sam })).status, 200);
+ const adaPage = await t.get(`/s/${slug}`, { as: ada });
+ assert.strictEqual(adaPage.status, 403);
+ assert.ok(adaPage.text.includes('not for you'), adaPage.text.slice(0, 300));
+ assert.strictEqual((await t.get(`/s/${slug}/download`, { as: ada })).status, 403);
+ assert.ok(!adaPage.text.includes('holiday.bin'), 'not even the name');
+ // The owner is named too, when they name themselves.
+ assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 24, usernames: ['not a name!'] })).status, 422);
+ });
+
+ await check('a folder can be shared, one file at a time', async () => {
+ const folder = (await t.get('/api/v1/folders', { as: kim, headers: SAME, json: { name: 'photos' } })).json().folder;
+ const inside = (await upload(t, kim, bytes(120, 'in-folder'), { name: 'in.png', folder: folder.id })).json().file;
+ const outside = (await upload(t, kim, bytes(120, 'outside'), { name: 'out.png' })).json().file;
+ const slug = (await share(t, kim, { folder_id: folder.id, hours: 24 })).json().share.slug;
+
+ const page = await t.get(`/s/${slug}`, { as: sam });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('photos') && page.text.includes('in.png'));
+ assert.ok(!page.text.includes('out.png'), 'a file outside the folder is not in the share');
+
+ const dl = await t.get(`/s/${slug}/f/${inside.id}/download`, { as: sam });
+ assert.strictEqual(dl.status, 200, dl.text.slice(0, 200));
+ assert.strictEqual((await t.get(`/s/${slug}/f/${outside.id}/download`, { as: sam })).status, 404);
+ assert.strictEqual((await t.get(`/s/${slug}/f/fil_01JABCDEFGHJKMNPQRSTVWXYZ0/download`, { as: sam })).status, 404);
+ });
+
+ await check('20 live share links is the most one person may have; revoked and expired ones do not count', async () => {
+ const nia = t.network.addUser('nia');
+ const f = await put(nia, 'spread.bin');
+ const made = [];
+ for (let i = 0; i < 20; i++) {
+ const r = await share(t, nia, { file_id: f.id, hours: 1 });
+ assert.strictEqual(r.status, 201, `share ${i}: ${r.text}`);
+ made.push(r.json().share.slug);
+ }
+ const refused = await share(t, nia, { file_id: f.id, hours: 1 });
+ assert.strictEqual(refused.status, 429);
+ assert.strictEqual(refused.json().code, 'share.limit');
+ assert.strictEqual((await t.get('/api/v1/usage', { as: nia })).json().usage.live_shares, 20);
+
+ // Revoking one frees a slot...
+ assert.strictEqual((await t.get(`/api/v1/shares/${made[0]}`, { as: nia, method: 'DELETE', headers: SAME })).status, 204);
+ assert.strictEqual((await share(t, nia, { file_id: f.id, hours: 1 })).status, 201, 'a revoked one frees a slot');
+ assert.strictEqual((await t.get('/api/v1/usage', { as: nia })).json().usage.live_shares, 20);
+ // ...and so does the hour passing.
+ clock += 2 * HOUR;
+ assert.strictEqual((await t.get('/api/v1/usage', { as: nia })).json().usage.live_shares, 0, 'the old ones expired');
+ assert.strictEqual((await share(t, nia, { file_id: f.id, hours: 1 })).status, 201);
+ });
+
+ await check('deleting a file takes its share links with it', async () => {
+ const ola = t.network.addUser('ola');
+ const f = await put(ola, 'doomed.bin');
+ const slug = (await share(t, ola, { file_id: f.id, hours: 24 })).json().share.slug;
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);
+ assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola, method: 'DELETE', headers: SAME })).status, 204);
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola })).status, 404, 'and the file is gone for its owner too');
+ assert.ok(t.media.deleted.length > 0, 'Media was asked to delete the object');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+'use strict';
+/**
+ * Uploading: the plain form (no JavaScript, one request), the chunked path the page's script uses, resume after a
+ * dropped part, and the per-person numbers — 512 MB a file, 2 GB stored, 50 uploads a day.
+ *
+ * The bytes end up in the stand-in OpenVibe.Media (test/helpers/media.js), which is the only place they live: the
+ * assertions here are about what MediaHub told Media and what it recorded, not about bytes it kept itself.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, sha256, upload, postForm } = require('./helpers/drive');
+
+const MB = 1024 * 1024;
+
+(async () => {
+ // Small parts (5 MB, Media's own minimum) so a two-part upload is a few megabytes, not a few hundred.
+ const t = await boot({
+ env: {
+ MEDIAHUB_PART_BYTES: String(5 * MB),
+ MEDIAHUB_MAX_FILE_BYTES: String(20 * MB),
+ MEDIAHUB_QUOTA_BYTES: String(40 * MB),
+ MEDIAHUB_UPLOADS_PER_DAY: '3',
+ },
+ });
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+
+ await check('the plain form uploads a file in one request, without JavaScript', async () => {
+ const data = bytes(5000, 'plain-form');
+ const r = await postForm(t, kim, { name: 'quarterly.bin' }, { filename: 'orig.bin', type: 'application/octet-stream', data });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ const id = r.headers.get('location').replace('/files/', '');
+ assert.match(id, /^fil_[0-9A-HJKMNP-TV-Z]{26}$/);
+
+ const meta = await t.get(`/api/v1/files/${id}`, { as: kim });
+ assert.strictEqual(meta.status, 200);
+ assert.strictEqual(meta.json().file.name, 'quarterly.bin', 'the typed name wins over the file name');
+ assert.strictEqual(meta.json().file.size, data.length);
+ assert.strictEqual(meta.json().file.sha256, sha256(data), 'Media computed the hash and MediaHub kept it');
+
+ const dl = await t.get(`/files/${id}/download`, { as: kim });
+ assert.strictEqual(dl.status, 200);
+ assert.strictEqual(sha256(dl.buffer), sha256(data), 'the bytes round-trip');
+ });
+
+ await check('the chunked upload the page script uses: start, parts, complete', async () => {
+ const data = bytes(6 * MB, 'chunked');
+ const started = await t.get('/api/v1/files', { as: kim, headers: SAME, json: { name: 'video.bin', size: data.length, content_type: 'application/octet-stream' } });
+ assert.strictEqual(started.status, 201, started.text);
+ const up = started.json().upload;
+ assert.strictEqual(up.parts_expected, 2, 'six megabytes at five megabytes a part');
+ assert.strictEqual(up.part_size, 5 * MB);
+ assert.deepStrictEqual(up.missing, []);
+
+ const first = await t.get(`/api/v1/uploads/${up.id}/parts/1`, { as: kim, method: 'PUT', body: data.subarray(0, up.part_size), headers: SAME });
+ assert.strictEqual(first.status, 200, first.text);
+
+ // The resume answer: part 1 is in, part 2 is what is left.
+ const resume = await t.get(`/api/v1/uploads/${up.id}`, { as: kim });
+ assert.strictEqual(resume.status, 200);
+ assert.deepStrictEqual(resume.json().upload.missing, [2]);
+ assert.strictEqual(resume.json().upload.status, 'open');
+
+ const second = await t.get(`/api/v1/uploads/${up.id}/parts/2`, { as: kim, method: 'PUT', body: data.subarray(up.part_size), headers: SAME });
+ assert.strictEqual(second.status, 200, second.text);
+ const done_ = await t.get(`/api/v1/uploads/${up.id}/complete`, { as: kim, json: {}, headers: SAME });
+ assert.strictEqual(done_.status, 201, done_.text);
+ assert.strictEqual(done_.json().file.size, data.length);
+ assert.strictEqual(done_.json().file.sha256, sha256(data));
+
+ // The session is gone once it is a file, and so is the resume route for it.
+ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}`, { as: kim })).status, 404);
+ });
+
+ await check('a part of the wrong size is refused, and one person cannot touch another\'s upload', async () => {
+ const data = bytes(6 * MB, 'parts');
+ const up = (await t.get('/api/v1/files', { as: kim, headers: SAME, json: { name: 'p.bin', size: data.length, content_type: 'application/octet-stream' } })).json().upload;
+
+ const short = await t.get(`/api/v1/uploads/${up.id}/parts/1`, { as: kim, method: 'PUT', body: data.subarray(0, 10), headers: SAME });
+ assert.strictEqual(short.status, 422);
+ assert.strictEqual(short.json().code, 'upload.part_size');
+ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}/parts/9`, { as: kim, method: 'PUT', body: data.subarray(0, up.part_size), headers: SAME })).status, 422);
+
+ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}`, { as: sam })).status, 404, 'not sam\'s upload');
+ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}/parts/1`, { as: sam, method: 'PUT', body: data.subarray(0, up.part_size), headers: SAME })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204, 'the owner can abandon it');
+ assert.strictEqual((await t.get(`/api/v1/uploads/${up.id}`, { as: kim })).status, 404);
+ });
+
+ await check('a file over the per-file cap is refused before anything is created', async () => {
+ const r = await t.get('/api/v1/files', { as: kim, headers: SAME, json: { name: 'huge.bin', size: 25 * MB, content_type: 'application/octet-stream' } });
+ assert.strictEqual(r.status, 413);
+ assert.strictEqual(r.json().code, 'file.too_large');
+ assert.ok(r.json().detail.includes('20'), r.json().detail);
+ });
+
+ await check('the day\'s uploads and the stored quota are enforced, from the environment', async () => {
+ const usage = (await t.get('/api/v1/usage', { as: kim })).json().usage;
+ assert.strictEqual(usage.uploads_per_day, 3);
+ assert.strictEqual(usage.max_file_bytes, 20 * MB);
+ assert.strictEqual(usage.bytes_limit, 40 * MB);
+ // kim has uploaded twice (the plain form and the chunked upload). One more, then the day is full.
+ assert.strictEqual(usage.uploads_today, 2, JSON.stringify(usage));
+ const third = await upload(t, kim, bytes(1000, 'third'), { name: 'third.bin' });
+ assert.strictEqual(third.status, 201, third.text);
+ const fourth = await upload(t, kim, bytes(1000, 'fourth'), { name: 'fourth.bin' });
+ assert.strictEqual(fourth.status, 429);
+ assert.strictEqual(fourth.json().code, 'file.daily_limit');
+ // The quota: sam has room, kim does not have 40 MB any more either.
+ const stored = (await t.get('/api/v1/usage', { as: kim })).json().usage.bytes_used;
+ const over = await t.get('/api/v1/files', { as: sam, headers: SAME, json: { name: 'ok.bin', size: 1000, content_type: 'text/plain' } });
+ assert.strictEqual(over.status, 201, 'another person has their own numbers');
+ assert.ok(stored > 6 * MB);
+ });
+
+ await check('an upload is private to its owner until it is shared', async () => {
+ // Somebody with their own numbers: kim's day is full by now, which is a different rule.
+ const nia = t.network.addUser('nia');
+ const up = await upload(t, nia, bytes(200, 'private'), { name: 'mine.bin' });
+ assert.strictEqual(up.status, 201, up.text);
+ const id = up.json().file.id;
+ assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: sam })).status, 404);
+ // A session-cookie DELETE is only accepted from this site, and it is still not sam's file.
+ assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: sam, method: 'DELETE' })).status, 403);
+ assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: sam, method: 'DELETE', headers: SAME })).status, 404);
+ assert.strictEqual((await t.get(`/files/${id}`, { as: sam })).status, 404);
+ assert.strictEqual((await t.get(`/files/${id}/download`, { as: sam })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: nia })).status, 200, 'its owner sees it');
+ assert.ok((await t.get('/api/v1/files', { as: sam })).json().files.every((f) => f.name !== 'mine.bin'));
+ });
+
+ await check('an empty or nameless form is refused with a page, not a crash', async () => {
+ const empty = await postForm(t, kim, {}, { filename: 'empty.bin', type: 'application/octet-stream', data: Buffer.alloc(0) });
+ assert.strictEqual(empty.status, 422);
+ assert.ok(empty.text.includes('No file was in that form'), empty.text.slice(0, 200));
+ const none = await postForm(t, kim, {}, null);
+ assert.strictEqual(none.status, 422);
+ assert.strictEqual((await t.get('/files/upload', { as: kim, headers: SAME, body: Buffer.from('not a form'), })).status, 422);
+ });
+
+ await check('Media being down is a refusal the person can read, never a broken page', async () => {
+ const ola = t.network.addUser('ola');
+ t.media.setDown(true);
+ const r = await upload(t, ola, bytes(100), { name: 'down.bin' });
+ assert.strictEqual(r.status, 502);
+ assert.match(r.json().code, /^media\./, r.json().code);
+ const form = await postForm(t, ola, { name: 'down.bin' }, { filename: 'down.bin', type: 'text/plain', data: bytes(100, 'down') });
+ assert.strictEqual(form.status, 422);
+ assert.ok(form.text.includes('could not store that just now'), form.text.slice(0, 400));
+ t.media.setDown(false);
+ assert.strictEqual((await upload(t, ola, bytes(100), { name: 'back.bin' })).status, 201, 'and it works again once Media is back');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -14,6 +14,7 @@ process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN = process.env.MEDIAHUB_API_RATE_LIMI
@@ -14,6 +14,7 @@ process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN = process.env.MEDIAHUB_API_RATE_LIMI
*/
*/
const http = require('http');
const http = require('http');
const { startNetwork } = require('./mocks');
const { startNetwork } = require('./mocks');
+const { startMedia, KEY } = require('./media');
const captured = [];
const captured = [];
for (const m of ['log', 'info', 'warn', 'error']) {
for (const m of ['log', 'info', 'warn', 'error']) {
@@ -23,10 +24,13 @@ for (const m of ['log', 'info', 'warn', 'error']) {
@@ -23,10 +24,13 @@ for (const m of ['log', 'info', 'warn', 'error']) {
async function boot(opts = {}) {
async function boot(opts = {}) {
const network = await startNetwork(opts.network || {});
const network = await startNetwork(opts.network || {});
+ // The stand-in for OpenVibe.Media (test/helpers/media.js): the bytes' real home, in this process.
+ const media = opts.media === false ? null : await startMedia(opts.media || {});
const env = {
const env = {
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.download', TRUST_PROXY: '1',
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.download', TRUST_PROXY: '1',
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'media-hub', OV_OAUTH_CLIENT_SECRET: 'media-hub-secret', COOKIE_SECURE: 'false',
OV_OAUTH_CLIENT_ID: 'media-hub', OV_OAUTH_CLIENT_SECRET: 'media-hub-secret', COOKIE_SECURE: 'false',
+ ...(media ? { MEDIAHUB_MEDIA_URL: media.url, MEDIAHUB_MEDIA_APP_KEY: KEY } : {}),
...(opts.env || {}),
...(opts.env || {}),
};
};
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
@@ -52,8 +56,10 @@ async function boot(opts = {}) {
@@ -52,8 +56,10 @@ async function boot(opts = {}) {
if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
let body = o.body;
let body = o.body;
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
- if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
- const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
+ // An empty form is still a POST with a body (urlencoded ""), not a GET: the route is a write either way.
+ if (o.form !== undefined) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const sendsBody = body !== undefined && body !== null;
+ const res = await fetch(base + p, { method: o.method || (sendsBody ? 'POST' : 'GET'), headers, body: sendsBody ? body : undefined, redirect: 'manual' });
const buf = Buffer.from(await res.arrayBuffer());
const buf = Buffer.from(await res.arrayBuffer());
const text = buf.toString('utf8');
const text = buf.toString('utf8');
return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
@@ -66,13 +72,14 @@ async function boot(opts = {}) {
@@ -66,13 +72,14 @@ async function boot(opts = {}) {
}
}
const t = {
const t = {
- base, network, config, ctx: built.ctx, get, signIn, dbDump,
+ base, network, media, config, ctx: built.ctx, get, signIn, dbDump,
logs: () => captured.join('\n'),
logs: () => captured.join('\n'),
async close() {
async close() {
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
built.ctx.keys.client.stop();
built.ctx.keys.client.stop();
await testdb.close();
await testdb.close();
await network.close();
await network.close();
+ if (media) await media.close();
},
},
};
};
return t;
return t;
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * What the drive tests share: the same-origin header every signed-in write needs (a session-cookie write is only
+ * accepted from openvibe.download itself), a chunked upload in one call, and small builders for a file and a share.
+ */
+const crypto = require('crypto');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+/** Deterministic bytes of `n`: a pattern long enough to notice a wrong byte, and hashable. */
+function bytes(n, seed = 'OpenVibe.MediaHub') {
+ const out = Buffer.alloc(n);
+ const block = Buffer.from(seed, 'utf8');
+ for (let i = 0; i < n; i++) out[i] = block[i % block.length] ^ (i & 0xff);
+ return out;
+}
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+
+/**
+ * Upload through the API the way the page's script does: POST /files, one PUT per part, then complete.
+ * Answers the final response (t.get's shape), so a caller can assert on it directly.
+ */
+async function upload(t, as, data, { name = 'file.bin', contentType = 'application/octet-stream', folder = null, onPart = null } = {}) {
+ const started = await t.get('/api/v1/files', {
+ as, headers: SAME,
+ json: { name, size: data.length, content_type: contentType, folder_id: folder },
+ });
+ if (started.status !== 201 || !started.json().upload) return started;
+ const up = started.json().upload;
+ for (let n = 1; n <= up.parts_expected; n++) {
+ const start = (n - 1) * up.part_size;
+ const slice = data.subarray(start, Math.min(start + up.part_size, data.length));
+ if (onPart) await onPart(n, up);
+ const sent = await t.get(`/api/v1/uploads/${up.id}/parts/${n}`, {
+ as, method: 'PUT', body: slice, headers: { ...SAME, 'content-type': 'application/octet-stream' },
+ });
+ if (sent.status !== 200) return sent;
+ }
+ return await t.get(`/api/v1/uploads/${up.id}/complete`, { as, json: {}, headers: SAME });
+}
+
+/** POST /api/v1/shares as somebody, same-origin by default. */
+const share = (t, as, body, o = {}) => t.get('/api/v1/shares', { as, json: body, headers: { ...SAME, ...(o.headers || {}) } });
+
+/**
+ * The body a browser sends for `<form method=post enctype="multipart/form-data">` with a few text fields and one
+ * file — what the no-JavaScript upload is. Returns { body, contentType } for t.get.
+ */
+function formBody(fields, file) {
+ const boundary = `----ovtest${crypto.randomBytes(8).toString('hex')}`;
+ const head = [];
+ for (const [name, value] of Object.entries(fields || {})) {
+ head.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`));
+ }
+ const fileHead = file ? [Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: ${file.type || 'application/octet-stream'}\r\n\r\n`)] : [];
+ const fileBytes = file ? [file.data, Buffer.from('\r\n')] : [];
+ return {
+ body: Buffer.concat([...head, ...fileHead, ...fileBytes, Buffer.from(`--${boundary}--\r\n`)]),
+ contentType: `multipart/form-data; boundary=${boundary}`,
+ };
+}
+
+/** POST the plain upload form (no JavaScript). */
+const postForm = (t, as, fields, file, o = {}) => {
+ const form = formBody(fields, file);
+ return t.get('/files/upload', { as, body: form.body, headers: { ...SAME, 'content-type': form.contentType, ...(o.headers || {}) } });
+};
+
+module.exports = { SAME, bytes, sha256, upload, share, formBody, postForm };
new file mode 100644
new file mode 100644
@@ -0,0 +1,267 @@
@@ -0,0 +1,267 @@
+'use strict';
+/**
+ * A stand-in for OpenVibe.Media: a small local HTTP server that implements exactly the object-API v2 endpoints
+ * MediaHub calls, with Media's own behaviour where it matters to this service:
+ *
+ * POST /api/v2/:app/objects init (single-part or a multipart session)
+ * PUT /api/v2/:app/objects/:id/content the bytes, exactly size_bytes of them
+ * POST /api/v2/:app/objects/:id/complete hash, size and lifecycle → ready
+ * POST /api/v2/:app/objects/:id/multipart (re)start a session
+ * GET /api/v2/:app/objects/:id/multipart/:uid parts received and what is missing (resume)
+ * PUT /api/v2/:app/objects/:id/multipart/:uid/parts/:n one part, exactly part_size bytes (the last less)
+ * POST /api/v2/:app/objects/:id/multipart/:uid/complete assemble into the object
+ * DELETE /api/v2/:app/objects/:id/multipart/:uid abort
+ * GET /api/v2/:app/objects/:id metadata
+ * GET /api/v2/:app/objects/:id/download a short-lived signed URL (host-independent signature)
+ * DELETE /api/v2/:app/objects/:id soft delete
+ * GET /o/:id?exp&sig the bytes, with the signature checked
+ *
+ * The app key is required on everything under /api/v2 (x-ov-subject names the owner at init), exactly as Media
+ * requires it, so a test can prove MediaHub never asks Media for anything with the wrong credential. `.requests`
+ * records every call ({ method, path, query, authorization, subject, bytes }) for the assertions that need it.
+ *
+ * Behaviour knobs: `setDown(true)` makes every call answer 503; `setKeyRejects(true)` answers 401 as a bad key
+ * would; `objects`/`bytesOf(id)` are what the store holds; `deleted` lists the ids Media was asked to delete.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+
+const KEY = 'media-hub-test-key';
+const SIGN_SECRET = 'stand-in-media-signing-secret';
+
+const sign = (objectId, exp) => crypto.createHmac('sha256', SIGN_SECRET).update(`get\n${objectId}\n${exp}`).digest('base64url');
+
+async function startMedia({ key = KEY, maxSingle = 256 * 1024 * 1024 } = {}) {
+ const objects = new Map(); // id → { id, size_bytes, mime_type, content_hash, lifecycle_status, visibility, owner_subject, filename, bytes }
+ const sessions = new Map(); // id → { id, object_id, part_size, total_size, parts_expected, status, parts: Map }
+ const requests = [];
+ const deleted = [];
+ const state = { down: false, keyRejects: false };
+ let seq = 0;
+
+ const json = (res, status, body) => {
+ const text = JSON.stringify(body);
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
+ res.end(text);
+ };
+ const problem = (res, status, code, detail) => json(res, status, { type: `https://openvibe.media/problems/${code}`, title: 'Error', status, code, detail });
+
+ const readRaw = (req) => new Promise((resolve, reject) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => resolve(Buffer.concat(chunks)));
+ req.on('error', reject);
+ });
+
+ const publicObject = (o) => ({
+ id: o.id, kind: 'file', visibility: o.visibility, lifecycle_status: o.lifecycle_status,
+ mime_type: o.mime_type, size_bytes: o.size_bytes, content_hash: o.content_hash,
+ owner: { subject: o.owner_subject }, metadata: { filename: o.filename },
+ created_at: o.created_at, updated_at: o.created_at, deleted_at: o.deleted_at || null,
+ });
+
+ const sessionPublic = (sess, { parts = true } = {}) => {
+ const out = {
+ upload_id: sess.id, object_id: sess.object_id, status: sess.status,
+ part_size: sess.part_size, total_size: sess.total_size, parts_expected: sess.parts_expected,
+ expires_at: new Date(Date.now() + 3600_000).toISOString(), created_at: sess.created_at,
+ };
+ if (parts) {
+ const have = [...sess.parts.keys()].sort((a, b) => a - b);
+ out.parts = have.map((n) => ({ part_number: n, size_bytes: sess.parts.get(n).length, sha256: sha256(sess.parts.get(n)) }));
+ out.received_bytes = have.reduce((a, n) => a + sess.parts.get(n).length, 0);
+ out.missing = [];
+ for (let n = 1; n <= sess.parts_expected; n++) if (!sess.parts.has(n)) out.missing.push(n);
+ }
+ return out;
+ };
+
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+ const partSize = (sess, n) => (n < sess.parts_expected ? sess.part_size : sess.total_size - sess.part_size * (sess.parts_expected - 1));
+
+ const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://x');
+ const raw = req.method === 'PUT' || req.method === 'POST' ? await readRaw(req) : Buffer.alloc(0);
+ const path = url.pathname;
+ const entry = { method: req.method, path, query: url.search, authorization: req.headers.authorization || null, subject: req.headers['x-ov-subject'] || null, bytes: raw.length };
+ requests.push(entry);
+ if (state.down) return problem(res, 503, 'media.down', 'Media is restarting');
+ if (!path.startsWith('/api/v2/') && !path.startsWith('/o/')) return problem(res, 404, 'route.not_found', 'no such route');
+
+ // ── The public bytes of an object (/o/:id), signature-checked like Media checks it ──
+ if (path.startsWith('/o/')) {
+ const id = decodeURIComponent(path.slice(3));
+ const obj = objects.get(id);
+ const exp = Number(url.searchParams.get('exp'));
+ const sig = url.searchParams.get('sig') || '';
+ const signed = Number.isInteger(exp) && exp * 1000 > Date.now() && sig === sign(id, exp);
+ if (!obj || obj.visibility === 'private' && !signed) return problem(res, 404, 'media.object.not_found', 'No such object');
+ if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Gone');
+ if (obj.lifecycle_status !== 'ready') return problem(res, 404, 'media.object.not_found', 'No such object');
+ const name = String(obj.filename || obj.id).replace(/["\\\r\n]/g, '_');
+ const headers = {
+ 'Content-Type': obj.mime_type || 'application/octet-stream',
+ 'Content-Disposition': `attachment; filename="${name}"`,
+ 'X-Content-Type-Options': 'nosniff',
+ 'Accept-Ranges': 'bytes',
+ };
+ // A Range read, as Media serves one for a packed or large object.
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
+ if (range && (range[1] || range[2])) {
+ const start = range[1] ? Number(range[1]) : Math.max(0, obj.bytes.length - Number(range[2]));
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), obj.bytes.length - 1) : obj.bytes.length - 1;
+ const slice = obj.bytes.subarray(start, end + 1);
+ res.writeHead(206, { ...headers, 'Content-Range': `bytes ${start}-${end}/${obj.bytes.length}`, 'Content-Length': String(slice.length) });
+ return res.end(slice);
+ }
+ res.writeHead(200, { ...headers, 'Content-Length': String(obj.bytes.length) });
+ return res.end(obj.bytes);
+ }
+
+ // ── Everything else is the tenant API, and needs the app key ──
+ const keyed = String(req.headers.authorization || '').startsWith('Bearer ') ? String(req.headers.authorization).slice(7) : null;
+ if (state.keyRejects || keyed !== key) return problem(res, 401, 'auth.required', 'Authentication required');
+
+ const rest = path.replace(/^\/api\/v2\//, '').split('/').filter(Boolean).map(decodeURIComponent);
+ if (rest.length < 2 || rest[1] !== 'objects') return problem(res, 404, 'route.not_found', 'no such route');
+ const tail = rest.slice(2);
+ const parseJson = () => { try { return raw.length ? JSON.parse(raw.toString('utf8')) : {}; } catch { return null; } };
+
+ // POST /objects — init
+ if (!tail.length && req.method === 'POST') {
+ const body = parseJson();
+ if (!body) return problem(res, 400, 'media.object.invalid', 'body is not JSON');
+ const size = Number(body.size_bytes);
+ if (!Number.isInteger(size) || size < 1) return problem(res, 400, 'media.object.invalid', 'size_bytes must be a positive integer');
+ const subject = req.headers['x-ov-subject'];
+ if (!subject || !ids.isSubjectId('user', subject)) return problem(res, 400, 'media.object.invalid', 'X-OV-Subject must be a user subject');
+ const wantParts = body.multipart === true;
+ if (!wantParts && size > maxSingle) return problem(res, 413, 'media.object.too_large', 'Single-part uploads are limited; send multipart: true');
+ seq += 1;
+ const obj = {
+ id: ids.newId('media', 1_700_000_000_000 + seq), visibility: body.visibility || 'private',
+ lifecycle_status: 'uploading', mime_type: body.mime_type || null, size_bytes: size,
+ content_hash: null, owner_subject: subject, filename: body.filename || null, bytes: null, x: true,
+ created_at: new Date().toISOString(),
+ };
+ objects.set(obj.id, obj);
+ const base = `/api/v2/${rest[0]}/objects/${obj.id}`;
+ if (!wantParts) {
+ const exp = Math.floor(Date.now() / 1000) + 3600;
+ return json(res, 201, {
+ id: obj.id, object: publicObject(obj),
+ upload: { method: 'PUT', url: `${base}/content?token=t`, token: 't', expires_at: null, max_bytes: size, content_type: obj.mime_type, complete_url: `${base}/complete`, multipart_url: `${base}/multipart` },
+ });
+ }
+ const sess = newSession(obj, body.part_size);
+ return json(res, 201, {
+ id: obj.id, object: publicObject(obj),
+ upload: { method: 'multipart', url: null, token: null, expires_at: null, max_bytes: size, content_type: obj.mime_type, complete_url: `${base}/complete`, multipart_url: `${base}/multipart`, multipart: sessionPublic(sess, { parts: false }) },
+ });
+ }
+
+ const obj = objects.get(tail[0]);
+ if (!obj) return problem(res, 404, 'media.object.not_found', 'No such object in this namespace');
+ const sub = tail.slice(1);
+
+ // PUT /objects/:id/content
+ if (!sub.length && req.method === 'PUT') {
+ if (obj.lifecycle_status !== 'uploading') return problem(res, 409, 'media.object.not_uploading', `Object is ${obj.lifecycle_status}`);
+ if (raw.length !== Number(obj.size_bytes)) return problem(res, 400, 'media.object.size_mismatch', `Declared ${obj.size_bytes} bytes, received ${raw.length}`);
+ obj.bytes = raw;
+ obj.content_hash = sha256(raw);
+ return json(res, 200, { id: obj.id, size_bytes: raw.length, content_hash: obj.content_hash });
+ }
+ // POST /objects/:id/complete
+ if (sub.length === 1 && sub[0] === 'complete' && req.method === 'POST') {
+ if (!obj.bytes) return problem(res, 409, 'media.object.no_content', 'PUT the content first');
+ obj.lifecycle_status = 'ready';
+ return json(res, 200, publicObject(obj));
+ }
+ // POST /objects/:id/multipart
+ if (sub.length === 1 && sub[0] === 'multipart' && req.method === 'POST') {
+ const body = parseJson() || {};
+ const sess = newSession(obj, body.part_size, Number(body.size_bytes) || Number(obj.size_bytes));
+ return json(res, 201, sessionPublic(sess, { parts: false }));
+ }
+ if (sub[0] === 'multipart' && sub.length >= 2) {
+ const sess = sessions.get(sub[1]);
+ if (!sess || sess.object_id !== obj.id) return problem(res, 404, 'media.upload.not_found', 'No such upload for this object');
+ // GET …/multipart/:uid — the resume answer
+ if (sub.length === 2 && req.method === 'GET') return json(res, 200, sessionPublic(sess));
+ // DELETE …/multipart/:uid — abort
+ if (sub.length === 2 && req.method === 'DELETE') { sess.status = 'aborted'; sess.parts.clear(); return json(res, 200, sessionPublic(sess, { parts: false })); }
+ // PUT …/multipart/:uid/parts/:n
+ if (sub.length === 4 && sub[2] === 'parts' && req.method === 'PUT') {
+ if (sess.status !== 'active') return problem(res, 409, 'media.upload.not_active', `The upload is ${sess.status}`);
+ const n = Number(sub[3]);
+ if (!Number.isInteger(n) || n < 1 || n > sess.parts_expected) return problem(res, 400, 'media.upload.invalid_part', `part_number must be 1-${sess.parts_expected}`);
+ const want = partSize(sess, n);
+ if (raw.length !== want) return problem(res, raw.length > want ? 413 : 400, 'media.upload.part_size_mismatch', `Part ${n} must be ${want} bytes`);
+ sess.parts.set(n, raw);
+ return json(res, 200, { part_number: n, size_bytes: raw.length, sha256: sha256(raw) });
+ }
+ // POST …/multipart/:uid/complete — assemble
+ if (sub.length === 3 && sub[2] === 'complete' && req.method === 'POST') {
+ if (sess.status !== 'active') return problem(res, 409, 'media.upload.not_active', `The upload is ${sess.status}`);
+ const missing = [];
+ for (let n = 1; n <= sess.parts_expected; n++) if (!sess.parts.has(n)) missing.push(n);
+ if (missing.length) return problem(res, 409, 'media.upload.incomplete', `parts missing: ${missing.join(', ')}`);
+ const parts = [];
+ for (let n = 1; n <= sess.parts_expected; n++) parts.push(sess.parts.get(n));
+ obj.bytes = Buffer.concat(parts);
+ obj.content_hash = sha256(obj.bytes);
+ obj.size_bytes = obj.bytes.length;
+ obj.lifecycle_status = 'ready';
+ sess.status = 'completed';
+ return json(res, 200, publicObject(obj));
+ }
+ }
+ // GET /objects/:id
+ if (!sub.length && req.method === 'GET') return json(res, 200, publicObject(obj));
+ // GET /objects/:id/download
+ if (sub.length === 1 && sub[0] === 'download' && req.method === 'GET') {
+ if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Object was deleted');
+ if (obj.lifecycle_status !== 'ready') return problem(res, 409, 'media.object.not_ready', `Object is ${obj.lifecycle_status}`);
+ const exp = Math.floor(Date.now() / 1000) + 300;
+ return json(res, 200, { url: `https://media.test/o/${encodeURIComponent(obj.id)}?exp=${exp}&sig=${sign(obj.id, exp)}`, expires_at: new Date(exp * 1000).toISOString(), public: false });
+ }
+ // DELETE /objects/:id
+ if (!sub.length && req.method === 'DELETE') {
+ obj.lifecycle_status = 'deleted';
+ obj.deleted_at = new Date().toISOString();
+ deleted.push(obj.id);
+ return json(res, 200, publicObject(obj));
+ }
+ return problem(res, 404, 'route.not_found', 'no such route');
+ });
+
+ function newSession(obj, requestedPartSize, total = Number(obj.size_bytes)) {
+ const size = Number(requestedPartSize) || 8 * 1024 * 1024;
+ seq += 1;
+ const sess = {
+ id: `up_${seq}${crypto.randomBytes(6).toString('hex')}`, object_id: obj.id, part_size: size,
+ total_size: total, parts_expected: Math.max(1, Math.ceil(total / size)), status: 'active',
+ parts: new Map(), created_at: new Date().toISOString(),
+ };
+ // An active session for the object is replaced, exactly as Media replaces it.
+ for (const other of sessions.values()) if (other.object_id === obj.id && other.status === 'active') other.status = 'replaced';
+ sessions.set(sess.id, sess);
+ return sess;
+ }
+
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const url = `http://127.0.0.1:${server.address().port}`;
+ return {
+ url, key, objects, sessions, requests, deleted, state,
+ bytesOf: (id) => (objects.get(id) && objects.get(id).bytes) || null,
+ objectOf: (id) => objects.get(id) || null,
+ setDown: (v) => { state.down = v; },
+ setKeyRejects: (v) => { state.keyRejects = v; },
+ reset: () => { requests.length = 0; },
+ close: () => new Promise((r) => server.close(r)),
+ };
+}
+
+module.exports = { startMedia, KEY, sign };
@@ -56,6 +56,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -56,6 +56,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
+ accountParts: [], accountConfirmations: [],
};
};
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
@@ -256,6 +257,17 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -256,6 +257,17 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
return json(200, { services: [{ id: 'network', name: 'OpenVibe.Network', status: 'stable', domains: ['openvibe.network'], capabilities: [], runtime: { status: 'up', basis: 'ready', checked_at: new Date().toISOString() } }] });
return json(200, { services: [{ id: 'network', name: 'OpenVibe.Network', status: 'stable', domains: ['openvibe.network'], capabilities: [], runtime: { status: 'up', basis: 'ready', checked_at: new Date().toISOString() } }] });
}
}
if (url.pathname === '/oauth/revoke') return json(200, {});
if (url.pathname === '/oauth/revoke') return json(200, {});
+ // ADR-033: the internal routes a service's account export and deletion answers come back to
+ // (openvibe-sdk/account-data). Recorded in `.accountParts` and `.accountConfirmations`, as Events' side is
+ // what a service test needs to see.
+ if (/^\/internal\/account-exports\/exp_[0-9A-HJKMNP-TV-Z]{26}\/parts$/.test(url.pathname)) {
+ st.accountParts.push({ path: url.pathname, body: JSON.parse(raw.toString('utf8') || '{}') });
+ return json(200, { ok: true });
+ }
+ if (/^\/internal\/account-deletions\/del_[0-9A-HJKMNP-TV-Z]{26}\/confirmations$/.test(url.pathname)) {
+ st.accountConfirmations.push({ path: url.pathname, body: JSON.parse(raw.toString('utf8') || '{}') });
+ return json(200, { ok: true });
+ }
if (url.pathname === '/oauth/token' && req.method === 'POST') {
if (url.pathname === '/oauth/token' && req.method === 'POST') {
const ct = String(req.headers['content-type'] || '');
const ct = String(req.headers['content-type'] || '');
const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
@@ -312,6 +324,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -312,6 +324,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
return {
return {
url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
+ accountParts: st.accountParts, accountConfirmations: st.accountConfirmations,
addUser, userToken,
addUser, userToken,
/** An authorization code the mock will exchange for this user if the verifier matches. */
/** An authorization code the mock will exchange for this user if the verifier matches. */
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
@@ -43,7 +43,8 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -43,7 +43,8 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
- for (const tag of ['<meta property="og:title"', 'data-ov-icon="media-hub"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
+ // openvibe-shared/app-icon slugs the site id to lower-case letters (media-hub → mediahub) in data-ov-icon.
+ for (const tag of ['<meta property="og:title"', 'data-ov-icon="mediahub"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
assert.ok(head.includes(tag), `head has ${tag}`);
}
}
assert.ok(r.text.includes('id="ov-footer"'), 'the server-rendered footer');
assert.ok(r.text.includes('id="ov-footer"'), 'the server-rendered footer');