openvibers/openvibe.connect Commits

Report a share; staff close it (CON-9 v0) (#11)

OP OpenVibers committed 11 days ago · 3f0bebf3cb · 9 files, +274 −10

Anyone reports a share at /report (linked from the gate's pages, the offline page and the home): its address
(the whole URL works), what is wrong (phishing, malware, illegal, copyright, abuse, other) and what they saw.
From this site only, five an hour per address, no address stored; a signed-in reporter's account is kept so
staff can follow up and goes with them on erasure (the report stays). Staff (Network role admin) read the
open reports at /admin/reports and close the share (its computer is disconnected, every open report on it is
settled, its address says it was closed after a report) or dismiss a report. Migration 0003 adds
share_reports and shares.closed_reason (expand).

Changes

ddiff --git a/README.md b/README.md
iindex 0c21c73..b55eeea 100644
---- a/README.md
++++ b/README.md
@@@ -42,6 +42,11 @@ only ever connects to `localhost` on the port you gave it.
Public shares (no sign-in) come once the abuse check and openvibe.website's entry in the Public Suffix List are in
(ADR-057, CON-9 and CON-21). Changing who can open a share signs everyone out of it.
++Anyone can report a share at [/report](https://connect.openvibe.website/report) (CON-9 v0): its address, what is
++wrong and what they saw. OpenVibe staff read the open reports at `/admin/reports` and can close a share; its computer
++is disconnected and its address then says it was closed after a report. A report keeps the reporter's account only
++when they were signed in, never an address.
++
## API
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
ddiff --git a/migrations/0003_share_reports.sql b/migrations/0003_share_reports.sql
nnew file mode 100644
iindex 0000000..328b310
---- /dev/null
++++ b/migrations/0003_share_reports.sql
@@@ -0,0 +1,20 @@
++-- phase: expand
++-- The abuse gate, v0 (CON-9): anyone can report a share; OpenVibe staff read the reports and close a share. A report
++-- names the share, a reason and what the reporter wrote; the reporter's account when signed in (erased by
++-- anonymizing: the report stays for safety). No address is stored. closed_reason says why a share was closed by
++-- someone other than its owner ('report'); null for every share today.
++CREATE TABLE IF NOT EXISTS share_reports (
++ id text PRIMARY KEY, -- rpt_<random>
++ share_id text NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
++ host text NOT NULL,
++ reason text NOT NULL CHECK (reason IN ('phishing', 'malware', 'illegal', 'copyright', 'abuse', 'other')),
++ details text,
++ reporter_subject text,
++ status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed_share', 'dismissed')),
++ created_at timestamptz NOT NULL DEFAULT now(),
++ handled_by text,
++ handled_at timestamptz
++);
++CREATE INDEX IF NOT EXISTS share_reports_open ON share_reports (created_at DESC) WHERE status = 'open';
++CREATE INDEX IF NOT EXISTS share_reports_reporter ON share_reports (reporter_subject) WHERE reporter_subject IS NOT NULL;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS closed_reason text;
ddiff --git a/public/css/connect.css b/public/css/connect.css
iindex 74bc3f1..75db2d8 100644
---- a/public/css/connect.css
++++ b/public/css/connect.css
@@@ -165,3 +165,13 @@
.cn .sc-room-pic { position: static; z-index: auto; width: auto; opacity: 1; pointer-events: auto; margin-top: 4px; }
.cn .cn-motif { width: 100%; }
}
++/* Reports (CON-9 v0): the staff queue. */
++.cn-reports { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.cn-report { padding: 14px 16px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); display: grid; gap: 6px; }
++.cn-report-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; }
++.cn-report-head a { overflow-wrap: anywhere; }
++.cn-report-head span, .cn-report p { color: var(--ovk-muted); font-size: 14px; margin: 0; }
++.cn-report blockquote { margin: 0; padding: 8px 12px; border-left: 3px solid var(--ovk-line); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; }
++.cn-report-acts { display: flex; flex-wrap: wrap; gap: 8px; }
++.cn-field textarea { box-sizing: border-box; width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 16px; resize: vertical; }
++
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex f0e87b6..cffdeb1 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -153,7 +153,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
}
// ── Pages the gate answers itself (self-contained: a share host loads nothing from anywhere else) ──
-- function sendPage(res, status, title, text, { link = null, headers = {}, refresh = 0, note = '' } = {}) {
++ function sendPage(res, status, title, text, { link = null, headers = {}, refresh = 0, note = '', report = '' } = {}) {
if (res.headersSent) { res.destroy(); return; }
res.writeHead(status, {
'content-type': 'text/html; charset=utf-8',
@@@ -165,7 +165,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
'referrer-policy': 'no-referrer',
...headers,
});
-- res.end(page(title, text, link, { refresh, note }));
++ res.end(page(title, text, link, { refresh, note, report }));
}
function wantsHtml(req) { return /text\/html/.test(String(req.headers.accept || '')) && (req.method === 'GET' || req.method === 'HEAD'); }
function sendProblem(req, res, status, code, title, text, extra = {}) {
@@@ -196,6 +196,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
sendProblem(req, res, 503, 'offline', `${share.owner_username}'s computer is not connected`, `Nothing is connected to ${share.host} right now.${last} If it is yours, start ov-connect on your computer.`, {
link: { href: `${config.baseUrl}/shares/${share.id}`, text: 'Open the share page' }, headers: { 'retry-after': '10' },
refresh: 10, note: 'This page asks again every 10 seconds and opens the app as soon as the computer is back.',
++ report: `${config.baseUrl}/report?host=${encodeURIComponent(share.host)}`,
});
return null;
}
@@@ -216,6 +217,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
// Its time is up: closed now, even before the sweep (app.js) marks it.
const expired = share.expired || (share.status === 'active' && share.expires_at && Date.parse(share.expires_at) <= now());
if (expired) { sendProblem(req, res, 410, 'expired', 'This share has ended', `${share.host} was shared until ${new Date(share.expires_at).toUTCString().replace(' GMT', ' UTC')}, and that time is up.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
++ if (share.status !== 'active' && share.closed_reason === 'report') { sendProblem(req, res, 410, 'closed', 'This share was closed', `OpenVibe closed ${share.host} after a report.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
return share;
}
@@@ -409,7 +411,7 @@ function ago(iso, nowMs = Date.now()) {
* The gate's own page: plain, readable in both themes, no outside resources. `refresh` (seconds) asks again by itself:
* a meta refresh, so it works under the page's no-script CSP.
*/
--function page(title, text, link, { refresh = 0, note = '' } = {}) {
++function page(title, text, link, { refresh = 0, note = '', report = '' } = {}) {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
${refresh ? `<meta http-equiv="refresh" content="${Number(refresh) | 0}">` : ''}<meta name="robots" content="noindex"><title>${escapeHtml(title)} · OpenVibe Connect</title>
<style>:root{color-scheme:light dark;--bg:#f6f7fb;--fg:#141824;--muted:#5b6275;--card:#fff;--line:#dfe3ee;--accent:#4B53C2}
@@@ -418,8 +420,8 @@ body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--
main{max-width:30rem;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:28px}
h1{font-size:1.35rem;margin:0 0 .5rem;text-wrap:balance}p{margin:0 0 1rem;color:var(--muted)}
a{display:inline-block;color:var(--accent);font-weight:600;text-decoration:none;padding:10px 0;min-height:24px}a:hover{text-decoration:underline}
--.mark{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:12px}.note{font-size:.9rem;padding:10px 12px;border-radius:10px;border:1px solid var(--line)}</style></head>
--<body><main><div class="mark">OpenVibe Connect</div><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${note ? `<p class="note">${escapeHtml(note)}</p>` : ''}${link ? `<a href="${escapeHtml(link.href)}">${escapeHtml(link.text)}</a>` : ''}</main></body></html>`;
++.mark{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:12px}.note{font-size:.9rem;padding:10px 12px;border-radius:10px;border:1px solid var(--line)}.report{margin:0;font-size:.85rem}.report a{font-weight:500;color:var(--muted)}</style></head>
++<body><main><div class="mark">OpenVibe Connect</div><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${note ? `<p class="note">${escapeHtml(note)}</p>` : ''}${link ? `<a href="${escapeHtml(link.href)}">${escapeHtml(link.text)}</a>` : ''}${report ? `<p class="report"><a href="${escapeHtml(report)}">Report this share</a></p>` : ''}</main></body></html>`;
}
module.exports = { createProxy, page, rawResponder };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 02b802c..8af2038 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -149,6 +149,60 @@ function createPageRoutes(ctx) {
return res.redirect(303, `${share.url}/__ovc/callback?code=${encodeURIComponent(code)}&next=${encodeURIComponent(next)}`);
});
++ // ── Reports (CON-9 v0): anyone reports a share; staff read them and close a share. ──────────────────────────────
++ const { REPORT_REASONS } = require('../shares');
++ const reportHits = new Map(); // address → times this hour (in memory only; never stored)
++ const overReports = (req) => {
++ const key = String(req.ip || ''), cut = Date.now() - 3600_000;
++ const hits = (reportHits.get(key) || []).filter((t) => t > cut);
++ if (hits.length >= 5) return true;
++ hits.push(Date.now());
++ reportHits.set(key, hits);
++ if (reportHits.size > 10_000) reportHits.delete(reportHits.keys().next().value);
++ return false;
++ };
++ r.get('/report', (req, res) => page(req, res, { title: 'Report a share', body: views.reportForm({ host: String(req.query.host || '').slice(0, 200), reasons: REPORT_REASONS, signedIn: signedIn(req) }) }));
++ r.post('/report', async (req, res) => {
++ const values = { host: req.body.host, reason: req.body.reason, details: req.body.details };
++ const again = (status, error) => page(req, res, { title: 'Report a share', body: views.reportForm({ values, error, reasons: REPORT_REASONS, signedIn: signedIn(req) }) }, status);
++ if (!sameOrigin(req, config.baseUrl)) return again(403, 'Send the report from connect.openvibe.website itself.');
++ if (overReports(req)) return again(429, 'That is a lot of reports in an hour. Try again later, or write to OpenVibe from the Help site.');
++ try {
++ await shares.report({ host: values.host, reason: values.reason, details: values.details, reporter: signedIn(req) ? req.viewer.subject : null });
++ } catch (err) {
++ if (!(err instanceof ShareError)) throw err;
++ return again(err.status || 422, err.message);
++ }
++ return page(req, res, { title: 'Report sent', body: views.message({ title: 'Thank you. The report is in.', text: 'OpenVibe staff read every report. If the share breaks the rules, it is closed and its address says so.', link: { href: '/', label: 'OpenVibe Connect' } }) }, 201);
++ });
++ const staffOnly = (req, res) => {
++ if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return false; }
++ if (req.viewer.role !== 'admin') { page(req, res, { title: 'Not found', body: views.notFound() }, 404); return false; }
++ return true;
++ };
++ r.get('/admin/reports', async (req, res) => {
++ if (!staffOnly(req, res)) return undefined;
++ res.set('Cache-Control', 'private, no-store');
++ const notice = req.query.done === 'close' ? 'The share is closed.' : req.query.done === 'dismiss' ? 'Dismissed.' : null;
++ return page(req, res, { title: 'Reports', body: views.reportsAdmin({ reports: await shares.openReports(), reasons: REPORT_REASONS, notice }) });
++ });
++ r.post('/admin/reports/:id/:action', async (req, res) => {
++ if (!staffOnly(req, res)) return undefined;
++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Handle reports on connect.openvibe.website itself.', link: { href: '/admin/reports', label: 'Reports' } }) }, 403);
++ try {
++ const out = await shares.handleReport(owner(req), req.params.id, req.params.action);
++ if (out.action === 'close' && out.share) {
++ tunnels.close(out.share.id, 4003, 'the share was closed after a report');
++ proxy.forget(out.share.host);
++ proxy.inspector.forget(out.share.id);
++ }
++ return res.redirect(303, `/admin/reports?done=${encodeURIComponent(out.action)}`);
++ } catch (err) {
++ if (!(err instanceof ShareError)) throw err;
++ return page(req, res, { title: 'Reports', body: views.message({ title: 'That did not work', text: err.message, link: { href: '/admin/reports', label: 'Reports' } }) }, err.status || 422);
++ }
++ });
++
r.get('/robots.txt', (req, res) => {
res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(`User-agent: *\nAllow: /$\nDisallow: /shares\nDisallow: /grant\nDisallow: /auth\nDisallow: /api\n`);
});
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 2a2c878..c714483 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -12,6 +12,10 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
{ table: 'share_visits', subject: 'subject', value: (usr) => usr, file: 'visits.json', erase: 'delete' },
++ // Reports a person made (CON-9): exported; on erasure the report stays (a share may have been closed on it) and
++ // the account is taken off it.
++ { table: 'share_reports', subject: 'reporter_subject', value: (usr) => usr, file: 'reports.json', erase: { anonymize: {} },
++ columns: ['id', 'host', 'reason', 'details', 'status', 'created_at'] },
{
table: 'shares', subject: 'owner_subject', value: (usr) => usr, file: 'shares.json', erase: 'delete',
columns: ['id', 'owner_username', 'label', 'host', 'local_port', 'mode', 'named', 'status', 'policy_version', 'key_prefix', 'note',
ddiff --git a/server/shares.js b/server/shares.js
iindex 88e5f21..5f79862 100644
---- a/server/shares.js
++++ b/server/shares.js
@@@ -29,6 +29,15 @@ const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
// How long a share may stay open by itself (CON-6): 10 minutes to 30 days, or until its owner closes it (null).
const EXPIRES_MIN = 600;
const EXPIRES_MAX = 30 * 86400;
++// What a report can be about (CON-9 v0). Copyright goes to the DMCA page too; the form says so.
++const REPORT_REASONS = {
++ phishing: 'It pretends to be someone else to get passwords or payment details',
++ malware: 'It offers harmful files or software',
++ illegal: 'Illegal content',
++ copyright: 'It copies my work',
++ abuse: 'Harassment, threats or spam',
++ other: 'Something else',
++};
const SIGNIN_NOT_YET = '"Anyone signed in" opens with the abuse check. For now, name the people who may open it.';
const isStaff = (owner) => Boolean(owner && owner.role === 'admin');
@@@ -118,6 +127,7 @@ function createShares(s, { config }) {
updated_at: iso(row.updated_at),
revoked_at: iso(row.revoked_at),
expires_at: iso(row.expires_at),
++ closed_reason: row.closed_reason || null,
// It closed itself: revoked at (or just after) the time it was given.
expired: row.status === 'revoked' && Boolean(row.expires_at && row.revoked_at && new Date(row.revoked_at) >= new Date(row.expires_at)),
last_connected_at: iso(row.last_connected_at),
@@@ -228,6 +238,45 @@ function createShares(s, { config }) {
return get(share.id);
}
++ // ── Reports (CON-9 v0) ──────────────────────────────────────────────────────────────────────────────────────
++ /** Anyone reports a share by its address. Returns { id, share }. */
++ async function report({ host, reason, details, reporter = null }) {
++ const parsed = parseHost(String(host || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''), domain);
++ const share = parsed ? await byHost(parsed.host) : null;
++ if (!share) throw new ShareError(404, 'share.not_found', 'There is no share at that address. Check it, or paste the whole address.');
++ if (!REPORT_REASONS[reason]) throw new ShareError(422, 'report.invalid_reason', 'Pick what the report is about.');
++ const text = String(details || '').trim().slice(0, 2000);
++ const id = `rpt_${crypto.randomBytes(12).toString('base64url')}`;
++ await db.exec('INSERT INTO share_reports (id, share_id, host, reason, details, reporter_subject) VALUES ($1, $2, $3, $4, $5, $6)',
++ [id, share.id, share.host, reason, text || null, reporter || null]);
++ return { id, share };
++ }
++ /** The open reports, newest first, with their share's owner and state (staff only; the route checks). */
++ async function openReports() {
++ return db.many(`SELECT r.id, r.share_id, r.host, r.reason, r.details, r.created_at, r.reporter_subject IS NOT NULL AS signed_in,
++ s.owner_username, s.status AS share_status, s.mode,
++ (SELECT count(*)::int FROM share_reports x WHERE x.share_id = r.share_id AND x.status = 'open') AS open_for_share
++ FROM share_reports r JOIN shares s ON s.id = r.share_id
++ WHERE r.status = 'open' ORDER BY r.created_at DESC LIMIT 200`);
++ }
++ /** Staff act on a report: 'close' closes its share (and settles every open report on it); 'dismiss' settles one. */
++ async function handleReport(staff, id, action) {
++ if (!isStaff(staff)) throw new ShareError(403, 'report.staff_only', 'Only OpenVibe staff handle reports.');
++ const r = await db.maybe('SELECT * FROM share_reports WHERE id = $1', [String(id || '')]);
++ if (!r) throw new ShareError(404, 'report.not_found', 'No such report.');
++ if (action === 'close') {
++ await db.exec(`UPDATE shares SET status = 'revoked', revoked_at = now(), updated_at = now(), closed_reason = 'report',
++ policy_version = policy_version + 1 WHERE id = $1 AND status = 'active'`, [r.share_id]);
++ await db.exec(`UPDATE share_reports SET status = 'closed_share', handled_by = $2, handled_at = now() WHERE share_id = $1 AND status = 'open'`, [r.share_id, staff.subject]);
++ return { action, share: await get(r.share_id) };
++ }
++ if (action === 'dismiss') {
++ await db.exec(`UPDATE share_reports SET status = 'dismissed', handled_by = $2, handled_at = now() WHERE id = $1 AND status = 'open'`, [r.id, staff.subject]);
++ return { action, share: await get(r.share_id) };
++ }
++ throw new ShareError(422, 'report.invalid_action', 'Close the share or dismiss the report.');
++ }
++
/** Close every share whose time is up (the sweep in app.js); returns them, so their computers are let go. */
async function expireDue() {
return db.many(
@@@ -275,7 +324,7 @@ function createShares(s, { config }) {
return false;
}
-- return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, expireDue, touchConnected, flushCounters, visits, allows, view, domain };
++ return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, touchConnected, flushCounters, visits, allows, view, domain };
}
function iso(v) {
@@@ -284,4 +333,4 @@ function iso(v) {
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
--module.exports = { createShares, ShareError, isStaff, hostFor, parseHost, handleOf, normalizeNamed, LABEL_RE, MODES, RESERVED_LABELS, sha256 };
++module.exports = { createShares, ShareError, isStaff, REPORT_REASONS, hostFor, parseHost, handleOf, normalizeNamed, LABEL_RE, MODES, RESERVED_LABELS, sha256 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 7074bf0..fefa06f 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -109,7 +109,7 @@ ${modeChoices(values.mode || 'owner', staff)}
</fieldset>
<label class="cn-field"><span>People (for "the people you name")</span><input name="named" maxlength="2000" value="${esc(values.named || '')}" placeholder="sam, riley" autocapitalize="none" autocomplete="off" spellcheck="false"><small>OpenVibe usernames, separated by commas</small></label>
<button class="cn-btn" type="submit">${kit.icon('plus')} Make the share</button>
--<p class="cn-fine">Every visitor signs in with OpenVibe first. Public shares, open to anyone, come once the abuse check is in.</p>
++<p class="cn-fine">Every visitor signs in with OpenVibe first. Public shares, open to anyone, come once the abuse check is in. Seen a share that tricks people? <a href="/report">Report it</a>.</p>
</form>`;
}
@@@ -285,7 +285,38 @@ function grantDenied({ share, viewer }) {
return `<div class="cn-wrap cn-page cn-narrow"><header class="cn-head"><span class="cn-state is-closed"><i aria-hidden="true"></i>Not for you</span>
<h1>You can't open ${esc(share.host)}</h1>
<p>${esc(share.owner_username)} has not let ${viewer && viewer.username ? `@${esc(viewer.username)}` : 'this account'} in. Ask them to add your OpenVibe username to the share.</p></header>
--<p><a class="cn-btn cn-btn-quiet" href="/auth/logout?next=${encodeURIComponent(`/grant?share=${share.id}`)}">Use another account</a></p></div>`;
++<p><a class="cn-btn cn-btn-quiet" href="/auth/logout?next=${encodeURIComponent(`/grant?share=${share.id}`)}">Use another account</a></p>
++<p class="cn-fine"><a href="/report?host=${encodeURIComponent(share.host)}">Report this share</a></p></div>`;
++}
++
++/**
++ * The report form (CON-9 v0): anyone, about any share, by its address. What a reporter writes reaches OpenVibe staff;
++ * the share's owner is not told who reported.
++ */
++function reportForm({ host = '', values = {}, error = null, reasons = {}, signedIn = false } = {}) {
++ const picked = values.reason || '';
++ return `<div class="cn-wrap cn-page cn-narrow"><header class="cn-head"><h1>Report a share</h1>
++<p>Tell OpenVibe about a share that tricks people, spreads harm or breaks the law. Staff read every report and can close the share. Its owner is not told who reported it.</p></header>
++${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
++<form class="cn-form" method="post" action="/report">
++<label class="cn-field"><span>The share's address</span><input name="host" required maxlength="200" value="${esc(values.host || host)}" placeholder="name--someone.openvibe.website" autocapitalize="none" autocomplete="off" spellcheck="false"></label>
++<fieldset class="cn-modes"><legend>What is wrong</legend>
++${Object.entries(reasons).map(([k, v]) => `<label><input type="radio" name="reason" value="${esc(k)}"${picked === k ? ' checked' : ''} required> <span>${esc(v)}</span></label>`).join('\n')}
++</fieldset>
++<label class="cn-field"><span>What you saw <small>(optional, up to 2,000 characters)</small></span><textarea name="details" maxlength="2000" rows="4">${esc(values.details || '')}</textarea></label>
++<button class="cn-btn" type="submit">${kit.icon('flag')} Send the report</button>
++<p class="cn-fine">${signedIn ? 'The report keeps your OpenVibe account so staff can follow up; your address is never stored.' : 'Nothing about you is stored with the report: no account, no address.'} A copyright claim also goes through <a href="/dmca">the DMCA page</a>.</p>
++</form></div>`;
++}
++
++/** The staff queue (CON-9 v0): open reports, newest first; close the share or dismiss the report. */
++function reportsAdmin({ reports = [], reasons = {}, notice = null, now = Date.now() } = {}) {
++ const rows = reports.map((r) => `<li class="cn-report"><div class="cn-report-head"><a href="https://${esc(r.host)}" rel="noopener noreferrer nofollow" target="_blank"><b>${esc(r.host)}</b></a><span>@${esc(r.owner_username)} · ${esc(r.mode)} · ${r.share_status === 'active' ? 'open' : 'closed'}${r.open_for_share > 1 ? ` · ${esc(r.open_for_share)} reports` : ''}</span></div>
++<p><b>${esc(reasons[r.reason] || r.reason)}</b> · ${esc(ago(r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, now))}${r.signed_in ? ' · signed in' : ''}</p>${r.details ? `<blockquote>${esc(r.details)}</blockquote>` : ''}
++<div class="cn-report-acts">${r.share_status === 'active' ? `<form method="post" action="/admin/reports/${esc(r.id)}/close"><button class="cn-btn cn-btn-bad" type="submit">${kit.icon('close')} Close the share</button></form>` : ''}<form method="post" action="/admin/reports/${esc(r.id)}/dismiss"><button class="cn-btn cn-btn-quiet" type="submit">Dismiss</button></form></div></li>`).join('');
++ return `<div class="cn-wrap cn-page"><header class="cn-head"><h1>Reports</h1><p>Open reports about shares, newest first. Closing a share settles every open report on it; its address then says it was closed after a report.</p></header>
++${notice ? `<p class="cn-notice" role="status">${esc(notice)}</p>` : ''}
++${reports.length ? `<ol class="cn-reports">${rows}</ol>` : '<p class="cn-empty">No open reports.</p>'}</div>`;
}
function message({ title, text, link = null }) {
@@@ -294,4 +325,4 @@ function message({ title, text, link = null }) {
function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }
--module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, until, requestRow, size };
++module.exports = { home, sharePage, grantDenied, reportForm, reportsAdmin, message, notFound, MODE_TEXT, fmtBytes, ago, until, requestRow, size };
ddiff --git a/test/reports.test.js b/test/reports.test.js
nnew file mode 100644
iindex 0000000..46a59ed
---- /dev/null
++++ b/test/reports.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++// The abuse gate, v0 (CON-9): anyone reports a share by its address (from this site only, five an hour per address,
++// no address stored); staff read the open reports and close a share, which disconnects its computer and makes the
++// address say it was closed after a report; a report can be dismissed. Nobody else sees the queue. A reporter's
++// account goes with them on erasure, and the report stays.
++// node test/reports.test.js
++const assert = require('assert');
++const { boot, startLocalApp } = require('./helpers/boot');
++const { check, done } = require('./helpers/check');
++
++const SAME = { origin: 'https://connect.openvibe.website' };
++
++(async () => {
++ const t = await boot();
++ const app = await startLocalApp();
++ const sam = t.network.addUser('sam_rpt');
++ const rita = t.network.addUser('rita_rpt');
++ const staff = t.network.addUser('staff_rpt', { role: 'admin' });
++ try {
++ const made = await t.makeShare(sam, { label: 'shop', port: app.port });
++ const c = t.client({ key: made.key, port: app.port });
++ await c.ready();
++
++ await check('anyone reports a share by its address; the form takes the whole URL too', async () => {
++ const form = await t.get(`/report?host=${made.share.host}`);
++ assert.equal(form.status, 200);
++ assert.ok(form.text.includes(`value="${made.share.host}"`));
++ assert.match(form.text, /name="reason" value="phishing"/);
++ const sent = await t.get('/report', { ...SAME, form: { host: `https://${made.share.host}/login`, reason: 'phishing', details: 'It asks for a bank password.' } });
++ assert.equal(sent.status, 201, sent.text);
++ assert.match(sent.text, /The report is in/);
++ const signed = await t.get('/report', { ...SAME, as: rita, form: { host: made.share.host, reason: 'abuse' } });
++ assert.equal(signed.status, 201);
++ });
++
++ await check('a report needs a share that exists, a reason, and this site', async () => {
++ assert.equal((await t.get('/report', { ...SAME, form: { host: 'nope--nobody.openvibe.website', reason: 'other' } })).status, 404);
++ assert.equal((await t.get('/report', { ...SAME, form: { host: made.share.host, reason: 'because' } })).status, 422);
++ assert.equal((await t.get('/report', { origin: 'https://evil.example', form: { host: made.share.host, reason: 'other' } })).status, 403);
++ });
++
++ await check('only staff see the queue', async () => {
++ assert.equal((await t.get('/admin/reports', { as: rita })).status, 404);
++ assert.equal((await t.get('/admin/reports')).status, 303, 'signed out: sign in');
++ const q = await t.get('/admin/reports', { as: staff });
++ assert.equal(q.status, 200);
++ assert.ok(q.text.includes(made.share.host));
++ assert.match(q.text, /It asks for a bank password\./);
++ assert.match(q.text, /2 reports/);
++ });
++
++ let ids = [];
++ await check('a report can be dismissed; closing the share settles the rest, disconnects it and the address says why', async () => {
++ const rows = await t.ctx.s.db.many("SELECT id, reason FROM share_reports WHERE share_id = $1 ORDER BY created_at", [made.share.id]);
++ ids = rows.map((r) => r.id);
++ assert.equal((await t.get(`/admin/reports/${ids[1]}/dismiss`, { ...SAME, as: staff, form: {}, method: 'POST' })).status, 303);
++ assert.equal((await t.get(`/admin/reports/${ids[0]}/close`, { ...SAME, as: rita, form: {}, method: 'POST' })).status, 404, 'not staff');
++ const closed = await t.get(`/admin/reports/${ids[0]}/close`, { ...SAME, as: staff, form: {}, method: 'POST' });
++ assert.equal(closed.status, 303);
++ assert.match(closed.headers.get('location'), /done=close/);
++ const states = (await t.ctx.s.db.many('SELECT status FROM share_reports WHERE share_id = $1 ORDER BY created_at', [made.share.id])).map((r) => r.status);
++ assert.deepStrictEqual(states, ['closed_share', 'dismissed']);
++ const row = (await t.get(`/api/v1/shares/${made.share.id}`, { as: sam })).json().share;
++ assert.deepStrictEqual([row.status, row.closed_reason], ['revoked', 'report']);
++ await new Promise((r) => setTimeout(r, 100));
++ assert.equal(t.ctx.tunnels.isConnected(made.share.id), false, 'its computer is let go');
++ const page = await t.share(made.share.host, '/', { headers: { accept: 'text/html' } });
++ assert.equal(page.status, 410);
++ assert.match(page.text, /closed shop--sam-rpt\.openvibe\.website after a report|after a report/);
++ assert.match((await t.get('/admin/reports', { as: staff })).text, /No open reports\./);
++ });
++
++ await check('the account goes with the reporter; the report stays', async () => {
++ const { TABLES } = require('../server/identity/account-data');
++ const map = TABLES.find((x) => x.table === 'share_reports');
++ assert.ok(map && map.subject === 'reporter_subject');
++ assert.deepStrictEqual(map.erase, { anonymize: {} });
++ assert.ok(!map.columns.includes('reporter_subject'));
++ });
++
++ await check('five reports an hour per address, then a polite no', async () => {
++ const other = await t.makeShare(rita, { label: 'blog', port: app.port });
++ let last;
++ for (let i = 0; i < 6; i++) last = await t.get('/report', { ...SAME, form: { host: other.share.host, reason: 'other' } });
++ assert.equal(last.status, 429);
++ });
++ } finally { await t.close(); await app.close(); }
++ done();
++})();