openvibers/openvibe.connect Commits
Tell me when it's back (CON-17) (#12)
Changes
nnew file mode 100644@@@ -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)++);@@@ -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 () => {@@@ -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 };@@@ -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 → Tunnelconst 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;});@@@ -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: {} },nnew file mode 100644@@@ -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 };@@@ -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) {@@@ -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;nnew file mode 100644@@@ -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();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -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)
+);
@@ -57,7 +57,13 @@ async function createApp(opts = {}) {
@@ -57,7 +57,13 @@ async function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
const shares = createShares(s, { config });
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 gate = createGate({ config, now: s.now });
const proxy = createProxy({ config, shares, tunnels, gate, log, now: s.now });
const proxy = createProxy({ config, shares, tunnels, gate, log, now: s.now });
@@ -66,7 +72,7 @@ async function createApp(opts = {}) {
@@ -66,7 +72,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? 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'); });
: 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. */
/** Shares whose time is up close themselves (CON-6): their computers are disconnected and their lists forgotten. */
ctx.expireSweep = async () => {
ctx.expireSweep = async () => {
@@ -153,19 +153,19 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -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) ──
// ── 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; }
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',
'cache-control': 'no-store',
'cache-control': 'no-store',
'cloudflare-cdn-cache-control': 'no-store',
'cloudflare-cdn-cache-control': 'no-store',
'x-robots-tag': 'noindex, nofollow',
'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',
'x-content-type-options': 'nosniff',
'referrer-policy': 'no-referrer',
'referrer-policy': 'no-referrer',
...headers,
...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 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.',
+ notify: who && who.subject ? '/__ovc/notify' : '',
report: `${config.baseUrl}/report?host=${encodeURIComponent(share.host)}`,
report: `${config.baseUrl}/report?host=${encodeURIComponent(share.host)}`,
});
});
return null;
return null;
@@ -236,6 +237,17 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -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' });
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();
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') {
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() } });
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()) {
@@ -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:
* 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 = '', 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">
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}
@@ -420,8 +432,8 @@ body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--
@@ -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}
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)}.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 };
module.exports = { createProxy, page, rawResponder };
@@ -91,7 +91,7 @@ class Tunnel {
@@ -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 wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024, perMessageDeflate: false });
const live = new Map(); // share id → Tunnel
const live = new Map(); // share id → Tunnel
const stats = { connects: 0, refused: 0, replaced: 0 };
const stats = { connects: 0, refused: 0, replaced: 0 };
@@ -144,6 +144,8 @@ function createTunnels({ shares, config, log = console }) {
@@ -144,6 +144,8 @@ function createTunnels({ shares, config, log = console }) {
tunnel = t;
tunnel = t;
stats.connects++;
stats.connects++;
shares.touchConnected(share.id).catch(() => {});
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 } });
t.send({ t: 'ready', v: frames.VERSION, share: { id: share.id, host: share.host, url: share.url, mode: share.mode } });
return undefined;
return undefined;
});
});
@@ -12,6 +12,8 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
@@ -12,6 +12,8 @@ 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' },
+ // "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
// 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.
// the account is taken off it.
{ table: 'share_reports', subject: 'reporter_subject', value: (usr) => usr, file: 'reports.json', erase: { anonymize: {} },
{ table: 'share_reports', subject: 'reporter_subject', value: (usr) => usr, file: 'reports.json', erase: { anonymize: {} },
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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 };
@@ -277,6 +277,19 @@ function createShares(s, { config }) {
@@ -277,6 +277,19 @@ function createShares(s, { config }) {
throw new ShareError(422, 'report.invalid_action', 'Close the share or dismiss the report.');
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. */
/** 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(
@@ -324,7 +337,7 @@ function createShares(s, { config }) {
@@ -324,7 +337,7 @@ function createShares(s, { config }) {
return false;
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) {
function iso(v) {
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
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();
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 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;
const port = server.address().port;
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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();
+})();