openvibers/openvibe.mediahub Commits

WIP: MediaHub drive (DeepSeek build stopped at 402 balance)

OP OpenVibers committed 8 Oct 2026 · 40aaf13379 · 36 files, +4026 −63


Changes

ddiff --git a/.env.example b/.env.example
iindex 43036cd..4004b14 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/deploy/nginx/openvibe.download.conf b/deploy/nginx/openvibe.download.conf
iindex 26265da..30b10f8 100644
---- a/deploy/nginx/openvibe.download.conf
++++ b/deploy/nginx/openvibe.download.conf
@@@ -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;
ddiff --git a/migrations/0002_mediahub.sql b/migrations/0002_mediahub.sql
nnew file mode 100644
iindex 0000000..8a64db8
---- /dev/null
++++ b/migrations/0002_mediahub.sql
@@@ -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
++);
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 5a11e3c..f7ca059 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -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; }
ddiff --git a/public/js/upload.js b/public/js/upload.js
nnew file mode 100644
iindex 0000000..f1b26d6
---- /dev/null
++++ b/public/js/upload.js
@@@ -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);
++ });
++})();
ddiff --git a/server/account.js b/server/account.js
nnew file mode 100644
iindex 0000000..83a4fa5
---- /dev/null
++++ b/server/account.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex fc12888..56893a0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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());
ddiff --git a/server/brand.js b/server/brand.js
nnew file mode 100644
iindex 0000000..d9e95d8
---- /dev/null
++++ b/server/brand.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex 95e77d0..387e649 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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'),
ddiff --git a/server/drive/rules.js b/server/drive/rules.js
nnew file mode 100644
iindex 0000000..f477e37
---- /dev/null
++++ b/server/drive/rules.js
@@@ -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,
++};
ddiff --git a/server/drive/service.js b/server/drive/service.js
nnew file mode 100644
iindex 0000000..5fa8c32
---- /dev/null
++++ b/server/drive/service.js
@@@ -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 };
ddiff --git a/server/drive/staff.js b/server/drive/staff.js
nnew file mode 100644
iindex 0000000..2a50004
---- /dev/null
++++ b/server/drive/staff.js
@@@ -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 };
ddiff --git a/server/drive/store.js b/server/drive/store.js
nnew file mode 100644
iindex 0000000..5963999
---- /dev/null
++++ b/server/drive/store.js
@@@ -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,
++};
ddiff --git a/server/drive/upload.js b/server/drive/upload.js
nnew file mode 100644
iindex 0000000..5ed60a0
---- /dev/null
++++ b/server/drive/upload.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 7b31f8a..6e13da0 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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 };
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 794cd1d..3d968d4 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -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 },
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 52a5974..5523d87 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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: [
ddiff --git a/server/http/form-upload.js b/server/http/form-upload.js
nnew file mode 100644
iindex 0000000..81219ce
---- /dev/null
++++ b/server/http/form-upload.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex efa5a54..8cb1de8 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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 whole
return r;
}
--module.exports = { createPageRoutes };
++module.exports = { createPageRoutes, SAFETY_RULES, ABUSE_EMAIL };
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 2f2c79d..1375229 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -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 };
ddiff --git a/server/http/router.js b/server/http/router.js
iindex 088ba89..4371978 100644
---- a/server/http/router.js
++++ b/server/http/router.js
@@@ -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 {
ddiff --git a/server/media/client.js b/server/media/client.js
nnew file mode 100644
iindex 0000000..159773a
---- /dev/null
++++ b/server/media/client.js
@@@ -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 };
ddiff --git a/server/network/identity.js b/server/network/identity.js
nnew file mode 100644
iindex 0000000..79979dc
---- /dev/null
++++ b/server/network/identity.js
@@@ -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 };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..6311428
---- /dev/null
++++ b/test/account-data.test.js
@@@ -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); });
ddiff --git a/test/brands.test.js b/test/brands.test.js
nnew file mode 100644
iindex 0000000..40644ce
---- /dev/null
++++ b/test/brands.test.js
@@@ -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 &amp; 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); });
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex 38a53e9..1357555 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -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');
ddiff --git a/test/drive-download.test.js b/test/drive-download.test.js
nnew file mode 100644
iindex 0000000..e2a2d80
---- /dev/null
++++ b/test/drive-download.test.js
@@@ -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); });
ddiff --git a/test/drive-pages.test.js b/test/drive-pages.test.js
nnew file mode 100644
iindex 0000000..d13e31d
---- /dev/null
++++ b/test/drive-pages.test.js
@@@ -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('&lt;script&gt;') || r.text.includes('&lt;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('&lt;b&gt;not bold&lt;/b&gt;'), '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); });
ddiff --git a/test/drive-reports.test.js b/test/drive-reports.test.js
nnew file mode 100644
iindex 0000000..3c90252
---- /dev/null
++++ b/test/drive-reports.test.js
@@@ -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); });
ddiff --git a/test/drive-shares.test.js b/test/drive-shares.test.js
nnew file mode 100644
iindex 0000000..78025f3
---- /dev/null
++++ b/test/drive-shares.test.js
@@@ -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); });
ddiff --git a/test/drive-upload.test.js b/test/drive-upload.test.js
nnew file mode 100644
iindex 0000000..8080934
---- /dev/null
++++ b/test/drive-upload.test.js
@@@ -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); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex df8262f..d44f3b1 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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;
ddiff --git a/test/helpers/drive.js b/test/helpers/drive.js
nnew file mode 100644
iindex 0000000..581ced7
---- /dev/null
++++ b/test/helpers/drive.js
@@@ -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 };
ddiff --git a/test/helpers/media.js b/test/helpers/media.js
nnew file mode 100644
iindex 0000000..0d14102
---- /dev/null
++++ b/test/helpers/media.js
@@@ -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 };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 76a3071..edccd7c 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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(),
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.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() } }] });
}
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.m
return {
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; },
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex 1d455f5..342fcc4 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -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');
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');