openvibers/openvibe.mediahub Commits

Download: back a folder up from your computer (sync keys on /sync for the openvibe-download command) (#75)

OP OpenVibers committed 29 days ago · 08bf5c0126 · 13 files, +301 −20

A person makes a sync key on /sync (ovdl_…, shown once, sha256 kept; mh_sync_keys, migration 0011) and
runs openvibe-download (openvibe-sdk 0.49.0) on their computer to keep a folder backed up in their drive.
The key acts for its owner on the drive's /api/v1 only (checked in that router, before the shared
principal): files, folders, uploads, usage. No share links (403 sync_key.not_allowed), nothing on Pics
or Video. Its uploads have their own daily cap (1,000) and request budgets; storage and file size are
capped the same. Revoked or unknown: 401 sync_key.invalid. The export lists keys without hashes. The
safety page says what sync keys and public share links do; /files links to /sync.

Changes

ddiff --git a/README.md b/README.md
iindex d553197..d89aec2 100644
---- a/README.md
++++ b/README.md
@@@ -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
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.
- 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 | |
|---|---|
ddiff --git a/migrations/0011_sync_keys.sql b/migrations/0011_sync_keys.sql
nnew file mode 100644
iindex 0000000..e5024ee
---- /dev/null
++++ b/migrations/0011_sync_keys.sql
@@@ -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);
ddiff --git a/server/account.js b/server/account.js
iindex 2a9b219..86a1fa4 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -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 }) {
ddiff --git a/server/app.js b/server/app.js
iindex 3576804..b0cc0a6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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);
ddiff --git a/server/config.js b/server/config.js
iindex 02dd960..1261398 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 file
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
},
// A plain <input type=file> form posts the whole file in one request; past this the page offers the
ddiff --git a/server/drive/service.js b/server/drive/service.js
iindex 28a8815..23f2159 100644
---- a/server/drive/service.js
++++ b/server/drive/service.js
@@@ -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 };
}
ddiff --git a/server/drive/syncKeys.js b/server/drive/syncKeys.js
nnew file mode 100644
iindex 0000000..06ff9e6
---- /dev/null
++++ b/server/drive/syncKeys.js
@@@ -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 };
ddiff --git a/server/drive/upload.js b/server/drive/upload.js
iindex 5ed60a0..c897f4c 100644
---- a/server/drive/upload.js
++++ b/server/drive/upload.js
@@@ -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.' };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 1ab44a7..3b7d5c1 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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);
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 97c3e94..f456fcc 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -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 },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex ec2f0de..37f47b4 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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>
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex d437092..a7a7ce4 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -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',
]);
ddiff --git a/test/sync-keys.test.js b/test/sync-keys.test.js
nnew file mode 100644
iindex 0000000..c3aa17d
---- /dev/null
++++ b/test/sync-keys.test.js
@@@ -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);
++})();