openvibers/openvibe.connect Commits
Report a share; staff close it (CON-9 v0) (#11)
Changes
@@@ -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.++## APIWith a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):nnew file mode 100644@@@ -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;@@@ -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; }++@@@ -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 };@@@ -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`);});@@@ -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',@@@ -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 };@@@ -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 };nnew file mode 100644@@@ -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();++})();
@@ -42,6 +42,11 @@ only ever connects to `localhost` on the port you gave it.
@@ -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
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.
(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
## API
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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;
@@ -165,3 +165,13 @@
@@ -165,3 +165,13 @@
.cn .sc-room-pic { position: static; z-index: auto; width: auto; opacity: 1; pointer-events: auto; margin-top: 4px; }
.cn .sc-room-pic { position: static; z-index: auto; width: auto; opacity: 1; pointer-events: auto; margin-top: 4px; }
.cn .cn-motif { width: 100%; }
.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; }
+
@@ -153,7 +153,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -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) ──
// ── 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; }
if (res.headersSent) { res.destroy(); return; }
res.writeHead(status, {
res.writeHead(status, {
'content-type': 'text/html; charset=utf-8',
'content-type': 'text/html; charset=utf-8',
@@ -165,7 +165,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -165,7 +165,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
'referrer-policy': 'no-referrer',
'referrer-policy': 'no-referrer',
...headers,
...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 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 = {}) {
function sendProblem(req, res, status, code, title, text, extra = {}) {
@@ -196,6 +196,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -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.`, {
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' },
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.',
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;
return null;
}
}
@@ -216,6 +217,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -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.
// 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());
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 (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; }
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;
return share;
}
}
@@ -409,7 +411,7 @@ function ago(iso, nowMs = Date.now()) {
@@ -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:
* 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.
* 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">
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>
${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}
<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(--
@@ -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}
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)}
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}
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 };
module.exports = { createProxy, page, rawResponder };
@@ -149,6 +149,60 @@ function createPageRoutes(ctx) {
@@ -149,6 +149,60 @@ function createPageRoutes(ctx) {
return res.redirect(303, `${share.url}/__ovc/callback?code=${encodeURIComponent(code)}&next=${encodeURIComponent(next)}`);
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) => {
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`);
res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(`User-agent: *\nAllow: /$\nDisallow: /shares\nDisallow: /grant\nDisallow: /auth\nDisallow: /api\n`);
});
});
@@ -12,6 +12,10 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
@@ -12,6 +12,10 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
const TABLES = [
{ table: 'share_visits', subject: 'subject', value: (usr) => usr, file: 'visits.json', erase: 'delete' },
{ 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',
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',
columns: ['id', 'owner_username', 'label', 'host', 'local_port', 'mode', 'named', 'status', 'policy_version', 'key_prefix', 'note',
@@ -29,6 +29,15 @@ const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
@@ -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).
// 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_MIN = 600;
const EXPIRES_MAX = 30 * 86400;
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 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');
const isStaff = (owner) => Boolean(owner && owner.role === 'admin');
@@ -118,6 +127,7 @@ function createShares(s, { config }) {
@@ -118,6 +127,7 @@ function createShares(s, { config }) {
updated_at: iso(row.updated_at),
updated_at: iso(row.updated_at),
revoked_at: iso(row.revoked_at),
revoked_at: iso(row.revoked_at),
expires_at: iso(row.expires_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.
// 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)),
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),
last_connected_at: iso(row.last_connected_at),
@@ -228,6 +238,45 @@ function createShares(s, { config }) {
@@ -228,6 +238,45 @@ function createShares(s, { config }) {
return get(share.id);
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. */
/** Close every share whose time is up (the sweep in app.js); returns them, so their computers are let go. */
async function expireDue() {
async function expireDue() {
return db.many(
return db.many(
@@ -275,7 +324,7 @@ function createShares(s, { config }) {
@@ -275,7 +324,7 @@ function createShares(s, { config }) {
return false;
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) {
function iso(v) {
@@ -284,4 +333,4 @@ function iso(v) {
@@ -284,4 +333,4 @@ function iso(v) {
return Number.isNaN(d.getTime()) ? null : d.toISOString();
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 };
@@ -109,7 +109,7 @@ ${modeChoices(values.mode || 'owner', staff)}
@@ -109,7 +109,7 @@ ${modeChoices(values.mode || 'owner', staff)}
</fieldset>
</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>
<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>
<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>`;
</form>`;
}
}
@@ -285,7 +285,38 @@ function grantDenied({ share, viewer }) {
@@ -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>
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>
<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>${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 }) {
function message({ title, text, link = null }) {
@@ -294,4 +325,4 @@ 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' } }); }
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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();
+})();