openvibers/openvibe.connect Commits
A share can close itself (CON-6: expiry) (#8)
Changes
@@@ -49,9 +49,9 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w| Call | What ||---|---|| `GET /api/v1/shares` | your shares, with `connected` |--| `POST /api/v1/shares` `{ label, port, mode?, named? }` | make one → `201 { share, key }` (the key is in this answer only) |++| `POST /api/v1/shares` `{ label, port, mode?, named?, expires_in? }` | make one → `201 { share, key }` (the key is in this answer only); `expires_in` (600 s to 30 days) closes it by itself || `GET /api/v1/shares/:id` | one share |--| `PATCH /api/v1/shares/:id` `{ mode?, named?, port?, note? }` | change it |++| `PATCH /api/v1/shares/:id` `{ mode?, named?, port?, note?, expires_in? }` | change it; `expires_in: null` keeps it open until you close it || `DELETE /api/v1/shares/:id` | close it (410 from then on) || `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected || `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- A share can close itself (CON-6, the contract's website.share@1 expires_at): "Closes in 1 hour / 8 hours / 1 day /++-- 7 days" on the share page. Null is "until you close it", every row's value today, and the code before this one++-- never reads it. The sweep (app.js) finds the due ones by this index.++ALTER TABLE shares ADD COLUMN IF NOT EXISTS expires_at timestamptz;++CREATE INDEX IF NOT EXISTS shares_expiring ON shares (expires_at) WHERE status = 'active' AND expires_at IS NOT NULL;@@@ -76,11 +76,11 @@.cn-field { display: grid; gap: 6px; min-width: 0; align-content: start; }.cn-field > span:first-child { font-size: 14px; font-weight: 600; }.cn-field small { font-size: 12.5px; color: var(--ovk-muted); }--.cn-field input, .cn-addr { box-sizing: border-box; width: 100%; min-height: var(--ovk-tap, 44px); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 16px; }++.cn-field input, .cn-field select, .cn-addr { box-sizing: border-box; width: 100%; min-height: var(--ovk-tap, 44px); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 16px; }.cn-addr { display: flex; align-items: center; gap: 0; padding: 0 12px; min-width: 0; cursor: text; }.cn-addr input { flex: 0 1 auto; field-sizing: content; width: 10ch; min-width: 6ch; max-width: 100%; border: 0; padding: 10px 0; min-height: 0; background: transparent; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 16px; }.cn-addr input:focus { outline: 0; }--.cn-addr:focus-within, .cn-field input:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }++.cn-addr:focus-within, .cn-field input:focus-visible, .cn-field select:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }.cn-addr-tail { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }.cn-modes { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; min-width: 0; }.cn-modes legend { font-size: 14px; font-weight: 600; margin-bottom: 8px; padding: 0; }@@@ -68,6 +68,19 @@ async function createApp(opts = {}) {const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, accountData, accountSend, log };++ /** Shares whose time is up close themselves (CON-6): their computers are disconnected and their lists forgotten. */++ ctx.expireSweep = async () => {++ let due = [];++ try { due = await shares.expireDue(); } catch (err) { log.warn('[OpenVibe.Connect] expiry sweep failed:', err.message); return []; }++ due.forEach((r) => { tunnels.close(r.id, 4003, 'the share ended'); proxy.forget(r.host); proxy.inspector.forget(r.id); });++ if (due.length) log.log(`[OpenVibe.Connect] ${due.length} share(s) ended on time`);++ return due;++ };++ if (opts.expirySweep !== false) {++ ctx.expiryTimer = setInterval(() => { ctx.expireSweep(); }, 30_000);++ ctx.expiryTimer.unref();++ }++/** A WebSocket upgrade: a share host goes through the gate; /tunnel on Connect's own host is a computer. */ctx.onUpgrade = (req, socket, head) => {if (proxy.isShareHost(req.headers.host)) { proxy.upgrade(req, socket, head).catch(() => socket.destroy()); return; }@@@ -207,6 +207,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>return null;}if (!share) { sendProblem(req, res, 404, 'no_share', 'No share here', `Nothing is shared at ${parsed.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }++ // Its time is up: closed now, even before the sweep (app.js) marks it.++ const expired = share.expired || (share.status === 'active' && share.expires_at && Date.parse(share.expires_at) <= now());++ if (expired) { sendProblem(req, res, 410, 'expired', 'This share has ended', `${share.host} was shared until ${new Date(share.expires_at).toUTCString().replace(' GMT', ' UTC')}, and that time is up.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }return share;}@@@ -5,9 +5,10 @@* Bearer). A session write must come from connect.openvibe.website itself (403 connect.cross_site).** GET /shares your shares (website.share@1 shapes, plus connected)-- * POST /shares { label, port, mode?, named? } → 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-- * PATCH /shares/:id { mode?, named?, port?, note? }++ * 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* 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* GET /shares/:id/visits who opened it in the last 7 days@@@ -44,7 +45,7 @@ function createApi(ctx) {r.post('/shares', guard(async (req, res) => {if (!req.principal.username) return fail(res, req, 403, 'identity.no_username', 'Your account needs a username before it can share.');const b = req.body || {};-- const { share, key } = await shares.create(owner(req), { label: b.label, port: b.port, mode: b.mode, named: b.named, note: b.note });++ const { share, key } = await shares.create(owner(req), { label: b.label, port: b.port, mode: b.mode, named: b.named, note: b.note, expires_in: b.expires_in });return res.status(201).json({ share: shape(share), key });}));@@@ -63,7 +64,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 });++ 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 });proxy.forget(updated.host);return res.json({ share: shape(updated) });}));@@@ -56,7 +56,7 @@ function createPageRoutes(ctx) {r.post('/shares', async (req, res) => {if (!guard(req, res)) return undefined;-- const input = { label: req.body.label, port: req.body.port, mode: req.body.mode, named: req.body.named };++ const input = { label: req.body.label, port: req.body.port, mode: req.body.mode, named: req.body.named, expires_in: req.body.expires_in };try {const { share, key } = await shares.create(owner(req), input);return page(req, res, { title: share.host, body: views.sharePage({ share, connected: false, key, baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', notice: 'Your share is made. Run the command below on your computer.' }) }, 201);@@@ -96,7 +96,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) });++ 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 });proxy.forget(updated.host);return res.redirect(303, `/shares/${encodeURIComponent(share.id)}?saved=1`);} catch (err) {@@@ -21,6 +21,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }() => { for (const t of timers) clearInterval(t); },() => ctx.tunnels.closeAll(),() => ctx.proxy.stop(),++ () => clearInterval(ctx.expiryTimer),() => ctx.gate.stop(),() => ctx.keys.client.stop(),async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },@@@ -26,6 +26,9 @@ const LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,39}$/;const MODES = ['owner', 'named', 'signin'];const RESERVED_LABELS = new Set(['www', 'api', 'app', 'admin', 'connect', 'hooks', 'edge', 'share', 'status', 'mail', 'space', 'auth', 'login', 'openvibe']);const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;++// How long a share may stay open by itself (CON-6): 10 minutes to 30 days, or until its owner closes it (null).++const EXPIRES_MIN = 600;++const EXPIRES_MAX = 30 * 86400;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');@@@ -79,6 +82,16 @@ function normalizeNamed(list) {return out;}++/** `expires_in` as given (seconds; '' / null / 0 / 'never' = no end) → seconds or null; a bad one throws. */++function expiresIn(value) {++ if (value == null || value === '' || value === 0 || value === '0' || value === 'never') return null;++ const n = Number(value);++ if (!Number.isInteger(n) || n < EXPIRES_MIN || n > EXPIRES_MAX) {++ throw new ShareError(422, 'share.invalid_expiry', 'A share can close itself after 10 minutes to 30 days, or stay open until you close it.');++ }++ return n;++}++function createShares(s, { config }) {const { db } = s;const domain = config.shares.domain;@@@ -104,6 +117,9 @@ function createShares(s, { config }) {created_at: iso(row.created_at),updated_at: iso(row.updated_at),revoked_at: iso(row.revoked_at),++ expires_at: iso(row.expires_at),++ // 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),last_visit_at: iso(row.last_visit_at),requests: Number(row.requests || 0),@@@ -146,6 +162,7 @@ function createShares(s, { config }) {const host = hostFor(label, owner.username, domain);if (!host) throw new ShareError(422, 'share.name_too_long', 'That name and your username make an address longer than 63 characters. Pick a shorter name.');const note = input.note ? String(input.note).slice(0, 200) : null;++ const ttl = expiresIn(input.expires_in);const live = await db.one("SELECT count(*)::int AS n FROM shares WHERE owner_subject = $1 AND status = 'active'", [owner.subject]);if (live.n >= config.shares.perPerson) throw new ShareError(409, 'share.limit', `You have ${live.n} shares open, the most for now. Close one first.`);@@@ -160,9 +177,9 @@ function createShares(s, { config }) {const id = s.newId('shr');try {await db.exec(-- `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, mode, named, key_prefix, key_hash, note)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)`,-- [id, owner.subject, String(owner.username).slice(0, 64), label, host, port, mode, JSON.stringify(named), key.slice(0, 10), sha256(key), note],++ `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, mode, named, key_prefix, key_hash, note, expires_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, CASE WHEN $12::int IS NULL THEN NULL ELSE now() + make_interval(secs => $12::int) END)`,++ [id, owner.subject, String(owner.username).slice(0, 64), label, host, port, mode, JSON.stringify(named), key.slice(0, 10), sha256(key), note, ttl],);} catch (err) {if (err && err.code === '23505') throw new ShareError(409, 'share.taken', 'That address is taken. Pick another name.');@@@ -183,10 +200,15 @@ 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);++ // 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 WHERE id = $1`,-- [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0],++ 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++ WHERE id = $1`,++ [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0, keepExpiry, ttl],);return get(share.id);}@@@ -206,6 +228,14 @@ function createShares(s, { config }) {return get(share.id);}++ /** 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(++ `UPDATE shares SET status = 'revoked', revoked_at = now(), updated_at = now(), policy_version = policy_version + 1++ WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= now() RETURNING id, host`,++ );++ }++async function touchConnected(id) { await db.exec('UPDATE shares SET last_connected_at = now() WHERE id = $1', [id]); }/** Flush counters gathered in memory: { [shareId]: { requests, bytesIn, bytesOut, visits: { [subject]: { username, requests, denied } } } } */@@@ -245,7 +275,7 @@ function createShares(s, { config }) {return false;}-- return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, touchConnected, flushCounters, visits, allows, view, domain };++ return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, expireDue, touchConnected, flushCounters, visits, allows, view, domain };}function iso(v) {@@@ -73,6 +73,27 @@ function modeChoices(current, staff) {}).join('');}++// How long a share stays open by itself (CON-6), as the forms offer it: seconds → words.++const CLOSES = [['', 'When you close it'], ['3600', 'In 1 hour'], ['28800', 'In 8 hours'], ['86400', 'In 1 day'], ['604800', 'In 7 days']];++++/** When a time to come is, in words: "in 3 hours", "in 2 days", "in 12 minutes". */++function until(iso, now = Date.now()) {++ const ms = Date.parse(iso) - now;++ if (!Number.isFinite(ms)) return '';++ if (ms <= 0) return 'now';++ const m = Math.round(ms / 60000);++ if (m < 60) return `in ${m} minute${m === 1 ? '' : 's'}`;++ const h = Math.round(m / 60);++ if (h < 48) return `in ${h} hour${h === 1 ? '' : 's'}`;++ const d = Math.round(h / 24);++ return `in ${d} days`;++}++++function closesField(current, { keep = null } = {}) {++ const opts = (keep ? [['keep', keep]] : []).concat(keep ? CLOSES.map(([v, t]) => [v || 'never', v ? `${t.replace('In ', '')} from now` : 'Only when you close it']) : CLOSES);++ return `<label class="cn-field cn-field-closes"><span>Closes by itself</span><select name="expires_in">${opts.map(([v, t]) => `<option value="${esc(v)}"${String(current) === v ? ' selected' : ''}>${esc(t)}</option>`).join('')}</select><small>The address answers "ended" after that, and the computer is disconnected.</small></label>`;++}++function createForm({ values = {}, error = null, username = '', staff = false } = {}) {const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';return `<form class="cn-form" method="post" action="/shares" id="new">@@@ -82,6 +103,7 @@ ${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.openvibe.website</span></span><small>a–z, 0–9 and hyphens</small></label><label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label></div>++${closesField(values.expires_in == null ? '' : values.expires_in)}<fieldset class="cn-modes"><legend>Who can open it</legend>${modeChoices(values.mode || 'owner', staff)}</fieldset>@@@ -223,6 +245,7 @@ ${modeChoices(share.mode, staff)}</fieldset><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' })}<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>`;@@@ -245,6 +268,7 @@ ${key ? keyBlock(share, key, baseUrl) : ''}<div><dt>Data</dt><dd>${esc(fmtBytes(share.bytes_in))} in · ${esc(fmtBytes(share.bytes_out))} out</dd></div><div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div><div><dt>Made</dt><dd>${esc(ago(share.created_at, now))}</dd></div>++${share.expires_at ? `<div><dt>${closed ? 'Ended' : 'Closes'}</dt><dd><time datetime="${esc(share.expires_at)}">${esc(closed ? ago(share.expires_at, now) : until(share.expires_at, now))}</time></dd></div>` : ''}</dl>${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>@@@ -270,4 +294,4 @@ function message({ title, text, link = null }) {function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }--module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, requestRow, size };++module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, until, requestRow, size };@@@ -277,6 +277,42 @@ async function until(fn, ms = 3000) {assert.deepStrictEqual([refused.status, refused.error], [502, 'local_refused'], 'nothing listening is named');});++ await check('a share can close itself (CON-6): set, changed, kept, refused when odd, and ended on time', async () => {++ const made2 = await t.get('/api/v1/shares', { as: alex, method: 'POST', json: { label: 'brief', port: app.port, expires_in: 3600 } });++ assert.strictEqual(made2.status, 201, made2.text);++ const { share: brief, key } = made2.json();++ const left = Date.parse(brief.expires_at) - Date.now();++ assert.ok(left > 3500 * 1000 && left <= 3600 * 1000, `about an hour (${left} ms)`);++ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, method: 'POST', json: { label: 'blink', port: app.port, expires_in: 5 } })).json().code, 'share.invalid_expiry');++ const kept = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex, method: 'PATCH', json: { note: 'still brief' } })).json().share;++ assert.strictEqual(kept.expires_at, brief.expires_at, 'a change without expires_in keeps the time');++ const open = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex, method: 'PATCH', json: { expires_in: null } })).json().share;++ assert.strictEqual(open.expires_at, null, 'null: open until closed');++ const again = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex, method: 'PATCH', json: { expires_in: 600 } })).json().share;++ assert.ok(Date.parse(again.expires_at) - Date.now() <= 600 * 1000);++ const pageText = (await t.get(`/shares/${brief.id}`, { as: alex })).text;++ assert.match(pageText, /<dt>Closes<\/dt><dd><time datetime="[^"]+">in 10 minutes<\/time>/);++ assert.match(pageText, /<option value="keep" selected>As it is: in 10 minutes<\/option>/);++++ const cBrief = t.client({ key, port: app.port });++ await cBrief.ready();++ const cookie = (await t.enter(alex, brief)).cookie;++ assert.strictEqual((await t.share(brief.host, '/', { cookie, headers: { accept: 'application/json' } })).status, 200);++ // The time is up: the address says so at once, and the sweep closes it and lets the computer go.++ await t.ctx.s.db.exec("UPDATE shares SET expires_at = now() - interval '1 second' WHERE id = $1", [brief.id]);++ t.ctx.proxy.forget(brief.host);++ const ended = await t.share(brief.host, '/', { cookie, headers: { accept: 'application/json' } });++ assert.strictEqual(ended.status, 410);++ assert.match(ended.text, /expired/);++ const due = await t.ctx.expireSweep();++ assert.deepStrictEqual(due.map((r) => r.id), [brief.id]);++ await until(() => cBrief.lines.some((l) => /Stopped/.test(l)));++ const after = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex })).json().share;++ assert.deepStrictEqual([after.status, after.expired], ['revoked', true]);++ assert.match((await t.get(`/shares/${brief.id}`, { as: alex })).text, /<dt>Ended<\/dt>/);++ assert.deepStrictEqual(await t.ctx.expireSweep(), [], 'once');++ });++await check('another person cannot see, change or close someone else\'s share', async () => {assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);
@@ -49,9 +49,9 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
@@ -49,9 +49,9 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
| Call | What |
| Call | What |
|---|---|
|---|---|
| `GET /api/v1/shares` | your shares, with `connected` |
| `GET /api/v1/shares` | your shares, with `connected` |
-| `POST /api/v1/shares` `{ label, port, mode?, named? }` | make one → `201 { share, key }` (the key is in this answer only) |
+| `POST /api/v1/shares` `{ label, port, mode?, named?, expires_in? }` | make one → `201 { share, key }` (the key is in this answer only); `expires_in` (600 s to 30 days) closes it by itself |
| `GET /api/v1/shares/:id` | one share |
| `GET /api/v1/shares/:id` | one share |
-| `PATCH /api/v1/shares/:id` `{ mode?, named?, port?, note? }` | change it |
+| `PATCH /api/v1/shares/:id` `{ mode?, named?, port?, note?, expires_in? }` | change it; `expires_in: null` keeps it open until you close it |
| `DELETE /api/v1/shares/:id` | close it (410 from then on) |
| `DELETE /api/v1/shares/:id` | close it (410 from then on) |
| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |
| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |
| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |
| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- A share can close itself (CON-6, the contract's website.share@1 expires_at): "Closes in 1 hour / 8 hours / 1 day /
+-- 7 days" on the share page. Null is "until you close it", every row's value today, and the code before this one
+-- never reads it. The sweep (app.js) finds the due ones by this index.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS expires_at timestamptz;
+CREATE INDEX IF NOT EXISTS shares_expiring ON shares (expires_at) WHERE status = 'active' AND expires_at IS NOT NULL;
@@ -76,11 +76,11 @@
@@ -76,11 +76,11 @@
.cn-field { display: grid; gap: 6px; min-width: 0; align-content: start; }
.cn-field { display: grid; gap: 6px; min-width: 0; align-content: start; }
.cn-field > span:first-child { font-size: 14px; font-weight: 600; }
.cn-field > span:first-child { font-size: 14px; font-weight: 600; }
.cn-field small { font-size: 12.5px; color: var(--ovk-muted); }
.cn-field small { font-size: 12.5px; color: var(--ovk-muted); }
-.cn-field input, .cn-addr { box-sizing: border-box; width: 100%; min-height: var(--ovk-tap, 44px); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 16px; }
+.cn-field input, .cn-field select, .cn-addr { box-sizing: border-box; width: 100%; min-height: var(--ovk-tap, 44px); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 16px; }
.cn-addr { display: flex; align-items: center; gap: 0; padding: 0 12px; min-width: 0; cursor: text; }
.cn-addr { display: flex; align-items: center; gap: 0; padding: 0 12px; min-width: 0; cursor: text; }
.cn-addr input { flex: 0 1 auto; field-sizing: content; width: 10ch; min-width: 6ch; max-width: 100%; border: 0; padding: 10px 0; min-height: 0; background: transparent; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 16px; }
.cn-addr input { flex: 0 1 auto; field-sizing: content; width: 10ch; min-width: 6ch; max-width: 100%; border: 0; padding: 10px 0; min-height: 0; background: transparent; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 16px; }
.cn-addr input:focus { outline: 0; }
.cn-addr input:focus { outline: 0; }
-.cn-addr:focus-within, .cn-field input:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }
+.cn-addr:focus-within, .cn-field input:focus-visible, .cn-field select:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }
.cn-addr-tail { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.cn-addr-tail { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.cn-modes { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; min-width: 0; }
.cn-modes { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; min-width: 0; }
.cn-modes legend { font-size: 14px; font-weight: 600; margin-bottom: 8px; padding: 0; }
.cn-modes legend { font-size: 14px; font-weight: 600; margin-bottom: 8px; padding: 0; }
@@ -68,6 +68,19 @@ async function createApp(opts = {}) {
@@ -68,6 +68,19 @@ async function createApp(opts = {}) {
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, accountData, accountSend, log };
+ /** Shares whose time is up close themselves (CON-6): their computers are disconnected and their lists forgotten. */
+ ctx.expireSweep = async () => {
+ let due = [];
+ try { due = await shares.expireDue(); } catch (err) { log.warn('[OpenVibe.Connect] expiry sweep failed:', err.message); return []; }
+ due.forEach((r) => { tunnels.close(r.id, 4003, 'the share ended'); proxy.forget(r.host); proxy.inspector.forget(r.id); });
+ if (due.length) log.log(`[OpenVibe.Connect] ${due.length} share(s) ended on time`);
+ return due;
+ };
+ if (opts.expirySweep !== false) {
+ ctx.expiryTimer = setInterval(() => { ctx.expireSweep(); }, 30_000);
+ ctx.expiryTimer.unref();
+ }
+
/** A WebSocket upgrade: a share host goes through the gate; /tunnel on Connect's own host is a computer. */
/** A WebSocket upgrade: a share host goes through the gate; /tunnel on Connect's own host is a computer. */
ctx.onUpgrade = (req, socket, head) => {
ctx.onUpgrade = (req, socket, head) => {
if (proxy.isShareHost(req.headers.host)) { proxy.upgrade(req, socket, head).catch(() => socket.destroy()); return; }
if (proxy.isShareHost(req.headers.host)) { proxy.upgrade(req, socket, head).catch(() => socket.destroy()); return; }
@@ -207,6 +207,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -207,6 +207,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
return null;
return null;
}
}
if (!share) { sendProblem(req, res, 404, 'no_share', 'No share here', `Nothing is shared at ${parsed.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (!share) { sendProblem(req, res, 404, 'no_share', 'No share here', `Nothing is shared at ${parsed.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
+ // Its time is up: closed now, even before the sweep (app.js) marks it.
+ const expired = share.expired || (share.status === 'active' && share.expires_at && Date.parse(share.expires_at) <= now());
+ if (expired) { sendProblem(req, res, 410, 'expired', 'This share has ended', `${share.host} was shared until ${new Date(share.expires_at).toUTCString().replace(' GMT', ' UTC')}, and that time is up.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
return share;
return share;
}
}
@@ -5,9 +5,10 @@
@@ -5,9 +5,10 @@
* Bearer). A session write must come from connect.openvibe.website itself (403 connect.cross_site).
* Bearer). A session write must come from connect.openvibe.website itself (403 connect.cross_site).
*
*
* 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? } → 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? }
+ * 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
* 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
* GET /shares/:id/visits who opened it in the last 7 days
* GET /shares/:id/visits who opened it in the last 7 days
@@ -44,7 +45,7 @@ function createApi(ctx) {
@@ -44,7 +45,7 @@ function createApi(ctx) {
r.post('/shares', guard(async (req, res) => {
r.post('/shares', guard(async (req, res) => {
if (!req.principal.username) return fail(res, req, 403, 'identity.no_username', 'Your account needs a username before it can share.');
if (!req.principal.username) return fail(res, req, 403, 'identity.no_username', 'Your account needs a username before it can share.');
const b = req.body || {};
const b = req.body || {};
- const { share, key } = await shares.create(owner(req), { label: b.label, port: b.port, mode: b.mode, named: b.named, note: b.note });
+ const { share, key } = await shares.create(owner(req), { label: b.label, port: b.port, mode: b.mode, named: b.named, note: b.note, expires_in: b.expires_in });
return res.status(201).json({ share: shape(share), key });
return res.status(201).json({ share: shape(share), key });
}));
}));
@@ -63,7 +64,7 @@ function createApi(ctx) {
@@ -63,7 +64,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 });
+ 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 });
proxy.forget(updated.host);
proxy.forget(updated.host);
return res.json({ share: shape(updated) });
return res.json({ share: shape(updated) });
}));
}));
@@ -56,7 +56,7 @@ function createPageRoutes(ctx) {
@@ -56,7 +56,7 @@ function createPageRoutes(ctx) {
r.post('/shares', async (req, res) => {
r.post('/shares', async (req, res) => {
if (!guard(req, res)) return undefined;
if (!guard(req, res)) return undefined;
- const input = { label: req.body.label, port: req.body.port, mode: req.body.mode, named: req.body.named };
+ const input = { label: req.body.label, port: req.body.port, mode: req.body.mode, named: req.body.named, expires_in: req.body.expires_in };
try {
try {
const { share, key } = await shares.create(owner(req), input);
const { share, key } = await shares.create(owner(req), input);
return page(req, res, { title: share.host, body: views.sharePage({ share, connected: false, key, baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', notice: 'Your share is made. Run the command below on your computer.' }) }, 201);
return page(req, res, { title: share.host, body: views.sharePage({ share, connected: false, key, baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', notice: 'Your share is made. Run the command below on your computer.' }) }, 201);
@@ -96,7 +96,7 @@ function createPageRoutes(ctx) {
@@ -96,7 +96,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) });
+ 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 });
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) {
@@ -21,6 +21,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
@@ -21,6 +21,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
() => { for (const t of timers) clearInterval(t); },
() => { for (const t of timers) clearInterval(t); },
() => ctx.tunnels.closeAll(),
() => ctx.tunnels.closeAll(),
() => ctx.proxy.stop(),
() => ctx.proxy.stop(),
+ () => clearInterval(ctx.expiryTimer),
() => ctx.gate.stop(),
() => ctx.gate.stop(),
() => ctx.keys.client.stop(),
() => ctx.keys.client.stop(),
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
@@ -26,6 +26,9 @@ const LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,39}$/;
@@ -26,6 +26,9 @@ const LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,39}$/;
const MODES = ['owner', 'named', 'signin'];
const MODES = ['owner', 'named', 'signin'];
const RESERVED_LABELS = new Set(['www', 'api', 'app', 'admin', 'connect', 'hooks', 'edge', 'share', 'status', 'mail', 'space', 'auth', 'login', 'openvibe']);
const RESERVED_LABELS = new Set(['www', 'api', 'app', 'admin', 'connect', 'hooks', 'edge', 'share', 'status', 'mail', 'space', 'auth', 'login', 'openvibe']);
const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
+// How long a share may stay open by itself (CON-6): 10 minutes to 30 days, or until its owner closes it (null).
+const EXPIRES_MIN = 600;
+const EXPIRES_MAX = 30 * 86400;
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');
@@ -79,6 +82,16 @@ function normalizeNamed(list) {
@@ -79,6 +82,16 @@ function normalizeNamed(list) {
return out;
return out;
}
}
+/** `expires_in` as given (seconds; '' / null / 0 / 'never' = no end) → seconds or null; a bad one throws. */
+function expiresIn(value) {
+ if (value == null || value === '' || value === 0 || value === '0' || value === 'never') return null;
+ const n = Number(value);
+ if (!Number.isInteger(n) || n < EXPIRES_MIN || n > EXPIRES_MAX) {
+ throw new ShareError(422, 'share.invalid_expiry', 'A share can close itself after 10 minutes to 30 days, or stay open until you close it.');
+ }
+ return n;
+}
+
function createShares(s, { config }) {
function createShares(s, { config }) {
const { db } = s;
const { db } = s;
const domain = config.shares.domain;
const domain = config.shares.domain;
@@ -104,6 +117,9 @@ function createShares(s, { config }) {
@@ -104,6 +117,9 @@ function createShares(s, { config }) {
created_at: iso(row.created_at),
created_at: iso(row.created_at),
updated_at: iso(row.updated_at),
updated_at: iso(row.updated_at),
revoked_at: iso(row.revoked_at),
revoked_at: iso(row.revoked_at),
+ expires_at: iso(row.expires_at),
+ // 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),
last_connected_at: iso(row.last_connected_at),
last_visit_at: iso(row.last_visit_at),
last_visit_at: iso(row.last_visit_at),
requests: Number(row.requests || 0),
requests: Number(row.requests || 0),
@@ -146,6 +162,7 @@ function createShares(s, { config }) {
@@ -146,6 +162,7 @@ function createShares(s, { config }) {
const host = hostFor(label, owner.username, domain);
const host = hostFor(label, owner.username, domain);
if (!host) throw new ShareError(422, 'share.name_too_long', 'That name and your username make an address longer than 63 characters. Pick a shorter name.');
if (!host) throw new ShareError(422, 'share.name_too_long', 'That name and your username make an address longer than 63 characters. Pick a shorter name.');
const note = input.note ? String(input.note).slice(0, 200) : null;
const note = input.note ? String(input.note).slice(0, 200) : null;
+ const ttl = expiresIn(input.expires_in);
const live = await db.one("SELECT count(*)::int AS n FROM shares WHERE owner_subject = $1 AND status = 'active'", [owner.subject]);
const live = await db.one("SELECT count(*)::int AS n FROM shares WHERE owner_subject = $1 AND status = 'active'", [owner.subject]);
if (live.n >= config.shares.perPerson) throw new ShareError(409, 'share.limit', `You have ${live.n} shares open, the most for now. Close one first.`);
if (live.n >= config.shares.perPerson) throw new ShareError(409, 'share.limit', `You have ${live.n} shares open, the most for now. Close one first.`);
@@ -160,9 +177,9 @@ function createShares(s, { config }) {
@@ -160,9 +177,9 @@ function createShares(s, { config }) {
const id = s.newId('shr');
const id = s.newId('shr');
try {
try {
await db.exec(
await db.exec(
- `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, mode, named, key_prefix, key_hash, note)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)`,
- [id, owner.subject, String(owner.username).slice(0, 64), label, host, port, mode, JSON.stringify(named), key.slice(0, 10), sha256(key), note],
+ `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, mode, named, key_prefix, key_hash, note, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, CASE WHEN $12::int IS NULL THEN NULL ELSE now() + make_interval(secs => $12::int) END)`,
+ [id, owner.subject, String(owner.username).slice(0, 64), label, host, port, mode, JSON.stringify(named), key.slice(0, 10), sha256(key), note, ttl],
);
);
} catch (err) {
} catch (err) {
if (err && err.code === '23505') throw new ShareError(409, 'share.taken', 'That address is taken. Pick another name.');
if (err && err.code === '23505') throw new ShareError(409, 'share.taken', 'That address is taken. Pick another name.');
@@ -183,10 +200,15 @@ function createShares(s, { config }) {
@@ -183,10 +200,15 @@ 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);
+ // 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(
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 WHERE id = $1`,
- [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0],
+ 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
+ WHERE id = $1`,
+ [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0, keepExpiry, ttl],
);
);
return get(share.id);
return get(share.id);
}
}
@@ -206,6 +228,14 @@ function createShares(s, { config }) {
@@ -206,6 +228,14 @@ function createShares(s, { config }) {
return get(share.id);
return get(share.id);
}
}
+ /** 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(
+ `UPDATE shares SET status = 'revoked', revoked_at = now(), updated_at = now(), policy_version = policy_version + 1
+ WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= now() RETURNING id, host`,
+ );
+ }
+
async function touchConnected(id) { await db.exec('UPDATE shares SET last_connected_at = now() WHERE id = $1', [id]); }
async function touchConnected(id) { await db.exec('UPDATE shares SET last_connected_at = now() WHERE id = $1', [id]); }
/** Flush counters gathered in memory: { [shareId]: { requests, bytesIn, bytesOut, visits: { [subject]: { username, requests, denied } } } } */
/** Flush counters gathered in memory: { [shareId]: { requests, bytesIn, bytesOut, visits: { [subject]: { username, requests, denied } } } } */
@@ -245,7 +275,7 @@ function createShares(s, { config }) {
@@ -245,7 +275,7 @@ function createShares(s, { config }) {
return false;
return false;
}
}
- return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, touchConnected, flushCounters, visits, allows, view, domain };
+ return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, expireDue, touchConnected, flushCounters, visits, allows, view, domain };
}
}
function iso(v) {
function iso(v) {
@@ -73,6 +73,27 @@ function modeChoices(current, staff) {
@@ -73,6 +73,27 @@ function modeChoices(current, staff) {
}).join('');
}).join('');
}
}
+// How long a share stays open by itself (CON-6), as the forms offer it: seconds → words.
+const CLOSES = [['', 'When you close it'], ['3600', 'In 1 hour'], ['28800', 'In 8 hours'], ['86400', 'In 1 day'], ['604800', 'In 7 days']];
+
+/** When a time to come is, in words: "in 3 hours", "in 2 days", "in 12 minutes". */
+function until(iso, now = Date.now()) {
+ const ms = Date.parse(iso) - now;
+ if (!Number.isFinite(ms)) return '';
+ if (ms <= 0) return 'now';
+ const m = Math.round(ms / 60000);
+ if (m < 60) return `in ${m} minute${m === 1 ? '' : 's'}`;
+ const h = Math.round(m / 60);
+ if (h < 48) return `in ${h} hour${h === 1 ? '' : 's'}`;
+ const d = Math.round(h / 24);
+ return `in ${d} days`;
+}
+
+function closesField(current, { keep = null } = {}) {
+ const opts = (keep ? [['keep', keep]] : []).concat(keep ? CLOSES.map(([v, t]) => [v || 'never', v ? `${t.replace('In ', '')} from now` : 'Only when you close it']) : CLOSES);
+ return `<label class="cn-field cn-field-closes"><span>Closes by itself</span><select name="expires_in">${opts.map(([v, t]) => `<option value="${esc(v)}"${String(current) === v ? ' selected' : ''}>${esc(t)}</option>`).join('')}</select><small>The address answers "ended" after that, and the computer is disconnected.</small></label>`;
+}
+
function createForm({ values = {}, error = null, username = '', staff = false } = {}) {
function createForm({ values = {}, error = null, username = '', staff = false } = {}) {
const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';
const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';
return `<form class="cn-form" method="post" action="/shares" id="new">
return `<form class="cn-form" method="post" action="/shares" id="new">
@@ -82,6 +103,7 @@ ${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
@@ -82,6 +103,7 @@ ${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.openvibe.website</span></span><small>a–z, 0–9 and hyphens</small></label>
<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.openvibe.website</span></span><small>a–z, 0–9 and hyphens</small></label>
<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label>
<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label>
</div>
</div>
+${closesField(values.expires_in == null ? '' : values.expires_in)}
<fieldset class="cn-modes"><legend>Who can open it</legend>
<fieldset class="cn-modes"><legend>Who can open it</legend>
${modeChoices(values.mode || 'owner', staff)}
${modeChoices(values.mode || 'owner', staff)}
</fieldset>
</fieldset>
@@ -223,6 +245,7 @@ ${modeChoices(share.mode, staff)}
@@ -223,6 +245,7 @@ ${modeChoices(share.mode, staff)}
</fieldset>
</fieldset>
<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' })}
<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>`;
@@ -245,6 +268,7 @@ ${key ? keyBlock(share, key, baseUrl) : ''}
@@ -245,6 +268,7 @@ ${key ? keyBlock(share, key, baseUrl) : ''}
<div><dt>Data</dt><dd>${esc(fmtBytes(share.bytes_in))} in · ${esc(fmtBytes(share.bytes_out))} out</dd></div>
<div><dt>Data</dt><dd>${esc(fmtBytes(share.bytes_in))} in · ${esc(fmtBytes(share.bytes_out))} out</dd></div>
<div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div>
<div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div>
<div><dt>Made</dt><dd>${esc(ago(share.created_at, now))}</dd></div>
<div><dt>Made</dt><dd>${esc(ago(share.created_at, now))}</dd></div>
+${share.expires_at ? `<div><dt>${closed ? 'Ended' : 'Closes'}</dt><dd><time datetime="${esc(share.expires_at)}">${esc(closed ? ago(share.expires_at, now) : until(share.expires_at, now))}</time></dd></div>` : ''}
</dl>
</dl>
${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
@@ -270,4 +294,4 @@ function message({ title, text, link = null }) {
@@ -270,4 +294,4 @@ function message({ title, text, link = null }) {
function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }
function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }
-module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, requestRow, size };
+module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, until, requestRow, size };
@@ -277,6 +277,42 @@ async function until(fn, ms = 3000) {
@@ -277,6 +277,42 @@ async function until(fn, ms = 3000) {
assert.deepStrictEqual([refused.status, refused.error], [502, 'local_refused'], 'nothing listening is named');
assert.deepStrictEqual([refused.status, refused.error], [502, 'local_refused'], 'nothing listening is named');
});
});
+ await check('a share can close itself (CON-6): set, changed, kept, refused when odd, and ended on time', async () => {
+ const made2 = await t.get('/api/v1/shares', { as: alex, method: 'POST', json: { label: 'brief', port: app.port, expires_in: 3600 } });
+ assert.strictEqual(made2.status, 201, made2.text);
+ const { share: brief, key } = made2.json();
+ const left = Date.parse(brief.expires_at) - Date.now();
+ assert.ok(left > 3500 * 1000 && left <= 3600 * 1000, `about an hour (${left} ms)`);
+ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, method: 'POST', json: { label: 'blink', port: app.port, expires_in: 5 } })).json().code, 'share.invalid_expiry');
+ const kept = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex, method: 'PATCH', json: { note: 'still brief' } })).json().share;
+ assert.strictEqual(kept.expires_at, brief.expires_at, 'a change without expires_in keeps the time');
+ const open = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex, method: 'PATCH', json: { expires_in: null } })).json().share;
+ assert.strictEqual(open.expires_at, null, 'null: open until closed');
+ const again = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex, method: 'PATCH', json: { expires_in: 600 } })).json().share;
+ assert.ok(Date.parse(again.expires_at) - Date.now() <= 600 * 1000);
+ const pageText = (await t.get(`/shares/${brief.id}`, { as: alex })).text;
+ assert.match(pageText, /<dt>Closes<\/dt><dd><time datetime="[^"]+">in 10 minutes<\/time>/);
+ assert.match(pageText, /<option value="keep" selected>As it is: in 10 minutes<\/option>/);
+
+ const cBrief = t.client({ key, port: app.port });
+ await cBrief.ready();
+ const cookie = (await t.enter(alex, brief)).cookie;
+ assert.strictEqual((await t.share(brief.host, '/', { cookie, headers: { accept: 'application/json' } })).status, 200);
+ // The time is up: the address says so at once, and the sweep closes it and lets the computer go.
+ await t.ctx.s.db.exec("UPDATE shares SET expires_at = now() - interval '1 second' WHERE id = $1", [brief.id]);
+ t.ctx.proxy.forget(brief.host);
+ const ended = await t.share(brief.host, '/', { cookie, headers: { accept: 'application/json' } });
+ assert.strictEqual(ended.status, 410);
+ assert.match(ended.text, /expired/);
+ const due = await t.ctx.expireSweep();
+ assert.deepStrictEqual(due.map((r) => r.id), [brief.id]);
+ await until(() => cBrief.lines.some((l) => /Stopped/.test(l)));
+ const after = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex })).json().share;
+ assert.deepStrictEqual([after.status, after.expired], ['revoked', true]);
+ assert.match((await t.get(`/shares/${brief.id}`, { as: alex })).text, /<dt>Ended<\/dt>/);
+ assert.deepStrictEqual(await t.ctx.expireSweep(), [], 'once');
+ });
+
await check('another person cannot see, change or close someone else\'s share', async () => {
await check('another person cannot see, change or close someone else\'s share', async () => {
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);