openvibers/openvibe.mediahub Commits
Download: back a folder up from your computer (sync keys on /sync for the openvibe-download command) (#75)
Changes
@@@ -90,7 +90,8 @@ On other services it calls:Public file hosting is the riskiest surface on the network: OpenVibe.Media has no malware or CSAM scanning yet. SoOpenVibe.Download v1 is a **private drive**, not a public host:--- Every upload and every file page needs a signed-in OpenVibe person. Apps, agents and anonymous callers cannot upload.++- Every upload and every file page needs a signed-in OpenVibe person, or a **sync key** that person made for their own++ backups (below). Apps, agents and anonymous callers cannot upload.- Files are private to their owner: another person's file id answers 404.- A share link (`/s/<slug>`, 24 random url-safe characters) expires in 1 hour to 30 days (7 by default), can be revoked,counts downloads, and is one of three kinds: for anyone **signed in** with the link (the default), for **named**@@@ -299,7 +300,23 @@ 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 |--## API (`/api/v1`, a signed-in person only)++## Backups: sync keys and `openvibe-download`++++A person keeps a folder on their computer backed up in their drive with the `openvibe-download` command++(openvibe-sdk 0.49.0, `openvibe-sdk/download`): `openvibe-download sync ~/Documents --to Backups/laptop` sends what is++new or changed (same size and sha256 is skipped), keeps the folder tree, and with `--delete` removes drive files whose++local copy is gone. It authenticates with a **sync key** made on `/sync` (`OPENVIBE_DOWNLOAD_KEY`):++++- `ovdl_` and 43 url-safe characters, shown once; only its sha256 is stored (`mh_sync_keys`, migration 0011), with++ the first 12 characters to tell keys apart. 10 live keys per person (`MEDIAHUB_SYNC_KEYS_MAX`).++- It acts for its owner on the drive's `/api/v1` only (the router checks it before the shared principal): files,++ folders, uploads and usage. Share links answer 403 `sync_key.not_allowed`, and Pics and Video never see it.++- Its uploads have their own daily cap (`MEDIAHUB_SYNC_UPLOADS_PER_DAY`, 1,000, against the browser's 50) and their++ own request budgets (`media-hub.sync.upload.start|complete`, 120 a minute, 1,000 an hour); storage and the per-file++ size are capped the same. A revoked or unknown key is 401 `sync_key.invalid`.++- The account export lists keys without their hashes; deleting the account deletes them.++++## API (`/api/v1`, a signed-in person, or their sync key)| Route | ||---|---|nnew file mode 100644@@@ -0,0 +1,16 @@++-- phase: expand++-- Sync keys (OpenVibe.Download backups): a person makes a key on /sync and gives it to the openvibe-download command++-- (openvibe-sdk) on their own computer, which keeps a folder backed up in their drive. A key acts for its owner on the++-- drive's /api/v1 only (files, folders, uploads, usage; never share links, Pics or Video). Only a hash is kept; the key++-- is shown once. A revoked key stays listed until its owner deletes the account.++CREATE TABLE mh_sync_keys (++ id text COLLATE "C" PRIMARY KEY, -- dsk_<ULID>++ owner text COLLATE "C" NOT NULL, -- user:usr_…++ name text NOT NULL, -- what the person called it (the computer it is for)++ token_hash text COLLATE "C" NOT NULL UNIQUE, -- sha256 hex of the whole key++ prefix text COLLATE "C" NOT NULL, -- the key's first characters, to tell keys apart in the list++ created_at text COLLATE "C" NOT NULL,++ last_used_at text COLLATE "C",++ revoked_at text COLLATE "C"++);++CREATE INDEX mh_sync_keys_owner ON mh_sync_keys (owner, created_at DESC);@@@ -47,6 +47,8 @@ const TABLES = [// people's reports on them cascade). The bytes are Media VODs, deleted with extraErase.{ table: 'video_upload_reports', subject: 'reporter', value: AS_REQUESTER, file: 'video_upload_reports.json' },{ table: 'video_uploads', subject: 'owner', value: AS_REQUESTER, file: 'videos.json' },++ // Sync keys (migrations/0011_sync_keys.sql): their names and dates, never the key's hash.++ { table: 'mh_sync_keys', subject: 'owner', value: AS_REQUESTER, file: 'sync_keys.json', columns: ['id', 'name', 'prefix', 'created_at', 'last_used_at', 'revoked_at'] },];function createAccount({ db, config, media, log = console }) {@@@ -26,6 +26,7 @@ const { openStore } = require('./db');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');++const { createSyncKeys } = require('./drive/syncKeys');const { createApi } = require('./http/api');const { createPageRoutes } = require('./http/pages');const { createVideoRoutes } = require('./video/pages');@@@ -73,7 +74,8 @@ async function createApp(opts = {}) {// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.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 ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, videoAi, 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 };// 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);@@@ -110,6 +110,8 @@ function load(env = process.env) {bytes: size(env.MEDIAHUB_QUOTA_BYTES, 25 * GB), // 25 GB stored (the owner's Q10: free accounts)maxFileBytes: size(env.MEDIAHUB_MAX_FILE_BYTES, 2 * GB), // 2 GB per fileuploadsPerDay: size(env.MEDIAHUB_UPLOADS_PER_DAY, 50), // 50 uploads a day (UTC)++ syncUploadsPerDay: size(env.MEDIAHUB_SYNC_UPLOADS_PER_DAY, 1000), // uploads a day through sync keys (backups)++ syncKeys: size(env.MEDIAHUB_SYNC_KEYS_MAX, 10), // live sync keys per personmaxShares: size(env.MEDIAHUB_MAX_SHARES, 20), // 20 live share links},// A plain <input type=file> form posts the whole file in one request; past this the page offers the@@@ -65,8 +65,11 @@ function createService({ s, media, identity, config, log = console }) {// ── Limits ──────────────────────────────────────────────-- /** Room for one more file of `size` bytes, and for uploads not to run past the day's cap. */-- async function uploadAllowed(owner, size) {++ /**++ * Room for one more file of `size` bytes, and for uploads not to run past the day's cap. `perDay` is a larger cap++ * for a sync key's uploads (a backup sends many files); storage stays capped the same.++ */++ async function uploadAllowed(owner, size, { perDay = config.quotas.uploadsPerDay } = {}) {if (!Number.isInteger(size) || size < 1) return { ok: false, code: 'file.invalid_size', detail: 'A file needs a size of at least one byte.' };if (size > config.quotas.maxFileBytes) {return { ok: false, code: 'file.too_large', detail: `One file may be at most ${rules.formatBytes(config.quotas.maxFileBytes)}. This one is ${rules.formatBytes(size)}.` };@@@ -76,8 +79,8 @@ function createService({ s, media, identity, config, log = console }) {return { ok: false, code: 'file.quota', detail: `Your drive holds ${rules.formatBytes(used)} of ${rules.formatBytes(config.quotas.bytes)}. This file needs ${rules.formatBytes(size)} more; delete something first.` };}const today = await store.uploadsToday(s, owner, dayOf(s.now()));-- if (today >= config.quotas.uploadsPerDay) {-- return { ok: false, code: 'file.daily_limit', detail: `You have uploaded ${config.quotas.uploadsPerDay} files today (the most one person may). Try again tomorrow.` };++ if (today >= perDay) {++ return { ok: false, code: 'file.daily_limit', detail: `You have uploaded ${perDay} files today (the most one person may). Try again tomorrow.` };}return { ok: true };}nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * Sync keys (migration 0011): what lets the openvibe-download command (openvibe-sdk) back a folder up into a person's++ * drive without a browser. A key is `ovdl_` and 43 url-safe characters; only its sha256 is stored, and it is shown++ * once. It acts for its owner on the drive's /api/v1 only (server/http/api.js decides that), so a leaked key can read++ * and fill the drive but cannot make share links or touch Pics or Video, and its owner revokes it on /sync.++ */++const crypto = require('crypto');++++const KEY_RE = /^ovdl_[A-Za-z0-9_-]{43}$/;++const NAME_MAX = 60;++/** How often a key's last-used time is written (a sync makes many requests). */++const TOUCH_MS = 5 * 60 * 1000;++++const hashOf = (key) => crypto.createHash('sha256').update(key).digest('hex');++++function createSyncKeys({ s, config }) {++ const max = () => Number(config.quotas.syncKeys) || 10;++ const touched = new Map();++++ const list = (owner) => s.db.many(++ 'SELECT id, name, prefix, created_at, last_used_at, revoked_at FROM mh_sync_keys WHERE owner = $1 ORDER BY created_at DESC', [owner]);++++ /** A new key for `owner`: { ok, key (shown once), row } or { ok: false, code, detail }. */++ async function create(owner, rawName) {++ const name = Array.from(String(rawName == null ? '' : rawName), (ch) => (ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127 ? ' ' : ch)).join('').trim();++ if (!name) return { ok: false, code: 'sync_key.name', detail: 'Name the key after the computer it is for.' };++ if (name.length > NAME_MAX) return { ok: false, code: 'sync_key.name', detail: `A name is at most ${NAME_MAX} characters.` };++ const live = Number(await s.db.value('SELECT count(*) FROM mh_sync_keys WHERE owner = $1 AND revoked_at IS NULL', [owner]));++ if (live >= max()) return { ok: false, code: 'sync_key.limit', detail: `You have ${max()} sync keys (the most one person may). Revoke one first.` };++ const key = `ovdl_${crypto.randomBytes(32).toString('base64url')}`;++ const row = await s.db.one(++ `INSERT INTO mh_sync_keys (id, owner, name, token_hash, prefix, created_at) VALUES ($1, $2, $3, $4, $5, $6)++ RETURNING id, name, prefix, created_at, last_used_at, revoked_at`,++ [s.newId('dsk'), owner, name, hashOf(key), key.slice(0, 12), s.iso()]);++ return { ok: true, key, row };++ }++++ async function revoke(owner, id) {++ const n = await s.db.exec('UPDATE mh_sync_keys SET revoked_at = $3 WHERE owner = $1 AND id = $2 AND revoked_at IS NULL', [owner, String(id || ''), s.iso()]);++ return n > 0;++ }++++ /** The owner a key acts for, or null (malformed, unknown or revoked). */++ async function resolve(key) {++ if (!KEY_RE.test(String(key || ''))) return null;++ const row = await s.db.maybe('SELECT id, owner FROM mh_sync_keys WHERE token_hash = $1 AND revoked_at IS NULL', [hashOf(key)]);++ if (!row) return null;++ const now = s.now();++ if ((touched.get(row.id) || 0) + TOUCH_MS < now) {++ touched.set(row.id, now);++ await s.db.exec('UPDATE mh_sync_keys SET last_used_at = $2 WHERE id = $1', [row.id, s.iso()]);++ }++ return row.owner;++ }++++ return { list, create, revoke, resolve, NAME_MAX, max };++}++++module.exports = { createSyncKeys, KEY_RE };@@@ -40,8 +40,8 @@ function createUploads({ s, media, config, service, log = console }) {const nowIso = () => new Date(s.now()).toISOString();/** Media's object is `uploading`; the session is Media's, the row is ours. */-- async function startSession({ owner, name, size, contentType, folderId = null }) {-- const room = await service.uploadAllowed(owner, size);++ async function startSession({ owner, name, size, contentType, folderId = null, perDay }) {++ const room = await service.uploadAllowed(owner, size, perDay ? { perDay } : {});if (!room.ok) return room;if (folderId && !await service.ownFolder(owner, folderId)) {return { ok: false, code: 'folder.not_found', detail: 'That folder does not exist.' };@@@ -52,12 +52,21 @@ function readBody(req, cap) {}function createApi(ctx) {-- const { config, s, principal, limits, drive, uploads } = ctx;++ const { config, s, principal, limits, drive, uploads, syncKeys } = ctx;const r = asyncRouter();const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });-- r.use(principal.middleware);++ // A sync key (server/drive/syncKeys.js) acts for its owner here and nowhere else: this router is the drive's API,++ // so the key never reaches Pics, Video or the pages. Anything else goes through the shared principal.++ r.use(async (req, res, next) => {++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ovdl_') || !syncKeys) return principal.middleware(req, res, next);++ const owner = await syncKeys.resolve(header.slice(7).trim());++ if (!owner) return problem(req, res, 401, 'sync_key.invalid', 'This sync key is not valid: it was revoked or never issued. Make a new one at openvibe.download/sync.');++ req.principal = { kind: 'user', requester: owner, project: null, viaSession: false, viaSyncKey: true, username: null, role: 'user' };++ return next();++ });/*** The chunked upload's part body is raw bytes, so it is mounted before the JSON parser: express must never try@@@ -152,7 +161,14 @@ function createApi(ctx) {});/** Start an upload. The bytes follow through PUT /uploads/:id/parts/:n (see the page's script). */-- r.post('/files', person, limits.budget('media-hub.upload.start'), async (req, res) => {++ /** A sync key's uploads are counted on their own budget (server/http/caller-limits.js). */++ const uploadBudget = (name) => {++ const plain = limits.budget(`media-hub.upload.${name}`);++ const sync = limits.budget(`media-hub.sync.upload.${name}`);++ return (req, res, next) => (req.principal.viaSyncKey ? sync : plain)(req, res, next);++ };++++ r.post('/files', person, uploadBudget('start'), async (req, res) => {const owner = personOf(req);const b = req.body || {};const size = Number(b.size);@@@ -160,7 +176,8 @@ function createApi(ctx) {const contentType = rules.normalizeType(b.content_type) || 'application/octet-stream';const folderId = b.folder_id ? String(b.folder_id) : null;if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');-- const out = await uploads.startSession({ owner, name, size, contentType, folderId });++ const perDay = req.principal.viaSyncKey ? config.quotas.syncUploadsPerDay : undefined;++ const out = await uploads.startSession({ owner, name, size, contentType, folderId, perDay });if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);res.set('Location', `/api/v1/uploads/${out.upload.id}`);return res.status(201).json({@@@ -198,7 +215,13 @@ function createApi(ctx) {});// ── Shares ──────────────────────────────────────────────-- r.post('/shares', person, limits.budget('media-hub.share.create'), async (req, res) => {++ /** A share link is made by the person themself (signed in, or their Network token), never by a sync key. */++ function notSyncKey(req, res, next) {++ if (!req.principal.viaSyncKey) return next();++ return problem(req, res, 403, 'sync_key.not_allowed', 'A sync key backs files up; share links are made at openvibe.download.');++ }++++ r.post('/shares', person, notSyncKey, limits.budget('media-hub.share.create'), async (req, res) => {const b = req.body || {};const fileId = b.file_id ? String(b.file_id) : null;const folderId = b.folder_id ? String(b.folder_id) : null;@@@ -217,7 +240,7 @@ function createApi(ctx) {});});-- r.delete('/shares/:slug', person, async (req, res) => {++ r.delete('/shares/:slug', person, notSyncKey, async (req, res) => {const row = await drive.revokeShare(personOf(req), String(req.params.slug || ''));if (!row) return problem(req, res, 404, 'share.not_found', 'No such share.');return res.status(204).end();@@@ -240,7 +263,7 @@ function createApi(ctx) {return res.json({ upload: out.upload });});-- r.post('/uploads/:id/complete', person, limits.budget('media-hub.upload.complete'), async (req, res) => {++ r.post('/uploads/:id/complete', person, uploadBudget('complete'), async (req, res) => {const out = await uploads.complete(personOf(req), String(req.params.id || ''));if (!out.ok) {const status = out.code === 'upload.not_found' ? 404 : (out.code === 'file.quota' ? 429 : 409);@@@ -44,6 +44,11 @@ const BUDGETS = {'media-hub.upload.part': { minute: 300, hour: 5_000 },'media-hub.upload.complete': { minute: 30, hour: 300 },'media-hub.folder.create': { minute: 20, hour: 120 },++ 'media-hub.sync_key.create': { minute: 5, hour: 20 },++ // 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 },++ 'media-hub.sync.upload.complete': { minute: 120, hour: 1_000 },'media-hub.share.create': { minute: 20, hour: 200 },'media-hub.playlist.create': { minute: 30, hour: 300 },'media-hub.playlist.write': { minute: 120, hour: 1_200 },@@@ -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 } = ctx;++ const { config, s, drive, uploads, media, syncKeys } = 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.@@@ -225,6 +225,7 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}scripts: ['js/upload.js'],body: html`<h1>${folder ? folder.name : 'Your files'}</h1>${usageBar(await drive.usage(owner))}++<p class="small"><a href="/sync">Back up a folder from your computer</a></p>${folder ? html`<p><a href="/files">← All files</a></p>` : ''}<section aria-labelledby="h-upload">${uploadForm(folder)}</section><section aria-labelledby="h-folders"><h2 id="h-folders">Folders</h2>@@@ -702,6 +703,63 @@ ${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);++ // ── 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' }];++ const keyState = (k) => (k.revoked_at ? `revoked ${time(k.revoked_at)}` : k.last_used_at ? html`last used ${time(k.last_used_at)}` : 'not used yet');++++ r.get('/sync', mustSignIn('/sync', 'A backup goes into your own drive, so it needs your OpenVibe account.'), async (req, res) => {++ const keys = await syncKeys.list(requesterOf(req));++ return page(req, res, {++ title: 'Back up a folder',++ crumbs: syncCrumbs,++ body: html`<h1>Back up a folder from your computer</h1>++<p class="sc-prose">The <code>openvibe-download</code> command keeps a folder on your computer copied into your drive: run it once, or on a schedule, and it sends only what is new or changed. It needs Node.js 20 or newer and a sync key from this page.</p>++<section aria-labelledby="h-steps"><h2 id="h-steps">How</h2>++<ol class="prose">++<li>Make a sync key below, named after the computer it is for. Copy it: it is shown once.</li>++<li>On that computer, put it in the environment as <code>OPENVIBE_DOWNLOAD_KEY</code>.</li>++<li>Run <code>${SYNC_COMMAND} sync ~/Documents --to Backups/laptop</code></li>++</ol>++<p class="small muted">Add <code>--dry-run</code> to see what it would send, and <code>--delete</code> to also remove drive files whose local copy is gone (without it, a backup only ever adds). Files keep their folders; a file is sent again when its size or contents change. Uploads through a sync key count against your ${rules.formatBytes(config.quotas.bytes)} and up to ${config.quotas.syncUploadsPerDay} files a day.</p></section>++<section aria-labelledby="h-keys"><h2 id="h-keys">Your sync keys</h2>++${keys.length ? html`<ul class="file-list">${keys.map((k) => html`<li class="file"><span class="file-main"><span class="file-name">${k.name}</span><span class="file-meta small muted"><code>${k.prefix}…</code> · made ${time(k.created_at)} · ${keyState(k)}</span></span>++${k.revoked_at ? '' : html`<form class="inline" method="post" action="/sync/keys/${k.id}/revoke"><button class="sc-btn" type="submit">Revoke</button></form>`}</li>`)}</ul>` : html`<p class="muted">No sync keys yet.</p>`}++<form class="inline" method="post" action="/sync/keys">++<label for="key-name">Computer</label> <input id="key-name" name="name" type="text" maxlength="${syncKeys.NAME_MAX}" placeholder="laptop" required> <button class="sc-btn sc-primary" type="submit">Make a sync key</button>++</form>++<p class="small muted">A key can read and fill your drive and nothing else: it cannot make share links, and it does nothing on OpenVibe.Pics or OpenVibe.Video. Revoke it here the moment a computer is lost.</p></section>`,++ });++ });++++ r.post('/sync/keys', mustSignIn('/sync'), budgeted('media-hub.sync_key.create'), async (req, res) => {++ if (!sameSite(req, res, '/sync')) return undefined;++ const out = await syncKeys.create(requesterOf(req), (req.body || {}).name);++ if (!out.ok) {++ return page(req, res, {++ title: 'No key was made', crumbs: syncCrumbs,++ body: html`<h1>No key was made</h1>${notice(out.detail, 'warn')}<p><a class="sc-btn" href="/sync">Back</a></p>`,++ }, out.code === 'sync_key.limit' ? 429 : 422);++ }++ res.set('Cache-Control', 'no-store');++ return page(req, res, {++ title: 'Your new sync key', crumbs: syncCrumbs,++ body: html`<h1>Your sync key for ${out.row.name}</h1>++${notice('Copy it now: it is not shown again. Anyone holding it can read and fill your drive.', 'warn')}++<p><code class="sync-key">${out.key}</code></p>++<p class="sc-prose">On that computer:</p>++<pre class="sync-command"><code>export OPENVIBE_DOWNLOAD_KEY=${out.key}++${SYNC_COMMAND} sync ~/Documents --to Backups/${out.row.name.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40) || 'computer'}</code></pre>++<p><a class="sc-btn" href="/sync">Done</a></p>`,++ });++ });++++ r.post('/sync/keys/:id/revoke', mustSignIn('/sync'), async (req, res) => {++ if (!sameSite(req, res, '/sync')) return undefined;++ await syncKeys.revoke(requesterOf(req), String(req.params.id || ''));++ return res.redirect(303, '/sync');++ });++// ── Safety ───────────────────────────────────────────────r.get('/safety', (req, res) => page(req, res, {index: true, cache: PUBLIC_CACHE,@@@ -715,9 +773,9 @@ ${notice('This is a private drive with share links. It is not a place to hide, a<ul class="prose">${SAFETY_RULES.map((x) => html`<li>${x}</li>`)}</ul></section><section class="sc-sec" aria-labelledby="h-uploads"><h2 id="h-uploads">Who can do what</h2><ul class="prose">--<li><b>Every upload needs a signed-in OpenVibe account.</b> There is no anonymous upload and no app or service token that can post a file here.</li>++<li><b>Every 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>Every file is private to the person who uploaded it.</b> Nobody else sees it in a list, and nobody else can download it.</li>--<li><b>Every download needs a signed-in OpenVibe account too.</b> A share link does not open a file on its own: the person opening it must be signed in, and the share page says who shared it.</li>++<li><b>A share 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>Downloads never happen in a page.</b> A file is always sent as an attachment with a no-sniff header, and a type that is not on a short safe list is sent as an opaque blob. Uploaded HTML and SVG are never rendered.</li></ul></section>@@@ -23,7 +23,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.share.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','pics.album.create', 'pics.image.upload', 'pics.image.write', 'pics.report.create',]);nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * Sync keys (server/drive/syncKeys.js, migration 0011): a person makes a key on /sync, shown once, for the++ * openvibe-download command to back a folder up into their drive.++ * - a key acts for its owner on the drive's /api/v1: list, folders, chunked uploads, delete;++ * - it never makes or revokes share links, and it is no key at all on the video host's API;++ * - its uploads have their own (larger) daily cap; storage stays capped the same;++ * - revoked, it stops at once; only its hash is stored, and the export lists keys without it.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes } = require('./helpers/drive');++++const MB = 1024 * 1024;++++(async () => {++ const t = await boot({++ env: {++ MEDIAHUB_PART_BYTES: String(5 * MB),++ MEDIAHUB_UPLOADS_PER_DAY: '1',++ MEDIAHUB_SYNC_UPLOADS_PER_DAY: '3',++ MEDIAHUB_SYNC_KEYS_MAX: '2',++ },++ });++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++ let key = null;++++ await check('a signed-in person makes a key on /sync; it is shown once and only its hash is kept', async () => {++ assert.strictEqual((await t.get('/sync')).status, 401, 'a guest is asked to sign in');++ const pageBefore = await t.get('/sync', { as: kim });++ assert.ok(pageBefore.text.includes('No sync keys yet.'));++ assert.strictEqual((await t.get('/sync/keys', { as: kim, form: { name: 'laptop' } })).status, 403, 'not from this site');++ const made = await t.get('/sync/keys', { as: kim, form: { name: 'laptop' }, headers: SAME });++ assert.strictEqual(made.status, 200, made.text.slice(0, 300));++ key = (/ovdl_[A-Za-z0-9_-]{43}/.exec(made.text) || [])[0];++ assert.ok(key, 'the key is on the page');++ const after = await t.get('/sync', { as: kim });++ assert.ok(after.text.includes('laptop') && after.text.includes(key.slice(0, 12)) && !after.text.includes(key), 'listed by its prefix, never again in full');++ const stored = await t.ctx.s.db.one('SELECT token_hash FROM mh_sync_keys WHERE owner = $1', [`user:${kim.subject}`]);++ assert.notStrictEqual(stored.token_hash, key);++ assert.match(stored.token_hash, /^[0-9a-f]{64}$/);++ });++++ await check('the key reads and fills its owner\'s drive through /api/v1, with the sync cap', async () => {++ const list = await t.get('/api/v1/files', { bearer: key });++ assert.strictEqual(list.status, 200, list.text);++ const folder = await t.get('/api/v1/folders', { bearer: key, json: { name: 'Backups' } });++ assert.strictEqual(folder.status, 201, folder.text);++ const folderId = folder.json().folder.id;++ for (let n = 1; n <= 3; n++) {++ const data = bytes(1000 + n, `backup-${n}`);++ const started = await t.get('/api/v1/files', { bearer: key, json: { name: `note-${n}.txt`, size: data.length, content_type: 'text/plain', folder_id: folderId } });++ assert.strictEqual(started.status, 201, `upload ${n}: ${started.text}`);++ const up = started.json().upload;++ const part = await t.get(`/api/v1/uploads/${up.id}/parts/1`, { bearer: key, method: 'PUT', body: data, headers: { 'content-type': 'application/octet-stream' } });++ assert.strictEqual(part.status, 200, part.text);++ const done_ = await t.get(`/api/v1/uploads/${up.id}/complete`, { bearer: key, json: {} });++ assert.strictEqual(done_.status, 201, done_.text);++ }++ const fourth = await t.get('/api/v1/files', { bearer: key, json: { name: 'note-4.txt', size: 10, content_type: 'text/plain' } });++ assert.strictEqual(fourth.status, 429, 'past the sync cap');++ assert.strictEqual(fourth.json().code, 'file.daily_limit');++ const inFolder = await t.get(`/api/v1/files?folder_id=${folderId}`, { as: kim });++ assert.strictEqual(inFolder.json().files.length, 3, 'the files are the owner\'s');++ assert.deepStrictEqual((await t.get(`/api/v1/files?folder_id=${folderId}`, { as: sam })).json().files, [], 'nobody else sees them');++ });++++ await check('a key makes no share links and is nothing on the video host', async () => {++ const files = (await t.get('/api/v1/files?limit=1', { as: kim })).json();++ const anyFile = files.files[0] || (await t.get(`/api/v1/files?folder_id=${files.folders[0].id}`, { as: kim })).json().files[0];++ const share = await t.get('/api/v1/shares', { bearer: key, json: { file_id: anyFile.id, hours: 24 } });++ assert.strictEqual(share.status, 403);++ assert.strictEqual(share.json().code, 'sync_key.not_allowed');++ const video = await t.get('/api/v1/video/playlists', { bearer: key, headers: { host: 'openvibe.video' } });++ assert.ok(video.status === 401 || video.status === 404, `the video API does not take it (${video.status})`);++ });++++ await check('the key limit holds; a revoked key stops at once; the export lists keys without hashes', async () => {++ assert.strictEqual((await t.get('/sync/keys', { as: kim, form: { name: 'desktop' }, headers: SAME })).status, 200);++ assert.strictEqual((await t.get('/sync/keys', { as: kim, form: { name: 'third' }, headers: SAME })).status, 429, 'two live keys at most here');++ const row = await t.ctx.s.db.one("SELECT id FROM mh_sync_keys WHERE owner = $1 AND name = 'laptop'", [`user:${kim.subject}`]);++ assert.strictEqual((await t.get(`/sync/keys/${row.id}/revoke`, { as: sam, form: {}, headers: SAME })).status, 303, 'someone else\'s revoke does nothing');++ assert.strictEqual((await t.get('/api/v1/files', { bearer: key })).status, 200, 'still valid');++ assert.strictEqual((await t.get(`/sync/keys/${row.id}/revoke`, { as: kim, form: {}, headers: SAME })).status, 303);++ const refused = await t.get('/api/v1/files', { bearer: key });++ assert.strictEqual(refused.status, 401);++ assert.strictEqual(refused.json().code, 'sync_key.invalid');++ assert.strictEqual((await t.get('/api/v1/files', { bearer: `ovdl_${'x'.repeat(43)}` })).status, 401, 'an unknown key');++ });++++ await done(t);++})();
@@ -90,7 +90,8 @@ On other services it calls:
@@ -90,7 +90,8 @@ On other services it calls:
Public file hosting is the riskiest surface on the network: OpenVibe.Media has no malware or CSAM scanning yet. So
Public file hosting is the riskiest surface on the network: OpenVibe.Media has no malware or CSAM scanning yet. So
OpenVibe.Download v1 is a **private drive**, not a public host:
OpenVibe.Download v1 is a **private drive**, not a public host:
-- Every upload and every file page needs a signed-in OpenVibe person. Apps, agents and anonymous callers cannot upload.
+- Every upload and every file page needs a signed-in OpenVibe person, or a **sync key** that person made for their own
+ backups (below). Apps, agents and anonymous callers cannot upload.
- Files are private to their owner: another person's file id answers 404.
- Files are private to their owner: another person's file id answers 404.
- A share link (`/s/<slug>`, 24 random url-safe characters) expires in 1 hour to 30 days (7 by default), can be revoked,
- A share link (`/s/<slug>`, 24 random url-safe characters) expires in 1 hour to 30 days (7 by default), can be revoked,
counts downloads, and is one of three kinds: for anyone **signed in** with the link (the default), for **named**
counts downloads, and is one of three kinds: for anyone **signed in** with the link (the default), for **named**
@@ -299,7 +300,23 @@ private, hidden or removed drops out of everyone's playlists (its owner's list s
@@ -299,7 +300,23 @@ 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 |
-## API (`/api/v1`, a signed-in person only)
+## Backups: sync keys and `openvibe-download`
+
+A person keeps a folder on their computer backed up in their drive with the `openvibe-download` command
+(openvibe-sdk 0.49.0, `openvibe-sdk/download`): `openvibe-download sync ~/Documents --to Backups/laptop` sends what is
+new or changed (same size and sha256 is skipped), keeps the folder tree, and with `--delete` removes drive files whose
+local copy is gone. It authenticates with a **sync key** made on `/sync` (`OPENVIBE_DOWNLOAD_KEY`):
+
+- `ovdl_` and 43 url-safe characters, shown once; only its sha256 is stored (`mh_sync_keys`, migration 0011), with
+ the first 12 characters to tell keys apart. 10 live keys per person (`MEDIAHUB_SYNC_KEYS_MAX`).
+- It acts for its owner on the drive's `/api/v1` only (the router checks it before the shared principal): files,
+ folders, uploads and usage. Share links answer 403 `sync_key.not_allowed`, and Pics and Video never see it.
+- Its uploads have their own daily cap (`MEDIAHUB_SYNC_UPLOADS_PER_DAY`, 1,000, against the browser's 50) and their
+ own request budgets (`media-hub.sync.upload.start|complete`, 120 a minute, 1,000 an hour); storage and the per-file
+ size are capped the same. A revoked or unknown key is 401 `sync_key.invalid`.
+- The account export lists keys without their hashes; deleting the account deletes them.
+
+## API (`/api/v1`, a signed-in person, or their sync key)
| Route | |
| Route | |
|---|---|
|---|---|
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+-- phase: expand
+-- Sync keys (OpenVibe.Download backups): a person makes a key on /sync and gives it to the openvibe-download command
+-- (openvibe-sdk) on their own computer, which keeps a folder backed up in their drive. A key acts for its owner on the
+-- drive's /api/v1 only (files, folders, uploads, usage; never share links, Pics or Video). Only a hash is kept; the key
+-- is shown once. A revoked key stays listed until its owner deletes the account.
+CREATE TABLE mh_sync_keys (
+ id text COLLATE "C" PRIMARY KEY, -- dsk_<ULID>
+ owner text COLLATE "C" NOT NULL, -- user:usr_…
+ name text NOT NULL, -- what the person called it (the computer it is for)
+ token_hash text COLLATE "C" NOT NULL UNIQUE, -- sha256 hex of the whole key
+ prefix text COLLATE "C" NOT NULL, -- the key's first characters, to tell keys apart in the list
+ created_at text COLLATE "C" NOT NULL,
+ last_used_at text COLLATE "C",
+ revoked_at text COLLATE "C"
+);
+CREATE INDEX mh_sync_keys_owner ON mh_sync_keys (owner, created_at DESC);
@@ -47,6 +47,8 @@ const TABLES = [
@@ -47,6 +47,8 @@ const TABLES = [
// people's reports on them cascade). The bytes are Media VODs, deleted with extraErase.
// people's reports on them cascade). The bytes are Media VODs, deleted with extraErase.
{ table: 'video_upload_reports', subject: 'reporter', value: AS_REQUESTER, file: 'video_upload_reports.json' },
{ table: 'video_upload_reports', subject: 'reporter', value: AS_REQUESTER, file: 'video_upload_reports.json' },
{ table: 'video_uploads', subject: 'owner', value: AS_REQUESTER, file: 'videos.json' },
{ table: 'video_uploads', subject: 'owner', value: AS_REQUESTER, file: 'videos.json' },
+ // Sync keys (migrations/0011_sync_keys.sql): their names and dates, never the key's hash.
+ { table: 'mh_sync_keys', subject: 'owner', value: AS_REQUESTER, file: 'sync_keys.json', columns: ['id', 'name', 'prefix', 'created_at', 'last_used_at', 'revoked_at'] },
];
];
function createAccount({ db, config, media, log = console }) {
function createAccount({ db, config, media, log = console }) {
@@ -26,6 +26,7 @@ const { openStore } = require('./db');
@@ -26,6 +26,7 @@ const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
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 { 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');
@@ -73,7 +74,8 @@ async function createApp(opts = {}) {
@@ -73,7 +74,8 @@ async function createApp(opts = {}) {
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
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 ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, videoAi, 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 };
// 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);
@@ -110,6 +110,8 @@ function load(env = process.env) {
@@ -110,6 +110,8 @@ function load(env = process.env) {
bytes: size(env.MEDIAHUB_QUOTA_BYTES, 25 * GB), // 25 GB stored (the owner's Q10: free accounts)
bytes: size(env.MEDIAHUB_QUOTA_BYTES, 25 * GB), // 25 GB stored (the owner's Q10: free accounts)
maxFileBytes: size(env.MEDIAHUB_MAX_FILE_BYTES, 2 * GB), // 2 GB per file
maxFileBytes: size(env.MEDIAHUB_MAX_FILE_BYTES, 2 * GB), // 2 GB per file
uploadsPerDay: size(env.MEDIAHUB_UPLOADS_PER_DAY, 50), // 50 uploads a day (UTC)
uploadsPerDay: size(env.MEDIAHUB_UPLOADS_PER_DAY, 50), // 50 uploads a day (UTC)
+ syncUploadsPerDay: size(env.MEDIAHUB_SYNC_UPLOADS_PER_DAY, 1000), // uploads a day through sync keys (backups)
+ syncKeys: size(env.MEDIAHUB_SYNC_KEYS_MAX, 10), // live sync keys per person
maxShares: size(env.MEDIAHUB_MAX_SHARES, 20), // 20 live share links
maxShares: size(env.MEDIAHUB_MAX_SHARES, 20), // 20 live share links
},
},
// A plain <input type=file> form posts the whole file in one request; past this the page offers the
// A plain <input type=file> form posts the whole file in one request; past this the page offers the
@@ -65,8 +65,11 @@ function createService({ s, media, identity, config, log = console }) {
@@ -65,8 +65,11 @@ function createService({ s, media, identity, config, log = console }) {
// ── Limits ──────────────────────────────────────────────
// ── Limits ──────────────────────────────────────────────
- /** Room for one more file of `size` bytes, and for uploads not to run past the day's cap. */
- async function uploadAllowed(owner, size) {
+ /**
+ * Room for one more file of `size` bytes, and for uploads not to run past the day's cap. `perDay` is a larger cap
+ * for a sync key's uploads (a backup sends many files); storage stays capped the same.
+ */
+ async function uploadAllowed(owner, size, { perDay = config.quotas.uploadsPerDay } = {}) {
if (!Number.isInteger(size) || size < 1) return { ok: false, code: 'file.invalid_size', detail: 'A file needs a size of at least one byte.' };
if (!Number.isInteger(size) || size < 1) return { ok: false, code: 'file.invalid_size', detail: 'A file needs a size of at least one byte.' };
if (size > config.quotas.maxFileBytes) {
if (size > config.quotas.maxFileBytes) {
return { ok: false, code: 'file.too_large', detail: `One file may be at most ${rules.formatBytes(config.quotas.maxFileBytes)}. This one is ${rules.formatBytes(size)}.` };
return { ok: false, code: 'file.too_large', detail: `One file may be at most ${rules.formatBytes(config.quotas.maxFileBytes)}. This one is ${rules.formatBytes(size)}.` };
@@ -76,8 +79,8 @@ function createService({ s, media, identity, config, log = console }) {
@@ -76,8 +79,8 @@ function createService({ s, media, identity, config, log = console }) {
return { ok: false, code: 'file.quota', detail: `Your drive holds ${rules.formatBytes(used)} of ${rules.formatBytes(config.quotas.bytes)}. This file needs ${rules.formatBytes(size)} more; delete something first.` };
return { ok: false, code: 'file.quota', detail: `Your drive holds ${rules.formatBytes(used)} of ${rules.formatBytes(config.quotas.bytes)}. This file needs ${rules.formatBytes(size)} more; delete something first.` };
}
}
const today = await store.uploadsToday(s, owner, dayOf(s.now()));
const today = await store.uploadsToday(s, owner, dayOf(s.now()));
- if (today >= config.quotas.uploadsPerDay) {
- return { ok: false, code: 'file.daily_limit', detail: `You have uploaded ${config.quotas.uploadsPerDay} files today (the most one person may). Try again tomorrow.` };
+ if (today >= perDay) {
+ return { ok: false, code: 'file.daily_limit', detail: `You have uploaded ${perDay} files today (the most one person may). Try again tomorrow.` };
}
}
return { ok: true };
return { ok: true };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * Sync keys (migration 0011): what lets the openvibe-download command (openvibe-sdk) back a folder up into a person's
+ * drive without a browser. A key is `ovdl_` and 43 url-safe characters; only its sha256 is stored, and it is shown
+ * once. It acts for its owner on the drive's /api/v1 only (server/http/api.js decides that), so a leaked key can read
+ * and fill the drive but cannot make share links or touch Pics or Video, and its owner revokes it on /sync.
+ */
+const crypto = require('crypto');
+
+const KEY_RE = /^ovdl_[A-Za-z0-9_-]{43}$/;
+const NAME_MAX = 60;
+/** How often a key's last-used time is written (a sync makes many requests). */
+const TOUCH_MS = 5 * 60 * 1000;
+
+const hashOf = (key) => crypto.createHash('sha256').update(key).digest('hex');
+
+function createSyncKeys({ s, config }) {
+ const max = () => Number(config.quotas.syncKeys) || 10;
+ const touched = new Map();
+
+ const list = (owner) => s.db.many(
+ 'SELECT id, name, prefix, created_at, last_used_at, revoked_at FROM mh_sync_keys WHERE owner = $1 ORDER BY created_at DESC', [owner]);
+
+ /** A new key for `owner`: { ok, key (shown once), row } or { ok: false, code, detail }. */
+ async function create(owner, rawName) {
+ const name = Array.from(String(rawName == null ? '' : rawName), (ch) => (ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127 ? ' ' : ch)).join('').trim();
+ if (!name) return { ok: false, code: 'sync_key.name', detail: 'Name the key after the computer it is for.' };
+ if (name.length > NAME_MAX) return { ok: false, code: 'sync_key.name', detail: `A name is at most ${NAME_MAX} characters.` };
+ const live = Number(await s.db.value('SELECT count(*) FROM mh_sync_keys WHERE owner = $1 AND revoked_at IS NULL', [owner]));
+ if (live >= max()) return { ok: false, code: 'sync_key.limit', detail: `You have ${max()} sync keys (the most one person may). Revoke one first.` };
+ const key = `ovdl_${crypto.randomBytes(32).toString('base64url')}`;
+ const row = await s.db.one(
+ `INSERT INTO mh_sync_keys (id, owner, name, token_hash, prefix, created_at) VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, name, prefix, created_at, last_used_at, revoked_at`,
+ [s.newId('dsk'), owner, name, hashOf(key), key.slice(0, 12), s.iso()]);
+ return { ok: true, key, row };
+ }
+
+ async function revoke(owner, id) {
+ const n = await s.db.exec('UPDATE mh_sync_keys SET revoked_at = $3 WHERE owner = $1 AND id = $2 AND revoked_at IS NULL', [owner, String(id || ''), s.iso()]);
+ return n > 0;
+ }
+
+ /** The owner a key acts for, or null (malformed, unknown or revoked). */
+ async function resolve(key) {
+ if (!KEY_RE.test(String(key || ''))) return null;
+ const row = await s.db.maybe('SELECT id, owner FROM mh_sync_keys WHERE token_hash = $1 AND revoked_at IS NULL', [hashOf(key)]);
+ if (!row) return null;
+ const now = s.now();
+ if ((touched.get(row.id) || 0) + TOUCH_MS < now) {
+ touched.set(row.id, now);
+ await s.db.exec('UPDATE mh_sync_keys SET last_used_at = $2 WHERE id = $1', [row.id, s.iso()]);
+ }
+ return row.owner;
+ }
+
+ return { list, create, revoke, resolve, NAME_MAX, max };
+}
+
+module.exports = { createSyncKeys, KEY_RE };
@@ -40,8 +40,8 @@ function createUploads({ s, media, config, service, log = console }) {
@@ -40,8 +40,8 @@ function createUploads({ s, media, config, service, log = console }) {
const nowIso = () => new Date(s.now()).toISOString();
const nowIso = () => new Date(s.now()).toISOString();
/** Media's object is `uploading`; the session is Media's, the row is ours. */
/** Media's object is `uploading`; the session is Media's, the row is ours. */
- async function startSession({ owner, name, size, contentType, folderId = null }) {
- const room = await service.uploadAllowed(owner, size);
+ async function startSession({ owner, name, size, contentType, folderId = null, perDay }) {
+ const room = await service.uploadAllowed(owner, size, perDay ? { perDay } : {});
if (!room.ok) return room;
if (!room.ok) return room;
if (folderId && !await service.ownFolder(owner, folderId)) {
if (folderId && !await service.ownFolder(owner, folderId)) {
return { ok: false, code: 'folder.not_found', detail: 'That folder does not exist.' };
return { ok: false, code: 'folder.not_found', detail: 'That folder does not exist.' };
@@ -52,12 +52,21 @@ function readBody(req, cap) {
@@ -52,12 +52,21 @@ function readBody(req, cap) {
}
}
function createApi(ctx) {
function createApi(ctx) {
- const { config, s, principal, limits, drive, uploads } = ctx;
+ const { config, s, principal, limits, drive, uploads, syncKeys } = 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 });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
- r.use(principal.middleware);
+ // A sync key (server/drive/syncKeys.js) acts for its owner here and nowhere else: this router is the drive's API,
+ // so the key never reaches Pics, Video or the pages. Anything else goes through the shared principal.
+ r.use(async (req, res, next) => {
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ovdl_') || !syncKeys) return principal.middleware(req, res, next);
+ const owner = await syncKeys.resolve(header.slice(7).trim());
+ if (!owner) return problem(req, res, 401, 'sync_key.invalid', 'This sync key is not valid: it was revoked or never issued. Make a new one at openvibe.download/sync.');
+ req.principal = { kind: 'user', requester: owner, project: null, viaSession: false, viaSyncKey: true, username: null, role: 'user' };
+ return next();
+ });
/**
/**
* The chunked upload's part body is raw bytes, so it is mounted before the JSON parser: express must never try
* The chunked upload's part body is raw bytes, so it is mounted before the JSON parser: express must never try
@@ -152,7 +161,14 @@ function createApi(ctx) {
@@ -152,7 +161,14 @@ function createApi(ctx) {
});
});
/** Start an upload. The bytes follow through PUT /uploads/:id/parts/:n (see the page's script). */
/** Start an upload. The bytes follow through PUT /uploads/:id/parts/:n (see the page's script). */
- r.post('/files', person, limits.budget('media-hub.upload.start'), async (req, res) => {
+ /** A sync key's uploads are counted on their own budget (server/http/caller-limits.js). */
+ const uploadBudget = (name) => {
+ const plain = limits.budget(`media-hub.upload.${name}`);
+ const sync = limits.budget(`media-hub.sync.upload.${name}`);
+ return (req, res, next) => (req.principal.viaSyncKey ? sync : plain)(req, res, next);
+ };
+
+ r.post('/files', person, uploadBudget('start'), async (req, res) => {
const owner = personOf(req);
const owner = personOf(req);
const b = req.body || {};
const b = req.body || {};
const size = Number(b.size);
const size = Number(b.size);
@@ -160,7 +176,8 @@ function createApi(ctx) {
@@ -160,7 +176,8 @@ function createApi(ctx) {
const contentType = rules.normalizeType(b.content_type) || 'application/octet-stream';
const contentType = rules.normalizeType(b.content_type) || 'application/octet-stream';
const folderId = b.folder_id ? String(b.folder_id) : null;
const folderId = b.folder_id ? String(b.folder_id) : null;
if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');
if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');
- const out = await uploads.startSession({ owner, name, size, contentType, folderId });
+ const perDay = req.principal.viaSyncKey ? config.quotas.syncUploadsPerDay : undefined;
+ const out = await uploads.startSession({ owner, name, size, contentType, folderId, perDay });
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
res.set('Location', `/api/v1/uploads/${out.upload.id}`);
res.set('Location', `/api/v1/uploads/${out.upload.id}`);
return res.status(201).json({
return res.status(201).json({
@@ -198,7 +215,13 @@ function createApi(ctx) {
@@ -198,7 +215,13 @@ function createApi(ctx) {
});
});
// ── Shares ──────────────────────────────────────────────
// ── Shares ──────────────────────────────────────────────
- r.post('/shares', person, limits.budget('media-hub.share.create'), async (req, res) => {
+ /** A share link is made by the person themself (signed in, or their Network token), never by a sync key. */
+ function notSyncKey(req, res, next) {
+ if (!req.principal.viaSyncKey) return next();
+ return problem(req, res, 403, 'sync_key.not_allowed', 'A sync key backs files up; share links are made at openvibe.download.');
+ }
+
+ r.post('/shares', person, notSyncKey, limits.budget('media-hub.share.create'), async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const fileId = b.file_id ? String(b.file_id) : null;
const fileId = b.file_id ? String(b.file_id) : null;
const folderId = b.folder_id ? String(b.folder_id) : null;
const folderId = b.folder_id ? String(b.folder_id) : null;
@@ -217,7 +240,7 @@ function createApi(ctx) {
@@ -217,7 +240,7 @@ function createApi(ctx) {
});
});
});
});
- r.delete('/shares/:slug', person, async (req, res) => {
+ r.delete('/shares/:slug', person, notSyncKey, async (req, res) => {
const row = await drive.revokeShare(personOf(req), String(req.params.slug || ''));
const row = await drive.revokeShare(personOf(req), String(req.params.slug || ''));
if (!row) return problem(req, res, 404, 'share.not_found', 'No such share.');
if (!row) return problem(req, res, 404, 'share.not_found', 'No such share.');
return res.status(204).end();
return res.status(204).end();
@@ -240,7 +263,7 @@ function createApi(ctx) {
@@ -240,7 +263,7 @@ function createApi(ctx) {
return res.json({ upload: out.upload });
return res.json({ upload: out.upload });
});
});
- r.post('/uploads/:id/complete', person, limits.budget('media-hub.upload.complete'), async (req, res) => {
+ r.post('/uploads/:id/complete', person, uploadBudget('complete'), async (req, res) => {
const out = await uploads.complete(personOf(req), String(req.params.id || ''));
const out = await uploads.complete(personOf(req), String(req.params.id || ''));
if (!out.ok) {
if (!out.ok) {
const status = out.code === 'upload.not_found' ? 404 : (out.code === 'file.quota' ? 429 : 409);
const status = out.code === 'upload.not_found' ? 404 : (out.code === 'file.quota' ? 429 : 409);
@@ -44,6 +44,11 @@ const BUDGETS = {
@@ -44,6 +44,11 @@ const BUDGETS = {
'media-hub.upload.part': { minute: 300, hour: 5_000 },
'media-hub.upload.part': { minute: 300, hour: 5_000 },
'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 },
+ // 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 },
+ 'media-hub.sync.upload.complete': { minute: 120, hour: 1_000 },
'media-hub.share.create': { minute: 20, hour: 200 },
'media-hub.share.create': { minute: 20, hour: 200 },
'media-hub.playlist.create': { minute: 30, hour: 300 },
'media-hub.playlist.create': { minute: 30, hour: 300 },
'media-hub.playlist.write': { minute: 120, hour: 1_200 },
'media-hub.playlist.write': { minute: 120, hour: 1_200 },
@@ -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 } = ctx;
+ const { config, s, drive, uploads, media, syncKeys } = 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.
@@ -225,6 +225,7 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
@@ -225,6 +225,7 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
scripts: ['js/upload.js'],
scripts: ['js/upload.js'],
body: html`<h1>${folder ? folder.name : 'Your files'}</h1>
body: html`<h1>${folder ? folder.name : 'Your files'}</h1>
${usageBar(await drive.usage(owner))}
${usageBar(await drive.usage(owner))}
+<p class="small"><a href="/sync">Back up a folder from your computer</a></p>
${folder ? html`<p><a href="/files">← All files</a></p>` : ''}
${folder ? html`<p><a href="/files">← All files</a></p>` : ''}
<section aria-labelledby="h-upload">${uploadForm(folder)}</section>
<section aria-labelledby="h-upload">${uploadForm(folder)}</section>
<section aria-labelledby="h-folders"><h2 id="h-folders">Folders</h2>
<section aria-labelledby="h-folders"><h2 id="h-folders">Folders</h2>
@@ -702,6 +703,63 @@ ${it.file ? html`<p><b>${it.file.name}</b> · ${rules.formatBytes(it.file.size)}
@@ -702,6 +703,63 @@ ${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);
+ // ── 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' }];
+ const keyState = (k) => (k.revoked_at ? `revoked ${time(k.revoked_at)}` : k.last_used_at ? html`last used ${time(k.last_used_at)}` : 'not used yet');
+
+ r.get('/sync', mustSignIn('/sync', 'A backup goes into your own drive, so it needs your OpenVibe account.'), async (req, res) => {
+ const keys = await syncKeys.list(requesterOf(req));
+ return page(req, res, {
+ title: 'Back up a folder',
+ crumbs: syncCrumbs,
+ body: html`<h1>Back up a folder from your computer</h1>
+<p class="sc-prose">The <code>openvibe-download</code> command keeps a folder on your computer copied into your drive: run it once, or on a schedule, and it sends only what is new or changed. It needs Node.js 20 or newer and a sync key from this page.</p>
+<section aria-labelledby="h-steps"><h2 id="h-steps">How</h2>
+<ol class="prose">
+<li>Make a sync key below, named after the computer it is for. Copy it: it is shown once.</li>
+<li>On that computer, put it in the environment as <code>OPENVIBE_DOWNLOAD_KEY</code>.</li>
+<li>Run <code>${SYNC_COMMAND} sync ~/Documents --to Backups/laptop</code></li>
+</ol>
+<p class="small muted">Add <code>--dry-run</code> to see what it would send, and <code>--delete</code> to also remove drive files whose local copy is gone (without it, a backup only ever adds). Files keep their folders; a file is sent again when its size or contents change. Uploads through a sync key count against your ${rules.formatBytes(config.quotas.bytes)} and up to ${config.quotas.syncUploadsPerDay} files a day.</p></section>
+<section aria-labelledby="h-keys"><h2 id="h-keys">Your sync keys</h2>
+${keys.length ? html`<ul class="file-list">${keys.map((k) => html`<li class="file"><span class="file-main"><span class="file-name">${k.name}</span><span class="file-meta small muted"><code>${k.prefix}…</code> · made ${time(k.created_at)} · ${keyState(k)}</span></span>
+${k.revoked_at ? '' : html`<form class="inline" method="post" action="/sync/keys/${k.id}/revoke"><button class="sc-btn" type="submit">Revoke</button></form>`}</li>`)}</ul>` : html`<p class="muted">No sync keys yet.</p>`}
+<form class="inline" method="post" action="/sync/keys">
+<label for="key-name">Computer</label> <input id="key-name" name="name" type="text" maxlength="${syncKeys.NAME_MAX}" placeholder="laptop" required> <button class="sc-btn sc-primary" type="submit">Make a sync key</button>
+</form>
+<p class="small muted">A key can read and fill your drive and nothing else: it cannot make share links, and it does nothing on OpenVibe.Pics or OpenVibe.Video. Revoke it here the moment a computer is lost.</p></section>`,
+ });
+ });
+
+ r.post('/sync/keys', mustSignIn('/sync'), budgeted('media-hub.sync_key.create'), async (req, res) => {
+ if (!sameSite(req, res, '/sync')) return undefined;
+ const out = await syncKeys.create(requesterOf(req), (req.body || {}).name);
+ if (!out.ok) {
+ return page(req, res, {
+ title: 'No key was made', crumbs: syncCrumbs,
+ body: html`<h1>No key was made</h1>${notice(out.detail, 'warn')}<p><a class="sc-btn" href="/sync">Back</a></p>`,
+ }, out.code === 'sync_key.limit' ? 429 : 422);
+ }
+ res.set('Cache-Control', 'no-store');
+ return page(req, res, {
+ title: 'Your new sync key', crumbs: syncCrumbs,
+ body: html`<h1>Your sync key for ${out.row.name}</h1>
+${notice('Copy it now: it is not shown again. Anyone holding it can read and fill your drive.', 'warn')}
+<p><code class="sync-key">${out.key}</code></p>
+<p class="sc-prose">On that computer:</p>
+<pre class="sync-command"><code>export OPENVIBE_DOWNLOAD_KEY=${out.key}
+${SYNC_COMMAND} sync ~/Documents --to Backups/${out.row.name.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40) || 'computer'}</code></pre>
+<p><a class="sc-btn" href="/sync">Done</a></p>`,
+ });
+ });
+
+ r.post('/sync/keys/:id/revoke', mustSignIn('/sync'), async (req, res) => {
+ if (!sameSite(req, res, '/sync')) return undefined;
+ await syncKeys.revoke(requesterOf(req), String(req.params.id || ''));
+ return res.redirect(303, '/sync');
+ });
+
// ── Safety ───────────────────────────────────────────────
// ── Safety ───────────────────────────────────────────────
r.get('/safety', (req, res) => page(req, res, {
r.get('/safety', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
@@ -715,9 +773,9 @@ ${notice('This is a private drive with share links. It is not a place to hide, a
@@ -715,9 +773,9 @@ ${notice('This is a private drive with share links. It is not a place to hide, a
<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> There is no anonymous upload and no app or service token that can post a file here.</li>
+<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>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>Every download needs a signed-in OpenVibe account too.</b> A share link does not open a file on its own: the person opening it must be signed in, and the share page says who shared it.</li>
+<li><b>A share 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>
<li><b>Downloads never happen in a page.</b> A file is always sent as an attachment with a no-sniff header, and a type that is not on a short safe list is sent as an opaque blob. Uploaded HTML and SVG are never rendered.</li>
<li><b>Downloads never happen in a page.</b> A file is always sent as an attachment with a no-sniff header, and a type that is not on a short safe list is sent as an opaque blob. Uploaded HTML and SVG are never rendered.</li>
</ul></section>
</ul></section>
@@ -23,7 +23,7 @@ const READ = 'media-hub.api.read';
@@ -23,7 +23,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.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
- 'media-hub.share.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,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * Sync keys (server/drive/syncKeys.js, migration 0011): a person makes a key on /sync, shown once, for the
+ * openvibe-download command to back a folder up into their drive.
+ * - a key acts for its owner on the drive's /api/v1: list, folders, chunked uploads, delete;
+ * - it never makes or revokes share links, and it is no key at all on the video host's API;
+ * - its uploads have their own (larger) daily cap; storage stays capped the same;
+ * - revoked, it stops at once; only its hash is stored, and the export lists keys without it.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes } = require('./helpers/drive');
+
+const MB = 1024 * 1024;
+
+(async () => {
+ const t = await boot({
+ env: {
+ MEDIAHUB_PART_BYTES: String(5 * MB),
+ MEDIAHUB_UPLOADS_PER_DAY: '1',
+ MEDIAHUB_SYNC_UPLOADS_PER_DAY: '3',
+ MEDIAHUB_SYNC_KEYS_MAX: '2',
+ },
+ });
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+ let key = null;
+
+ await check('a signed-in person makes a key on /sync; it is shown once and only its hash is kept', async () => {
+ assert.strictEqual((await t.get('/sync')).status, 401, 'a guest is asked to sign in');
+ const pageBefore = await t.get('/sync', { as: kim });
+ assert.ok(pageBefore.text.includes('No sync keys yet.'));
+ assert.strictEqual((await t.get('/sync/keys', { as: kim, form: { name: 'laptop' } })).status, 403, 'not from this site');
+ const made = await t.get('/sync/keys', { as: kim, form: { name: 'laptop' }, headers: SAME });
+ assert.strictEqual(made.status, 200, made.text.slice(0, 300));
+ key = (/ovdl_[A-Za-z0-9_-]{43}/.exec(made.text) || [])[0];
+ assert.ok(key, 'the key is on the page');
+ const after = await t.get('/sync', { as: kim });
+ assert.ok(after.text.includes('laptop') && after.text.includes(key.slice(0, 12)) && !after.text.includes(key), 'listed by its prefix, never again in full');
+ const stored = await t.ctx.s.db.one('SELECT token_hash FROM mh_sync_keys WHERE owner = $1', [`user:${kim.subject}`]);
+ assert.notStrictEqual(stored.token_hash, key);
+ assert.match(stored.token_hash, /^[0-9a-f]{64}$/);
+ });
+
+ await check('the key reads and fills its owner\'s drive through /api/v1, with the sync cap', async () => {
+ const list = await t.get('/api/v1/files', { bearer: key });
+ assert.strictEqual(list.status, 200, list.text);
+ const folder = await t.get('/api/v1/folders', { bearer: key, json: { name: 'Backups' } });
+ assert.strictEqual(folder.status, 201, folder.text);
+ const folderId = folder.json().folder.id;
+ for (let n = 1; n <= 3; n++) {
+ const data = bytes(1000 + n, `backup-${n}`);
+ const started = await t.get('/api/v1/files', { bearer: key, json: { name: `note-${n}.txt`, size: data.length, content_type: 'text/plain', folder_id: folderId } });
+ assert.strictEqual(started.status, 201, `upload ${n}: ${started.text}`);
+ const up = started.json().upload;
+ const part = await t.get(`/api/v1/uploads/${up.id}/parts/1`, { bearer: key, method: 'PUT', body: data, headers: { 'content-type': 'application/octet-stream' } });
+ assert.strictEqual(part.status, 200, part.text);
+ const done_ = await t.get(`/api/v1/uploads/${up.id}/complete`, { bearer: key, json: {} });
+ assert.strictEqual(done_.status, 201, done_.text);
+ }
+ const fourth = await t.get('/api/v1/files', { bearer: key, json: { name: 'note-4.txt', size: 10, content_type: 'text/plain' } });
+ assert.strictEqual(fourth.status, 429, 'past the sync cap');
+ assert.strictEqual(fourth.json().code, 'file.daily_limit');
+ const inFolder = await t.get(`/api/v1/files?folder_id=${folderId}`, { as: kim });
+ assert.strictEqual(inFolder.json().files.length, 3, 'the files are the owner\'s');
+ assert.deepStrictEqual((await t.get(`/api/v1/files?folder_id=${folderId}`, { as: sam })).json().files, [], 'nobody else sees them');
+ });
+
+ await check('a key makes no share links and is nothing on the video host', async () => {
+ const files = (await t.get('/api/v1/files?limit=1', { as: kim })).json();
+ const anyFile = files.files[0] || (await t.get(`/api/v1/files?folder_id=${files.folders[0].id}`, { as: kim })).json().files[0];
+ const share = await t.get('/api/v1/shares', { bearer: key, json: { file_id: anyFile.id, hours: 24 } });
+ assert.strictEqual(share.status, 403);
+ assert.strictEqual(share.json().code, 'sync_key.not_allowed');
+ const video = await t.get('/api/v1/video/playlists', { bearer: key, headers: { host: 'openvibe.video' } });
+ assert.ok(video.status === 401 || video.status === 404, `the video API does not take it (${video.status})`);
+ });
+
+ await check('the key limit holds; a revoked key stops at once; the export lists keys without hashes', async () => {
+ assert.strictEqual((await t.get('/sync/keys', { as: kim, form: { name: 'desktop' }, headers: SAME })).status, 200);
+ assert.strictEqual((await t.get('/sync/keys', { as: kim, form: { name: 'third' }, headers: SAME })).status, 429, 'two live keys at most here');
+ const row = await t.ctx.s.db.one("SELECT id FROM mh_sync_keys WHERE owner = $1 AND name = 'laptop'", [`user:${kim.subject}`]);
+ assert.strictEqual((await t.get(`/sync/keys/${row.id}/revoke`, { as: sam, form: {}, headers: SAME })).status, 303, 'someone else\'s revoke does nothing');
+ assert.strictEqual((await t.get('/api/v1/files', { bearer: key })).status, 200, 'still valid');
+ assert.strictEqual((await t.get(`/sync/keys/${row.id}/revoke`, { as: kim, form: {}, headers: SAME })).status, 303);
+ const refused = await t.get('/api/v1/files', { bearer: key });
+ assert.strictEqual(refused.status, 401);
+ assert.strictEqual(refused.json().code, 'sync_key.invalid');
+ assert.strictEqual((await t.get('/api/v1/files', { bearer: `ovdl_${'x'.repeat(43)}` })).status, 401, 'an unknown key');
+ });
+
+ await done(t);
+})();