openvibers/openvibe.mediahub Commits
Download: uploads without an account (drops), built and off until plan T10's gate (#76)
Changes
@@@ -300,6 +300,21 @@ private, hidden or removed drops out of everyone's playlists (its owner's list s| `/safety` | anyone | what is not allowed, how reports work, [email protected] || `/updates` | anyone | what shipped |++## Uploads without an account (drops, off)++++The owner's Q10 (MediaFire-style), built and **off** until the DMCA designated agent and the CSAM hash-matching account++exist (plan T10, the same gate as Pics' signed-out uploads): `MEDIAHUB_DOWNLOAD_ANONYMOUS=1` turns it on.++++- The home page (signed out) gets an upload box ([public/js/drop.js](public/js/drop.js)): `POST /api/v1/drops`++ answers a session and a token, the parts are `PUT /api/v1/drops/:id/parts/:n` with `X-Drop-Token`, and++ `POST /api/v1/drops/:id/complete` answers the public page `/d/<slug>` and a delete link shown once (the key's sha256 is++ kept). The token, not the address, holds the session, so a phone that changes network keeps uploading.++- Up to 2 GB a file, 10 files and 10 GB a day per address (`MEDIAHUB_DROP_*`), an ownerless OpenVibe.Media object,++ kept 30 days after the last download (an hourly sweep deletes the rest). `mh_drops`, migration 0012.++- `/d/<slug>`: name, size, downloads, Download (an attachment with the drive's headers), and a Report form. Any report++ of illegal content, or three distinct ones, suspends it; staff restore it or take it down from `/staff`, and a++ taken-down drop's sha256 goes on `mh_drop_blocklist`, so the same bytes are refused (451 `drop.blocked`).++## Backups: sync keys and `openvibe-download`A person keeps a folder on their computer backed up in their drive with the `openvibe-download` commandnnew file mode 100644@@@ -0,0 +1,46 @@++-- phase: expand++-- Drops (OpenVibe.Download, the owner's Q10, MediaFire-style): a file uploaded without an account, up to 2 GB, with a++-- public page /d/<slug> and a delete key shown once (kept as its sha256). A drop is kept 30 days after its last++-- download, then the sweep deletes it. Reports suspend it (any report of illegal content, or three distinct ones);++-- staff restore it or remove it, and a removed drop's sha256 goes on mh_drop_blocklist so the same file cannot come++-- back. Off until the owner registers the DMCA agent and the CSAM hash-matching account (plan T10, with Pics):++-- MEDIAHUB_DOWNLOAD_ANONYMOUS=1.++CREATE TABLE mh_drops (++ id text COLLATE "C" PRIMARY KEY, -- drp_<ULID>++ slug text COLLATE "C" NOT NULL UNIQUE, -- 24 random url-safe characters: the /d/<slug> address++ object_id text COLLATE "C" NOT NULL, -- med_… : an ownerless object in OpenVibe.Media++ name text NOT NULL,++ size bigint NOT NULL,++ content_type text COLLATE "C" NOT NULL,++ sha256 text COLLATE "C",++ uploader text COLLATE "C" NOT NULL, -- ip:<keyed hash of the address>, for the per-address limits++ delete_key_hash text COLLATE "C" NOT NULL,++ state text COLLATE "C" NOT NULL DEFAULT 'active',++ report_count integer NOT NULL DEFAULT 0,++ downloads integer NOT NULL DEFAULT 0,++ created_at text COLLATE "C" NOT NULL,++ last_download_at text COLLATE "C",++ removed_at text COLLATE "C",++ CHECK (state IN ('active', 'suspended', 'removed'))++);++CREATE INDEX mh_drops_uploader ON mh_drops (uploader, created_at);++CREATE INDEX mh_drops_live ON mh_drops (state, created_at) WHERE state <> 'removed';++++CREATE TABLE mh_drop_reports (++ drop_id text COLLATE "C" NOT NULL REFERENCES mh_drops (id) ON DELETE CASCADE,++ reporter text COLLATE "C" NOT NULL, -- user:usr_…, or ip:<keyed hash> signed out++ reason text COLLATE "C" NOT NULL,++ note text NOT NULL DEFAULT '',++ created_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (drop_id, reporter)++);++++-- A drop's upload session is an ordinary mh_uploads row whose owner is drop:<sha256 of its token>; the address it++-- came from rides along for the per-address limits.++ALTER TABLE mh_uploads ADD COLUMN drop_uploader text COLLATE "C";++++CREATE TABLE mh_drop_blocklist (++ sha256 text COLLATE "C" PRIMARY KEY,++ drop_id text COLLATE "C",++ created_at text COLLATE "C" NOT NULL++);nnew file mode 100644@@@ -0,0 +1,91 @@++/* OpenVibe.Download — uploading without an account (server/drive/drops.js), on the home page when drops are on.++ *++ * The file goes up in the drive's parts: POST /api/v1/drops (name, size, type) answers a session and a token, each++ * part is a PUT with the header X-Drop-Token, then POST …/complete answers the public link and a delete link shown++ * once. Nothing here is put in a URL but the session id; the token travels in a header.++ */++(function () {++ 'use strict';++ var box = document.querySelector('[data-drop]');++ if (!box || !window.fetch) return;++ var input = box.querySelector('input[type=file]');++ var button = box.querySelector('[data-drop-send]');++ var bar = box.querySelector('progress');++ var status = box.querySelector('[role=status]');++ if (!input || !button) return;++++ function say(text) { status.textContent = text; }++ function progress(done, total) { bar.hidden = false; bar.max = total; bar.value = done; }++ function problem(res) {++ return res.json().then(function (j) { return (j && (j.detail || j.code)) || ('HTTP ' + res.status); }, function () { return 'HTTP ' + res.status; });++ }++ function linkLine(label, href) {++ var p = document.createElement('p');++ p.appendChild(document.createTextNode(label + ' '));++ var a = document.createElement('a');++ a.href = href;++ a.textContent = href;++ p.appendChild(a);++ return p;++ }++++ button.addEventListener('click', function () {++ var file = input.files && input.files[0];++ if (!file) { say('Choose a file first.'); return; }++ button.disabled = true;++ say('Starting…');++ var token = null;++ var upload = null;++ fetch('/api/v1/drops', {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ name: file.name, size: file.size, content_type: file.type || 'application/octet-stream' }),++ }).then(function (res) {++ if (!res.ok) return problem(res).then(function (m) { throw new Error(m); });++ return res.json();++ }).then(function (j) {++ token = j.token;++ upload = j.upload;++ var n = 0;++ function next() {++ n += 1;++ if (n > upload.parts_expected) return Promise.resolve();++ var start = (n - 1) * upload.part_size;++ var part = file.slice(start, Math.min(file.size, start + upload.part_size));++ return fetch('/api/v1/drops/' + encodeURIComponent(upload.id) + '/parts/' + n, {++ method: 'PUT',++ headers: { 'Content-Type': 'application/octet-stream', 'X-Drop-Token': token },++ body: part,++ }).then(function (res) {++ if (!res.ok) return problem(res).then(function (m) { throw new Error(m); });++ progress(Math.min(file.size, start + part.size), file.size);++ say('Uploading… ' + Math.round((Math.min(file.size, start + part.size) / file.size) * 100) + '%');++ return next();++ });++ }++ return next();++ }).then(function () {++ say('Finishing…');++ return fetch('/api/v1/drops/' + encodeURIComponent(upload.id) + '/complete', {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', 'X-Drop-Token': token },++ body: '{}',++ });++ }).then(function (res) {++ if (!res.ok) return problem(res).then(function (m) { throw new Error(m); });++ return res.json();++ }).then(function (j) {++ status.textContent = '';++ var done = document.createElement('p');++ done.textContent = 'Uploaded. Anyone with this link can download it:';++ status.appendChild(done);++ status.appendChild(linkLine('Link:', j.drop.url));++ status.appendChild(linkLine('Delete link (shown once, keep it):', j.delete_url));++ button.disabled = false;++ input.value = '';++ }).catch(function (err) {++ say('The upload did not finish: ' + (err && err.message ? err.message : err));++ button.disabled = false;++ });++ });++}());@@@ -27,6 +27,7 @@ const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');const { createSyncKeys } = require('./drive/syncKeys');++const { createDrops } = require('./drive/drops');const { createApi } = require('./http/api');const { createPageRoutes } = require('./http/pages');const { createVideoRoutes } = require('./video/pages');@@@ -75,7 +76,8 @@ async function createApp(opts = {}) {const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });const syncKeys = createSyncKeys({ s, config });-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, videoAi, syncKeys, fetchImpl, log };++ const drops = createDrops({ s, media, config, uploads, log });++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, videoAi, syncKeys, drops, fetchImpl, log };// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.ctx.indexnow = opts.indexnow || (() => {const warn = (...a) => (log.warn || console.warn)(...a);@@@ -71,6 +71,17 @@ function load(env = process.env) {indexnowKey: String(env.INDEXNOW_KEY || '').trim(),},++ // Drops (server/drive/drops.js): files uploaded to openvibe.download without an account (the owner's Q10), each++ // with a public /d/<slug> page and a delete key, kept 30 days after the last download. Off until the DMCA agent++ // and the CSAM hash-matching account exist (plan T10, with Pics' signed-out uploads): MEDIAHUB_DOWNLOAD_ANONYMOUS=1.++ drops: {++ enabled: env.MEDIAHUB_DOWNLOAD_ANONYMOUS === '1',++ maxBytes: size(env.MEDIAHUB_DROP_MAX_BYTES, 2 * GB),++ perDay: size(env.MEDIAHUB_DROPS_PER_DAY, 10), // drops a day per address++ bytesPerDay: size(env.MEDIAHUB_DROP_BYTES_PER_DAY, 10 * GB),++ idleDays: Math.max(1, size(env.MEDIAHUB_DROP_IDLE_DAYS, 30)),++ },++// OpenVibe.Pics (server/pics): picture hosting on openvibe.pics, chosen by Host header like the video host.// The bytes are private Media objects, re-encoded here with their metadata stripped and served only through// this service (/i/<slug>.<ext>). Signed-out uploads stay off until the DMCA agent and the CSAMnnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++/**++ * Drops (migration 0012): a file uploaded to openvibe.download without an account — the owner's Q10, MediaFire-style.++ * Up to 2 GB in the drive's 8 MB parts, an ownerless OpenVibe.Media object, a public page /d/<slug> and a delete key++ * shown once. Kept 30 days after its last download. Off until MEDIAHUB_DOWNLOAD_ANONYMOUS=1 (plan T10's gate: the DMCA++ * agent and the CSAM hash-matching account, as for Pics' signed-out uploads).++ *++ * start({ ip, name, size, contentType }) per-address limits, a Media multipart session → { upload, token }++ * parts / status / abort the drive's own session code (server/drive/upload.js), the session's++ * owner being `drop:<sha256 of the token>`: the token, not the address,++ * proves the session is yours (a phone that changes network keeps going)++ * complete({ token, id }) Media assembles it; a blocklisted sha256 is refused and deleted →++ * { drop, deleteKey }++ * report / suspend / restore / remove reports suspend (illegal at once, else three distinct); staff restore or++ * remove, and a removed drop's sha256 is blocklisted++ * sweep() drops nobody downloaded for idleDays are deleted (bytes and all)++ */++const crypto = require('crypto');++const store = require('./store');++const rules = require('./rules');++++const SLUG_RE = /^[A-Za-z0-9_-]{24}$/;++const TOKEN_RE = /^[A-Za-z0-9_-]{32,64}$/;++const sha256 = (v) => crypto.createHash('sha256').update(String(v)).digest('hex');++++function createDrops({ s, media, config, uploads, log = console }) {++ const cfg = config.drops;++ const addressKey = crypto.createHash('sha256').update(`download-address:${config.oauth.clientSecret || 'dev'}`).digest();++ const addressId = (ip) => `ip:${crypto.createHmac('sha256', addressKey).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;++ const ownerOf = (token) => `drop:${sha256(token)}`;++ const dayStart = () => `${new Date(s.now()).toISOString().slice(0, 10)}T00:00:00.000Z`;++++ const get = (slug) => (SLUG_RE.test(String(slug || '')) ? s.db.maybe('SELECT * FROM mh_drops WHERE slug = $1', [String(slug)]) : null);++++ async function start({ ip, name, size, contentType }) {++ if (!cfg.enabled) return { ok: false, status: 403, code: 'drop.off', detail: 'Uploads without an account are not open yet. Sign in to upload to your own drive.' };++ const bytes = Number(size);++ if (!Number.isSafeInteger(bytes) || bytes < 1) return { ok: false, status: 422, code: 'file.invalid_size', detail: 'A file needs a size of at least one byte.' };++ if (bytes > cfg.maxBytes) return { ok: false, status: 413, code: 'file.too_large', detail: `A file uploaded without an account may be at most ${rules.formatBytes(cfg.maxBytes)}.` };++ const uploader = addressId(ip);++ const today = await s.db.one(++ "SELECT count(*)::int AS n, coalesce(sum(size), 0)::bigint AS b FROM mh_drops WHERE uploader = $1 AND created_at >= $2", [uploader, dayStart()]);++ if (Number(today.n) >= cfg.perDay) return { ok: false, status: 429, code: 'file.daily_limit', detail: `${cfg.perDay} uploads a day without an account. Sign in for your own drive, or try again tomorrow.` };++ if (Number(today.b) + bytes > cfg.bytesPerDay) return { ok: false, status: 429, code: 'file.daily_limit', detail: `${rules.formatBytes(cfg.bytesPerDay)} a day without an account. Sign in for your own drive, or try again tomorrow.` };++ const fileName = rules.fileName(name, 'file');++ const type = rules.normalizeType(contentType) || 'application/octet-stream';++ const started = await media.init({ size: bytes, contentType: type, name: fileName, multipart: true, partSize: config.partBytes, ownerless: true });++ if (!started.ok) return { ok: false, status: 502, 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: bytes, partSize: config.partBytes });++ if (!fresh.ok) {++ await media.remove(objectId);++ return { ok: false, status: 502, code: fresh.code, detail: fresh.detail };++ }++ session = fresh.data;++ }++ const token = crypto.randomBytes(24).toString('base64url');++ const row = await store.insertUpload(s, {++ id: s.newId('upl'), owner: ownerOf(token), object_id: objectId, upload_id: session.upload_id,++ name: fileName, size: bytes, content_type: type, folder_id: null, drop_uploader: uploader,++ part_size: session.part_size, parts_expected: session.parts_expected,++ created_at: s.iso(), expires_at: new Date(s.now() + config.uploadTtlSeconds * 1000).toISOString(),++ });++ return { ok: true, token, upload: { id: row.id, name: fileName, size: bytes, content_type: type, part_size: row.part_size, parts_expected: row.parts_expected, missing: [], expires_at: row.expires_at } };++ }++++ const sessionOwner = (token) => (TOKEN_RE.test(String(token || '')) ? ownerOf(token) : null);++ const putPart = (token, id, n, buffer) => {++ const owner = sessionOwner(token);++ return owner ? uploads.putPart(owner, id, n, buffer) : { ok: false, code: 'upload.not_found', detail: 'No such upload.' };++ };++ const status = (token, id) => {++ const owner = sessionOwner(token);++ return owner ? uploads.status(owner, id) : { ok: false, code: 'upload.not_found', detail: 'No such upload.' };++ };++ const abort = (token, id) => {++ const owner = sessionOwner(token);++ return owner ? uploads.abort(owner, id) : { ok: false, code: 'upload.not_found', detail: 'No such upload.' };++ };++++ async function complete({ token, id }) {++ const owner = sessionOwner(token);++ const row = owner ? await store.getUpload(s, String(id || '')) : null;++ if (!row || row.owner !== owner || row.expires_at <= s.iso()) return { ok: false, status: 404, 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, status: 502, code: done.code, detail: done.detail };++ const object = done.data || {};++ const hash = object.content_hash || null;++ await store.dropUpload(s, row.id);++ if (hash && await s.db.maybe('SELECT 1 FROM mh_drop_blocklist WHERE sha256 = $1', [hash])) {++ await media.remove(row.object_id);++ return { ok: false, status: 451, code: 'drop.blocked', detail: 'This file was taken down from OpenVibe.Download and cannot be uploaded again.' };++ }++ const deleteKey = crypto.randomBytes(18).toString('base64url');++ const drop = await s.db.one(++ `INSERT INTO mh_drops (id, slug, object_id, name, size, content_type, sha256, uploader, delete_key_hash, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,++ [s.newId('drp'), crypto.randomBytes(18).toString('base64url'), row.object_id, row.name,++ Number(object.size_bytes) || row.size, object.mime_type || row.content_type, hash, row.drop_uploader || 'ip:unknown', sha256(deleteKey), s.iso()]);++ return { ok: true, drop, deleteKey };++ }++++ const countDownload = (drop) => s.db.exec('UPDATE mh_drops SET downloads = downloads + 1, last_download_at = $2 WHERE id = $1', [drop.id, s.iso()]);++++ function keyMatches(drop, key) {++ if (!drop || typeof key !== 'string' || !key) return false;++ const a = Buffer.from(sha256(key));++ const b = Buffer.from(drop.delete_key_hash);++ return a.length === b.length && crypto.timingSafeEqual(a, b);++ }++++ /** Delete it (bytes and all); `block` puts its sha256 on the blocklist (a staff takedown). */++ async function remove(drop, { block = false } = {}) {++ if (!drop || drop.state === 'removed') return false;++ const gone = await media.remove(drop.object_id);++ if (!gone.ok && gone.status !== 404) log.warn(`[MediaHub] drop ${drop.id}: Media refused the delete: ${gone.status} ${gone.code}`);++ await s.db.exec("UPDATE mh_drops SET state = 'removed', removed_at = $2 WHERE id = $1", [drop.id, s.iso()]);++ if (block && drop.sha256) {++ await s.db.exec('INSERT INTO mh_drop_blocklist (sha256, drop_id, created_at) VALUES ($1, $2, $3) ON CONFLICT (sha256) DO NOTHING', [drop.sha256, drop.id, s.iso()]);++ }++ return true;++ }++++ async function report(drop, { reporter, reason, note }) {++ if (!rules.REASONS.includes(reason)) return { ok: false, code: 'report.reason', detail: 'Choose why.' };++ const added = await s.db.maybe(++ 'INSERT INTO mh_drop_reports (drop_id, reporter, reason, note, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (drop_id, reporter) DO NOTHING RETURNING drop_id',++ [drop.id, reporter, reason, rules.cleanText(note, 500), s.iso()]);++ if (!added) return { ok: true, recorded: false };++ const n = Number(await s.db.value('SELECT count(*) FROM mh_drop_reports WHERE drop_id = $1', [drop.id]));++ const suspend = drop.state === 'active' && (rules.SUSPEND_IMMEDIATELY.includes(reason) || n >= rules.SUSPEND_AT_REPORTS);++ await s.db.exec(`UPDATE mh_drops SET report_count = $2${suspend ? ", state = 'suspended'" : ''} WHERE id = $1`, [drop.id, n]);++ return { ok: true, recorded: true, suspended: suspend };++ }++++ const restore = (drop) => s.db.exec("UPDATE mh_drops SET state = 'active' WHERE id = $1 AND state = 'suspended'", [drop.id]);++ const suspended = () => s.db.many("SELECT * FROM mh_drops WHERE state = 'suspended' ORDER BY created_at DESC LIMIT 200");++++ /** Drops nobody has downloaded for idleDays: deleted. → how many. */++ async function sweep() {++ const before = new Date(s.now() - cfg.idleDays * 86_400_000).toISOString();++ const idle = await s.db.many(++ "SELECT * FROM mh_drops WHERE state <> 'removed' AND coalesce(last_download_at, created_at) < $1 ORDER BY id LIMIT 200", [before]);++ let n = 0;++ for (const drop of idle) if (await remove(drop)) n++;++ return n;++ }++++ return { enabled: () => cfg.enabled, config: cfg, addressId, get, start, putPart, status, abort, complete, countDownload, keyMatches, remove, report, restore, suspended, sweep };++}++++module.exports = { createDrops, SLUG_RE };@@@ -150,10 +150,10 @@ const usageFor = (s, owner) => s.db.many(`SELECT * FROM ${USAGE} WHERE owner = $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)`,++ `INSERT INTO ${UPLOADS} (id, owner, object_id, upload_id, name, size, content_type, folder_id, part_size, parts_expected, created_at, expires_at, drop_uploader)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,[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]);++ row.part_size, row.parts_expected, row.created_at, row.expires_at, row.drop_uploader || null]);return getUpload(s, row.id);}@@@ -52,7 +52,7 @@ function readBody(req, cap) {}function createApi(ctx) {-- const { config, s, principal, limits, drive, uploads, syncKeys } = ctx;++ const { config, s, principal, limits, drive, uploads, syncKeys, drops } = ctx;const r = asyncRouter();const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });@@@ -81,8 +81,60 @@ function createApi(ctx) {return res.json({ part: out.part, size: out.size });});++ // ── Drops: uploads without an account (server/drive/drops.js; off until MEDIAHUB_DOWNLOAD_ANONYMOUS=1) ──++ // The session is proven by X-Drop-Token (shown when it starts), never by the caller's address.++ const dropToken = (req) => String(req.get('x-drop-token') || '');++ const dropRefused = (req, res, out) => problem(req, res, out.status || (out.code === 'upload.not_found' ? 404 : 422), out.code, out.detail);++ r.put('/drops/:id/parts/:n', limits.budget('media-hub.upload.part'), async (req, res) => {++ if (!drops || !drops.enabled()) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');++ 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 drops.putPart(dropToken(req), String(req.params.id || ''), req.params.n, got.buffer);++ if (!out.ok) return dropRefused(req, res, out);++ return res.json({ part: out.part, size: out.size });++ });++r.use(express.json({ limit: '64kb' }));++ r.post('/drops', limits.budget('media-hub.drop.start'), async (req, res) => {++ if (!drops) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');++ const b = req.body || {};++ const out = await drops.start({ ip: req.ip, name: b.name, size: b.size, contentType: b.content_type });++ if (!out.ok) return dropRefused(req, res, out);++ return res.status(201).json({++ upload: out.upload, token: out.token,++ note: 'Send each part with PUT /api/v1/drops/:id/parts/:n and the header X-Drop-Token, then POST /api/v1/drops/:id/complete.',++ });++ });++++ r.get('/drops/:id', async (req, res) => {++ if (!drops || !drops.enabled()) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');++ const out = await drops.status(dropToken(req), String(req.params.id || ''));++ if (!out.ok) return dropRefused(req, res, out);++ return res.json({ upload: out.upload });++ });++++ r.post('/drops/:id/complete', limits.budget('media-hub.upload.complete'), async (req, res) => {++ if (!drops || !drops.enabled()) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');++ const out = await drops.complete({ token: dropToken(req), id: String(req.params.id || '') });++ if (!out.ok) return dropRefused(req, res, out);++ const d = out.drop;++ return res.status(201).json({++ drop: { slug: d.slug, url: `${config.baseUrl}/d/${d.slug}`, name: d.name, size: Number(d.size), content_type: d.content_type, created_at: d.created_at },++ delete_key: out.deleteKey,++ delete_url: `${config.baseUrl}/d/${d.slug}/delete?key=${out.deleteKey}`,++ note: `Keep the delete link: it is shown once. The file is kept ${drops.config.idleDays} days after its last download.`,++ });++ });++++ r.delete('/drops/:id', async (req, res) => {++ if (!drops || !drops.enabled()) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');++ const out = await drops.abort(dropToken(req), String(req.params.id || ''));++ if (!out.ok) return dropRefused(req, res, out);++ return res.status(204).end();++ });++const personOf = (req) => (req.principal.kind === 'user' ? req.principal.requester : null);/**@@@ -45,6 +45,8 @@ const BUDGETS = {'media-hub.upload.complete': { minute: 30, hour: 300 },'media-hub.folder.create': { minute: 20, hour: 120 },'media-hub.sync_key.create': { minute: 5, hour: 20 },++ // A drop (an upload without an account) started per address: the per-day count and bytes are the drop's own caps.++ 'media-hub.drop.start': { minute: 10, hour: 40 },// A backup through a sync key starts and finishes many small files: its own, larger numbers (the daily file cap// and the storage quota still hold).'media-hub.sync.upload.start': { minute: 120, hour: 1_000 },@@@ -58,7 +58,7 @@ const SAFETY_RULES = [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, s, drive, uploads, media, syncKeys } = ctx;++ const { config, s, drive, uploads, media, syncKeys, drops } = 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.@@@ -151,7 +151,7 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}eyebrow: `${SITE_NAME} · a private drive`,title: 'Your files,',accent: 'shared on your terms.',-- lede: `A private drive for your files, ${rules.formatBytes(config.quotas.bytes)} free, with share links you control: for named people, for anyone signed in, or a public download page anyone with the link can use. Uploading needs a signed-in OpenVibe account.`,++ lede: `A private drive for your files, ${rules.formatBytes(config.quotas.bytes)} free, with share links you control: for named people, for anyone signed in, or a public download page anyone with the link can use.${drops.enabled() ? ` No account? Upload a file up to ${rules.formatBytes(drops.config.maxBytes)} below and get a public link.` : ' Uploading needs a signed-in OpenVibe account.'}`,actions: signedIn(req)? [{ 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' }],@@@ -161,16 +161,18 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,jsonLd: homeJsonLd(config),styles: [showcase.STYLESHEET],++ scripts: drops.enabled() && !signedIn(req) ? ['js/drop.js'] : [],body: html`${raw(hero)}++${drops.enabled() && !signedIn(req) ? dropForm() : ''}${raw(showcase.features({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:upload', title: 'Your own drive', text: `Folders and files, private to you: ${rules.formatBytes(config.quotas.bytes)} stored, up to ${rules.formatBytes(config.quotas.maxFileBytes)} in one file.` },-- { 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:link', title: 'Share links', text: `One link per file or folder, expiring in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, revocable at any moment, with its downloads counted.` },{ icon: 'ov:account', title: 'You choose who opens it', text: 'Named OpenVibe accounts, anyone signed in, or a public download page for anyone with the link. Uploading always needs a signed-in 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:tools', title: 'Backups and an API', text: 'Keep a folder on your computer backed up with the openvibe-download command, and do everything else over /api/v1.' },{ 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.' },],}))}@@@ -180,7 +182,7 @@ ${raw(showcase.steps({items: [{ 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: 'Share', text: `Make a link that expires in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, public or for signed-in people, 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.' },],}))}@@@ -666,6 +668,7 @@ ${notice(out.suspendedconst 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 });++ const suspendedDrops = await drops.suspended();return page(req, res, {title: 'Report queue',description: 'Share links people reported, for OpenVibe staff to restore or remove.',@@@ -679,7 +682,13 @@ ${it.file ? html`<p><b>${it.file.name}</b> · ${rules.formatBytes(it.file.size)}<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>`}`,++</section>`) : html`<p class="muted">Nothing has been reported.</p>`}++${suspendedDrops.length ? html`<h2>Suspended uploads without an account</h2>${suspendedDrops.map((d) => html`<section class="card staff-item">++<h3><a href="/d/${d.slug}">/d/${d.slug}</a></h3>++<p><b>${d.name}</b> · ${rules.formatBytes(Number(d.size))} · ${d.content_type} · ${Number(d.report_count)} report${Number(d.report_count) === 1 ? '' : 's'} · uploaded ${time(d.created_at)} · object <code>${d.object_id}</code></p>++<form class="inline" method="post" action="/staff/drops/${d.slug}/restore"><button class="sc-btn" type="submit">Restore it</button></form>++<form class="inline" method="post" action="/staff/drops/${d.slug}/remove"><button class="sc-btn" type="submit">Take it down (and block the file)</button></form>++</section>`)}` : ''}`,});});@@@ -703,6 +712,113 @@ ${it.file ? html`<p><b>${it.file.name}</b> · ${rules.formatBytes(it.file.size)}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);++ // ── Drops: a file uploaded without an account (server/drive/drops.js; off until MEDIAHUB_DOWNLOAD_ANONYMOUS=1) ──++ const dropForm = () => html`<section class="card upload" aria-labelledby="h-drop" data-drop>++<h2 id="h-drop">Upload without an account</h2>++<div class="field"><label for="drop-file">File</label><input id="drop-file" type="file" required></div>++<p><button class="sc-btn sc-primary" type="button" data-drop-send>Upload and get a link</button></p>++<p class="small muted">Up to ${rules.formatBytes(drops.config.maxBytes)}, ${drops.config.perDay} files a day. The link is public: anyone who has it can download. The file is kept ${drops.config.idleDays} days after its last download, and you get a delete link once. Read <a href="/safety">the rules</a> first.</p>++<noscript><p class="small">Uploading without an account needs JavaScript (the file goes up in parts). <a href="/auth/login?next=%2Ffiles">Sign in</a> to upload with a plain form.</p></noscript>++<progress id="drop-progress" max="100" value="0" hidden></progress>++<div id="drop-status" class="small" role="status"></div>++</section>`;++ const dropCrumbs = (drop) => [{ label: 'Home', href: '/' }, { label: drop ? drop.name : 'A file' }];++ const dropOff = (req, res) => page(req, res, { title: 'Not found', crumbs: dropCrumbs(null), body: html`<h1>Not found</h1><p class="muted">No file here. <a href="/">OpenVibe.Download</a>.</p>` }, 404);++ /** The drop behind /d/<slug>: active, or suspended (shown as such); removed or unknown is a 404, or a 410 when it was there. */++ async function openDrop(req, res) {++ if (!drops.enabled()) return dropOff(req, res) && null;++ const drop = await drops.get(req.params.slug);++ if (!drop) { dropOff(req, res); return null; }++ if (drop.state === 'removed') {++ page(req, res, { title: 'The file is gone', crumbs: dropCrumbs(drop), body: html`<h1>This file is gone</h1><p class="muted">It was deleted by its uploader, taken down, or not downloaded for ${drops.config.idleDays} days.</p>` }, 410);++ return null;++ }++ return drop;++ }++ const dropReporter = (req) => (signedIn(req) ? requesterOf(req) : drops.addressId(req.ip));++++ r.get('/d/:slug', async (req, res) => {++ const drop = await openDrop(req, res);++ if (!drop) return undefined;++ const suspended = drop.state === 'suspended';++ return page(req, res, {++ title: `${drop.name} · OpenVibe.Download`,++ noindex: true, cache: cache.htmlHeaders({ private: true }),++ crumbs: dropCrumbs(drop),++ body: html`<h1>${drop.name}</h1>++${suspended ? notice('This file was reported and is suspended until OpenVibe staff look at it.', 'warn') : notice('Uploaded without an account: OpenVibe does not know who sent it. Download only what you trust.', 'info')}++${table(['Size', 'Type', 'Uploaded', 'Downloads'], [[rules.formatBytes(Number(drop.size)), drop.content_type, time(drop.created_at), String(Number(drop.downloads) || 0)]])}++${suspended ? '' : html`<p><a class="sc-btn sc-primary" href="/d/${drop.slug}/download" rel="nofollow">Download</a></p>`}++<p class="small muted">Kept ${drops.config.idleDays} days after its last download.</p>++${suspended ? '' : html`<section class="card" aria-labelledby="h-report"><h2 id="h-report">Report this</h2>++<form method="post" action="/d/${drop.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('/d/:slug/download', async (req, res) => {++ const drop = await openDrop(req, res);++ if (!drop) return undefined;++ if (drop.state !== 'active') return res.redirect(303, `/d/${drop.slug}`);++ await drops.countDownload(drop);++ return sendFile(req, res, drop);++ });++++ r.post('/d/:slug/report', async (req, res) => {++ const drop = await openDrop(req, res);++ if (!drop) return undefined;++ if (!sameSite(req, res, `/d/${drop.slug}`)) return undefined;++ const b = req.body || {};++ const out = await drops.report(drop, { reporter: dropReporter(req), reason: String(b.reason || ''), note: b.note });++ return page(req, res, {++ title: 'Thanks', crumbs: dropCrumbs(drop),++ body: html`<h1>${out.ok ? 'Thanks: OpenVibe staff will see it' : 'The report did not go through'}</h1>${out.ok ? (out.recorded ? (out.suspended ? notice('The file is suspended until staff decide.', 'ok') : notice('Your report was counted.', 'ok')) : notice('You reported this file already.', 'info')) : notice(out.detail, 'warn')}<p><a class="sc-btn" href="/d/${drop.slug}">Back</a></p>`,++ }, out.ok ? 200 : 422);++ });++++ r.get('/d/:slug/delete', async (req, res) => {++ const drop = await openDrop(req, res);++ if (!drop) return undefined;++ const key = String(req.query.key || '');++ return page(req, res, {++ title: 'Delete this file', noindex: true, cache: cache.htmlHeaders({ private: true }), crumbs: dropCrumbs(drop),++ body: html`<h1>Delete ${drop.name}?</h1>++<form method="post" action="/d/${drop.slug}/delete">++<div class="field"><label for="key">Delete key</label><input id="key" name="key" type="text" value="${key}" required autocomplete="off"></div>++<p><button class="sc-btn" type="submit">Delete it for good</button></p></form>`,++ });++ });++++ r.post('/d/:slug/delete', async (req, res) => {++ const drop = await openDrop(req, res);++ if (!drop) return undefined;++ if (!sameSite(req, res, `/d/${drop.slug}`)) return undefined;++ if (!drops.keyMatches(drop, String((req.body || {}).key || ''))) {++ return page(req, res, { title: 'Wrong key', crumbs: dropCrumbs(drop), body: html`<h1>That is not this file's delete key</h1><p><a class="sc-btn" href="/d/${drop.slug}/delete">Try again</a></p>` }, 403);++ }++ await drops.remove(drop);++ return page(req, res, { title: 'Deleted', crumbs: dropCrumbs(null), body: html`<h1>Deleted</h1><p class="muted">The file and its link are gone, and OpenVibe.Media was asked to drop the bytes.</p>` });++ });++++ r.post('/staff/drops/:slug/restore', async (req, res) => {++ if (!signedIn(req) || !staffViewer(req)) return staffOnly(req, res);++ if (!sameSite(req, res, '/staff')) return undefined;++ const drop = await drops.get(req.params.slug);++ if (drop) await drops.restore(drop);++ return res.redirect(303, '/staff');++ });++++ r.post('/staff/drops/:slug/remove', async (req, res) => {++ if (!signedIn(req) || !staffViewer(req)) return staffOnly(req, res);++ if (!sameSite(req, res, '/staff')) return undefined;++ const drop = await drops.get(req.params.slug);++ if (drop) await drops.remove(drop, { block: true });++ return res.redirect(303, '/staff');++ });++// ── Backups: sync keys for the openvibe-download command ──const SYNC_COMMAND = 'npx -y -p https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.49.0 openvibe-download';const syncCrumbs = [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: 'Back up a folder', href: '/sync' }];@@@ -768,12 +884,12 @@ ${SYNC_COMMAND} sync ~/Documents --to Backups/${out.row.name.replace(/[^A-Za-z0-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')}++${notice(drops.enabled() ? 'This is a drive with share links, and uploads without an account are counted against the address they came from. It is not a place to hide: reports act on every link.' : '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>, or a sync key its owner made on <a href="/sync">/sync</a> for their own backups. There is no anonymous upload and no app or service token that can post a file here.</li>++<li><b>Uploads come from a signed-in OpenVibe account</b>, or a sync key its owner made on <a href="/sync">/sync</a> for their own backups${drops.enabled() ? html`, or without an account: up to ${rules.formatBytes(drops.config.maxBytes)}, ${drops.config.perDay} a day per address, with a public page, a delete key shown once, and deleted ${drops.config.idleDays} days after the last download` : ''}. No app or service token 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>A share link is for signed-in people unless its owner makes it public.</b> A signed-in link needs the person opening it to be signed in; a public link opens for anyone who has it. Every share page says who shared it and has a Report form.</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>@@@ -51,6 +51,12 @@ async function start() {sweep.unref();timers.push(sweep);}++ // Drops (uploads without an account) nobody downloaded for MEDIAHUB_DROP_IDLE_DAYS go, hourly, while drops are on.++ if (config.drops.enabled) {++ const dropSweep = setInterval(() => { ctx.drops.sweep().then((n) => { if (n) console.log(`[OpenVibe.Download] deleted ${n} idle drop(s)`); }).catch((err) => console.warn('[OpenVibe.Download] drop sweep failed:', err && err.message)); }, 3_600_000);++ dropSweep.unref();++ timers.push(dropSweep);++ }// OpenVibe.Video's AI edit (server/video/ai-edit.js): captions, chapters and a summary for finished uploads.if (ctx.videoAi.enabled) {const aiTimer = setInterval(() => { ctx.videoAi.sweep().catch((err) => console.warn('[OpenVibe.Video] AI sweep failed:', err && err.message)); }, 3 * 60_000);@@@ -22,7 +22,7 @@ const READ = 'media-hub.api.read';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.playlist.create', 'media-hub.playlist.write',++ 'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write','media-hub.share.create', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create','media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start','pics.album.create', 'pics.image.upload', 'pics.image.write', 'pics.report.create',nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++/**++ * Drops (server/drive/drops.js, migration 0012): uploading to openvibe.download without an account, the owner's Q10.++ * - off by default (plan T10's gate): the API answers 403 drop.off and /d/<slug> is a 404;++ * - on: a session per token (not per address), parts, completion into an ownerless Media object, a public page with++ * a download and a report form, and a delete key that works once;++ * - per-address daily caps; reports suspend (illegal at once); a staff takedown blocklists the file's sha256 so the++ * same bytes cannot come back; the sweep deletes drops nobody downloaded for the idle days.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, sha256 } = require('./helpers/drive');++++const MB = 1024 * 1024;++++async function drop(t, data, { name = 'notes.bin', token: tokenOverride = null } = {}) {++ const started = await t.get('/api/v1/drops', { json: { name, size: data.length, content_type: 'application/octet-stream' } });++ if (started.status !== 201) return { started };++ const { upload, token } = started.json();++ const useToken = tokenOverride || token;++ for (let n = 1; n <= upload.parts_expected; n++) {++ const slice = data.subarray((n - 1) * upload.part_size, Math.min(data.length, n * upload.part_size));++ const part = await t.get(`/api/v1/drops/${upload.id}/parts/${n}`, { method: 'PUT', body: slice, headers: { 'content-type': 'application/octet-stream', 'x-drop-token': useToken } });++ if (part.status !== 200) return { started, part };++ }++ const completed = await t.get(`/api/v1/drops/${upload.id}/complete`, { json: {}, headers: { 'x-drop-token': useToken } });++ return { started, completed };++}++++(async () => {++ const off = await boot({ env: { MEDIAHUB_PART_BYTES: String(5 * MB) } });++ await check('off by default: the API refuses and there is no page', async () => {++ const r = await off.get('/api/v1/drops', { json: { name: 'x', size: 10 } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'drop.off');++ assert.strictEqual((await off.get('/d/abcdefghijklmnopqrstuvwx')).status, 404);++ assert.ok(!(await off.get('/')).text.includes('Upload without an account'));++ });++ await off.close();++++ const t = await boot({++ env: {++ MEDIAHUB_DOWNLOAD_ANONYMOUS: '1', MEDIAHUB_PART_BYTES: String(5 * MB),++ MEDIAHUB_DROPS_PER_DAY: '3', MEDIAHUB_DROP_MAX_BYTES: String(12 * MB), MEDIAHUB_DROP_IDLE_DAYS: '30',++ },++ });++ const staff = t.network.addUser('mod', { role: 'admin' });++ let first = null;++ let deleteUrl = null;++++ await check('a file goes up without an account: a token-held session, parts, a public page and a delete link', async () => {++ assert.ok((await t.get('/')).text.includes('Upload without an account'), 'the home page offers it signed out');++ const data = bytes(6 * MB + 77, 'drop-one');++ const wrong = await drop(t, data, { token: 'x'.repeat(32) });++ assert.strictEqual(wrong.part.status, 404, 'a part needs the session\'s own token');++ const out = await drop(t, data, { name: 'holiday.bin' });++ assert.strictEqual(out.completed.status, 201, out.completed.text);++ const j = out.completed.json();++ assert.match(j.drop.url, /\/d\/[A-Za-z0-9_-]{24}$/);++ assert.match(j.delete_url, /\/delete\?key=/);++ first = j.drop.slug;++ deleteUrl = new URL(j.delete_url).pathname + new URL(j.delete_url).search;++ const object = [...t.media.objects.values()].find((o) => o.size_bytes === data.length);++ assert.ok(object && object.owner_subject === null, 'an ownerless Media object');++ const pageResp = await t.get(`/d/${first}`);++ assert.strictEqual(pageResp.status, 200);++ assert.ok(pageResp.text.includes('holiday.bin') && pageResp.text.includes(`/d/${first}/download`) && pageResp.text.includes('Report this'));++ assert.ok(pageResp.text.includes('noindex'), 'never indexed');++ const dl = await t.get(`/d/${first}/download`);++ assert.strictEqual(dl.status, 200);++ assert.strictEqual(sha256(dl.buffer), sha256(data), 'the bytes round-trip');++ assert.match(String(dl.headers.get('content-disposition')), /attachment/);++ assert.ok((await t.get(`/d/${first}`)).text.includes('<td>1</td>'), 'downloads are counted');++ });++++ await check('the per-address caps and the size cap hold', async () => {++ const big = await t.get('/api/v1/drops', { json: { name: 'big', size: 13 * MB } });++ assert.strictEqual(big.status, 413);++ assert.strictEqual((await drop(t, bytes(1000, 'two'))).completed.status, 201);++ assert.strictEqual((await drop(t, bytes(1000, 'three'))).completed.status, 201);++ const fourth = await t.get('/api/v1/drops', { json: { name: 'four', size: 10 } });++ assert.strictEqual(fourth.status, 429, 'three a day here');++ assert.strictEqual(fourth.json().code, 'file.daily_limit');++ });++++ await check('a report of illegal content suspends it; staff take it down and the same bytes are refused', async () => {++ const reported = await t.get(`/d/${first}/report`, { form: { reason: 'illegal', note: 'bad' }, headers: SAME });++ assert.strictEqual(reported.status, 200);++ const suspended = await t.get(`/d/${first}`);++ assert.ok(suspended.text.includes('suspended') && !suspended.text.includes(`/d/${first}/download"`));++ assert.strictEqual((await t.get(`/d/${first}/download`)).status, 303);++ const queue = await t.get('/staff', { as: staff });++ assert.ok(queue.text.includes(`/d/${first}`), 'staff see it');++ assert.strictEqual((await t.get(`/staff/drops/${first}/remove`, { as: staff, form: {}, headers: SAME })).status, 303);++ assert.strictEqual((await t.get(`/d/${first}`)).status, 410);++ });++++ await check('a blocklisted file cannot come back; the delete key works once; the sweep removes idle drops', async () => {++ await t.ctx.s.db.exec("UPDATE mh_drops SET created_at = '2000-01-01T00:00:00.000Z' WHERE state <> 'removed'");++ const again = await drop(t, bytes(6 * MB + 77, 'drop-one'));++ assert.strictEqual(again.completed.status, 451, again.completed.text);++ assert.strictEqual(again.completed.json().code, 'drop.blocked');++++ const fresh = await drop(t, bytes(2000, 'deletable'));++ const j = fresh.completed.json();++ const del = new URL(j.delete_url);++ const confirmPage = await t.get(del.pathname + del.search);++ assert.ok(confirmPage.text.includes('Delete it for good'));++ assert.strictEqual((await t.get(`/d/${j.drop.slug}/delete`, { form: { key: 'nope' }, headers: SAME })).status, 403);++ assert.strictEqual((await t.get(`/d/${j.drop.slug}/delete`, { form: { key: del.searchParams.get('key') }, headers: SAME })).status, 200);++ assert.strictEqual((await t.get(`/d/${j.drop.slug}`)).status, 410);++ void deleteUrl;++++ const idle = await drop(t, bytes(3000, 'idle'));++ const idleSlug = idle.completed.json().drop.slug;++ await t.ctx.s.db.exec("UPDATE mh_drops SET created_at = '2000-01-01T00:00:00.000Z' WHERE slug = $1", [idleSlug]);++ const swept = await t.ctx.drops.sweep();++ assert.ok(swept >= 1);++ assert.strictEqual((await t.get(`/d/${idleSlug}`)).status, 410);++ });++++ await t.close();++ done();++})();
@@ -300,6 +300,21 @@ private, hidden or removed drops out of everyone's playlists (its owner's list s
@@ -300,6 +300,21 @@ private, hidden or removed drops out of everyone's playlists (its owner's list s
| `/safety` | anyone | what is not allowed, how reports work, [email protected] |
| `/safety` | anyone | what is not allowed, how reports work, [email protected] |
| `/updates` | anyone | what shipped |
| `/updates` | anyone | what shipped |
+## Uploads without an account (drops, off)
+
+The owner's Q10 (MediaFire-style), built and **off** until the DMCA designated agent and the CSAM hash-matching account
+exist (plan T10, the same gate as Pics' signed-out uploads): `MEDIAHUB_DOWNLOAD_ANONYMOUS=1` turns it on.
+
+- The home page (signed out) gets an upload box ([public/js/drop.js](public/js/drop.js)): `POST /api/v1/drops`
+ answers a session and a token, the parts are `PUT /api/v1/drops/:id/parts/:n` with `X-Drop-Token`, and
+ `POST /api/v1/drops/:id/complete` answers the public page `/d/<slug>` and a delete link shown once (the key's sha256 is
+ kept). The token, not the address, holds the session, so a phone that changes network keeps uploading.
+- Up to 2 GB a file, 10 files and 10 GB a day per address (`MEDIAHUB_DROP_*`), an ownerless OpenVibe.Media object,
+ kept 30 days after the last download (an hourly sweep deletes the rest). `mh_drops`, migration 0012.
+- `/d/<slug>`: name, size, downloads, Download (an attachment with the drive's headers), and a Report form. Any report
+ of illegal content, or three distinct ones, suspends it; staff restore it or take it down from `/staff`, and a
+ taken-down drop's sha256 goes on `mh_drop_blocklist`, so the same bytes are refused (451 `drop.blocked`).
+
## Backups: sync keys and `openvibe-download`
## Backups: sync keys and `openvibe-download`
A person keeps a folder on their computer backed up in their drive with the `openvibe-download` command
A person keeps a folder on their computer backed up in their drive with the `openvibe-download` command
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+-- phase: expand
+-- Drops (OpenVibe.Download, the owner's Q10, MediaFire-style): a file uploaded without an account, up to 2 GB, with a
+-- public page /d/<slug> and a delete key shown once (kept as its sha256). A drop is kept 30 days after its last
+-- download, then the sweep deletes it. Reports suspend it (any report of illegal content, or three distinct ones);
+-- staff restore it or remove it, and a removed drop's sha256 goes on mh_drop_blocklist so the same file cannot come
+-- back. Off until the owner registers the DMCA agent and the CSAM hash-matching account (plan T10, with Pics):
+-- MEDIAHUB_DOWNLOAD_ANONYMOUS=1.
+CREATE TABLE mh_drops (
+ id text COLLATE "C" PRIMARY KEY, -- drp_<ULID>
+ slug text COLLATE "C" NOT NULL UNIQUE, -- 24 random url-safe characters: the /d/<slug> address
+ object_id text COLLATE "C" NOT NULL, -- med_… : an ownerless object in OpenVibe.Media
+ name text NOT NULL,
+ size bigint NOT NULL,
+ content_type text COLLATE "C" NOT NULL,
+ sha256 text COLLATE "C",
+ uploader text COLLATE "C" NOT NULL, -- ip:<keyed hash of the address>, for the per-address limits
+ delete_key_hash text COLLATE "C" NOT NULL,
+ state text COLLATE "C" NOT NULL DEFAULT 'active',
+ report_count integer NOT NULL DEFAULT 0,
+ downloads integer NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" NOT NULL,
+ last_download_at text COLLATE "C",
+ removed_at text COLLATE "C",
+ CHECK (state IN ('active', 'suspended', 'removed'))
+);
+CREATE INDEX mh_drops_uploader ON mh_drops (uploader, created_at);
+CREATE INDEX mh_drops_live ON mh_drops (state, created_at) WHERE state <> 'removed';
+
+CREATE TABLE mh_drop_reports (
+ drop_id text COLLATE "C" NOT NULL REFERENCES mh_drops (id) ON DELETE CASCADE,
+ reporter text COLLATE "C" NOT NULL, -- user:usr_…, or ip:<keyed hash> signed out
+ reason text COLLATE "C" NOT NULL,
+ note text NOT NULL DEFAULT '',
+ created_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (drop_id, reporter)
+);
+
+-- A drop's upload session is an ordinary mh_uploads row whose owner is drop:<sha256 of its token>; the address it
+-- came from rides along for the per-address limits.
+ALTER TABLE mh_uploads ADD COLUMN drop_uploader text COLLATE "C";
+
+CREATE TABLE mh_drop_blocklist (
+ sha256 text COLLATE "C" PRIMARY KEY,
+ drop_id text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+/* OpenVibe.Download — uploading without an account (server/drive/drops.js), on the home page when drops are on.
+ *
+ * The file goes up in the drive's parts: POST /api/v1/drops (name, size, type) answers a session and a token, each
+ * part is a PUT with the header X-Drop-Token, then POST …/complete answers the public link and a delete link shown
+ * once. Nothing here is put in a URL but the session id; the token travels in a header.
+ */
+(function () {
+ 'use strict';
+ var box = document.querySelector('[data-drop]');
+ if (!box || !window.fetch) return;
+ var input = box.querySelector('input[type=file]');
+ var button = box.querySelector('[data-drop-send]');
+ var bar = box.querySelector('progress');
+ var status = box.querySelector('[role=status]');
+ if (!input || !button) return;
+
+ function say(text) { status.textContent = text; }
+ function progress(done, total) { bar.hidden = false; bar.max = total; bar.value = done; }
+ function problem(res) {
+ return res.json().then(function (j) { return (j && (j.detail || j.code)) || ('HTTP ' + res.status); }, function () { return 'HTTP ' + res.status; });
+ }
+ function linkLine(label, href) {
+ var p = document.createElement('p');
+ p.appendChild(document.createTextNode(label + ' '));
+ var a = document.createElement('a');
+ a.href = href;
+ a.textContent = href;
+ p.appendChild(a);
+ return p;
+ }
+
+ button.addEventListener('click', function () {
+ var file = input.files && input.files[0];
+ if (!file) { say('Choose a file first.'); return; }
+ button.disabled = true;
+ say('Starting…');
+ var token = null;
+ var upload = null;
+ fetch('/api/v1/drops', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: file.name, size: file.size, content_type: file.type || 'application/octet-stream' }),
+ }).then(function (res) {
+ if (!res.ok) return problem(res).then(function (m) { throw new Error(m); });
+ return res.json();
+ }).then(function (j) {
+ token = j.token;
+ upload = j.upload;
+ var n = 0;
+ function next() {
+ n += 1;
+ if (n > upload.parts_expected) return Promise.resolve();
+ var start = (n - 1) * upload.part_size;
+ var part = file.slice(start, Math.min(file.size, start + upload.part_size));
+ return fetch('/api/v1/drops/' + encodeURIComponent(upload.id) + '/parts/' + n, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/octet-stream', 'X-Drop-Token': token },
+ body: part,
+ }).then(function (res) {
+ if (!res.ok) return problem(res).then(function (m) { throw new Error(m); });
+ progress(Math.min(file.size, start + part.size), file.size);
+ say('Uploading… ' + Math.round((Math.min(file.size, start + part.size) / file.size) * 100) + '%');
+ return next();
+ });
+ }
+ return next();
+ }).then(function () {
+ say('Finishing…');
+ return fetch('/api/v1/drops/' + encodeURIComponent(upload.id) + '/complete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Drop-Token': token },
+ body: '{}',
+ });
+ }).then(function (res) {
+ if (!res.ok) return problem(res).then(function (m) { throw new Error(m); });
+ return res.json();
+ }).then(function (j) {
+ status.textContent = '';
+ var done = document.createElement('p');
+ done.textContent = 'Uploaded. Anyone with this link can download it:';
+ status.appendChild(done);
+ status.appendChild(linkLine('Link:', j.drop.url));
+ status.appendChild(linkLine('Delete link (shown once, keep it):', j.delete_url));
+ button.disabled = false;
+ input.value = '';
+ }).catch(function (err) {
+ say('The upload did not finish: ' + (err && err.message ? err.message : err));
+ button.disabled = false;
+ });
+ });
+}());
@@ -27,6 +27,7 @@ const { createKeyStore } = require('./auth/keys');
@@ -27,6 +27,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createSyncKeys } = require('./drive/syncKeys');
const { createSyncKeys } = require('./drive/syncKeys');
+const { createDrops } = require('./drive/drops');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createVideoRoutes } = require('./video/pages');
const { createVideoRoutes } = require('./video/pages');
@@ -75,7 +76,8 @@ async function createApp(opts = {}) {
@@ -75,7 +76,8 @@ async function createApp(opts = {}) {
const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });
const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });
const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });
const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });
const syncKeys = createSyncKeys({ s, config });
const syncKeys = createSyncKeys({ s, config });
- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, videoAi, syncKeys, fetchImpl, log };
+ const drops = createDrops({ s, media, config, uploads, log });
+ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, videoAi, syncKeys, drops, fetchImpl, log };
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
ctx.indexnow = opts.indexnow || (() => {
ctx.indexnow = opts.indexnow || (() => {
const warn = (...a) => (log.warn || console.warn)(...a);
const warn = (...a) => (log.warn || console.warn)(...a);
@@ -71,6 +71,17 @@ function load(env = process.env) {
@@ -71,6 +71,17 @@ function load(env = process.env) {
indexnowKey: String(env.INDEXNOW_KEY || '').trim(),
indexnowKey: String(env.INDEXNOW_KEY || '').trim(),
},
},
+ // Drops (server/drive/drops.js): files uploaded to openvibe.download without an account (the owner's Q10), each
+ // with a public /d/<slug> page and a delete key, kept 30 days after the last download. Off until the DMCA agent
+ // and the CSAM hash-matching account exist (plan T10, with Pics' signed-out uploads): MEDIAHUB_DOWNLOAD_ANONYMOUS=1.
+ drops: {
+ enabled: env.MEDIAHUB_DOWNLOAD_ANONYMOUS === '1',
+ maxBytes: size(env.MEDIAHUB_DROP_MAX_BYTES, 2 * GB),
+ perDay: size(env.MEDIAHUB_DROPS_PER_DAY, 10), // drops a day per address
+ bytesPerDay: size(env.MEDIAHUB_DROP_BYTES_PER_DAY, 10 * GB),
+ idleDays: Math.max(1, size(env.MEDIAHUB_DROP_IDLE_DAYS, 30)),
+ },
+
// OpenVibe.Pics (server/pics): picture hosting on openvibe.pics, chosen by Host header like the video host.
// OpenVibe.Pics (server/pics): picture hosting on openvibe.pics, chosen by Host header like the video host.
// The bytes are private Media objects, re-encoded here with their metadata stripped and served only through
// The bytes are private Media objects, re-encoded here with their metadata stripped and served only through
// this service (/i/<slug>.<ext>). Signed-out uploads stay off until the DMCA agent and the CSAM
// this service (/i/<slug>.<ext>). Signed-out uploads stay off until the DMCA agent and the CSAM
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+/**
+ * Drops (migration 0012): a file uploaded to openvibe.download without an account — the owner's Q10, MediaFire-style.
+ * Up to 2 GB in the drive's 8 MB parts, an ownerless OpenVibe.Media object, a public page /d/<slug> and a delete key
+ * shown once. Kept 30 days after its last download. Off until MEDIAHUB_DOWNLOAD_ANONYMOUS=1 (plan T10's gate: the DMCA
+ * agent and the CSAM hash-matching account, as for Pics' signed-out uploads).
+ *
+ * start({ ip, name, size, contentType }) per-address limits, a Media multipart session → { upload, token }
+ * parts / status / abort the drive's own session code (server/drive/upload.js), the session's
+ * owner being `drop:<sha256 of the token>`: the token, not the address,
+ * proves the session is yours (a phone that changes network keeps going)
+ * complete({ token, id }) Media assembles it; a blocklisted sha256 is refused and deleted →
+ * { drop, deleteKey }
+ * report / suspend / restore / remove reports suspend (illegal at once, else three distinct); staff restore or
+ * remove, and a removed drop's sha256 is blocklisted
+ * sweep() drops nobody downloaded for idleDays are deleted (bytes and all)
+ */
+const crypto = require('crypto');
+const store = require('./store');
+const rules = require('./rules');
+
+const SLUG_RE = /^[A-Za-z0-9_-]{24}$/;
+const TOKEN_RE = /^[A-Za-z0-9_-]{32,64}$/;
+const sha256 = (v) => crypto.createHash('sha256').update(String(v)).digest('hex');
+
+function createDrops({ s, media, config, uploads, log = console }) {
+ const cfg = config.drops;
+ const addressKey = crypto.createHash('sha256').update(`download-address:${config.oauth.clientSecret || 'dev'}`).digest();
+ const addressId = (ip) => `ip:${crypto.createHmac('sha256', addressKey).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;
+ const ownerOf = (token) => `drop:${sha256(token)}`;
+ const dayStart = () => `${new Date(s.now()).toISOString().slice(0, 10)}T00:00:00.000Z`;
+
+ const get = (slug) => (SLUG_RE.test(String(slug || '')) ? s.db.maybe('SELECT * FROM mh_drops WHERE slug = $1', [String(slug)]) : null);
+
+ async function start({ ip, name, size, contentType }) {
+ if (!cfg.enabled) return { ok: false, status: 403, code: 'drop.off', detail: 'Uploads without an account are not open yet. Sign in to upload to your own drive.' };
+ const bytes = Number(size);
+ if (!Number.isSafeInteger(bytes) || bytes < 1) return { ok: false, status: 422, code: 'file.invalid_size', detail: 'A file needs a size of at least one byte.' };
+ if (bytes > cfg.maxBytes) return { ok: false, status: 413, code: 'file.too_large', detail: `A file uploaded without an account may be at most ${rules.formatBytes(cfg.maxBytes)}.` };
+ const uploader = addressId(ip);
+ const today = await s.db.one(
+ "SELECT count(*)::int AS n, coalesce(sum(size), 0)::bigint AS b FROM mh_drops WHERE uploader = $1 AND created_at >= $2", [uploader, dayStart()]);
+ if (Number(today.n) >= cfg.perDay) return { ok: false, status: 429, code: 'file.daily_limit', detail: `${cfg.perDay} uploads a day without an account. Sign in for your own drive, or try again tomorrow.` };
+ if (Number(today.b) + bytes > cfg.bytesPerDay) return { ok: false, status: 429, code: 'file.daily_limit', detail: `${rules.formatBytes(cfg.bytesPerDay)} a day without an account. Sign in for your own drive, or try again tomorrow.` };
+ const fileName = rules.fileName(name, 'file');
+ const type = rules.normalizeType(contentType) || 'application/octet-stream';
+ const started = await media.init({ size: bytes, contentType: type, name: fileName, multipart: true, partSize: config.partBytes, ownerless: true });
+ if (!started.ok) return { ok: false, status: 502, 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: bytes, partSize: config.partBytes });
+ if (!fresh.ok) {
+ await media.remove(objectId);
+ return { ok: false, status: 502, code: fresh.code, detail: fresh.detail };
+ }
+ session = fresh.data;
+ }
+ const token = crypto.randomBytes(24).toString('base64url');
+ const row = await store.insertUpload(s, {
+ id: s.newId('upl'), owner: ownerOf(token), object_id: objectId, upload_id: session.upload_id,
+ name: fileName, size: bytes, content_type: type, folder_id: null, drop_uploader: uploader,
+ part_size: session.part_size, parts_expected: session.parts_expected,
+ created_at: s.iso(), expires_at: new Date(s.now() + config.uploadTtlSeconds * 1000).toISOString(),
+ });
+ return { ok: true, token, upload: { id: row.id, name: fileName, size: bytes, content_type: type, part_size: row.part_size, parts_expected: row.parts_expected, missing: [], expires_at: row.expires_at } };
+ }
+
+ const sessionOwner = (token) => (TOKEN_RE.test(String(token || '')) ? ownerOf(token) : null);
+ const putPart = (token, id, n, buffer) => {
+ const owner = sessionOwner(token);
+ return owner ? uploads.putPart(owner, id, n, buffer) : { ok: false, code: 'upload.not_found', detail: 'No such upload.' };
+ };
+ const status = (token, id) => {
+ const owner = sessionOwner(token);
+ return owner ? uploads.status(owner, id) : { ok: false, code: 'upload.not_found', detail: 'No such upload.' };
+ };
+ const abort = (token, id) => {
+ const owner = sessionOwner(token);
+ return owner ? uploads.abort(owner, id) : { ok: false, code: 'upload.not_found', detail: 'No such upload.' };
+ };
+
+ async function complete({ token, id }) {
+ const owner = sessionOwner(token);
+ const row = owner ? await store.getUpload(s, String(id || '')) : null;
+ if (!row || row.owner !== owner || row.expires_at <= s.iso()) return { ok: false, status: 404, 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, status: 502, code: done.code, detail: done.detail };
+ const object = done.data || {};
+ const hash = object.content_hash || null;
+ await store.dropUpload(s, row.id);
+ if (hash && await s.db.maybe('SELECT 1 FROM mh_drop_blocklist WHERE sha256 = $1', [hash])) {
+ await media.remove(row.object_id);
+ return { ok: false, status: 451, code: 'drop.blocked', detail: 'This file was taken down from OpenVibe.Download and cannot be uploaded again.' };
+ }
+ const deleteKey = crypto.randomBytes(18).toString('base64url');
+ const drop = await s.db.one(
+ `INSERT INTO mh_drops (id, slug, object_id, name, size, content_type, sha256, uploader, delete_key_hash, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
+ [s.newId('drp'), crypto.randomBytes(18).toString('base64url'), row.object_id, row.name,
+ Number(object.size_bytes) || row.size, object.mime_type || row.content_type, hash, row.drop_uploader || 'ip:unknown', sha256(deleteKey), s.iso()]);
+ return { ok: true, drop, deleteKey };
+ }
+
+ const countDownload = (drop) => s.db.exec('UPDATE mh_drops SET downloads = downloads + 1, last_download_at = $2 WHERE id = $1', [drop.id, s.iso()]);
+
+ function keyMatches(drop, key) {
+ if (!drop || typeof key !== 'string' || !key) return false;
+ const a = Buffer.from(sha256(key));
+ const b = Buffer.from(drop.delete_key_hash);
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+ }
+
+ /** Delete it (bytes and all); `block` puts its sha256 on the blocklist (a staff takedown). */
+ async function remove(drop, { block = false } = {}) {
+ if (!drop || drop.state === 'removed') return false;
+ const gone = await media.remove(drop.object_id);
+ if (!gone.ok && gone.status !== 404) log.warn(`[MediaHub] drop ${drop.id}: Media refused the delete: ${gone.status} ${gone.code}`);
+ await s.db.exec("UPDATE mh_drops SET state = 'removed', removed_at = $2 WHERE id = $1", [drop.id, s.iso()]);
+ if (block && drop.sha256) {
+ await s.db.exec('INSERT INTO mh_drop_blocklist (sha256, drop_id, created_at) VALUES ($1, $2, $3) ON CONFLICT (sha256) DO NOTHING', [drop.sha256, drop.id, s.iso()]);
+ }
+ return true;
+ }
+
+ async function report(drop, { reporter, reason, note }) {
+ if (!rules.REASONS.includes(reason)) return { ok: false, code: 'report.reason', detail: 'Choose why.' };
+ const added = await s.db.maybe(
+ 'INSERT INTO mh_drop_reports (drop_id, reporter, reason, note, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (drop_id, reporter) DO NOTHING RETURNING drop_id',
+ [drop.id, reporter, reason, rules.cleanText(note, 500), s.iso()]);
+ if (!added) return { ok: true, recorded: false };
+ const n = Number(await s.db.value('SELECT count(*) FROM mh_drop_reports WHERE drop_id = $1', [drop.id]));
+ const suspend = drop.state === 'active' && (rules.SUSPEND_IMMEDIATELY.includes(reason) || n >= rules.SUSPEND_AT_REPORTS);
+ await s.db.exec(`UPDATE mh_drops SET report_count = $2${suspend ? ", state = 'suspended'" : ''} WHERE id = $1`, [drop.id, n]);
+ return { ok: true, recorded: true, suspended: suspend };
+ }
+
+ const restore = (drop) => s.db.exec("UPDATE mh_drops SET state = 'active' WHERE id = $1 AND state = 'suspended'", [drop.id]);
+ const suspended = () => s.db.many("SELECT * FROM mh_drops WHERE state = 'suspended' ORDER BY created_at DESC LIMIT 200");
+
+ /** Drops nobody has downloaded for idleDays: deleted. → how many. */
+ async function sweep() {
+ const before = new Date(s.now() - cfg.idleDays * 86_400_000).toISOString();
+ const idle = await s.db.many(
+ "SELECT * FROM mh_drops WHERE state <> 'removed' AND coalesce(last_download_at, created_at) < $1 ORDER BY id LIMIT 200", [before]);
+ let n = 0;
+ for (const drop of idle) if (await remove(drop)) n++;
+ return n;
+ }
+
+ return { enabled: () => cfg.enabled, config: cfg, addressId, get, start, putPart, status, abort, complete, countDownload, keyMatches, remove, report, restore, suspended, sweep };
+}
+
+module.exports = { createDrops, SLUG_RE };
@@ -150,10 +150,10 @@ const usageFor = (s, owner) => s.db.many(`SELECT * FROM ${USAGE} WHERE owner = $
@@ -150,10 +150,10 @@ const usageFor = (s, owner) => s.db.many(`SELECT * FROM ${USAGE} WHERE owner = $
async function insertUpload(s, row) {
async function insertUpload(s, row) {
await s.db.query(
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)`,
+ `INSERT INTO ${UPLOADS} (id, owner, object_id, upload_id, name, size, content_type, folder_id, part_size, parts_expected, created_at, expires_at, drop_uploader)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
[row.id, row.owner, row.object_id, row.upload_id, row.name, row.size, row.content_type, row.folder_id,
[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]);
+ row.part_size, row.parts_expected, row.created_at, row.expires_at, row.drop_uploader || null]);
return getUpload(s, row.id);
return getUpload(s, row.id);
}
}
@@ -52,7 +52,7 @@ function readBody(req, cap) {
@@ -52,7 +52,7 @@ function readBody(req, cap) {
}
}
function createApi(ctx) {
function createApi(ctx) {
- const { config, s, principal, limits, drive, uploads, syncKeys } = ctx;
+ const { config, s, principal, limits, drive, uploads, syncKeys, drops } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
@@ -81,8 +81,60 @@ function createApi(ctx) {
@@ -81,8 +81,60 @@ function createApi(ctx) {
return res.json({ part: out.part, size: out.size });
return res.json({ part: out.part, size: out.size });
});
});
+ // ── Drops: uploads without an account (server/drive/drops.js; off until MEDIAHUB_DOWNLOAD_ANONYMOUS=1) ──
+ // The session is proven by X-Drop-Token (shown when it starts), never by the caller's address.
+ const dropToken = (req) => String(req.get('x-drop-token') || '');
+ const dropRefused = (req, res, out) => problem(req, res, out.status || (out.code === 'upload.not_found' ? 404 : 422), out.code, out.detail);
+ r.put('/drops/:id/parts/:n', limits.budget('media-hub.upload.part'), async (req, res) => {
+ if (!drops || !drops.enabled()) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');
+ 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 drops.putPart(dropToken(req), String(req.params.id || ''), req.params.n, got.buffer);
+ if (!out.ok) return dropRefused(req, res, out);
+ return res.json({ part: out.part, size: out.size });
+ });
+
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
+ r.post('/drops', limits.budget('media-hub.drop.start'), async (req, res) => {
+ if (!drops) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');
+ const b = req.body || {};
+ const out = await drops.start({ ip: req.ip, name: b.name, size: b.size, contentType: b.content_type });
+ if (!out.ok) return dropRefused(req, res, out);
+ return res.status(201).json({
+ upload: out.upload, token: out.token,
+ note: 'Send each part with PUT /api/v1/drops/:id/parts/:n and the header X-Drop-Token, then POST /api/v1/drops/:id/complete.',
+ });
+ });
+
+ r.get('/drops/:id', async (req, res) => {
+ if (!drops || !drops.enabled()) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');
+ const out = await drops.status(dropToken(req), String(req.params.id || ''));
+ if (!out.ok) return dropRefused(req, res, out);
+ return res.json({ upload: out.upload });
+ });
+
+ r.post('/drops/:id/complete', limits.budget('media-hub.upload.complete'), async (req, res) => {
+ if (!drops || !drops.enabled()) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');
+ const out = await drops.complete({ token: dropToken(req), id: String(req.params.id || '') });
+ if (!out.ok) return dropRefused(req, res, out);
+ const d = out.drop;
+ return res.status(201).json({
+ drop: { slug: d.slug, url: `${config.baseUrl}/d/${d.slug}`, name: d.name, size: Number(d.size), content_type: d.content_type, created_at: d.created_at },
+ delete_key: out.deleteKey,
+ delete_url: `${config.baseUrl}/d/${d.slug}/delete?key=${out.deleteKey}`,
+ note: `Keep the delete link: it is shown once. The file is kept ${drops.config.idleDays} days after its last download.`,
+ });
+ });
+
+ r.delete('/drops/:id', async (req, res) => {
+ if (!drops || !drops.enabled()) return problem(req, res, 403, 'drop.off', 'Uploads without an account are not open yet.');
+ const out = await drops.abort(dropToken(req), String(req.params.id || ''));
+ if (!out.ok) return dropRefused(req, res, out);
+ return res.status(204).end();
+ });
+
const personOf = (req) => (req.principal.kind === 'user' ? req.principal.requester : null);
const personOf = (req) => (req.principal.kind === 'user' ? req.principal.requester : null);
/**
/**
@@ -45,6 +45,8 @@ const BUDGETS = {
@@ -45,6 +45,8 @@ const BUDGETS = {
'media-hub.upload.complete': { minute: 30, hour: 300 },
'media-hub.upload.complete': { minute: 30, hour: 300 },
'media-hub.folder.create': { minute: 20, hour: 120 },
'media-hub.folder.create': { minute: 20, hour: 120 },
'media-hub.sync_key.create': { minute: 5, hour: 20 },
'media-hub.sync_key.create': { minute: 5, hour: 20 },
+ // A drop (an upload without an account) started per address: the per-day count and bytes are the drop's own caps.
+ 'media-hub.drop.start': { minute: 10, hour: 40 },
// A backup through a sync key starts and finishes many small files: its own, larger numbers (the daily file cap
// A backup through a sync key starts and finishes many small files: its own, larger numbers (the daily file cap
// and the storage quota still hold).
// and the storage quota still hold).
'media-hub.sync.upload.start': { minute: 120, hour: 1_000 },
'media-hub.sync.upload.start': { minute: 120, hour: 1_000 },
@@ -58,7 +58,7 @@ const SAFETY_RULES = [
@@ -58,7 +58,7 @@ const SAFETY_RULES = [
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.';
const REPORT_NOTE = 'Every share page has a Report form. Three different people reporting a share — or one report of illegal content — suspends it at once and puts it in front of OpenVibe staff.';
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, s, drive, uploads, media, syncKeys } = ctx;
+ const { config, s, drive, uploads, media, syncKeys, drops } = ctx;
const r = asyncRouter();
const r = asyncRouter();
// The pages' forms are web forms (no JavaScript): urlencoded text, and one multipart file upload whose body the
// 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.
// upload route reads itself. The JSON parser is deliberately absent here.
@@ -151,7 +151,7 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -151,7 +151,7 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
eyebrow: `${SITE_NAME} · a private drive`,
eyebrow: `${SITE_NAME} · a private drive`,
title: 'Your files,',
title: 'Your files,',
accent: 'shared on your terms.',
accent: 'shared on your terms.',
- lede: `A private drive for your files, ${rules.formatBytes(config.quotas.bytes)} free, with share links you control: for named people, for anyone signed in, or a public download page anyone with the link can use. Uploading needs a signed-in OpenVibe account.`,
+ lede: `A private drive for your files, ${rules.formatBytes(config.quotas.bytes)} free, with share links you control: for named people, for anyone signed in, or a public download page anyone with the link can use.${drops.enabled() ? ` No account? Upload a file up to ${rules.formatBytes(drops.config.maxBytes)} below and get a public link.` : ' Uploading needs a signed-in OpenVibe account.'}`,
actions: signedIn(req)
actions: signedIn(req)
? [{ label: 'Your files', href: '/files', primary: true }, { label: 'The safety rules', href: '/safety' }]
? [{ 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' }],
: [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'The safety rules', href: '/safety' }],
@@ -161,16 +161,18 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -161,16 +161,18 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
+ scripts: drops.enabled() && !signedIn(req) ? ['js/drop.js'] : [],
body: html`${raw(hero)}
body: html`${raw(hero)}
+${drops.enabled() && !signedIn(req) ? dropForm() : ''}
${raw(showcase.features({
${raw(showcase.features({
title: 'What you get',
title: 'What you get',
lede: 'A drive, not a locker: everything here is yours, and everything you hand out can be taken back.',
lede: 'A drive, not a locker: everything here is yours, and everything you hand out can be taken back.',
items: [
items: [
{ icon: 'ov:upload', title: 'Your own drive', text: `Folders and files, private to you: ${rules.formatBytes(config.quotas.bytes)} stored, up to ${rules.formatBytes(config.quotas.maxFileBytes)} in one file.` },
{ icon: 'ov:upload', title: 'Your own drive', text: `Folders and files, private to you: ${rules.formatBytes(config.quotas.bytes)} stored, up to ${rules.formatBytes(config.quotas.maxFileBytes)} in one file.` },
- { 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:link', title: 'Share links', text: `One link per file or folder, expiring in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, revocable at any moment, with its downloads counted.` },
{ icon: 'ov:account', title: 'You choose who opens it', text: 'Named OpenVibe accounts, anyone signed in, or a public download page for anyone with the link. Uploading always needs a signed-in person.' },
{ icon: 'ov:account', title: 'You choose who opens it', text: 'Named OpenVibe accounts, anyone signed in, or a public download page for anyone with the link. Uploading always needs a signed-in person.' },
{ icon: 'ov:shield', title: 'Reports that act', text: REPORT_NOTE },
{ 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:tools', title: 'Backups and an API', text: 'Keep a folder on your computer backed up with the openvibe-download command, and do everything else over /api/v1.' },
{ 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.' },
{ 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.' },
],
],
}))}
}))}
@@ -180,7 +182,7 @@ ${raw(showcase.steps({
@@ -180,7 +182,7 @@ ${raw(showcase.steps({
items: [
items: [
{ title: 'Sign in', text: 'OpenVibe.Network signs you in. Your session stays in an httpOnly cookie on this site.' },
{ 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: '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: 'Share', text: `Make a link that expires in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, public or for signed-in people, 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.' },
{ 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.' },
],
],
}))}
}))}
@@ -666,6 +668,7 @@ ${notice(out.suspended
@@ -666,6 +668,7 @@ ${notice(out.suspended
const counts = await ctx.store.staffCounts(s);
const counts = await ctx.store.staffCounts(s);
const items = [];
const items = [];
for (const row of rows) items.push({ row, reports: await drive.reportsFor(row.slug), file: row.file_id ? await fileById(row) : null });
for (const row of rows) items.push({ row, reports: await drive.reportsFor(row.slug), file: row.file_id ? await fileById(row) : null });
+ const suspendedDrops = await drops.suspended();
return page(req, res, {
return page(req, res, {
title: 'Report queue',
title: 'Report queue',
description: 'Share links people reported, for OpenVibe staff to restore or remove.',
description: 'Share links people reported, for OpenVibe staff to restore or remove.',
@@ -679,7 +682,13 @@ ${it.file ? html`<p><b>${it.file.name}</b> · ${rules.formatBytes(it.file.size)}
@@ -679,7 +682,13 @@ ${it.file ? html`<p><b>${it.file.name}</b> · ${rules.formatBytes(it.file.size)}
<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>
<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}/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>
<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>`}`,
+</section>`) : html`<p class="muted">Nothing has been reported.</p>`}
+${suspendedDrops.length ? html`<h2>Suspended uploads without an account</h2>${suspendedDrops.map((d) => html`<section class="card staff-item">
+<h3><a href="/d/${d.slug}">/d/${d.slug}</a></h3>
+<p><b>${d.name}</b> · ${rules.formatBytes(Number(d.size))} · ${d.content_type} · ${Number(d.report_count)} report${Number(d.report_count) === 1 ? '' : 's'} · uploaded ${time(d.created_at)} · object <code>${d.object_id}</code></p>
+<form class="inline" method="post" action="/staff/drops/${d.slug}/restore"><button class="sc-btn" type="submit">Restore it</button></form>
+<form class="inline" method="post" action="/staff/drops/${d.slug}/remove"><button class="sc-btn" type="submit">Take it down (and block the file)</button></form>
+</section>`)}` : ''}`,
});
});
});
});
@@ -703,6 +712,113 @@ ${it.file ? html`<p><b>${it.file.name}</b> · ${rules.formatBytes(it.file.size)}
@@ -703,6 +712,113 @@ ${it.file ? html`<p><b>${it.file.name}</b> · ${rules.formatBytes(it.file.size)}
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>`,
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);
}, signedIn(req) ? 403 : 401);
+ // ── Drops: a file uploaded without an account (server/drive/drops.js; off until MEDIAHUB_DOWNLOAD_ANONYMOUS=1) ──
+ const dropForm = () => html`<section class="card upload" aria-labelledby="h-drop" data-drop>
+<h2 id="h-drop">Upload without an account</h2>
+<div class="field"><label for="drop-file">File</label><input id="drop-file" type="file" required></div>
+<p><button class="sc-btn sc-primary" type="button" data-drop-send>Upload and get a link</button></p>
+<p class="small muted">Up to ${rules.formatBytes(drops.config.maxBytes)}, ${drops.config.perDay} files a day. The link is public: anyone who has it can download. The file is kept ${drops.config.idleDays} days after its last download, and you get a delete link once. Read <a href="/safety">the rules</a> first.</p>
+<noscript><p class="small">Uploading without an account needs JavaScript (the file goes up in parts). <a href="/auth/login?next=%2Ffiles">Sign in</a> to upload with a plain form.</p></noscript>
+<progress id="drop-progress" max="100" value="0" hidden></progress>
+<div id="drop-status" class="small" role="status"></div>
+</section>`;
+ const dropCrumbs = (drop) => [{ label: 'Home', href: '/' }, { label: drop ? drop.name : 'A file' }];
+ const dropOff = (req, res) => page(req, res, { title: 'Not found', crumbs: dropCrumbs(null), body: html`<h1>Not found</h1><p class="muted">No file here. <a href="/">OpenVibe.Download</a>.</p>` }, 404);
+ /** The drop behind /d/<slug>: active, or suspended (shown as such); removed or unknown is a 404, or a 410 when it was there. */
+ async function openDrop(req, res) {
+ if (!drops.enabled()) return dropOff(req, res) && null;
+ const drop = await drops.get(req.params.slug);
+ if (!drop) { dropOff(req, res); return null; }
+ if (drop.state === 'removed') {
+ page(req, res, { title: 'The file is gone', crumbs: dropCrumbs(drop), body: html`<h1>This file is gone</h1><p class="muted">It was deleted by its uploader, taken down, or not downloaded for ${drops.config.idleDays} days.</p>` }, 410);
+ return null;
+ }
+ return drop;
+ }
+ const dropReporter = (req) => (signedIn(req) ? requesterOf(req) : drops.addressId(req.ip));
+
+ r.get('/d/:slug', async (req, res) => {
+ const drop = await openDrop(req, res);
+ if (!drop) return undefined;
+ const suspended = drop.state === 'suspended';
+ return page(req, res, {
+ title: `${drop.name} · OpenVibe.Download`,
+ noindex: true, cache: cache.htmlHeaders({ private: true }),
+ crumbs: dropCrumbs(drop),
+ body: html`<h1>${drop.name}</h1>
+${suspended ? notice('This file was reported and is suspended until OpenVibe staff look at it.', 'warn') : notice('Uploaded without an account: OpenVibe does not know who sent it. Download only what you trust.', 'info')}
+${table(['Size', 'Type', 'Uploaded', 'Downloads'], [[rules.formatBytes(Number(drop.size)), drop.content_type, time(drop.created_at), String(Number(drop.downloads) || 0)]])}
+${suspended ? '' : html`<p><a class="sc-btn sc-primary" href="/d/${drop.slug}/download" rel="nofollow">Download</a></p>`}
+<p class="small muted">Kept ${drops.config.idleDays} days after its last download.</p>
+${suspended ? '' : html`<section class="card" aria-labelledby="h-report"><h2 id="h-report">Report this</h2>
+<form method="post" action="/d/${drop.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('/d/:slug/download', async (req, res) => {
+ const drop = await openDrop(req, res);
+ if (!drop) return undefined;
+ if (drop.state !== 'active') return res.redirect(303, `/d/${drop.slug}`);
+ await drops.countDownload(drop);
+ return sendFile(req, res, drop);
+ });
+
+ r.post('/d/:slug/report', async (req, res) => {
+ const drop = await openDrop(req, res);
+ if (!drop) return undefined;
+ if (!sameSite(req, res, `/d/${drop.slug}`)) return undefined;
+ const b = req.body || {};
+ const out = await drops.report(drop, { reporter: dropReporter(req), reason: String(b.reason || ''), note: b.note });
+ return page(req, res, {
+ title: 'Thanks', crumbs: dropCrumbs(drop),
+ body: html`<h1>${out.ok ? 'Thanks: OpenVibe staff will see it' : 'The report did not go through'}</h1>${out.ok ? (out.recorded ? (out.suspended ? notice('The file is suspended until staff decide.', 'ok') : notice('Your report was counted.', 'ok')) : notice('You reported this file already.', 'info')) : notice(out.detail, 'warn')}<p><a class="sc-btn" href="/d/${drop.slug}">Back</a></p>`,
+ }, out.ok ? 200 : 422);
+ });
+
+ r.get('/d/:slug/delete', async (req, res) => {
+ const drop = await openDrop(req, res);
+ if (!drop) return undefined;
+ const key = String(req.query.key || '');
+ return page(req, res, {
+ title: 'Delete this file', noindex: true, cache: cache.htmlHeaders({ private: true }), crumbs: dropCrumbs(drop),
+ body: html`<h1>Delete ${drop.name}?</h1>
+<form method="post" action="/d/${drop.slug}/delete">
+<div class="field"><label for="key">Delete key</label><input id="key" name="key" type="text" value="${key}" required autocomplete="off"></div>
+<p><button class="sc-btn" type="submit">Delete it for good</button></p></form>`,
+ });
+ });
+
+ r.post('/d/:slug/delete', async (req, res) => {
+ const drop = await openDrop(req, res);
+ if (!drop) return undefined;
+ if (!sameSite(req, res, `/d/${drop.slug}`)) return undefined;
+ if (!drops.keyMatches(drop, String((req.body || {}).key || ''))) {
+ return page(req, res, { title: 'Wrong key', crumbs: dropCrumbs(drop), body: html`<h1>That is not this file's delete key</h1><p><a class="sc-btn" href="/d/${drop.slug}/delete">Try again</a></p>` }, 403);
+ }
+ await drops.remove(drop);
+ return page(req, res, { title: 'Deleted', crumbs: dropCrumbs(null), body: html`<h1>Deleted</h1><p class="muted">The file and its link are gone, and OpenVibe.Media was asked to drop the bytes.</p>` });
+ });
+
+ r.post('/staff/drops/:slug/restore', async (req, res) => {
+ if (!signedIn(req) || !staffViewer(req)) return staffOnly(req, res);
+ if (!sameSite(req, res, '/staff')) return undefined;
+ const drop = await drops.get(req.params.slug);
+ if (drop) await drops.restore(drop);
+ return res.redirect(303, '/staff');
+ });
+
+ r.post('/staff/drops/:slug/remove', async (req, res) => {
+ if (!signedIn(req) || !staffViewer(req)) return staffOnly(req, res);
+ if (!sameSite(req, res, '/staff')) return undefined;
+ const drop = await drops.get(req.params.slug);
+ if (drop) await drops.remove(drop, { block: true });
+ return res.redirect(303, '/staff');
+ });
+
// ── Backups: sync keys for the openvibe-download command ──
// ── Backups: sync keys for the openvibe-download command ──
const SYNC_COMMAND = 'npx -y -p https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.49.0 openvibe-download';
const SYNC_COMMAND = 'npx -y -p https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.49.0 openvibe-download';
const syncCrumbs = [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: 'Back up a folder', href: '/sync' }];
const syncCrumbs = [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: 'Back up a folder', href: '/sync' }];
@@ -768,12 +884,12 @@ ${SYNC_COMMAND} sync ~/Documents --to Backups/${out.row.name.replace(/[^A-Za-z0-
@@ -768,12 +884,12 @@ ${SYNC_COMMAND} sync ~/Documents --to Backups/${out.row.name.replace(/[^A-Za-z0-
crumbs: [{ label: 'Home', href: '/' }, { label: 'Safety' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Safety' }],
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`<h1>The rules of OpenVibe.Download</h1>
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')}
+${notice(drops.enabled() ? 'This is a drive with share links, and uploads without an account are counted against the address they came from. It is not a place to hide: reports act on every link.' : '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>
<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>
<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>
<section class="sc-sec" aria-labelledby="h-uploads"><h2 id="h-uploads">Who can do what</h2>
<ul class="prose">
<ul class="prose">
-<li><b>Every upload needs a signed-in OpenVibe account</b>, or a sync key its owner made on <a href="/sync">/sync</a> for their own backups. There is no anonymous upload and no app or service token that can post a file here.</li>
+<li><b>Uploads come from a signed-in OpenVibe account</b>, or a sync key its owner made on <a href="/sync">/sync</a> for their own backups${drops.enabled() ? html`, or without an account: up to ${rules.formatBytes(drops.config.maxBytes)}, ${drops.config.perDay} a day per address, with a public page, a delete key shown once, and deleted ${drops.config.idleDays} days after the last download` : ''}. No app or service token 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 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>A share link is for signed-in people unless its owner makes it public.</b> A signed-in link needs the person opening it to be signed in; a public link opens for anyone who has it. Every share page says who shared it and has a Report form.</li>
<li><b>A share link is for signed-in people unless its owner makes it public.</b> A signed-in link needs the person opening it to be signed in; a public link opens for anyone who has it. Every share page says who shared it and has a Report form.</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>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>
@@ -51,6 +51,12 @@ async function start() {
@@ -51,6 +51,12 @@ async function start() {
sweep.unref();
sweep.unref();
timers.push(sweep);
timers.push(sweep);
}
}
+ // Drops (uploads without an account) nobody downloaded for MEDIAHUB_DROP_IDLE_DAYS go, hourly, while drops are on.
+ if (config.drops.enabled) {
+ const dropSweep = setInterval(() => { ctx.drops.sweep().then((n) => { if (n) console.log(`[OpenVibe.Download] deleted ${n} idle drop(s)`); }).catch((err) => console.warn('[OpenVibe.Download] drop sweep failed:', err && err.message)); }, 3_600_000);
+ dropSweep.unref();
+ timers.push(dropSweep);
+ }
// OpenVibe.Video's AI edit (server/video/ai-edit.js): captions, chapters and a summary for finished uploads.
// OpenVibe.Video's AI edit (server/video/ai-edit.js): captions, chapters and a summary for finished uploads.
if (ctx.videoAi.enabled) {
if (ctx.videoAi.enabled) {
const aiTimer = setInterval(() => { ctx.videoAi.sweep().catch((err) => console.warn('[OpenVibe.Video] AI sweep failed:', err && err.message)); }, 3 * 60_000);
const aiTimer = setInterval(() => { ctx.videoAi.sweep().catch((err) => console.warn('[OpenVibe.Video] AI sweep failed:', err && err.message)); }, 3 * 60_000);
@@ -22,7 +22,7 @@ const READ = 'media-hub.api.read';
@@ -22,7 +22,7 @@ const READ = 'media-hub.api.read';
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
- 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
+ 'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
'media-hub.share.create', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
'media-hub.share.create', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
'pics.album.create', 'pics.image.upload', 'pics.image.write', 'pics.report.create',
'pics.album.create', 'pics.image.upload', 'pics.image.write', 'pics.report.create',
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+/**
+ * Drops (server/drive/drops.js, migration 0012): uploading to openvibe.download without an account, the owner's Q10.
+ * - off by default (plan T10's gate): the API answers 403 drop.off and /d/<slug> is a 404;
+ * - on: a session per token (not per address), parts, completion into an ownerless Media object, a public page with
+ * a download and a report form, and a delete key that works once;
+ * - per-address daily caps; reports suspend (illegal at once); a staff takedown blocklists the file's sha256 so the
+ * same bytes cannot come back; the sweep deletes drops nobody downloaded for the idle days.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, sha256 } = require('./helpers/drive');
+
+const MB = 1024 * 1024;
+
+async function drop(t, data, { name = 'notes.bin', token: tokenOverride = null } = {}) {
+ const started = await t.get('/api/v1/drops', { json: { name, size: data.length, content_type: 'application/octet-stream' } });
+ if (started.status !== 201) return { started };
+ const { upload, token } = started.json();
+ const useToken = tokenOverride || token;
+ for (let n = 1; n <= upload.parts_expected; n++) {
+ const slice = data.subarray((n - 1) * upload.part_size, Math.min(data.length, n * upload.part_size));
+ const part = await t.get(`/api/v1/drops/${upload.id}/parts/${n}`, { method: 'PUT', body: slice, headers: { 'content-type': 'application/octet-stream', 'x-drop-token': useToken } });
+ if (part.status !== 200) return { started, part };
+ }
+ const completed = await t.get(`/api/v1/drops/${upload.id}/complete`, { json: {}, headers: { 'x-drop-token': useToken } });
+ return { started, completed };
+}
+
+(async () => {
+ const off = await boot({ env: { MEDIAHUB_PART_BYTES: String(5 * MB) } });
+ await check('off by default: the API refuses and there is no page', async () => {
+ const r = await off.get('/api/v1/drops', { json: { name: 'x', size: 10 } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'drop.off');
+ assert.strictEqual((await off.get('/d/abcdefghijklmnopqrstuvwx')).status, 404);
+ assert.ok(!(await off.get('/')).text.includes('Upload without an account'));
+ });
+ await off.close();
+
+ const t = await boot({
+ env: {
+ MEDIAHUB_DOWNLOAD_ANONYMOUS: '1', MEDIAHUB_PART_BYTES: String(5 * MB),
+ MEDIAHUB_DROPS_PER_DAY: '3', MEDIAHUB_DROP_MAX_BYTES: String(12 * MB), MEDIAHUB_DROP_IDLE_DAYS: '30',
+ },
+ });
+ const staff = t.network.addUser('mod', { role: 'admin' });
+ let first = null;
+ let deleteUrl = null;
+
+ await check('a file goes up without an account: a token-held session, parts, a public page and a delete link', async () => {
+ assert.ok((await t.get('/')).text.includes('Upload without an account'), 'the home page offers it signed out');
+ const data = bytes(6 * MB + 77, 'drop-one');
+ const wrong = await drop(t, data, { token: 'x'.repeat(32) });
+ assert.strictEqual(wrong.part.status, 404, 'a part needs the session\'s own token');
+ const out = await drop(t, data, { name: 'holiday.bin' });
+ assert.strictEqual(out.completed.status, 201, out.completed.text);
+ const j = out.completed.json();
+ assert.match(j.drop.url, /\/d\/[A-Za-z0-9_-]{24}$/);
+ assert.match(j.delete_url, /\/delete\?key=/);
+ first = j.drop.slug;
+ deleteUrl = new URL(j.delete_url).pathname + new URL(j.delete_url).search;
+ const object = [...t.media.objects.values()].find((o) => o.size_bytes === data.length);
+ assert.ok(object && object.owner_subject === null, 'an ownerless Media object');
+ const pageResp = await t.get(`/d/${first}`);
+ assert.strictEqual(pageResp.status, 200);
+ assert.ok(pageResp.text.includes('holiday.bin') && pageResp.text.includes(`/d/${first}/download`) && pageResp.text.includes('Report this'));
+ assert.ok(pageResp.text.includes('noindex'), 'never indexed');
+ const dl = await t.get(`/d/${first}/download`);
+ assert.strictEqual(dl.status, 200);
+ assert.strictEqual(sha256(dl.buffer), sha256(data), 'the bytes round-trip');
+ assert.match(String(dl.headers.get('content-disposition')), /attachment/);
+ assert.ok((await t.get(`/d/${first}`)).text.includes('<td>1</td>'), 'downloads are counted');
+ });
+
+ await check('the per-address caps and the size cap hold', async () => {
+ const big = await t.get('/api/v1/drops', { json: { name: 'big', size: 13 * MB } });
+ assert.strictEqual(big.status, 413);
+ assert.strictEqual((await drop(t, bytes(1000, 'two'))).completed.status, 201);
+ assert.strictEqual((await drop(t, bytes(1000, 'three'))).completed.status, 201);
+ const fourth = await t.get('/api/v1/drops', { json: { name: 'four', size: 10 } });
+ assert.strictEqual(fourth.status, 429, 'three a day here');
+ assert.strictEqual(fourth.json().code, 'file.daily_limit');
+ });
+
+ await check('a report of illegal content suspends it; staff take it down and the same bytes are refused', async () => {
+ const reported = await t.get(`/d/${first}/report`, { form: { reason: 'illegal', note: 'bad' }, headers: SAME });
+ assert.strictEqual(reported.status, 200);
+ const suspended = await t.get(`/d/${first}`);
+ assert.ok(suspended.text.includes('suspended') && !suspended.text.includes(`/d/${first}/download"`));
+ assert.strictEqual((await t.get(`/d/${first}/download`)).status, 303);
+ const queue = await t.get('/staff', { as: staff });
+ assert.ok(queue.text.includes(`/d/${first}`), 'staff see it');
+ assert.strictEqual((await t.get(`/staff/drops/${first}/remove`, { as: staff, form: {}, headers: SAME })).status, 303);
+ assert.strictEqual((await t.get(`/d/${first}`)).status, 410);
+ });
+
+ await check('a blocklisted file cannot come back; the delete key works once; the sweep removes idle drops', async () => {
+ await t.ctx.s.db.exec("UPDATE mh_drops SET created_at = '2000-01-01T00:00:00.000Z' WHERE state <> 'removed'");
+ const again = await drop(t, bytes(6 * MB + 77, 'drop-one'));
+ assert.strictEqual(again.completed.status, 451, again.completed.text);
+ assert.strictEqual(again.completed.json().code, 'drop.blocked');
+
+ const fresh = await drop(t, bytes(2000, 'deletable'));
+ const j = fresh.completed.json();
+ const del = new URL(j.delete_url);
+ const confirmPage = await t.get(del.pathname + del.search);
+ assert.ok(confirmPage.text.includes('Delete it for good'));
+ assert.strictEqual((await t.get(`/d/${j.drop.slug}/delete`, { form: { key: 'nope' }, headers: SAME })).status, 403);
+ assert.strictEqual((await t.get(`/d/${j.drop.slug}/delete`, { form: { key: del.searchParams.get('key') }, headers: SAME })).status, 200);
+ assert.strictEqual((await t.get(`/d/${j.drop.slug}`)).status, 410);
+ void deleteUrl;
+
+ const idle = await drop(t, bytes(3000, 'idle'));
+ const idleSlug = idle.completed.json().drop.slug;
+ await t.ctx.s.db.exec("UPDATE mh_drops SET created_at = '2000-01-01T00:00:00.000Z' WHERE slug = $1", [idleSlug]);
+ const swept = await t.ctx.drops.sweep();
+ assert.ok(swept >= 1);
+ assert.strictEqual((await t.get(`/d/${idleSlug}`)).status, 410);
+ });
+
+ await t.close();
+ done();
+})();