openvibers/openvibe.connect Commits

While the computer is off, a share can send browsers to the owner's site (CON-17) (#33)

OP OpenVibers committed 10 days ago · 0233d4960e · 8 files, +105 −8

A share names one of the sites on its domain (an OpenVibe.Space site); after the gate, a browser opening it while no
computer is connected is sent there (a bare site keeps the path asked for), and a program still gets the 503.

Changes

ddiff --git a/migrations/0011_share_offline_site.sql b/migrations/0011_share_offline_site.sql
nnew file mode 100644
iindex 0000000..4538986
---- /dev/null
++++ b/migrations/0011_share_offline_site.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- CON-17 (2026-11-03): while a share's computer is off, a browser may be sent to one of the sites on the same domain
++-- (an OpenVibe.Space site, https://<name>.openvibe.website) instead of the waiting page. NULL keeps the waiting page.
++-- The address is checked in server/shares.js (the domain is configuration); the column only bounds it.
++ALTER TABLE shares ADD COLUMN offline_site text;
++ALTER TABLE shares ADD CONSTRAINT shares_offline_site CHECK (offline_site IS NULL OR (length(offline_site) <= 300 AND offline_site ~ '^https?://'));
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex b880070..18fb608 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -220,6 +220,17 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
if (overRate(share)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'rate_limited', 'Too many requests', 'This share had too many requests in the last minute. Try again shortly.', { headers: { 'retry-after': '30' } }); return null; }
if (overBytes(share)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'over_daily_limit', 'Over today\'s limit', `${share.owner_username}'s shares moved as much data as they can today. They open again at midnight UTC.`); return null; }
const tunnel = tunnels.get(share.id);
++ if (!tunnel && share.offline_site && wantsHtml(req)) {
++ // CON-17: the owner named a site for when the computer is off. A bare site address keeps the page asked
++ // for (a static copy at the same paths); one with a path takes every visitor to that page. Only the path
++ // and query of the request are used, so the host is always the owner's site on this domain.
++ const site = new URL(share.offline_site);
++ const asked = new URL(req.url || '/', 'http://x');
++ const location = site.pathname === '/' && !site.search ? `${site.origin}${asked.pathname}${asked.search}` : site.href;
++ res.writeHead(302, { location, 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'retry-after': '10' });
++ res.end();
++ return null;
++ }
if (!tunnel) {
// Keep-online, v0 (CON-17): a browser waits on this page, asking again every 10 seconds, and lands on the app
// the moment its computer is back; when it was last connected says how long the wait may be.
ddiff --git a/server/http/api.js b/server/http/api.js
iindex bbab26a..66f977f 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -7,8 +7,9 @@
* GET /shares your shares (website.share@1 shapes, plus connected)
* POST /shares { label, port, mode?, named?, expires_in? } → 201 { share, key } the key is in this answer only
* GET /shares/:id one of your shares
-- * PATCH /shares/:id { mode?, named?, port?, note?, expires_in? } expires_in: seconds from now (600 to 30 days),
-- * or null to stay open until you close it
++ * PATCH /shares/:id { mode?, named?, port?, note?, expires_in?, offline_site? } expires_in: seconds from now
++ * (600 to 30 days), or null to stay open until you close it; offline_site: where a browser
++ * goes while the computer is off, one of the sites on the domain (CON-17), '' or null to clear
* DELETE /shares/:id close it (the host answers 410 from then on)
* POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected
* PUT /shares/:id/node { node: 'nod_…' } one of your computers serves it, as OpenVibe Node (CON-4): the key
@@@ -72,7 +73,7 @@ function createApi(ctx) {
const share = await mine(req, res);
if (!share) return undefined;
const b = req.body || {};
-- const updated = await shares.setPolicy(owner(req), share.id, { mode: b.mode, named: b.named, port: b.port, note: b.note, expires_in: b.expires_in });
++ const updated = await shares.setPolicy(owner(req), share.id, { mode: b.mode, named: b.named, port: b.port, note: b.note, expires_in: b.expires_in, offline_site: b.offline_site });
proxy.forget(updated.host);
return res.json({ share: shape(updated) });
}));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c70ff5b..2c902ac 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -112,7 +112,7 @@ function createPageRoutes(ctx) {
const share = await mine(req, res);
if (!share) return undefined;
try {
-- const updated = await shares.setPolicy(owner(req), share.id, { mode: req.body.mode, named: req.body.named, port: req.body.port === undefined || req.body.port === '' ? undefined : Number(req.body.port), expires_in: req.body.expires_in === undefined ? undefined : req.body.expires_in });
++ const updated = await shares.setPolicy(owner(req), share.id, { mode: req.body.mode, named: req.body.named, port: req.body.port === undefined || req.body.port === '' ? undefined : Number(req.body.port), expires_in: req.body.expires_in === undefined ? undefined : req.body.expires_in, offline_site: req.body.offline_site });
proxy.forget(updated.host);
return res.redirect(303, `/shares/${encodeURIComponent(share.id)}?saved=1`);
} catch (err) {
ddiff --git a/server/shares.js b/server/shares.js
iindex c048f49..ad7c3fd 100644
---- a/server/shares.js
++++ b/server/shares.js
@@@ -5,7 +5,8 @@
*
* create(owner, { label, port, mode, named, note }) → { share, key } the key is returned once, never stored
* list(ownerSubject), get(id), byHost(host), byKey(key)
-- * setPolicy(owner, id, { mode, named }) bumps policy_version: every visitor's cookie goes stale
++ * setPolicy(owner, id, { mode, named, offline_site }) bumps policy_version when who may open it changes
++ * offlineSite(value, { domain, scheme }) the CON-17 fallback rule (pure): a site on the domain, or null
* rotateKey(owner, id) → key the old key stops working; a connected tunnel is closed
* bindNode(owner, id, machine | null) one of the owner's computers serves it (CON-4); the key stops
* working while one is bound. null: back to a new connect key
@@@ -44,6 +45,29 @@ const REPORT_REASONS = {
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');
++// A Space site's name: one DNS label with single hyphens, so never a share's "--" host (OpenVibe.Space's NAME_RE).
++const SITE_LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,62}$/;
++const OFFLINE_SITE_HELP = 'The fallback is one of the sites on this domain, such as your OpenVibe.Space site: https://<name>.openvibe.website, optionally with a path.';
++
++/**
++ * Where a browser goes while a share's computer is off (CON-17): '' / null / undefined-as-cleared → null; otherwise
++ * a site on the share domain itself (https://<name>.<domain>, one label, never a share's "--" host), no port and no
++ * credentials, a path and query allowed, the fragment dropped. Anything else throws, so a share can never send its
++ * visitors off the domain.
++ */
++function offlineSite(value, { domain, scheme = 'https' }) {
++ if (value == null || String(value).trim() === '') return null;
++ let u;
++ try { u = new URL(String(value).trim()); } catch { throw new ShareError(422, 'share.invalid_offline_site', OFFLINE_SITE_HELP); }
++ const suffix = `.${domain}`;
++ const label = u.hostname.endsWith(suffix) ? u.hostname.slice(0, -suffix.length) : '';
++ if (u.protocol !== `${scheme}:` || u.port || u.username || u.password || !SITE_LABEL_RE.test(label)) {
++ throw new ShareError(422, 'share.invalid_offline_site', OFFLINE_SITE_HELP);
++ }
++ u.hash = '';
++ if (u.href.length > 300) throw new ShareError(422, 'share.invalid_offline_site', 'That address is too long.');
++ return u.href;
++}
class ShareError extends Error {
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
@@@ -133,6 +157,8 @@ function createShares(s, { config }) {
revoked_at: iso(row.revoked_at),
expires_at: iso(row.expires_at),
closed_reason: row.closed_reason || null,
++ // CON-17: where a browser goes while the computer is off (one of the sites on the domain), or null.
++ offline_site: row.offline_site || 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),
@@@ -215,15 +241,19 @@ function createShares(s, { config }) {
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'share.invalid_port', 'The port is a number from 1 to 65535.');
const note = input.note === undefined ? share.note : (input.note ? String(input.note).slice(0, 200) : null);
const policyChanged = mode !== share.mode || JSON.stringify(named) !== JSON.stringify(share.named);
++ // offline_site: undefined keeps it; '' / null clears it (not a policy change: nobody is signed out).
++ const keepOffline = input.offline_site === undefined;
++ const offline = keepOffline ? null : offlineSite(input.offline_site, { domain, scheme });
// expires_in: undefined keeps the time it has; null / '' / 'never' takes it away; seconds set it from now.
const keepExpiry = input.expires_in === undefined || input.expires_in === 'keep';
const ttl = keepExpiry ? null : expiresIn(input.expires_in);
await db.exec(
`UPDATE shares SET mode = $2, named = $3::jsonb, local_port = $4, note = $5, updated_at = now(),
policy_version = policy_version + $6,
-- expires_at = CASE WHEN $7::boolean THEN expires_at WHEN $8::int IS NULL THEN NULL ELSE now() + make_interval(secs => $8::int) END
++ expires_at = CASE WHEN $7::boolean THEN expires_at WHEN $8::int IS NULL THEN NULL ELSE now() + make_interval(secs => $8::int) END,
++ offline_site = CASE WHEN $9::boolean THEN offline_site ELSE $10 END
WHERE id = $1`,
-- [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0, keepExpiry, ttl],
++ [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0, keepExpiry, ttl, keepOffline, offline],
);
return get(share.id);
}
@@@ -377,4 +407,4 @@ function iso(v) {
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
--module.exports = { createShares, ShareError, isStaff, REPORT_REASONS, hostFor, parseHost, handleOf, normalizeNamed, LABEL_RE, MODES, RESERVED_LABELS, sha256 };
++module.exports = { offlineSite, createShares, ShareError, isStaff, REPORT_REASONS, hostFor, parseHost, handleOf, normalizeNamed, LABEL_RE, MODES, RESERVED_LABELS, sha256 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 354739d..085a3ae 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -365,6 +365,7 @@ ${modeChoices(share.mode, staff)}
<label class="cn-field"><span>People (for "the people you name")</span><input name="named" maxlength="2000" value="${esc(share.named.join(', '))}" placeholder="sam, riley" autocapitalize="none" autocomplete="off" spellcheck="false"><small>OpenVibe usernames, separated by commas</small></label>
<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(share.local_port)}"></label>
${closesField('keep', { keep: share.expires_at ? `As it is: ${until(share.expires_at, now)}` : 'As it is: only when you close it' })}
++<label class="cn-field"><span>While your computer is off <small>(optional)</small></span><input name="offline_site" type="url" maxlength="300" value="${esc(share.offline_site || '')}" placeholder="https://yourname.openvibe.website" autocapitalize="none" autocomplete="off" spellcheck="false"><small>Send visitors to one of your OpenVibe.Space sites instead of the waiting page. The site's address alone keeps the page they asked for; with a path, everyone goes to that page.</small></label>
<button class="cn-btn" type="submit">${kit.icon('check')} Save</button>
<p class="cn-fine">Changing who can open it signs everyone out of this share; the people still allowed are let back in when they next open it.</p>
</form>`;
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 11e94e9..eb9b481 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -61,6 +61,21 @@ const { check, done } = require('./helpers/check');
assert.match(page.text, /localhost:8791/);
});
++ await check('while the computer is off (CON-17): the form saves a site, shows it, refuses one off the domain, clears it', async () => {
++ const scheme = new URL((await t.get('/api/v1/shares', { as: kim })).json().shares[0].url).protocol;
++ const site = `${scheme}//kim.openvibe.website/`;
++ const r = await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', offline_site: site } });
++ assert.strictEqual(r.status, 303);
++ const page = await t.get(r.headers.get('location'), { as: kim });
++ assert.match(page.text, /While your computer is off/);
++ assert.ok(page.text.includes(`name="offline_site" type="url" maxlength="300" value="${site}"`), 'the saved site is shown');
++ const bad = await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', offline_site: 'https://evil.example/' } });
++ assert.strictEqual(bad.status, 422);
++ assert.match(bad.text, /one of the sites on this domain/);
++ await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', offline_site: '' } });
++ assert.strictEqual((await t.get('/api/v1/shares', { as: kim })).json().shares[0].offline_site, null, 'cleared');
++ });
++
await check('another site cannot change a share (the form must come from Connect)', async () => {
const r = await t.get(`/shares/${id}/close`, { as: kim, method: 'POST', form: {}, origin: 'https://evil.example' });
assert.strictEqual(r.status, 403);
ddiff --git a/test/share.test.js b/test/share.test.js
iindex b995b55..f85c2d0 100644
---- a/test/share.test.js
++++ b/test/share.test.js
@@@ -105,6 +105,39 @@ async function until(fn, ms = 3000) {
assert.match(page.headers['content-security-policy'], /default-src 'none'/, 'still no script');
});
++ await check('nothing connected, with a site named for it: a browser goes there, a program still waits (CON-17)', async () => {
++ const shareUrl = new URL(made.share.url);
++ const domain = made.share.host.split('.').slice(1).join('.');
++ const site = `${shareUrl.protocol}//alex.${domain}`;
++ const patch = (offline_site) => t.get(`/api/v1/shares/${made.share.id}`, { as: alex, method: 'PATCH', json: { offline_site } });
++ // Only a site on the share domain: never another host, a share's "--" host, a deeper name, a port or credentials.
++ for (const bad of ['ftp://alex.' + domain, `${shareUrl.protocol}//evil.example/`, `${shareUrl.protocol}//a--alex.${domain}`, `${shareUrl.protocol}//x.alex.${domain}`,
++ `${shareUrl.protocol}//alex.${domain}:8443/`, `${shareUrl.protocol}//u:p@alex.${domain}/`, `${shareUrl.protocol}//${domain}/`, 'not a url']) {
++ const r = await patch(bad);
++ assert.strictEqual(r.status, 422, bad);
++ assert.strictEqual(r.json().code, 'share.invalid_offline_site', bad);
++ }
++ const set = await patch(`${site}/#top`);
++ assert.strictEqual(set.status, 200, set.text);
++ assert.strictEqual(set.json().share.offline_site, `${site}/`, 'normalised, the fragment dropped');
++ assert.strictEqual(set.json().share.policy_version, made.share.policy_version, 'nobody is signed out for it');
++ const html = { cookie: alexCookie, headers: { accept: 'text/html' } };
++ const page = await t.share(made.share.host, '/docs/a?b=1', html);
++ assert.strictEqual(page.status, 302);
++ assert.strictEqual(page.headers.location, `${site}/docs/a?b=1`, 'a bare site keeps the page asked for');
++ assert.strictEqual(page.headers['cache-control'], 'no-store');
++ const sneaky = await t.share(made.share.host, '//evil.example/x', html);
++ assert.ok(sneaky.headers.location.startsWith(`${site}/`), `stays on the site: ${sneaky.headers.location}`);
++ assert.strictEqual((await t.share(made.share.host, '/', { cookie: alexCookie, headers: { accept: 'application/json' } })).status, 503, 'a program still gets the 503 and its Retry-After');
++ assert.strictEqual((await t.share(made.share.host, '/', { headers: { accept: 'text/html' } })).status, 302, 'the gate still comes first');
++ assert.notStrictEqual((await t.share(made.share.host, '/', { headers: { accept: 'text/html' } })).headers.location, `${site}/`, 'a visitor not let in is sent to sign in, not to the site');
++ await patch(`${site}/away.html?from=share`);
++ assert.strictEqual((await t.share(made.share.host, '/docs/a', html)).headers.location, `${site}/away.html?from=share`, 'a site with a path is the one page');
++ const cleared = await patch('');
++ assert.strictEqual(cleared.json().share.offline_site, null);
++ assert.strictEqual((await t.share(made.share.host, '/', html)).status, 503, 'cleared: the waiting page again');
++ });
++
await check('the client connects with its key and the share answers from the local app', async () => {
c = t.client({ key: made.key, port: app.port });
const ready = await c.ready();