openvibers/openvibe.connect Commits
While the computer is off, a share can send browsers to the owner's site (CON-17) (#33)
Changes
nnew file mode 100644@@@ -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?://'));@@@ -220,6 +220,17 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nuif (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.@@@ -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) });}));@@@ -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) {@@@ -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 ENDWHERE 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 };@@@ -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>`;@@@ -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);@@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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?://'));
@@ -220,6 +220,17 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -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 (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; }
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);
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) {
if (!tunnel) {
// Keep-online, v0 (CON-17): a browser waits on this page, asking again every 10 seconds, and lands on the app
// 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.
// the moment its computer is back; when it was last connected says how long the wait may be.
@@ -7,8 +7,9 @@
@@ -7,8 +7,9 @@
* GET /shares your shares (website.share@1 shapes, plus connected)
* 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
* 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
* 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)
* 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
* 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
* 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) {
@@ -72,7 +73,7 @@ function createApi(ctx) {
const share = await mine(req, res);
const share = await mine(req, res);
if (!share) return undefined;
if (!share) return undefined;
const b = req.body || {};
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);
proxy.forget(updated.host);
return res.json({ share: shape(updated) });
return res.json({ share: shape(updated) });
}));
}));
@@ -112,7 +112,7 @@ function createPageRoutes(ctx) {
@@ -112,7 +112,7 @@ function createPageRoutes(ctx) {
const share = await mine(req, res);
const share = await mine(req, res);
if (!share) return undefined;
if (!share) return undefined;
try {
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);
proxy.forget(updated.host);
return res.redirect(303, `/shares/${encodeURIComponent(share.id)}?saved=1`);
return res.redirect(303, `/shares/${encodeURIComponent(share.id)}?saved=1`);
} catch (err) {
} catch (err) {
@@ -5,7 +5,8 @@
@@ -5,7 +5,8 @@
*
*
* create(owner, { label, port, mode, named, note }) → { share, key } the key is returned once, never stored
* create(owner, { label, port, mode, named, note }) → { share, key } the key is returned once, never stored
* list(ownerSubject), get(id), byHost(host), byKey(key)
* 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
* 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
* 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
* working while one is bound. null: back to a new connect key
@@ -44,6 +45,29 @@ const REPORT_REASONS = {
@@ -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 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');
+// 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 {
class ShareError extends Error {
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
@@ -133,6 +157,8 @@ function createShares(s, { config }) {
@@ -133,6 +157,8 @@ function createShares(s, { config }) {
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,
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.
// 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),
@@ -215,15 +241,19 @@ function createShares(s, { config }) {
@@ -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.');
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 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);
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.
// 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 keepExpiry = input.expires_in === undefined || input.expires_in === 'keep';
const ttl = keepExpiry ? null : expiresIn(input.expires_in);
const ttl = keepExpiry ? null : expiresIn(input.expires_in);
await db.exec(
await db.exec(
`UPDATE shares SET mode = $2, named = $3::jsonb, local_port = $4, note = $5, updated_at = now(),
`UPDATE shares SET mode = $2, named = $3::jsonb, local_port = $4, note = $5, updated_at = now(),
policy_version = policy_version + $6,
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`,
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);
return get(share.id);
}
}
@@ -377,4 +407,4 @@ function iso(v) {
@@ -377,4 +407,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, 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 };
@@ -365,6 +365,7 @@ ${modeChoices(share.mode, staff)}
@@ -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"><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>
<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' })}
${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>
<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>
<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>`;
</form>`;
@@ -61,6 +61,21 @@ const { check, done } = require('./helpers/check');
@@ -61,6 +61,21 @@ const { check, done } = require('./helpers/check');
assert.match(page.text, /localhost:8791/);
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 () => {
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' });
const r = await t.get(`/shares/${id}/close`, { as: kim, method: 'POST', form: {}, origin: 'https://evil.example' });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
@@ -105,6 +105,39 @@ async function until(fn, ms = 3000) {
@@ -105,6 +105,39 @@ async function until(fn, ms = 3000) {
assert.match(page.headers['content-security-policy'], /default-src 'none'/, 'still no script');
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 () => {
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 });
c = t.client({ key: made.key, port: app.port });
const ready = await c.ready();
const ready = await c.ready();