openvibers/openvibe.connect Commits

Tell me when it's back (CON-17) (#12)

OP OpenVibers committed 11 days ago · bf4ec607dd · 9 files, +165 −11

On the offline page a visitor the gate knows (signed in through the share's grant) can ask to be told; the page's
CSP allows that one same-host form post (form-action 'self', still no script). When the computer connects, everyone
waiting gets one notification (service connect, network.notifications.push, addressed by subject) and the list
empties. At most 500 waiting per share; asking from another site is refused; asking while it is back just opens it.
Migration 0004 adds share_waiters (expand); account data exports and erases it.

Changes

ddiff --git a/migrations/0004_share_waiters.sql b/migrations/0004_share_waiters.sql
nnew file mode 100644
iindex 0000000..9c70d75
---- /dev/null
++++ b/migrations/0004_share_waiters.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++-- "Tell me when it's back" (CON-17): a signed-in visitor who found a share offline asks for one OpenVibe notification
++-- when its computer connects again. One row per share and person, deleted once told (or with the share).
++CREATE TABLE IF NOT EXISTS share_waiters (
++ share_id text NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
++ subject text NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (share_id, subject)
++);
ddiff --git a/server/app.js b/server/app.js
iindex 4f39ad8..923ecd4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -57,7 +57,13 @@ async function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
const shares = createShares(s, { config });
-- const tunnels = createTunnels({ shares, config, log });
++ // "Tell me when it's back" (CON-17): when a share's computer connects, everyone waiting on it gets one notification.
++ const notifier = opts.notifier || require('./notify').createNotifier({ config, fetchImpl, log });
++ const tunnels = createTunnels({ shares, config, log, onConnect: async (share) => {
++ const waiting = await shares.takeWaiters(share.id);
++ for (const subject of waiting) await notifier.shareBack(subject, share);
++ if (waiting.length) log.log(`[OpenVibe.Connect] told ${waiting.length} person(s) ${share.host} is back`);
++ } });
const gate = createGate({ config, now: s.now });
const proxy = createProxy({ config, shares, tunnels, gate, log, now: s.now });
@@@ -66,7 +72,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Connect cannot answer account events'); });
-- const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, accountData, accountSend, log };
++ const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, notifier, accountData, accountSend, log };
/** Shares whose time is up close themselves (CON-6): their computers are disconnected and their lists forgotten. */
ctx.expireSweep = async () => {
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex cffdeb1..967b549 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -153,19 +153,19 @@ 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 = '', report = '' } = {}) {
++ function sendPage(res, status, title, text, { link = null, headers = {}, refresh = 0, note = '', report = '', notify = '' } = {}) {
if (res.headersSent) { res.destroy(); return; }
res.writeHead(status, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'cloudflare-cdn-cache-control': 'no-store',
'x-robots-tag': 'noindex, nofollow',
-- 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
++ 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action ${notify ? "'self'" : "'none'"}; frame-ancestors 'none'`,
'x-content-type-options': 'nosniff',
'referrer-policy': 'no-referrer',
...headers,
});
-- res.end(page(title, text, link, { refresh, note, report }));
++ res.end(page(title, text, link, { refresh, note, report, notify }));
}
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.',
++ notify: who && who.subject ? '/__ovc/notify' : '',
report: `${config.baseUrl}/report?host=${encodeURIComponent(share.host)}`,
});
return null;
@@@ -236,6 +237,17 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
res.writeHead(303, { location: gate.safeNext(q.get('next')), 'set-cookie': gate.cookieHeader(cookie), 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
return res.end();
}
++ if (path === '/__ovc/notify' && req.method === 'POST') {
++ const { ours } = gate.splitCookies(req.headers.cookie);
++ const who = ours ? gate.verify(ours, share) : null;
++ if (!who || !who.subject) return sendPage(res, 401, 'Sign in first', 'Open the share again to sign in, then ask to be told.');
++ if (String(req.headers.origin || `https://${share.host}`) !== `https://${share.host}`) return sendPage(res, 403, 'Not from this page', 'Ask from the share\'s own page.');
++ if (tunnels.get(share.id)) { res.writeHead(303, { location: '/', 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store' }); return res.end(); }
++ const ok = await shares.addWaiter(share.id, who.subject).catch(() => false);
++ return sendPage(res, ok ? 200 : 429, ok ? 'We will tell you' : 'Too many waiting', ok
++ ? `You get one OpenVibe notification when ${share.owner_username}'s computer connects again. This page keeps asking too.`
++ : 'Too many people are waiting on this share already. This page keeps asking every 10 seconds.', { refresh: 10 });
++ }
if (path === '/__ovc/signout') {
return sendPage(res, 200, 'Signed out of this share', `You are signed out of ${share.host}. Your OpenVibe sign-in is untouched.`, { headers: { 'set-cookie': gate.clearCookieHeader() } });
}
@@@ -411,7 +423,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 = '', report = '' } = {}) {
++function page(title, text, link, { refresh = 0, note = '', report = '', notify = '' } = {}) {
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}
@@@ -420,8 +432,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)}.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>`;
++.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)}form{margin:0 0 1rem}button{font:inherit;font-weight:600;padding:10px 16px;min-height:44px;border-radius:10px;border:1px solid var(--line);background:var(--accent);color:#fff;cursor:pointer}.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>` : ''}${notify ? `<form method="post" action="${escapeHtml(notify)}"><button type="submit">Tell me when it is back</button></form>` : ''}${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/edge/tunnels.js b/server/edge/tunnels.js
iindex 6a5ba07..14faec0 100644
---- a/server/edge/tunnels.js
++++ b/server/edge/tunnels.js
@@@ -91,7 +91,7 @@ class Tunnel {
}
}
--function createTunnels({ shares, config, log = console }) {
++function createTunnels({ shares, config, log = console, onConnect = null }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024, perMessageDeflate: false });
const live = new Map(); // share id → Tunnel
const stats = { connects: 0, refused: 0, replaced: 0 };
@@@ -144,6 +144,8 @@ function createTunnels({ shares, config, log = console }) {
tunnel = t;
stats.connects++;
shares.touchConnected(share.id).catch(() => {});
++ // The people who asked to be told it is back (CON-17); never in the way of the connection.
++ if (onConnect) Promise.resolve().then(() => onConnect(share)).catch(() => {});
t.send({ t: 'ready', v: frames.VERSION, share: { id: share.id, host: share.host, url: share.url, mode: share.mode } });
return undefined;
});
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex c714483..3986c6e 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -12,6 +12,8 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
{ table: 'share_visits', subject: 'subject', value: (usr) => usr, file: 'visits.json', erase: 'delete' },
++ // "Tell me when it's back" (CON-17): the shares a person waits on, until told.
++ { table: 'share_waiters', subject: 'subject', value: (usr) => usr, file: 'waiting.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: {} },
ddiff --git a/server/notify.js b/server/notify.js
nnew file mode 100644
iindex 0000000..2655b7b
---- /dev/null
++++ b/server/notify.js
@@@ -0,0 +1,50 @@
++'use strict';
++
++/**
++ * OpenVibe notifications from Connect (CON-17, "tell me when it's back"): one notification, pushed as service connect
++ * (POST /internal/notifications/push, contracts network.notification-push-request@1, capability
++ * network.notifications.push, Network namespace connect), addressed by the person's usr_ subject.
++ *
++ * Best effort: off without a client secret, never retried, and a failure never touches a share. → { sent, reason? }
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createNotifier({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = config.networkInternalUrl;
++ const enabled = Boolean(base && config.oauth && config.oauth.clientSecret);
++ const tokens = enabled ? serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope: 'network.notifications.push', fetchImpl,
++ }) : null;
++
++ async function push(subject, body) {
++ if (!enabled) return { sent: false, reason: 'off' };
++ if (!SUBJECT.test(String(subject || ''))) return { sent: false, reason: 'bad_subject' };
++ try {
++ const res = await fetchImpl(`${base}/internal/notifications/push`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify({ ...body, subject, service: 'connect' }),
++ signal: AbortSignal.timeout(10_000),
++ });
++ return res.ok ? { sent: true } : { sent: false, reason: `network_${res.status}` };
++ } catch (err) {
++ log.warn(`[OpenVibe.Connect] notification not sent: ${(err && err.message) || err}`);
++ return { sent: false, reason: 'error' };
++ }
++ }
++
++ /** The share's computer is back: tell one person who asked. */
++ async function shareBack(subject, share) {
++ return push(subject, {
++ category: 'service', icon: '🔌', title: `${share.host} is back`,
++ message: `${share.owner_username}'s computer is connected again. Open it while it is.`, url: share.url,
++ });
++ }
++
++ return { push, shareBack, enabled };
++}
++
++module.exports = { createNotifier };
ddiff --git a/server/shares.js b/server/shares.js
iindex 5f79862..557daa5 100644
---- a/server/shares.js
++++ b/server/shares.js
@@@ -277,6 +277,19 @@ function createShares(s, { config }) {
throw new ShareError(422, 'report.invalid_action', 'Close the share or dismiss the report.');
}
++ // ── "Tell me when it's back" (CON-17) ───────────────────────────────────────────────────────────────────────
++ /** A person asks to be told when the share's computer connects again (at most 500 waiting per share). */
++ async function addWaiter(shareId, subject) {
++ const n = Number((await db.maybe('SELECT count(*)::int AS n FROM share_waiters WHERE share_id = $1', [shareId]) || {}).n || 0);
++ if (n >= 500) return false;
++ await db.exec('INSERT INTO share_waiters (share_id, subject) VALUES ($1, $2) ON CONFLICT DO NOTHING', [shareId, subject]);
++ return true;
++ }
++ /** Everyone waiting on a share, taken off the list at once (each is told once). */
++ async function takeWaiters(shareId) {
++ return (await db.many('DELETE FROM share_waiters WHERE share_id = $1 RETURNING subject', [shareId])).map((r) => r.subject);
++ }
++
/** 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(
@@@ -324,7 +337,7 @@ function createShares(s, { config }) {
return false;
}
-- return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, touchConnected, flushCounters, visits, allows, view, domain };
++ return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };
}
function iso(v) {
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 1df2d9c..fba2373 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, notifier: opts.notifier });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.on('upgrade', built.ctx.onUpgrade); s.listen(0, '127.0.0.1', () => resolve(s)); });
const port = server.address().port;
ddiff --git a/test/notify-back.test.js b/test/notify-back.test.js
nnew file mode 100644
iindex 0000000..e5c7d1e
---- /dev/null
++++ b/test/notify-back.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++// "Tell me when it's back" (CON-17): a visitor the gate knows finds a share offline, asks from the offline page, and
++// gets one OpenVibe notification when its computer connects; then the list is empty. Strangers, other sites and a
++// share that is already back are handled plainly.
++// node test/notify-back.test.js
++const assert = require('assert');
++const { boot, startLocalApp } = require('./helpers/boot');
++const { check, done } = require('./helpers/check');
++
++(async () => {
++ const told = [];
++ const notifier = { enabled: true, shareBack: async (subject, share) => { told.push([subject, share.host]); return { sent: true }; } };
++ const t = await boot({ notifier });
++ const app = await startLocalApp();
++ const alex = t.network.addUser('alex_back');
++ try {
++ const made = await t.makeShare(alex, { label: 'later', port: app.port });
++ const host = made.share.host;
++ const cookie = (await t.enter(alex, made.share)).cookie;
++ const ORIGIN = { origin: `https://${host}` };
++
++ await check('offline, the page offers to tell the visitor, and its CSP lets that one form post here', async () => {
++ const page = await t.share(host, '/', { cookie });
++ assert.equal(page.status, 503);
++ assert.match(page.text, /<form method="post" action="\/__ovc\/notify"><button type="submit">Tell me when it is back<\/button><\/form>/);
++ assert.match(page.headers['content-security-policy'], /form-action 'self'/);
++ assert.match(page.headers['content-security-policy'], /default-src 'none'/, 'still no script');
++ });
++
++ await check('asking adds them once; strangers and other sites cannot', async () => {
++ const asked = await t.share(host, '/__ovc/notify', { method: 'POST', cookie, headers: ORIGIN });
++ assert.equal(asked.status, 200, asked.text);
++ assert.match(asked.text, /We will tell you/);
++ await t.share(host, '/__ovc/notify', { method: 'POST', cookie, headers: ORIGIN });
++ const rows = await t.ctx.s.db.many('SELECT subject FROM share_waiters WHERE share_id = $1', [made.share.id]);
++ assert.deepStrictEqual(rows.map((r) => r.subject), [alex.subject], 'once');
++ assert.equal((await t.share(host, '/__ovc/notify', { method: 'POST', headers: ORIGIN })).status, 401, 'no gate cookie');
++ assert.equal((await t.share(host, '/__ovc/notify', { method: 'POST', cookie, headers: { origin: 'https://evil.example' } })).status, 403);
++ });
++
++ await check('when the computer connects, each waiting person is told once and the list empties', async () => {
++ const c = t.client({ key: made.key, port: app.port });
++ await c.ready();
++ for (let i = 0; i < 50 && !told.length; i++) await new Promise((r) => setTimeout(r, 20));
++ assert.deepStrictEqual(told, [[alex.subject, host]]);
++ assert.equal((await t.ctx.s.db.many('SELECT 1 FROM share_waiters WHERE share_id = $1', [made.share.id])).length, 0);
++ // Asking while it is back just opens it.
++ const back = await t.share(host, '/__ovc/notify', { method: 'POST', cookie, headers: ORIGIN });
++ assert.equal(back.status, 303);
++ c.stop();
++ });
++
++ await check('the waiting list goes with the person (account data)', async () => {
++ const { TABLES } = require('../server/identity/account-data');
++ const map = TABLES.find((x) => x.table === 'share_waiters');
++ assert.ok(map && map.subject === 'subject' && map.erase === 'delete');
++ });
++ } finally { await t.close(); await app.close(); }
++ done();
++})();