openvibers/openvibe.mediahub Commits

Download: public links (no account to download), 25 GB free, 2 GB files (#66)

OP OpenVibers committed 29 days ago · 154dda0f47 · 12 files, +134 −36

The owner's Q10 for openvibe.download: a MediaFire-style public download page, and free accounts with 25 GB. A share can now be public (migration 0007): its page and downloads open for anyone with the link; it cannot also name accounts; anyone can report it (a signed-out reporter is a keyed hash of the address), and reports still suspend it. Links last up to 30 days. Defaults: 25 GB stored, 2 GB per file. The share form offers who can open it (signed in, public, named); the home says so. Signed-out uploads are not in this change.

Changes

ddiff --git a/.env.example b/.env.example
iindex e551474..b1d0166 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 1e9af2f..7809ad3 100644
---- a/README.md
++++ b/README.md
@@@ -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`) then
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:
- 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 last
boundary is refused.
- Session tokens are httpOnly cookies. Secrets live only in the env file, and no secret or request body is logged.
ddiff --git a/migrations/0007_public_shares.sql b/migrations/0007_public_shares.sql
nnew file mode 100644
iindex 0000000..60ad003
---- /dev/null
++++ b/migrations/0007_public_shares.sql
@@@ -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;
ddiff --git a/server/config.js b/server/config.js
iindex 56b1d9f..b3479a0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 file
uploadsPerDay: size(env.MEDIAHUB_UPLOADS_PER_DAY, 50), // 50 uploads a day (UTC)
maxShares: size(env.MEDIAHUB_MAX_SHARES, 20), // 20 live share links
},
ddiff --git a/server/drive/rules.js b/server/drive/rules.js
iindex 9cd1d33..f7ff0f6 100644
---- a/server/drive/rules.js
++++ b/server/drive/rules.js
@@@ -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. */
ddiff --git a/server/drive/service.js b/server/drive/service.js
iindex 32c4221..28a8815 100644
---- a/server/drive/service.js
++++ b/server/drive/service.js
@@@ -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 };
}
ddiff --git a/server/drive/store.js b/server/drive/store.js
iindex 5963999..65d8bd3 100644
---- a/server/drive/store.js
++++ b/server/drive/store.js
@@@ -68,10 +68,10 @@ const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE i
async function insertShare(s, row) {
await s.db.query(
-- `INSERT INTO ${SHARES} (slug, file_id, folder_id, owner, owner_username, expires_at, allowed_subjects, created_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)`,
++ `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);
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a356b1b..1ab44a7 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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}`);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex dbb5679..4687c68 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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',
ddiff --git a/test/drive-pages.test.js b/test/drive-pages.test.js
iindex d13e31d..a2379c9 100644
---- a/test/drive-pages.test.js
++++ b/test/drive-pages.test.js
@@@ -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');
ddiff --git a/test/drive-public-shares.test.js b/test/drive-public-shares.test.js
nnew file mode 100644
iindex 0000000..0ace540
---- /dev/null
++++ b/test/drive-public-shares.test.js
@@@ -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); });
ddiff --git a/test/drive-shares.test.js b/test/drive-shares.test.js
iindex 429d844..109ab4c 100644
---- a/test/drive-shares.test.js
++++ b/test/drive-shares.test.js
@@@ -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');