openvibers/openvibe.connect Commits

An offline share waits for its computer (CON-17 v0) (#10)

OP OpenVibers committed 11 days ago · 53a79ce13f · 2 files, +37 −10

When nothing is connected a browser no longer lands on a dead end: the page asks again every 10 seconds (a meta
refresh, so it works under the gate's no-script CSP) and opens the app the moment the computer is back, and
says when it was last connected. The answer is 503 with Retry-After: 10 (was 502, Retry-After: 5).

Changes

ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex 56696e0..f0e87b6 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -153,7 +153,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
}
// ── Pages the gate answers itself (self-contained: a share host loads nothing from anywhere else) ──
-- function sendPage(res, status, title, text, { link = null, headers = {} } = {}) {
++ function sendPage(res, status, title, text, { link = null, headers = {}, refresh = 0, note = '' } = {}) {
if (res.headersSent) { res.destroy(); return; }
res.writeHead(status, {
'content-type': 'text/html; charset=utf-8',
@@@ -165,7 +165,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
'referrer-policy': 'no-referrer',
...headers,
});
-- res.end(page(title, text, link));
++ res.end(page(title, text, link, { refresh, note }));
}
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 = {}) {
@@@ -190,7 +190,13 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
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) {
-- sendProblem(req, res, 502, 'offline', `${share.owner_username}'s computer is not connected`, `Nothing is connected to ${share.host} right now. 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': '5' } });
++ // 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.
++ const last = share.last_connected_at ? ` It was last connected ${ago(share.last_connected_at, 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.',
++ });
return null;
}
if (tunnel.busy) { sendProblem(req, res, 503, 'busy', 'Busy', 'This share is answering as many requests as it can at once. Try again in a moment.', { headers: { 'retry-after': '2' } }); return null; }
@@@ -387,18 +393,33 @@ const STATUS_TEXT = { 302: 'Found', 400: 'Bad Request', 401: 'Unauthorized', 404
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
--/** The gate's own page: plain, readable in both themes, no outside resources. */
--function page(title, text, link) {
++/** "3 minutes ago", "2 hours ago", "4 days ago". */
++function ago(iso, nowMs = Date.now()) {
++ const s = Math.max(0, Math.round((nowMs - Date.parse(iso)) / 1000));
++ if (!Number.isFinite(s)) return 'a while ago';
++ if (s < 90) return 'a minute ago';
++ const m = Math.round(s / 60);
++ if (m < 90) return `${m} minutes ago`;
++ const h = Math.round(m / 60);
++ if (h < 36) return `${h} hour${h === 1 ? '' : 's'} ago`;
++ return `${Math.round(h / 24)} days ago`;
++}
++
++/**
++ * 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 = '' } = {}) {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
--<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}
@media (prefers-color-scheme:dark){:root{--bg:#0d131d;--fg:#e8ecf5;--muted:#9aa3b8;--card:#141c2a;--line:#253047;--accent:#8EA0FF}}
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:24px max(16px,env(safe-area-inset-left))}
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}</style></head>
--<body><main><div class="mark">OpenVibe Connect</div><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${link ? `<a href="${escapeHtml(link.href)}">${escapeHtml(link.text)}</a>` : ''}</main></body></html>`;
++.mark{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:12px}.note{font-size:.9rem;padding:10px 12px;border-radius:10px;border:1px solid var(--line)}</style></head>
++<body><main><div class="mark">OpenVibe Connect</div><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${note ? `<p class="note">${escapeHtml(note)}</p>` : ''}${link ? `<a href="${escapeHtml(link.href)}">${escapeHtml(link.text)}</a>` : ''}</main></body></html>`;
}
module.exports = { createProxy, page, rawResponder };
ddiff --git a/test/share.test.js b/test/share.test.js
iindex 97666ab..b995b55 100644
---- a/test/share.test.js
++++ b/test/share.test.js
@@@ -93,10 +93,16 @@ async function until(fn, ms = 3000) {
await t.get(`/api/v1/shares/${other.share.id}`, { as: alex, method: 'DELETE' });
});
-- await check('nothing connected: the visitor is told the computer is not connected', async () => {
++ await check('nothing connected: the visitor is told so, and a browser waits for it (CON-17)', async () => {
const r = await t.share(made.share.host, '/', { cookie: alexCookie });
-- assert.strictEqual(r.status, 502);
++ assert.strictEqual(r.status, 503);
++ assert.strictEqual(r.headers['retry-after'], '10');
assert.match(r.text, /computer is not connected/);
++ const page = await t.share(made.share.host, '/', { cookie: alexCookie, headers: { accept: 'text/html' } });
++ assert.strictEqual(page.status, 503);
++ assert.match(page.text, /<meta http-equiv="refresh" content="10">/, 'asks again by itself, with no script');
++ assert.match(page.text, /asks again every 10 seconds and opens the app as soon as the computer is back/);
++ assert.match(page.headers['content-security-policy'], /default-src 'none'/, 'still no script');
});
await check('the client connects with its key and the share answers from the local app', async () => {