openvibers/openvibe.mediahub Commits
Download: public links (no account to download), 25 GB free, 2 GB files (#66)
Changes
@@@ -39,8 +39,8 @@ MEDIAHUB_MEDIA_APP=media-hub#MEDIAHUB_MEDIA_TIMEOUT_MS=30000# ── Per person (Media's quotas are per tenant, so these are enforced here) ──--#MEDIAHUB_QUOTA_BYTES=2147483648--#MEDIAHUB_MAX_FILE_BYTES=536870912++#MEDIAHUB_QUOTA_BYTES=26843545600++#MEDIAHUB_MAX_FILE_BYTES=2147483648#MEDIAHUB_UPLOADS_PER_DAY=50#MEDIAHUB_MAX_SHARES=20# The most a plain <input type=file> form may post in one request; keep it in step with client_max_body_size for@@@ -92,10 +92,12 @@ 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.- 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 7 days (7 by default), can be revoked,-- counts downloads, and **needs the downloader to be signed in**. It may be limited to named OpenVibe accounts. The names-- are resolved through OpenVibe.Network to account subjects, and only subjects are matched, because a username can-- change hands. A name no account has is refused.++- 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**++ OpenVibe accounts, or **public** (`public: true`, migration `0007_public_shares.sql`; the owner's Q10, MediaFire-style):++ its page and its downloads open for anyone with the link, no account needed. Named accounts are resolved through++ OpenVibe.Network to account subjects, and only subjects are matched, because a username can change hands; a name no++ account has is refused, and a public link cannot also name accounts.- Every share page has a Report form (malware, illegal content, copyright, something else). Three distinct reports,or any report of illegal content, suspend the share at once. Staff (Network role `admin` or `global_mod`) thenrestore it, or remove the file, which also deletes the object in Media.@@@ -109,7 +111,8 @@ OpenVibe.Download v1 is a **private drive**, not a public host:- OpenVibe.Media makes it as a signed WebP variant of the private object, and MediaHub passes it through: `nosniff`,a sandbox CSP, `private, max-age=300`, never an attachment and never counted as a download.- Only the owner gets one, or whoever may open the share. Other files show their extension.--- Per-person limits, all from the environment: 2 GB stored, 512 MB per file, 50 uploads a day, 20 live share links.++- Per-person limits, all from the environment: 25 GB stored and 2 GB per file (the owner's Q10 for free accounts), 50++ uploads a day, 20 live share links.## How it works@@@ -390,8 +393,9 @@ Uploads stream through nginx unbuffered; downloads are not buffered either.Reporting a vulnerability: [SECURITY.md](SECURITY.md).--- Uploads are account-only, files are private, and share links need a signed-in downloader. Every download is-- attributable, and none renders as a page.++- Uploads are account-only and files are private. A share link needs a signed-in downloader unless its owner made it++ public; a public link's page takes reports from anyone (a signed-out reporter is a keyed hash of the address, never++ the address), and three reports, or one of illegal content, suspend it. No download renders as a page.- The form reader streams files to disk. A text field over 64 KB, a second file, or a body cut off before its lastboundary is refused.- Session tokens are httpOnly cookies. Secrets live only in the env file, and no secret or request body is logged.nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- OpenVibe.Download: public share links (MediaFire-style, the owner's Q10). A share marked public opens and downloads++-- for anyone with the link, signed in or not; it cannot also name accounts. Reports on it may come from people++-- without an account, counted by a keyed hash of their address (never the address).++ALTER TABLE mh_shares ADD COLUMN IF NOT EXISTS public boolean NOT NULL DEFAULT false;@@@ -95,8 +95,8 @@ function load(env = process.env) {// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one// tenant), so the per-person numbers are enforced in MediaHub and nowhere else.quotas: {-- bytes: size(env.MEDIAHUB_QUOTA_BYTES, 2 * GB), // 2 GB stored-- maxFileBytes: size(env.MEDIAHUB_MAX_FILE_BYTES, 512 * MB), // 512 MB per file++ 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)maxShares: size(env.MEDIAHUB_MAX_SHARES, 20), // 20 live share links},@@@ -45,7 +45,7 @@ const SUSPEND_AT_REPORTS = 3;const SUSPEND_IMMEDIATELY = ['illegal'];const EXPIRY_MIN_HOURS = 1;--const EXPIRY_MAX_HOURS = 7 * 24;++const EXPIRY_MAX_HOURS = 30 * 24;const EXPIRY_DEFAULT_HOURS = 7 * 24;/** The declared content type, normalised, or null when it is not a media type at all. */@@@ -128,6 +128,7 @@ const EXPIRY_CHOICES = [{ hours: 24, label: '1 day' },{ hours: 72, label: '3 days' },{ hours: 24 * 7, label: '7 days' },++ { hours: 24 * 30, label: '30 days' },];/** "1.4 GB" — how a size is shown. Binary units, one decimal from MB up. */@@@ -180,7 +180,7 @@ function createService({ s, media, identity, config, log = console }) {* each is asked of Network's identity resolver, and the ones it cannot resolve are kept as typed and matched* against a viewer's username claim later. No names at all = any signed-in person with the link.*/-- async function createShare(owner, { fileId = null, folderId = null, hours, names = [], ownerUsername = null }) {++ async function createShare(owner, { fileId = null, folderId = null, hours, names = [], ownerUsername = null, isPublic = false }) {const room = await shareAllowed(owner);if (!room.ok) return room;if (fileId && !await ownFile(owner, fileId)) return { ok: false, code: 'file.not_found', detail: 'No such file.' };@@@ -188,6 +188,7 @@ function createService({ s, media, identity, config, log = console }) {if (!fileId && !folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder.' };if (fileId && folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder, not both.' };const wanted = [...new Set(names.map((n) => String(n || '').trim().toLowerCase()).filter(Boolean))];++ if (isPublic && wanted.length) return { ok: false, code: 'share.invalid', detail: 'A public link is for anyone with it, so it cannot also name accounts. Choose one.' };for (const n of wanted) {if (!rules.isUsername(n)) return { ok: false, code: 'share.bad_username', detail: `"${rules.cleanName(n, 40)}" is not an OpenVibe username.` };}@@@ -199,7 +200,7 @@ function createService({ s, media, identity, config, log = console }) {const expiresAt = new Date(s.now() + rules.expiryHours(hours) * 3_600_000).toISOString();const share = await store.insertShare(s, {slug: newSlug(), file_id: fileId || null, folder_id: folderId || null, owner,-- owner_username: ownerUsername, expires_at: expiresAt, allowed_subjects: allowed, created_at: nowIso(),++ owner_username: ownerUsername, expires_at: expiresAt, allowed_subjects: allowed, created_at: nowIso(), public: isPublic === true,});return { ok: true, share, unresolved: resolved.unresolved };}@@@ -68,10 +68,10 @@ const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE iasync function insertShare(s, row) {await s.db.query(-- `INSERT INTO ${SHARES} (slug, file_id, folder_id, owner, owner_username, expires_at, allowed_subjects, created_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)`,++ `INSERT INTO ${SHARES} (slug, file_id, folder_id, owner, owner_username, expires_at, allowed_subjects, created_at, public)++ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)`,[row.slug, row.file_id, row.folder_id, row.owner, row.owner_username || null, row.expires_at,-- row.allowed_subjects ? JSON.stringify(row.allowed_subjects) : null, row.created_at]);++ row.allowed_subjects ? JSON.stringify(row.allowed_subjects) : null, row.created_at, row.public === true]);return getShare(s, row.slug);}@@@ -127,6 +127,7 @@ function createApi(ctx) {revoked_at: row.revoked_at, suspended_at: row.suspended_at, created_at: row.created_at,allowed: drive.parseAllow(row.allowed_subjects),limited: Boolean(row.allowed_subjects),++ public: row.public === true,});// ── Liveness ────────────────────────────────────────────@@@ -206,7 +207,7 @@ function createApi(ctx) {const usernames = Array.isArray(b.usernames) ? b.usernames: (typeof b.usernames === 'string' && b.usernames.trim() ? b.usernames.split(/[\s,]+/) : []);const out = await drive.createShare(personOf(req), {-- fileId, folderId, hours: b.hours, names: usernames, ownerUsername: req.principal.username,++ fileId, folderId, hours: b.hours, names: usernames, ownerUsername: req.principal.username, isPublic: b.public === true,});if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);res.set('Location', `/api/v1/shares/${out.share.slug}`);@@@ -151,7 +151,7 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}eyebrow: `${SITE_NAME} · a private drive`,title: 'Your files,',accent: 'shared on your terms.',-- lede: 'A private drive for your files, with share links you control. Uploads and every file page need a signed-in OpenVibe account — there is no anonymous upload and no anonymous download here.',++ lede: 'A private drive for your files, 25 GB free, with share links you control: for named people, for anyone signed in, or a public download page anyone with the link can use. Uploading needs a signed-in OpenVibe account.',actions: signedIn(req)? [{ label: 'Your files', href: '/files', primary: true }, { label: 'The safety rules', href: '/safety' }]: [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'The safety rules', href: '/safety' }],@@@ -257,7 +257,11 @@ ${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type,<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2><form method="post" action="/files/${row.id}/share"><div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>--<div class="field"><label for="usernames">Limited to OpenVibe usernames (optional)</label><input id="usernames" name="usernames" type="text" maxlength="300" placeholder="kim, sam"><p class="small muted">Leave empty for any signed-in person with the link. Everyone who opens it still has to be signed in.</p></div>++<fieldset class="field"><legend>Who can open it</legend>++<label class="check"><input type="radio" name="who" value="signed_in" checked> Anyone signed in to OpenVibe with the link</label>++<label class="check"><input type="radio" name="who" value="public"> Anyone with the link, no account needed (a public download page)</label>++<label class="check"><input type="radio" name="who" value="named"> Only these OpenVibe usernames:</label>++<input id="usernames" name="usernames" type="text" maxlength="300" placeholder="kim, sam" aria-label="OpenVibe usernames"></fieldset><p><button class="sc-btn sc-primary" type="submit">Create the link</button></p></form>${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh) => html`<li class="file">${shareBadge(drive.stateOf(sh, s.iso()))} <a href="/s/${sh.slug}">/s/${sh.slug}</a> <span class="small muted">expires ${time(sh.expires_at)} · ${Number(sh.downloads)} downloads</span></li>`)}</ul>` : ''}@@@ -337,9 +341,10 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)const owner = requesterOf(req);const row = await drive.ownFile(owner, String(req.params.id || ''));if (!row) return notFoundFile(req, res);-- const names = String((req.body || {}).usernames || '').split(/[\s,]+/).filter(Boolean).slice(0, 50);++ const who = String((req.body || {}).who || '');++ const names = who === 'public' ? [] : String((req.body || {}).usernames || '').split(/[\s,]+/).filter(Boolean).slice(0, 50);const out = await drive.createShare(owner, {-- fileId: row.id, hours: (req.body || {}).hours, names, ownerUsername: req.viewer.username || null,++ fileId: row.id, hours: (req.body || {}).hours, names, ownerUsername: req.viewer.username || null, isPublic: who === 'public',});if (!out.ok) {return page(req, res, {@@@ -354,7 +359,7 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)body: html`<h1>Share link made</h1><p>The link is <a href="/s/${out.share.slug}"><code>/s/${out.share.slug}</code></a> — it expires ${time(out.share.expires_at)}${out.share.allowed_subjects ? ' and only the people you named can open it' : ''}.</p>--<p class="muted">Everyone who opens it has to be signed in to OpenVibe before they can download.</p>++<p class="muted">${out.share.public ? 'Anyone with the link can open the page and download, no account needed. Revoke it at any time.' : 'Everyone who opens it has to be signed in to OpenVibe before they can download.'}</p><p><a class="sc-btn sc-primary" href="/s/${out.share.slug}">Open the share page</a> <a class="sc-btn" href="/files/${row.id}">Back to the file</a></p>`,});});@@@ -496,17 +501,28 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)return share;}++ /** Public links (mh_shares.public) open for anyone with them; every other link still needs a signed-in person. */++ const signedInUnlessPublic = (why) => {++ const gate = mustSignIn('here', why);++ return async (req, res, next) => {++ const slug = String(req.params.slug || '');++ const share = rules.isSlug(slug) ? await drive.shareBySlug(slug) : null;++ if (share && share.public === true) return next();++ return gate(req, res, next);++ };++ };++// A single-file share's picture, for whoever may open the share (the same checks as the page; not a download).-- r.get('/s/:slug/preview', mustSignIn('here', 'A share link still needs an OpenVibe account.'), async (req, res) => {++ r.get('/s/:slug/preview', signedInUnlessPublic('A share link still needs an OpenVibe account.'), async (req, res) => {const share = await openShare(req, res);if (!share) return undefined;return sendPreview(req, res, share.file_id ? await fileById(share) : null);});-- r.get('/s/:slug', mustSignIn('here', 'A share link still needs an OpenVibe account: uploads, downloads and reports here all belong to a signed-in person.'), async (req, res) => {++ r.get('/s/:slug', signedInUnlessPublic('This share link needs an OpenVibe account: its owner shared it with signed-in people only.'), async (req, res) => {const share = await openShare(req, res);if (!share) return undefined;-- const mine = share.owner === requesterOf(req);++ const mine = signedIn(req) && share.owner === requesterOf(req);if (share.file_id) {const file = await fileById(share);if (!file) return shareGone(req, res, share);@@@ -514,7 +530,7 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)title: `${file.name} · shared by ${share.owner_username || 'an OpenVibe member'}`,crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],body: html`<h1>${file.name}</h1>--${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}++${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}${rules.isPreviewable(file.content_type) ? html`<figure class="file-preview"><img src="/s/${share.slug}/preview?w=640" alt="${file.name}" decoding="async"></figure>` : ''}${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>@@@ -528,7 +544,7 @@ ${mine ? '' : reportForm(share)}title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder' }],body: html`<h1>${folder ? folder.name : 'A folder'}</h1>--${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}++${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)}</p>${files.length ? html`<ul class="file-list">${files.map((f) => html`<li class="file"><span class="file-main"><span class="file-name">${f.name}</span><span class="file-meta small muted">${fileSize(f)} · ${f.content_type}</span></span><a class="sc-btn" href="/s/${share.slug}/f/${f.id}/download">Download</a></li>`)}</ul>` : html`<p class="muted">The folder is empty.</p>`}${mine ? '' : reportForm(share)}`,@@@ -555,7 +571,7 @@ ${mine ? '' : reportForm(share)}`,</form><p class="small muted">${REPORT_NOTE}</p></section>`;-- r.get('/s/:slug/download', mustSignIn('here', 'A share link still needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {++ r.get('/s/:slug/download', signedInUnlessPublic('This share link needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {const share = await openShare(req, res);if (!share) return undefined;const file = share.file_id ? await fileById(share) : null;@@@ -564,7 +580,7 @@ ${mine ? '' : reportForm(share)}`,return sendFile(req, res, file);});-- r.get('/s/:slug/f/:fileId/download', mustSignIn('here', 'A share link still needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {++ r.get('/s/:slug/f/:fileId/download', signedInUnlessPublic('This share link needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {const share = await openShare(req, res);if (!share) return undefined;if (!share.folder_id) return res.redirect(303, `/s/${share.slug}`); // a file share has no files inside it@@@ -581,7 +597,7 @@ ${mine ? '' : reportForm(share)}`,return sendFile(req, res, file);});-- r.post('/s/:slug/report', mustSignIn('here'), async (req, res) => {++ r.post('/s/:slug/report', signedInUnlessPublic('Sign in to report this link.'), async (req, res) => {if (!sameSite(req, res, '/s')) return undefined;const slug = String(req.params.slug || '');const reason = String((req.body || {}).reason || '');@@@ -593,7 +609,7 @@ ${mine ? '' : reportForm(share)}`,body: html`<h1>That report was not sent</h1>${notice('Pick one of the reasons on the list.', 'warn')}<p><a class="sc-btn" href="/s/${slug}">Back to the share</a></p>`,}, 422);}-- const out = await drive.report(slug, { reporter: requesterOf(req), reason, note });++ const out = await drive.report(slug, { reporter: signedIn(req) ? requesterOf(req) : ctx.pics.addressId(req.ip), reason, note });if (out.error === 'share.own') {return page(req, res, {title: 'That is your share',@@@ -27,10 +27,11 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');assert.strictEqual((await t.get('/safety')).status, 200);});-- await check('the home page says what this is, the rules, and that everything needs an account', async () => {++ await check('the home page says what this is, the rules, the free space, and that uploading needs an account', async () => {const home = await t.get('/');assert.ok(home.text.includes('private drive'), 'it says what it is');-- assert.ok(home.text.includes('no anonymous upload'), 'and that nothing is anonymous');++ assert.ok(home.text.includes('Uploading needs a signed-in OpenVibe account'), 'uploads are account-only');++ assert.ok(home.text.includes('25 GB free') && home.text.includes('public download page'), 'the free space and the public links');assert.ok(home.text.includes('Sign in with OpenVibe'), 'a way in');assert.ok(home.text.includes('/safety'), 'a link to the rules');const safety = await t.get('/safety');nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * Public share links (migrations/0007_public_shares.sql; the owner's Q10, MediaFire-style): a share marked public opens++ * and downloads for anyone with the link, signed in or not; it cannot also name accounts; a signed-out visitor can++ * report it (counted by a keyed hash of their address); reports still suspend it; an ordinary link still needs an++ * account. Free accounts hold 25 GB with files up to 2 GB.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, upload, share } = require('./helpers/drive');++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++ try {++ const file = (await upload(t, kim, bytes(500, 'public'), { name: 'notes.txt', contentType: 'text/plain' })).json().file;++++ await check('a public link opens and downloads with no account; an ordinary link still asks to sign in', async () => {++ const pub = await share(t, kim, { file_id: file.id, public: true, hours: 24 * 30 });++ assert.strictEqual(pub.status, 201, pub.text);++ assert.strictEqual(pub.json().share.public, true);++ const slug = pub.json().share.slug;++ const page = await t.get(`/s/${slug}`);++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));++ assert.ok(page.text.includes('notes.txt') && page.text.includes('Shared publicly'));++ const dl = await t.get(`/s/${slug}/download`);++ assert.strictEqual(dl.status, 200);++ assert.ok(dl.buffer.equals(bytes(500, 'public')), 'the file, to anyone with the link');++++ const plain = (await share(t, kim, { file_id: file.id })).json().share.slug;++ assert.strictEqual((await t.get(`/s/${plain}`)).status, 401, 'not public: sign in first');++ assert.strictEqual((await t.get(`/s/${plain}/download`)).status, 401);++ });++++ await check('public and named accounts do not mix', async () => {++ const r = await share(t, kim, { file_id: file.id, public: true, usernames: ['lee'] });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.json().code, 'share.invalid');++ });++++ await check('a signed-out visitor can report a public link; three reports suspend it', async () => {++ const slug = (await share(t, kim, { file_id: file.id, public: true })).json().share.slug;++ const report = (ip) => t.get(`/s/${slug}/report`, { method: 'POST', form: { reason: 'other', note: '' }, headers: { ...SAME, 'x-forwarded-for': ip } });++ assert.strictEqual((await report('203.0.113.1')).status, 200);++ await report('203.0.113.1');++ await report('203.0.113.2');++ await t.get(`/s/${slug}/report`, { as: lee, method: 'POST', form: { reason: 'other', note: '' }, headers: SAME });++ assert.strictEqual((await t.get(`/s/${slug}/download`)).status, 403, 'suspended until staff look');++ const rows = await t.ctx.s.db.many('SELECT reporter FROM mh_reports WHERE slug = $1', [slug]);++ assert.strictEqual(rows.length, 3, 'one report per address or person');++ assert.ok(rows.every((x) => !x.reporter.includes('203.0.113')), 'addresses are never stored');++ });++++ await check('free accounts: 25 GB, files up to 2 GB', () => {++ assert.strictEqual(t.config.quotas.bytes, 25 * 1024 ** 3);++ assert.strictEqual(t.config.quotas.maxFileBytes, 2 * 1024 ** 3);++ });++++ await check('the share form offers the public option', async () => {++ const r = await t.get(`/files/${file.id}`, { as: kim });++ assert.ok(r.text.includes('name="who" value="public"') && r.text.includes('no account needed'));++ const made = await t.get(`/files/${file.id}/share`, { as: kim, method: 'POST', form: { who: 'public', hours: '72', usernames: '' }, headers: SAME });++ assert.strictEqual(made.status, 200);++ assert.ok(made.text.includes('no account needed'));++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -39,9 +39,9 @@ const HOUR = 3_600_000;assert.strictEqual(s.expires_at, new Date(clock + 24 * HOUR).toISOString());assert.strictEqual((await share(t, kim, { file_id: file.id })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString(), 'seven days by default');-- // The clamp: an hour is the shortest, a week the longest.++ // The clamp: an hour is the shortest, thirty days the longest (nothing means the default, a week).assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 0 })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString());-- assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 999 })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString());++ assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 9999 })).json().share.expires_at, new Date(clock + 30 * 24 * HOUR).toISOString());// A file that is not yours has no share.const sams = await put(sam, 'sams.bin');
@@ -39,8 +39,8 @@ MEDIAHUB_MEDIA_APP=media-hub
@@ -39,8 +39,8 @@ MEDIAHUB_MEDIA_APP=media-hub
#MEDIAHUB_MEDIA_TIMEOUT_MS=30000
#MEDIAHUB_MEDIA_TIMEOUT_MS=30000
# ── Per person (Media's quotas are per tenant, so these are enforced here) ──
# ── Per person (Media's quotas are per tenant, so these are enforced here) ──
-#MEDIAHUB_QUOTA_BYTES=2147483648
-#MEDIAHUB_MAX_FILE_BYTES=536870912
+#MEDIAHUB_QUOTA_BYTES=26843545600
+#MEDIAHUB_MAX_FILE_BYTES=2147483648
#MEDIAHUB_UPLOADS_PER_DAY=50
#MEDIAHUB_UPLOADS_PER_DAY=50
#MEDIAHUB_MAX_SHARES=20
#MEDIAHUB_MAX_SHARES=20
# The most a plain <input type=file> form may post in one request; keep it in step with client_max_body_size for
# The most a plain <input type=file> form may post in one request; keep it in step with client_max_body_size for
@@ -92,10 +92,12 @@ OpenVibe.Download v1 is a **private drive**, not a public host:
@@ -92,10 +92,12 @@ 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. 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 7 days (7 by default), can be revoked,
- counts downloads, and **needs the downloader to be signed in**. It may be limited to named OpenVibe accounts. The names
- are resolved through OpenVibe.Network to account subjects, and only subjects are matched, because a username can
- change hands. A name no account has is refused.
+- 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**
+ OpenVibe accounts, or **public** (`public: true`, migration `0007_public_shares.sql`; the owner's Q10, MediaFire-style):
+ its page and its downloads open for anyone with the link, no account needed. Named accounts are resolved through
+ OpenVibe.Network to account subjects, and only subjects are matched, because a username can change hands; a name no
+ account has is refused, and a public link cannot also name accounts.
- Every share page has a Report form (malware, illegal content, copyright, something else). Three distinct reports,
- Every share page has a Report form (malware, illegal content, copyright, something else). Three distinct reports,
or any report of illegal content, suspend the share at once. Staff (Network role `admin` or `global_mod`) then
or any report of illegal content, suspend the share at once. Staff (Network role `admin` or `global_mod`) then
restore it, or remove the file, which also deletes the object in Media.
restore it, or remove the file, which also deletes the object in Media.
@@ -109,7 +111,8 @@ OpenVibe.Download v1 is a **private drive**, not a public host:
@@ -109,7 +111,8 @@ OpenVibe.Download v1 is a **private drive**, not a public host:
- OpenVibe.Media makes it as a signed WebP variant of the private object, and MediaHub passes it through: `nosniff`,
- OpenVibe.Media makes it as a signed WebP variant of the private object, and MediaHub passes it through: `nosniff`,
a sandbox CSP, `private, max-age=300`, never an attachment and never counted as a download.
a sandbox CSP, `private, max-age=300`, never an attachment and never counted as a download.
- Only the owner gets one, or whoever may open the share. Other files show their extension.
- Only the owner gets one, or whoever may open the share. Other files show their extension.
-- Per-person limits, all from the environment: 2 GB stored, 512 MB per file, 50 uploads a day, 20 live share links.
+- Per-person limits, all from the environment: 25 GB stored and 2 GB per file (the owner's Q10 for free accounts), 50
+ uploads a day, 20 live share links.
## How it works
## How it works
@@ -390,8 +393,9 @@ Uploads stream through nginx unbuffered; downloads are not buffered either.
@@ -390,8 +393,9 @@ Uploads stream through nginx unbuffered; downloads are not buffered either.
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
-- Uploads are account-only, files are private, and share links need a signed-in downloader. Every download is
- attributable, and none renders as a page.
+- Uploads are account-only and files are private. A share link needs a signed-in downloader unless its owner made it
+ public; a public link's page takes reports from anyone (a signed-out reporter is a keyed hash of the address, never
+ the address), and three reports, or one of illegal content, suspend it. No download renders as a page.
- The form reader streams files to disk. A text field over 64 KB, a second file, or a body cut off before its last
- The form reader streams files to disk. A text field over 64 KB, a second file, or a body cut off before its last
boundary is refused.
boundary is refused.
- Session tokens are httpOnly cookies. Secrets live only in the env file, and no secret or request body is logged.
- Session tokens are httpOnly cookies. Secrets live only in the env file, and no secret or request body is logged.
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- OpenVibe.Download: public share links (MediaFire-style, the owner's Q10). A share marked public opens and downloads
+-- for anyone with the link, signed in or not; it cannot also name accounts. Reports on it may come from people
+-- without an account, counted by a keyed hash of their address (never the address).
+ALTER TABLE mh_shares ADD COLUMN IF NOT EXISTS public boolean NOT NULL DEFAULT false;
@@ -95,8 +95,8 @@ function load(env = process.env) {
@@ -95,8 +95,8 @@ function load(env = process.env) {
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
// tenant), so the per-person numbers are enforced in MediaHub and nowhere else.
// tenant), so the per-person numbers are enforced in MediaHub and nowhere else.
quotas: {
quotas: {
- bytes: size(env.MEDIAHUB_QUOTA_BYTES, 2 * GB), // 2 GB stored
- maxFileBytes: size(env.MEDIAHUB_MAX_FILE_BYTES, 512 * MB), // 512 MB per file
+ 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)
uploadsPerDay: size(env.MEDIAHUB_UPLOADS_PER_DAY, 50), // 50 uploads a day (UTC)
maxShares: size(env.MEDIAHUB_MAX_SHARES, 20), // 20 live share links
maxShares: size(env.MEDIAHUB_MAX_SHARES, 20), // 20 live share links
},
},
@@ -45,7 +45,7 @@ const SUSPEND_AT_REPORTS = 3;
@@ -45,7 +45,7 @@ const SUSPEND_AT_REPORTS = 3;
const SUSPEND_IMMEDIATELY = ['illegal'];
const SUSPEND_IMMEDIATELY = ['illegal'];
const EXPIRY_MIN_HOURS = 1;
const EXPIRY_MIN_HOURS = 1;
-const EXPIRY_MAX_HOURS = 7 * 24;
+const EXPIRY_MAX_HOURS = 30 * 24;
const EXPIRY_DEFAULT_HOURS = 7 * 24;
const EXPIRY_DEFAULT_HOURS = 7 * 24;
/** The declared content type, normalised, or null when it is not a media type at all. */
/** The declared content type, normalised, or null when it is not a media type at all. */
@@ -128,6 +128,7 @@ const EXPIRY_CHOICES = [
@@ -128,6 +128,7 @@ const EXPIRY_CHOICES = [
{ hours: 24, label: '1 day' },
{ hours: 24, label: '1 day' },
{ hours: 72, label: '3 days' },
{ hours: 72, label: '3 days' },
{ hours: 24 * 7, label: '7 days' },
{ hours: 24 * 7, label: '7 days' },
+ { hours: 24 * 30, label: '30 days' },
];
];
/** "1.4 GB" — how a size is shown. Binary units, one decimal from MB up. */
/** "1.4 GB" — how a size is shown. Binary units, one decimal from MB up. */
@@ -180,7 +180,7 @@ function createService({ s, media, identity, config, log = console }) {
@@ -180,7 +180,7 @@ function createService({ s, media, identity, config, log = console }) {
* each is asked of Network's identity resolver, and the ones it cannot resolve are kept as typed and matched
* each is asked of Network's identity resolver, and the ones it cannot resolve are kept as typed and matched
* against a viewer's username claim later. No names at all = any signed-in person with the link.
* against a viewer's username claim later. No names at all = any signed-in person with the link.
*/
*/
- async function createShare(owner, { fileId = null, folderId = null, hours, names = [], ownerUsername = null }) {
+ async function createShare(owner, { fileId = null, folderId = null, hours, names = [], ownerUsername = null, isPublic = false }) {
const room = await shareAllowed(owner);
const room = await shareAllowed(owner);
if (!room.ok) return room;
if (!room.ok) return room;
if (fileId && !await ownFile(owner, fileId)) return { ok: false, code: 'file.not_found', detail: 'No such file.' };
if (fileId && !await ownFile(owner, fileId)) return { ok: false, code: 'file.not_found', detail: 'No such file.' };
@@ -188,6 +188,7 @@ function createService({ s, media, identity, config, log = console }) {
@@ -188,6 +188,7 @@ function createService({ s, media, identity, config, log = console }) {
if (!fileId && !folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder.' };
if (!fileId && !folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder.' };
if (fileId && folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder, not both.' };
if (fileId && folderId) return { ok: false, code: 'share.invalid', detail: 'A share names one file or one folder, not both.' };
const wanted = [...new Set(names.map((n) => String(n || '').trim().toLowerCase()).filter(Boolean))];
const wanted = [...new Set(names.map((n) => String(n || '').trim().toLowerCase()).filter(Boolean))];
+ if (isPublic && wanted.length) return { ok: false, code: 'share.invalid', detail: 'A public link is for anyone with it, so it cannot also name accounts. Choose one.' };
for (const n of wanted) {
for (const n of wanted) {
if (!rules.isUsername(n)) return { ok: false, code: 'share.bad_username', detail: `"${rules.cleanName(n, 40)}" is not an OpenVibe username.` };
if (!rules.isUsername(n)) return { ok: false, code: 'share.bad_username', detail: `"${rules.cleanName(n, 40)}" is not an OpenVibe username.` };
}
}
@@ -199,7 +200,7 @@ function createService({ s, media, identity, config, log = console }) {
@@ -199,7 +200,7 @@ function createService({ s, media, identity, config, log = console }) {
const expiresAt = new Date(s.now() + rules.expiryHours(hours) * 3_600_000).toISOString();
const expiresAt = new Date(s.now() + rules.expiryHours(hours) * 3_600_000).toISOString();
const share = await store.insertShare(s, {
const share = await store.insertShare(s, {
slug: newSlug(), file_id: fileId || null, folder_id: folderId || null, owner,
slug: newSlug(), file_id: fileId || null, folder_id: folderId || null, owner,
- owner_username: ownerUsername, expires_at: expiresAt, allowed_subjects: allowed, created_at: nowIso(),
+ owner_username: ownerUsername, expires_at: expiresAt, allowed_subjects: allowed, created_at: nowIso(), public: isPublic === true,
});
});
return { ok: true, share, unresolved: resolved.unresolved };
return { ok: true, share, unresolved: resolved.unresolved };
}
}
@@ -68,10 +68,10 @@ const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE i
@@ -68,10 +68,10 @@ const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE i
async function insertShare(s, row) {
async function insertShare(s, row) {
await s.db.query(
await s.db.query(
- `INSERT INTO ${SHARES} (slug, file_id, folder_id, owner, owner_username, expires_at, allowed_subjects, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)`,
+ `INSERT INTO ${SHARES} (slug, file_id, folder_id, owner, owner_username, expires_at, allowed_subjects, created_at, public)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)`,
[row.slug, row.file_id, row.folder_id, row.owner, row.owner_username || null, row.expires_at,
[row.slug, row.file_id, row.folder_id, row.owner, row.owner_username || null, row.expires_at,
- row.allowed_subjects ? JSON.stringify(row.allowed_subjects) : null, row.created_at]);
+ row.allowed_subjects ? JSON.stringify(row.allowed_subjects) : null, row.created_at, row.public === true]);
return getShare(s, row.slug);
return getShare(s, row.slug);
}
}
@@ -127,6 +127,7 @@ function createApi(ctx) {
@@ -127,6 +127,7 @@ function createApi(ctx) {
revoked_at: row.revoked_at, suspended_at: row.suspended_at, created_at: row.created_at,
revoked_at: row.revoked_at, suspended_at: row.suspended_at, created_at: row.created_at,
allowed: drive.parseAllow(row.allowed_subjects),
allowed: drive.parseAllow(row.allowed_subjects),
limited: Boolean(row.allowed_subjects),
limited: Boolean(row.allowed_subjects),
+ public: row.public === true,
});
});
// ── Liveness ────────────────────────────────────────────
// ── Liveness ────────────────────────────────────────────
@@ -206,7 +207,7 @@ function createApi(ctx) {
@@ -206,7 +207,7 @@ function createApi(ctx) {
const usernames = Array.isArray(b.usernames) ? b.usernames
const usernames = Array.isArray(b.usernames) ? b.usernames
: (typeof b.usernames === 'string' && b.usernames.trim() ? b.usernames.split(/[\s,]+/) : []);
: (typeof b.usernames === 'string' && b.usernames.trim() ? b.usernames.split(/[\s,]+/) : []);
const out = await drive.createShare(personOf(req), {
const out = await drive.createShare(personOf(req), {
- fileId, folderId, hours: b.hours, names: usernames, ownerUsername: req.principal.username,
+ fileId, folderId, hours: b.hours, names: usernames, ownerUsername: req.principal.username, isPublic: b.public === true,
});
});
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/shares/${out.share.slug}`);
res.set('Location', `/api/v1/shares/${out.share.slug}`);
@@ -151,7 +151,7 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -151,7 +151,7 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
eyebrow: `${SITE_NAME} · a private drive`,
eyebrow: `${SITE_NAME} · a private drive`,
title: 'Your files,',
title: 'Your files,',
accent: 'shared on your terms.',
accent: 'shared on your terms.',
- lede: 'A private drive for your files, with share links you control. Uploads and every file page need a signed-in OpenVibe account — there is no anonymous upload and no anonymous download here.',
+ lede: 'A private drive for your files, 25 GB free, with share links you control: for named people, for anyone signed in, or a public download page anyone with the link can use. Uploading needs a signed-in OpenVibe account.',
actions: signedIn(req)
actions: signedIn(req)
? [{ label: 'Your files', href: '/files', primary: true }, { label: 'The safety rules', href: '/safety' }]
? [{ label: 'Your files', href: '/files', primary: true }, { label: 'The safety rules', href: '/safety' }]
: [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'The safety rules', href: '/safety' }],
: [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'The safety rules', href: '/safety' }],
@@ -257,7 +257,11 @@ ${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type,
@@ -257,7 +257,11 @@ ${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type,
<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2>
<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2>
<form method="post" action="/files/${row.id}/share">
<form method="post" action="/files/${row.id}/share">
<div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>
<div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>
-<div class="field"><label for="usernames">Limited to OpenVibe usernames (optional)</label><input id="usernames" name="usernames" type="text" maxlength="300" placeholder="kim, sam"><p class="small muted">Leave empty for any signed-in person with the link. Everyone who opens it still has to be signed in.</p></div>
+<fieldset class="field"><legend>Who can open it</legend>
+<label class="check"><input type="radio" name="who" value="signed_in" checked> Anyone signed in to OpenVibe with the link</label>
+<label class="check"><input type="radio" name="who" value="public"> Anyone with the link, no account needed (a public download page)</label>
+<label class="check"><input type="radio" name="who" value="named"> Only these OpenVibe usernames:</label>
+<input id="usernames" name="usernames" type="text" maxlength="300" placeholder="kim, sam" aria-label="OpenVibe usernames"></fieldset>
<p><button class="sc-btn sc-primary" type="submit">Create the link</button></p>
<p><button class="sc-btn sc-primary" type="submit">Create the link</button></p>
</form>
</form>
${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh) => html`<li class="file">${shareBadge(drive.stateOf(sh, s.iso()))} <a href="/s/${sh.slug}">/s/${sh.slug}</a> <span class="small muted">expires ${time(sh.expires_at)} · ${Number(sh.downloads)} downloads</span></li>`)}</ul>` : ''}
${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh) => html`<li class="file">${shareBadge(drive.stateOf(sh, s.iso()))} <a href="/s/${sh.slug}">/s/${sh.slug}</a> <span class="small muted">expires ${time(sh.expires_at)} · ${Number(sh.downloads)} downloads</span></li>`)}</ul>` : ''}
@@ -337,9 +341,10 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
@@ -337,9 +341,10 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
const owner = requesterOf(req);
const owner = requesterOf(req);
const row = await drive.ownFile(owner, String(req.params.id || ''));
const row = await drive.ownFile(owner, String(req.params.id || ''));
if (!row) return notFoundFile(req, res);
if (!row) return notFoundFile(req, res);
- const names = String((req.body || {}).usernames || '').split(/[\s,]+/).filter(Boolean).slice(0, 50);
+ const who = String((req.body || {}).who || '');
+ const names = who === 'public' ? [] : String((req.body || {}).usernames || '').split(/[\s,]+/).filter(Boolean).slice(0, 50);
const out = await drive.createShare(owner, {
const out = await drive.createShare(owner, {
- fileId: row.id, hours: (req.body || {}).hours, names, ownerUsername: req.viewer.username || null,
+ fileId: row.id, hours: (req.body || {}).hours, names, ownerUsername: req.viewer.username || null, isPublic: who === 'public',
});
});
if (!out.ok) {
if (!out.ok) {
return page(req, res, {
return page(req, res, {
@@ -354,7 +359,7 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
@@ -354,7 +359,7 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
body: html`<h1>Share link made</h1>
body: html`<h1>Share link made</h1>
<p>The link is <a href="/s/${out.share.slug}"><code>/s/${out.share.slug}</code></a> — it expires ${time(out.share.expires_at)}${out.share.allowed_subjects ? ' and only the people you named can open it' : ''}.</p>
<p>The link is <a href="/s/${out.share.slug}"><code>/s/${out.share.slug}</code></a> — it expires ${time(out.share.expires_at)}${out.share.allowed_subjects ? ' and only the people you named can open it' : ''}.</p>
-<p class="muted">Everyone who opens it has to be signed in to OpenVibe before they can download.</p>
+<p class="muted">${out.share.public ? 'Anyone with the link can open the page and download, no account needed. Revoke it at any time.' : 'Everyone who opens it has to be signed in to OpenVibe before they can download.'}</p>
<p><a class="sc-btn sc-primary" href="/s/${out.share.slug}">Open the share page</a> <a class="sc-btn" href="/files/${row.id}">Back to the file</a></p>`,
<p><a class="sc-btn sc-primary" href="/s/${out.share.slug}">Open the share page</a> <a class="sc-btn" href="/files/${row.id}">Back to the file</a></p>`,
});
});
});
});
@@ -496,17 +501,28 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
@@ -496,17 +501,28 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
return share;
return share;
}
}
+ /** Public links (mh_shares.public) open for anyone with them; every other link still needs a signed-in person. */
+ const signedInUnlessPublic = (why) => {
+ const gate = mustSignIn('here', why);
+ return async (req, res, next) => {
+ const slug = String(req.params.slug || '');
+ const share = rules.isSlug(slug) ? await drive.shareBySlug(slug) : null;
+ if (share && share.public === true) return next();
+ return gate(req, res, next);
+ };
+ };
+
// A single-file share's picture, for whoever may open the share (the same checks as the page; not a download).
// A single-file share's picture, for whoever may open the share (the same checks as the page; not a download).
- r.get('/s/:slug/preview', mustSignIn('here', 'A share link still needs an OpenVibe account.'), async (req, res) => {
+ r.get('/s/:slug/preview', signedInUnlessPublic('A share link still needs an OpenVibe account.'), async (req, res) => {
const share = await openShare(req, res);
const share = await openShare(req, res);
if (!share) return undefined;
if (!share) return undefined;
return sendPreview(req, res, share.file_id ? await fileById(share) : null);
return sendPreview(req, res, share.file_id ? await fileById(share) : null);
});
});
- r.get('/s/:slug', mustSignIn('here', 'A share link still needs an OpenVibe account: uploads, downloads and reports here all belong to a signed-in person.'), async (req, res) => {
+ r.get('/s/:slug', signedInUnlessPublic('This share link needs an OpenVibe account: its owner shared it with signed-in people only.'), async (req, res) => {
const share = await openShare(req, res);
const share = await openShare(req, res);
if (!share) return undefined;
if (!share) return undefined;
- const mine = share.owner === requesterOf(req);
+ const mine = signedIn(req) && share.owner === requesterOf(req);
if (share.file_id) {
if (share.file_id) {
const file = await fileById(share);
const file = await fileById(share);
if (!file) return shareGone(req, res, share);
if (!file) return shareGone(req, res, share);
@@ -514,7 +530,7 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
@@ -514,7 +530,7 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
title: `${file.name} · shared by ${share.owner_username || 'an OpenVibe member'}`,
title: `${file.name} · shared by ${share.owner_username || 'an OpenVibe member'}`,
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],
body: html`<h1>${file.name}</h1>
body: html`<h1>${file.name}</h1>
-${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
+${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
${rules.isPreviewable(file.content_type) ? html`<figure class="file-preview"><img src="/s/${share.slug}/preview?w=640" alt="${file.name}" decoding="async"></figure>` : ''}
${rules.isPreviewable(file.content_type) ? html`<figure class="file-preview"><img src="/s/${share.slug}/preview?w=640" alt="${file.name}" decoding="async"></figure>` : ''}
${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}
${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}
<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>
<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>
@@ -528,7 +544,7 @@ ${mine ? '' : reportForm(share)}
@@ -528,7 +544,7 @@ ${mine ? '' : reportForm(share)}
title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,
title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder' }],
body: html`<h1>${folder ? folder.name : 'A folder'}</h1>
body: html`<h1>${folder ? folder.name : 'A folder'}</h1>
-${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
+${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)}</p>
<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)}</p>
${files.length ? html`<ul class="file-list">${files.map((f) => html`<li class="file"><span class="file-main"><span class="file-name">${f.name}</span><span class="file-meta small muted">${fileSize(f)} · ${f.content_type}</span></span><a class="sc-btn" href="/s/${share.slug}/f/${f.id}/download">Download</a></li>`)}</ul>` : html`<p class="muted">The folder is empty.</p>`}
${files.length ? html`<ul class="file-list">${files.map((f) => html`<li class="file"><span class="file-main"><span class="file-name">${f.name}</span><span class="file-meta small muted">${fileSize(f)} · ${f.content_type}</span></span><a class="sc-btn" href="/s/${share.slug}/f/${f.id}/download">Download</a></li>`)}</ul>` : html`<p class="muted">The folder is empty.</p>`}
${mine ? '' : reportForm(share)}`,
${mine ? '' : reportForm(share)}`,
@@ -555,7 +571,7 @@ ${mine ? '' : reportForm(share)}`,
@@ -555,7 +571,7 @@ ${mine ? '' : reportForm(share)}`,
</form>
</form>
<p class="small muted">${REPORT_NOTE}</p></section>`;
<p class="small muted">${REPORT_NOTE}</p></section>`;
- r.get('/s/:slug/download', mustSignIn('here', 'A share link still needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {
+ r.get('/s/:slug/download', signedInUnlessPublic('This share link needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {
const share = await openShare(req, res);
const share = await openShare(req, res);
if (!share) return undefined;
if (!share) return undefined;
const file = share.file_id ? await fileById(share) : null;
const file = share.file_id ? await fileById(share) : null;
@@ -564,7 +580,7 @@ ${mine ? '' : reportForm(share)}`,
@@ -564,7 +580,7 @@ ${mine ? '' : reportForm(share)}`,
return sendFile(req, res, file);
return sendFile(req, res, file);
});
});
- r.get('/s/:slug/f/:fileId/download', mustSignIn('here', 'A share link still needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {
+ r.get('/s/:slug/f/:fileId/download', signedInUnlessPublic('This share link needs an OpenVibe account before anything can be downloaded.'), async (req, res) => {
const share = await openShare(req, res);
const share = await openShare(req, res);
if (!share) return undefined;
if (!share) return undefined;
if (!share.folder_id) return res.redirect(303, `/s/${share.slug}`); // a file share has no files inside it
if (!share.folder_id) return res.redirect(303, `/s/${share.slug}`); // a file share has no files inside it
@@ -581,7 +597,7 @@ ${mine ? '' : reportForm(share)}`,
@@ -581,7 +597,7 @@ ${mine ? '' : reportForm(share)}`,
return sendFile(req, res, file);
return sendFile(req, res, file);
});
});
- r.post('/s/:slug/report', mustSignIn('here'), async (req, res) => {
+ r.post('/s/:slug/report', signedInUnlessPublic('Sign in to report this link.'), async (req, res) => {
if (!sameSite(req, res, '/s')) return undefined;
if (!sameSite(req, res, '/s')) return undefined;
const slug = String(req.params.slug || '');
const slug = String(req.params.slug || '');
const reason = String((req.body || {}).reason || '');
const reason = String((req.body || {}).reason || '');
@@ -593,7 +609,7 @@ ${mine ? '' : reportForm(share)}`,
@@ -593,7 +609,7 @@ ${mine ? '' : reportForm(share)}`,
body: html`<h1>That report was not sent</h1>${notice('Pick one of the reasons on the list.', 'warn')}<p><a class="sc-btn" href="/s/${slug}">Back to the share</a></p>`,
body: html`<h1>That report was not sent</h1>${notice('Pick one of the reasons on the list.', 'warn')}<p><a class="sc-btn" href="/s/${slug}">Back to the share</a></p>`,
}, 422);
}, 422);
}
}
- const out = await drive.report(slug, { reporter: requesterOf(req), reason, note });
+ const out = await drive.report(slug, { reporter: signedIn(req) ? requesterOf(req) : ctx.pics.addressId(req.ip), reason, note });
if (out.error === 'share.own') {
if (out.error === 'share.own') {
return page(req, res, {
return page(req, res, {
title: 'That is your share',
title: 'That is your share',
@@ -27,10 +27,11 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
@@ -27,10 +27,11 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
assert.strictEqual((await t.get('/safety')).status, 200);
assert.strictEqual((await t.get('/safety')).status, 200);
});
});
- await check('the home page says what this is, the rules, and that everything needs an account', async () => {
+ await check('the home page says what this is, the rules, the free space, and that uploading needs an account', async () => {
const home = await t.get('/');
const home = await t.get('/');
assert.ok(home.text.includes('private drive'), 'it says what it is');
assert.ok(home.text.includes('private drive'), 'it says what it is');
- assert.ok(home.text.includes('no anonymous upload'), 'and that nothing is anonymous');
+ assert.ok(home.text.includes('Uploading needs a signed-in OpenVibe account'), 'uploads are account-only');
+ assert.ok(home.text.includes('25 GB free') && home.text.includes('public download page'), 'the free space and the public links');
assert.ok(home.text.includes('Sign in with OpenVibe'), 'a way in');
assert.ok(home.text.includes('Sign in with OpenVibe'), 'a way in');
assert.ok(home.text.includes('/safety'), 'a link to the rules');
assert.ok(home.text.includes('/safety'), 'a link to the rules');
const safety = await t.get('/safety');
const safety = await t.get('/safety');
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * Public share links (migrations/0007_public_shares.sql; the owner's Q10, MediaFire-style): a share marked public opens
+ * and downloads for anyone with the link, signed in or not; it cannot also name accounts; a signed-out visitor can
+ * report it (counted by a keyed hash of their address); reports still suspend it; an ordinary link still needs an
+ * account. Free accounts hold 25 GB with files up to 2 GB.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, upload, share } = require('./helpers/drive');
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+ try {
+ const file = (await upload(t, kim, bytes(500, 'public'), { name: 'notes.txt', contentType: 'text/plain' })).json().file;
+
+ await check('a public link opens and downloads with no account; an ordinary link still asks to sign in', async () => {
+ const pub = await share(t, kim, { file_id: file.id, public: true, hours: 24 * 30 });
+ assert.strictEqual(pub.status, 201, pub.text);
+ assert.strictEqual(pub.json().share.public, true);
+ const slug = pub.json().share.slug;
+ const page = await t.get(`/s/${slug}`);
+ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
+ assert.ok(page.text.includes('notes.txt') && page.text.includes('Shared publicly'));
+ const dl = await t.get(`/s/${slug}/download`);
+ assert.strictEqual(dl.status, 200);
+ assert.ok(dl.buffer.equals(bytes(500, 'public')), 'the file, to anyone with the link');
+
+ const plain = (await share(t, kim, { file_id: file.id })).json().share.slug;
+ assert.strictEqual((await t.get(`/s/${plain}`)).status, 401, 'not public: sign in first');
+ assert.strictEqual((await t.get(`/s/${plain}/download`)).status, 401);
+ });
+
+ await check('public and named accounts do not mix', async () => {
+ const r = await share(t, kim, { file_id: file.id, public: true, usernames: ['lee'] });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.json().code, 'share.invalid');
+ });
+
+ await check('a signed-out visitor can report a public link; three reports suspend it', async () => {
+ const slug = (await share(t, kim, { file_id: file.id, public: true })).json().share.slug;
+ const report = (ip) => t.get(`/s/${slug}/report`, { method: 'POST', form: { reason: 'other', note: '' }, headers: { ...SAME, 'x-forwarded-for': ip } });
+ assert.strictEqual((await report('203.0.113.1')).status, 200);
+ await report('203.0.113.1');
+ await report('203.0.113.2');
+ await t.get(`/s/${slug}/report`, { as: lee, method: 'POST', form: { reason: 'other', note: '' }, headers: SAME });
+ assert.strictEqual((await t.get(`/s/${slug}/download`)).status, 403, 'suspended until staff look');
+ const rows = await t.ctx.s.db.many('SELECT reporter FROM mh_reports WHERE slug = $1', [slug]);
+ assert.strictEqual(rows.length, 3, 'one report per address or person');
+ assert.ok(rows.every((x) => !x.reporter.includes('203.0.113')), 'addresses are never stored');
+ });
+
+ await check('free accounts: 25 GB, files up to 2 GB', () => {
+ assert.strictEqual(t.config.quotas.bytes, 25 * 1024 ** 3);
+ assert.strictEqual(t.config.quotas.maxFileBytes, 2 * 1024 ** 3);
+ });
+
+ await check('the share form offers the public option', async () => {
+ const r = await t.get(`/files/${file.id}`, { as: kim });
+ assert.ok(r.text.includes('name="who" value="public"') && r.text.includes('no account needed'));
+ const made = await t.get(`/files/${file.id}/share`, { as: kim, method: 'POST', form: { who: 'public', hours: '72', usernames: '' }, headers: SAME });
+ assert.strictEqual(made.status, 200);
+ assert.ok(made.text.includes('no account needed'));
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -39,9 +39,9 @@ const HOUR = 3_600_000;
@@ -39,9 +39,9 @@ const HOUR = 3_600_000;
assert.strictEqual(s.expires_at, new Date(clock + 24 * HOUR).toISOString());
assert.strictEqual(s.expires_at, new Date(clock + 24 * HOUR).toISOString());
assert.strictEqual((await share(t, kim, { file_id: file.id })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString(), 'seven days by default');
assert.strictEqual((await share(t, kim, { file_id: file.id })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString(), 'seven days by default');
- // The clamp: an hour is the shortest, a week the longest.
+ // The clamp: an hour is the shortest, thirty days the longest (nothing means the default, a week).
assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 0 })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString());
assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 0 })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString());
- assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 999 })).json().share.expires_at, new Date(clock + 7 * 24 * HOUR).toISOString());
+ assert.strictEqual((await share(t, kim, { file_id: file.id, hours: 9999 })).json().share.expires_at, new Date(clock + 30 * 24 * HOUR).toISOString());
// A file that is not yours has no share.
// A file that is not yours has no share.
const sams = await put(sam, 'sams.bin');
const sams = await put(sam, 'sams.bin');