openvibers/openvibe-git Commits

Clone to one of my computers from the repository page (#20)

OP OpenVibers committed 3 days ago · 11a09587ca · 9 files, +405 −25

* Clone to one of my computers from the repository page

The Get it panel lists the person's own Link computers and sends the clone down the watch the chosen one already
holds, so the page needs no terminal command. The request is addressed to that machine (queued, then waiting while
it asks its owner, cloning with git's percentage, done with the folder, refused or failed with the reason), and only
the computer it went to can move it. A request lives two minutes; an expired one is refused and its read token
revoked. Another person's computer cannot be targeted, and a repository the viewer may not read is a 404.

With no computer online the panel says so and keeps the terminal-command link.

Checked: node test/run.js — link-relay and clone-to-machine pass, including an unapproved request, another
person's computer, an offline one, and an expired one.

* Open the Get it panel to the clone's progress line

A clone started from the panel goes back to the repository with ?clone=<id>#get; the sheet opens itself so the
plain progress line is in sight without a second click.

Changes

ddiff --git a/README.md b/README.md
iindex 87d6958..aa41ff2 100644
---- a/README.md
++++ b/README.md
@@@ -82,14 +82,22 @@ G3 adds Link's outbound relay (contracts `git.clone-request@1` and `git.machine@
HTTPS with the one-time token, asks the person there, clones into a folder inside the root they chose, and posts
approved / progress / done / refused / failed back; the page's progress line polls the same row. The clone URL's
read token reaches only the Link, is kept in this process, and is revoked when the request ends.
++- **Clone to one of my computers:** the Get it panel lists the person's own Link computers, one plain button each.
++ The button makes a `clr_…` request addressed to that computer and sends it down the watch the computer already
++ holds (`kind: clone`, the repository, the https clone URL with a short read token, who asked, and the end of the two
++ minutes). The computer answers `waiting` (it is asking its owner), `approved`, `cloning` with git's percentage,
++ then `done` with the folder, or `refused` / `failed`; the panel's progress line follows the same row. Nothing is
++ written until the person answers there, and only the computer the request went to can move it. With no computer
++ online the panel says so and keeps the terminal-command link above.
- **Your computers** (`/machines`) makes an `mch_…` link for `openvibe-link git connect '<link>'`. Link publishes
the repositories under its root, read-only: the page lists them with the branch, head and date, and browses tree,
file and log through the poll the machine opened. A machine that has not checked in for thirty seconds is
-- **offline since …**; there is no write path.
++ **offline since …**; the only write it ever does is a clone the person approved there.
--The routes are `POST /api/v1/clone-requests`, `GET /api/v1/clone-requests/:id`, `POST|GET /api/v1/machines`, and the
--Link's own `/api/v1/link/clone-requests/:id` and `/api/v1/link/machines/:id/{hello,watch,results}`. Tests:
--`test/link-relay.test.js`.
++The routes are `POST /api/v1/clone-requests`, `GET /api/v1/clone-requests/:id`,
++`POST /api/v1/repos/:owner/:name/clone-to-machine` and `GET …/clone-to-machine/:id`, `POST|GET /api/v1/machines`, and
++the Link's own `/api/v1/link/clone-requests/:id`, `/api/v1/link/machines/:id/{hello,watch,results}` and
++`POST /api/v1/link/machines/:id/clone-requests/:clr`. Tests: `test/link-relay.test.js`, `test/clone-to-machine.test.js`.
<!-- versions:start -->
- openvibe-contracts: v0.384.0
ddiff --git a/migrations/0006_clone_to_machine.sql b/migrations/0006_clone_to_machine.sql
nnew file mode 100644
iindex 0000000..4616a46
---- /dev/null
++++ b/migrations/0006_clone_to_machine.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- G3, step 3: a "clone to my computer" request can be addressed to one of the person's own Link computers, so the
++-- page knows where the request went and the computer's own approval is the only way it starts. Two steps come
++-- before the clone: queued (on its way over the machine's watch) and waiting (the computer has it and is asking).
++
++ALTER TABLE git_clone_requests ADD COLUMN machine_id text;
++ALTER TABLE git_clone_requests DROP CONSTRAINT IF EXISTS git_clone_requests_state_check;
++ALTER TABLE git_clone_requests ADD CONSTRAINT git_clone_requests_state_check
++ CHECK (state IN ('queued', 'waiting', 'pending', 'approved', 'cloning', 'done', 'refused', 'failed', 'expired'));
++CREATE INDEX git_clone_requests_machine ON git_clone_requests (machine_id) WHERE machine_id IS NOT NULL;
ddiff --git a/public/js/git.js b/public/js/git.js
iindex d25d913..31bcf8e 100644
---- a/public/js/git.js
++++ b/public/js/git.js
@@@ -50,10 +50,11 @@
const line = document.querySelector('[data-clone-request]');
if (line) {
const id = line.getAttribute('data-clone-request');
-- const words = { pending: 'Waiting for your answer in Link…', approved: 'Approved; Link is starting git…', cloning: 'Cloning…', done: 'Cloned.', refused: 'Refused in Link.', failed: 'The clone failed.', expired: 'The request expired after two minutes.' };
++ const endpoint = line.getAttribute('data-clone-endpoint') || `/api/v1/clone-requests/${encodeURIComponent(id)}`;
++ const words = { queued: 'Sent to your computer…', waiting: 'Waiting for your answer in Link…', pending: 'Waiting for your answer in Link…', approved: 'Approved; Link is starting git…', cloning: 'Cloning…', done: 'Cloned.', refused: 'Refused in Link.', failed: 'The clone failed.', expired: 'The request expired after two minutes.' };
const tick = async () => {
try {
-- const res = await fetch(`/api/v1/clone-requests/${encodeURIComponent(id)}`, { credentials: 'same-origin' });
++ const res = await fetch(endpoint, { credentials: 'same-origin' });
if (!res.ok) return;
const data = await res.json();
const req = data.request || {};
ddiff --git a/server/http/link.js b/server/http/link.js
iindex 20df61f..4e6d9e9 100644
---- a/server/http/link.js
++++ b/server/http/link.js
@@@ -58,6 +58,37 @@ function createLinkRoutes(ctx) {
return res.set('Cache-Control', 'no-store').json({ request });
}));
++ // ── Clone to one of my computers ────────────────────────
++ // The request rides the watch that computer already holds; only the computer it was addressed to can report on it.
++ r.post('/repos/:owner/:name/clone-to-machine', h(async (req, res) => {
++ const me = requirePerson(req, res);
++ if (!me) return undefined;
++ const row = await repos.getRow(String(req.params.owner || '').toLowerCase(), String(req.params.name || '').toLowerCase());
++ if (!row || !repos.canView(row, me)) return problem(req, res, 404, 'repo.not_found', 'No such repository, or it is private.');
++ try {
++ const request = await relay.createCloneToMachine({ viewer: me, row, machineId: req.body && req.body.machine });
++ return res.set('Cache-Control', 'no-store').status(201).json({ request });
++ } catch (err) { return fail(req, res, err); }
++ }));
++
++ r.get('/repos/:owner/:name/clone-to-machine/:id', h(async (req, res) => {
++ const me = viewer(req);
++ if (!me) return problem(req, res, 401, 'auth.required', 'Sign in first.');
++ const request = await relay.getForPage({ id: String(req.params.id), subject: me.subject });
++ const repo = `${String(req.params.owner || '').toLowerCase()}/${String(req.params.name || '').toLowerCase()}`;
++ if (!request || request.repo !== repo) return problem(req, res, 404, 'clone.not_found', 'No such clone request.');
++ return res.set('Cache-Control', 'no-store').json({ request });
++ }));
++
++ // The computer's report on the clone it was asked to run, over its own one-time token.
++ r.post('/link/machines/:id/clone-requests/:cloneId', h(async (req, res) => {
++ const request = await relay.machineCloneUpdate({
++ machineId: String(req.params.id), token: linkToken(req), id: String(req.params.cloneId), body: req.body || {},
++ });
++ if (!request) return problem(req, res, 404, 'clone.not_found', 'No such clone request for this computer.');
++ return res.set('Cache-Control', 'no-store').json({ request });
++ }));
++
// The Link's end of the same request.
r.get('/link/clone-requests/:id', h(async (req, res) => {
const doc = await relay.linkGet({ id: String(req.params.id), token: linkToken(req) });
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1d6b085..d919d8c 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -65,14 +65,15 @@ function createPageRoutes(ctx) {
async function repoContext(req, row) {
const me = viewer(req);
-- const [starred, watching, parent] = await Promise.all([
++ const [starred, watching, parent, machines] = await Promise.all([
repos.isStarred(row, me && me.subject),
repos.isWatching(row, me && me.subject),
row.fork_of ? repos.byId(row.fork_of) : Promise.resolve(null),
++ me && me.subject ? relay.listMachines({ subject: me.subject }) : Promise.resolve(null),
]);
const start = { starred, watching, forkOf: parent ? `${parent.owner_username}/${parent.name}` : null, isOwner: repos.isOwner(row, me) };
const card = row.github_full_name ? await github.card(row.github_full_name, { branch: row.default_branch }) : null;
-- return { ...start, card, languages: row.languages };
++ return { ...start, card, languages: row.languages, machines };
}
async function readReadme(row, ref) {
@@@ -244,7 +245,10 @@ function createPageRoutes(ctx) {
const ref = row.default_branch || 'main';
const [tree, context] = await Promise.all([git.readTree(row.owner_username, row.name, ref, '').catch(() => null), repoContext(req, row)]);
const readme = tree ? await readReadme(row, ref) : null;
-- return page(req, res, views.repoPage({ row, viewer: viewer(req), tree: tree || { path: '', entries: [] }, readme, ...context }), meta(req, row));
++ const cloneRequest = req.query.clone && signedIn(req)
++ ? await relay.getForPage({ id: String(req.query.clone), subject: viewer(req).subject })
++ : null;
++ return page(req, res, views.repoPage({ row, viewer: viewer(req), tree: tree || { path: '', entries: [] }, readme, cloneRequest, ...context }), meta(req, row));
}));
r.post('/:owner/:name/clone', h(async (req, res) => {
@@@ -259,6 +263,29 @@ function createPageRoutes(ctx) {
}
}));
++ // Clone to one of the person's own Link computers, from a plain button in the Get it panel. The request rides the
++ // watch that computer already holds; this goes back to the repository with its progress line open.
++ r.post('/:owner/:name/clone-to-machine', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ try {
++ const request = await relay.createCloneToMachine({ viewer: viewer(req), row, machineId: req.body.machine });
++ return res.redirect(303, `/${row.owner_username}/${row.name}?clone=${encodeURIComponent(request.id)}#get`);
++ } catch (err) {
++ return page(req, res, views.message({ title: 'The clone request was not made', text: err.message, link: { href: `/${row.owner_username}/${row.name}`, label: 'Back to the repository' } }), { status: err.status || 422 });
++ }
++ }));
++
++ r.get('/:owner/:name/clone-to-machine/:id', h(async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, loginTo(req.originalUrl));
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ const request = await relay.getForPage({ id: String(req.params.id), subject: viewer(req).subject });
++ if (!request || request.repo !== `${row.owner_username}/${row.name}`) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ return page(req, res, machineViews.cloneToMachinePage({ row, request }), { title: `Clone ${row.owner_username}/${row.name} to your computer`, index: false });
++ }));
++
r.get('/:owner/:name/tree', h(async (req, res) => {
const row = await rowFor(req, res);
if (!row) return undefined;
ddiff --git a/server/link-relay.js b/server/link-relay.js
iindex 00129e1..c9c8765 100644
---- a/server/link-relay.js
++++ b/server/link-relay.js
@@@ -21,6 +21,9 @@ const OFFLINE_AFTER_MS = 30_000;
const WATCH_MS = 25_000;
const BROWSE_MS = 8_000;
const TERMINAL = new Set(['done', 'refused', 'failed', 'expired']);
++// Before the clone runs: queued (on its way to one of the person's own computers) and waiting (that computer has
++// it and is asking them). Both end after the same two minutes; a clone already running is left to finish.
++const UNSTARTED = new Set(['queued', 'waiting', 'pending', 'approved']);
const newOpaque = () => crypto.randomBytes(32).toString('base64url');
const newID = (prefix) => `${prefix}_${crypto.randomBytes(12).toString('hex').toUpperCase().slice(0, 26)}`;
@@@ -49,13 +52,14 @@ function createLinkRelay(ctx) {
...(progressOf(row) ? { progress: progressOf(row) } : {}),
...(row.path ? { path: row.path } : {}),
...(row.reason ? { reason: row.reason } : {}),
++ ...(row.machine_id ? { machine_id: row.machine_id } : {}),
});
const isExpired = (row) => Date.parse(row.expires_at) <= now();
/** End a request past its two minutes: the row says expired and the clone's read token is revoked. */
async function expire(row) {
-- await store.execute("UPDATE git_clone_requests SET state = 'expired', updated_at = now() WHERE id = ? AND state IN ('pending', 'approved')", [row.id]);
++ await store.execute("UPDATE git_clone_requests SET state = 'expired', updated_at = now() WHERE id = ? AND state IN ('queued', 'waiting', 'pending', 'approved')", [row.id]);
const secret = cloneURLs.get(row.id);
cloneURLs.delete(row.id);
if (secret) await store.execute('UPDATE git_tokens SET revoked_at = now() WHERE id = ? AND revoked_at IS NULL', [secret.token_id]).catch(() => {});
@@@ -67,7 +71,7 @@ function createLinkRelay(ctx) {
const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
if (!row) return null;
if (!safeEqual(hashToken(token), row.token_hash)) return null;
-- if ((row.state === 'pending' || row.state === 'approved') && isExpired(row)) await expire(row);
++ if (UNSTARTED.has(row.state) && isExpired(row)) await expire(row);
return row;
}
@@@ -96,10 +100,97 @@ function createLinkRelay(ctx) {
return { request: cloneDoc({ id, repo_full: `${row.owner_username}/${row.name}`, state: 'pending', created_at: new Date(now()).toISOString(), expires_at: expires, progress: null, path: null, reason: null }), link };
}
++ /**
++ * Clone to one of the person's own Link computers. The request rides the watch that computer already holds (the
++ * same poll the /machines pages browse through), so the person needs no terminal command; the computer asks its
++ * owner, and until they answer nothing is written there. The clone URL's short read token travels only to that
++ * machine: never to the page, never into the database.
++ */
++ async function createCloneToMachine({ viewer, row, machineId }) {
++ if (!viewer || !viewer.subject) {
++ throw Object.assign(new Error('Sign in to clone to your computer.'), { status: 401, code: 'auth.required' });
++ }
++ if (!repos.canView(row, viewer)) {
++ throw Object.assign(new Error('No such repository.'), { status: 404, code: 'repo.not_found' });
++ }
++ const machineRow = await store.maybe('SELECT * FROM git_machines WHERE id = ? AND subject = ?', [String(machineId || ''), viewer.subject]);
++ if (!machineRow) {
++ throw Object.assign(new Error('No such computer of yours.'), { status: 404, code: 'machine.not_found' });
++ }
++ if (!machineDoc(machineRow).online) {
++ throw Object.assign(new Error(`${machineRow.name} is not online: start Link there, then ask again.`), { status: 409, code: 'machine.offline' });
++ }
++ const id = store.newId('clr');
++ const token = newOpaque();
++ const expires = new Date(now() + CLONE_TTL_MS).toISOString();
++ const minted = await mintToken(store, { subject: viewer.subject, scopes: ['git.repo.read'], hours: 1, label: `clone ${row.owner_username}/${row.name}` });
++ const host = config.baseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '');
++ const cloneURL = `https://x-access-token:${minted.token}@${host}/${row.owner_username}/${row.name}.git`;
++ await store.execute(
++ `INSERT INTO git_clone_requests (id, subject, repo_id, repo_full, state, token_hash, machine_id, created_at, expires_at, updated_at)
++ VALUES (?, ?, ?, ?, 'queued', ?, ?, now(), ?, now())`,
++ [id, viewer.subject, row.id, `${row.owner_username}/${row.name}`, hashToken(token), machineRow.id, expires],
++ );
++ cloneURLs.set(id, { url: cloneURL, token_id: minted.id });
++ const request = {
++ id: newID('req'), kind: 'clone', clone_id: id, repo: `${row.owner_username}/${row.name}`,
++ url: cloneURL, requested_by: viewer.username || viewer.subject, expires_at: expires,
++ };
++ const q = queue.get(machineRow.id) || [];
++ q.push(request);
++ queue.set(machineRow.id, q);
++ notify(machineRow.id, request);
++ return cloneDoc({
++ id, repo_full: `${row.owner_username}/${row.name}`, state: 'queued', machine_id: machineRow.id,
++ created_at: new Date(now()).toISOString(), expires_at: expires, progress: null, path: null, reason: null,
++ });
++ }
++
++ /**
++ * The machine's own report on a clone it was asked to run, over its one-time token: waiting (it is asking its
++ * owner), approved, cloning with git's percentage, done with the folder, refused or failed. Only the machine the
++ * request was addressed to can move it, and only in order.
++ */
++ async function machineCloneUpdate({ machineId, token, id, body }) {
++ const machineRow = await machineFor(machineId, token);
++ if (!machineRow) return null;
++ await touchMachine(machineId);
++ const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
++ if (!row || row.machine_id !== machineId) return null;
++ if (TERMINAL.has(row.state)) return cloneDoc(row);
++ if (UNSTARTED.has(row.state) && isExpired(row)) {
++ await expire(row);
++ return cloneDoc({ ...row, state: 'expired' });
++ }
++ const state = String(body.state || '');
++ const from = {
++ queued: ['waiting', 'approved', 'refused', 'failed'],
++ waiting: ['approved', 'cloning', 'refused', 'failed'],
++ approved: ['cloning', 'done', 'failed', 'refused'],
++ cloning: ['cloning', 'done', 'failed', 'refused'],
++ }[row.state] || [];
++ if (!from.includes(state)) return cloneDoc(row);
++ const progress = (state === 'cloning' || state === 'done') && body.progress && Number.isFinite(Number(body.progress.percent))
++ ? { percent: Math.max(0, Math.min(100, Math.round(Number(body.progress.percent)))), line: String(body.progress.line || '').slice(0, 200) }
++ : null;
++ const path = state === 'done' ? String(body.path || '').slice(0, 4096) : null;
++ const reason = (state === 'refused' || state === 'failed') ? String(body.reason || '').slice(0, 300) : null;
++ await store.execute(
++ `UPDATE git_clone_requests SET state = ?, progress = COALESCE(?, progress), path = COALESCE(?, path), reason = COALESCE(?, reason), updated_at = now() WHERE id = ?`,
++ [state, progress ? JSON.stringify(progress) : null, path, reason, id],
++ );
++ if (TERMINAL.has(state)) {
++ const secret = cloneURLs.get(id);
++ cloneURLs.delete(id);
++ if (secret) await store.execute('UPDATE git_tokens SET revoked_at = now() WHERE id = ? AND revoked_at IS NULL', [secret.token_id]).catch(() => {});
++ }
++ return cloneDoc({ ...row, state, progress: progress || row.progress, path: path || row.path, reason: reason || row.reason });
++ }
++
async function getForPage({ id, subject }) {
const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
if (!row || row.subject !== subject) return null;
-- if ((row.state === 'pending' || row.state === 'approved') && isExpired(row)) await expire(row);
++ if (UNSTARTED.has(row.state) && isExpired(row)) await expire(row);
return cloneDoc(row);
}
@@@ -107,7 +198,7 @@ function createLinkRelay(ctx) {
const row = await loadForLink(id, token);
if (!row) return null;
const doc = cloneDoc(row);
-- if (row.state === 'pending' || row.state === 'approved') {
++ if (UNSTARTED.has(row.state)) {
const secret = cloneURLs.get(id);
if (secret) doc.clone_url = secret.url;
}
@@@ -196,7 +287,16 @@ function createLinkRelay(ctx) {
if (!row) return null;
await touchMachine(id);
const q = queue.get(id);
-- if (q && q.length) return { online: true, request: q.shift() };
++ // A clone request that waited out its two minutes is never handed over: it is over, and the page says so.
++ while (q && q.length) {
++ const next = q.shift();
++ if (next.kind === 'clone') {
++ const clone = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [next.clone_id]);
++ if (!clone || TERMINAL.has(clone.state)) continue;
++ if (isExpired(clone)) { await expire(clone); continue; }
++ }
++ return { online: true, request: next };
++ }
return await new Promise((resolve) => {
const set = wake.get(id) || new Set();
wake.set(id, set);
@@@ -266,7 +366,7 @@ function createLinkRelay(ctx) {
}
return {
-- createClone, getForPage, linkGet, linkUpdate,
++ createClone, getForPage, linkGet, linkUpdate, createCloneToMachine, machineCloneUpdate,
createMachine, machineHello, machineWatch, machineResult, listMachines, getMachine, browse,
// For tests.
CLONE_TTL_MS, OFFLINE_AFTER_MS,
ddiff --git a/server/views/index.js b/server/views/index.js
iindex ceb0c02..ecf698b 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -134,7 +134,7 @@ function repoTabs(row, active, ref) {
return `<nav class="git-tabs" aria-label="Repository">${tabs.map(([key, label, href]) => `<a class="git-tab${active === key ? ' is-on' : ''}" href="${esc(href)}${key === 'commits' && ref ? qs({ ref }) : ''}"${active === key ? ' aria-current="page"' : ''}>${esc(label)}</a>`).join('')}</nav>`;
}
--function repoHead({ row, viewer, starred, watching, forkOf }) {
++function repoHead({ row, viewer, starred, watching, forkOf, machines = null, cloneRequest = null }) {
const signedIn = viewer && viewer.kind === 'user';
const base = `/${row.owner_username}/${row.name}`;
const starAction = signedIn
@@@ -153,14 +153,46 @@ function repoHead({ row, viewer, starred, watching, forkOf }) {
</div>
<div class="git-repo-actions">${starAction}${watchAction}${signedIn ? `<form method="post" action="${base}/fork" class="git-inline"><button class="ovk-btn ovk-btn--ghost" type="submit">${icon('forward')} Fork <b>${row.forks}</b></button></form>` : `<a class="ovk-btn ovk-btn--ghost" href="/auth/login?next=${encodeURIComponent(base)}">${icon('forward')} Fork <b>${row.forks}</b></a>`}</div>
</div>
-- ${getIt(row, signedIn)}
++ ${getIt(row, signedIn, machines, cloneRequest)}
</header>`;
}
--function getIt(row, signedIn) {
++/** One plain button per online Link computer; the offline ones say since when, and nothing is sent to them. */
++function machineChoices(full, machines) {
++ const online = machines.filter((m) => m.online);
++ const offline = machines.filter((m) => !m.online);
++ if (!online.length) {
++ return `<p>No Link computer is online right now. Start Link on your computer, then ask again.</p>
++ <form method="post" action="/${esc(full)}/clone" class="git-inline"><button class="ovk-btn ovk-btn--quiet" type="submit">${icon('computer')} Get a terminal command instead</button></form>
++ ${offline.length ? `<p class="muted">${offline.map((m) => `${esc(m.name)} is offline since ${esc(ago(m.offline_since || m.last_seen_at))}`).join(' · ')}</p>` : ''}`;
++ }
++ return `${online.map((m) => `<form method="post" action="/${esc(full)}/clone-to-machine" class="git-inline">
++ <input type="hidden" name="machine" value="${esc(m.id)}">
++ <button class="ovk-btn ovk-btn--primary" type="submit">${icon('computer')} Clone to ${esc(m.name)}</button>
++ </form>`).join('')}
++ ${offline.length ? `<p class="muted">${offline.map((m) => `${esc(m.name)} is offline since ${esc(ago(m.offline_since || m.last_seen_at))}`).join(' · ')}</p>` : ''}
++ <p class="muted"><a href="/machines">Your computers</a></p>`;
++}
++
++/** The plain words of a clone request's state; the progress line (git.js) uses the same ones. */
++function cloneWords(request) {
++ const words = {
++ queued: 'Sent to your computer…', waiting: 'Waiting for your answer in Link…',
++ pending: 'Waiting for your answer in Link…', approved: 'Approved; Link is starting git…',
++ cloning: 'Cloning…', done: 'Cloned.', refused: 'Refused in Link.', failed: 'The clone failed.',
++ expired: 'The request expired after two minutes.',
++ };
++ let text = words[request.state] || request.state || '';
++ if (request.progress && (request.state === 'cloning' || request.state === 'done')) text = `${request.progress.percent}% · ${request.progress.line}`;
++ if (request.state === 'done' && request.path) text = `Cloned into ${request.path}`;
++ if (request.reason) text = `${text} ${request.reason}`;
++ return text;
++}
++
++function getIt(row, signedIn, machines = null, cloneRequest = null) {
const clone = `${row.owner_username}/${row.name}.git`;
const full = `${row.owner_username}/${row.name}`;
-- return `<details class="git-get" id="get">
++ return `<details class="git-get" id="get"${cloneRequest ? ' open' : ''}>
<summary class="ovk-btn ovk-btn--primary">${icon('download')} Get it <span class="git-get-caret">${icon('down')}</span></summary>
<div class="git-sheet">
<h2>Get this repository</h2>
@@@ -173,10 +205,13 @@ function getIt(row, signedIn) {
<div class="git-get-choice">
<b>Clone to my computer</b>
<p>OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.</p>
-- ${signedIn
-- ? `<form method="post" action="/${esc(full)}/clone" class="git-inline"><button class="ovk-btn ovk-btn--primary" type="submit">${icon('computer')} Clone to my computer</button></form>`
-- : `<a class="ovk-btn" href="/auth/login?next=${encodeURIComponent('/' + full)}">${icon('computer')} Clone to my computer</a>`}
++ ${!signedIn
++ ? `<a class="ovk-btn" href="/auth/login?next=${encodeURIComponent('/' + full)}">${icon('computer')} Clone to my computer</a>`
++ : machines === null
++ ? `<form method="post" action="/${esc(full)}/clone" class="git-inline"><button class="ovk-btn ovk-btn--primary" type="submit">${icon('computer')} Clone to my computer</button></form>`
++ : machineChoices(full, machines)}
</div>
++ ${cloneRequest ? `<p class="git-clone-line" data-clone-request="${esc(cloneRequest.id)}" data-clone-endpoint="/api/v1/repos/${esc(full)}/clone-to-machine/${esc(cloneRequest.id)}" role="status" aria-live="polite">${esc(cloneWords(cloneRequest))}</p>` : ''}
<div class="git-get-choice">
<b>Open in Codes</b>
<p>A Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.</p>
@@@ -226,11 +261,11 @@ ${repoHead({ row, viewer, starred, watching, forkOf })}
</div></div>`;
}
--function repoPage({ row, viewer, starred, watching, forkOf, tree, readme, card, languages, isOwner = false }) {
++function repoPage({ row, viewer, starred, watching, forkOf, tree, readme, card, languages, isOwner = false, machines = null, cloneRequest = null }) {
const base = `/${row.owner_username}/${row.name}`;
const crumbs = [{ name: row.name, path: '' }, ...String(tree.path || '').split('/').filter(Boolean).map((seg, i, all) => ({ name: seg, path: all.slice(0, i + 1).join('/') }))];
return `<div class="git-repo">
--${repoHead({ row, viewer, starred, watching, forkOf })}
++${repoHead({ row, viewer, starred, watching, forkOf, machines, cloneRequest })}
<div class="git-repo-grid">
<div class="git-repo-main">
${repoTabs(row, 'code')}
ddiff --git a/server/views/machine.js b/server/views/machine.js
iindex b099466..83721e9 100644
---- a/server/views/machine.js
++++ b/server/views/machine.js
@@@ -30,6 +30,30 @@ function clonePage({ row, request, link }) {
</div>`;
}
++/** The clone's own page: the same plain progress line the Get it panel shows, for a link opened on its own. */
++function cloneToMachinePage({ row, request }) {
++ const full = `${row.owner_username}/${row.name}`;
++ return `<div class="git-list-page git-clone">
++<header class="git-page-head"><p class="git-crumb"><a href="/${esc(full)}">${esc(full)}</a></p><h1>Clone to my computer</h1>
++<p>Your computer asks you in Link before it writes anything. The folder is inside the root you chose there, and the request ends after two minutes.</p></header>
++<div class="git-get-choice">
++ <p class="git-clone-line" data-clone-request="${esc(request.id)}" data-clone-endpoint="/api/v1/repos/${esc(full)}/clone-to-machine/${esc(request.id)}" role="status" aria-live="polite">${esc(words(request))}</p>
++ <p class="muted"><a href="/${esc(full)}">Back to ${esc(full)}</a></p>
++</div>
++</div>`;
++}
++
++function words(request) {
++ const text = {
++ queued: 'Sent to your computer…', waiting: 'Waiting for your answer in Link…',
++ pending: 'Waiting for your answer in Link…', approved: 'Approved; Link is starting git…',
++ cloning: 'Cloning…', done: 'Cloned.', refused: 'Refused in Link.', failed: 'The clone failed.',
++ expired: 'The request expired after two minutes.',
++ }[request.state] || request.state || '';
++ if (request.state === 'done' && request.path) return `Cloned into ${request.path}`;
++ return request.reason ? `${text} ${request.reason}` : text;
++}
++
function machinesPage({ viewer, machines = [], error = null }) {
const name = viewer && viewer.username ? viewer.username : 'you';
const rows = machines.map((m) => `<li class="git-machine">
@@@ -140,4 +164,4 @@ function machineError({ machine, repo, error }) {
</div>`;
}
--module.exports = { clonePage, machinesPage, machineConnectPage, machinePage, machineRepoPage, machineFilePage, machineCommitsPage };
++module.exports = { clonePage, cloneToMachinePage, machinesPage, machineConnectPage, machinePage, machineRepoPage, machineFilePage, machineCommitsPage };
ddiff --git a/test/clone-to-machine.test.js b/test/clone-to-machine.test.js
nnew file mode 100644
iindex 0000000..46abf4e
---- /dev/null
++++ b/test/clone-to-machine.test.js
@@@ -0,0 +1,144 @@
++'use strict';
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ let nowMs = Date.now();
++ const t = await boot({ now: () => nowMs });
++ try {
++ const sam = t.asUser('sam');
++ const other = t.asUser('other');
++ const demo = await t.ctx.repos.create({ actor: sam, name: 'demo', description: 'A demo.', starter: 'empty', visibility: 'public' });
++ await t.ctx.repos.create({ actor: other, name: 'secret', description: 'Private.', starter: 'empty', visibility: 'private' });
++
++ // Two of sam's computers, and one of other's.
++ const madeMine = await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-laptop' } });
++ const mine = madeMine.json().machine;
++ const mineURL = madeMine.json().link.url;
++ const mineToken = new URL(mineURL).searchParams.get('token');
++ let helloURL = new URL(mineURL); helloURL.pathname += '/hello';
++ await fetch(helloURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'sam-laptop', os: 'linux', repos: [] }) });
++
++ const madeSecond = await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-tower' } });
++ const second = madeSecond.json().machine;
++ const secondToken = new URL(madeSecond.json().link.url).searchParams.get('token');
++ const madeTheirs = await t.get('/api/v1/machines', { as: other, json: { name: 'other-laptop' } });
++ const theirs = madeTheirs.json().machine;
++ // A computer that never checks in stays offline in the panel's list.
++ await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-desk' } });
++
++ // ── The request and its refusals ────────────────────
++ const anon = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { json: { machine: mine.id } });
++ assert.equal(anon.status, 401, 'signed out');
++ const privateOne = await t.get('/api/v1/repos/other/secret/clone-to-machine', { as: sam, json: { machine: mine.id } });
++ assert.equal(privateOne.status, 404, 'a repository sam may not read is 404, never 403');
++ const notMyMachine = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: theirs.id } });
++ assert.equal(notMyMachine.status, 404, "another person's computer cannot be targeted");
++ const noMachine = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: 'mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA' } });
++ assert.equal(noMachine.status, 404, 'an id that is nobody\'s is 404');
++ const theirRepo = await t.get('/api/v1/repos/other/secret/clone-to-machine', { as: other, json: { machine: mine.id } });
++ assert.equal(theirRepo.status, 404, 'other cannot address sam\'s machine even for their own repository');
++
++ // An offline computer: 409, nothing queued.
++ const offlineMachine = madeSecond.json().machine;
++ const offline = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: offlineMachine.id } });
++ assert.equal(offline.status, 409);
++ assert.equal(offline.json().code, 'machine.offline');
++
++ // ── Sent to the computer over its own watch ─────────
++ const made = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: mine.id } });
++ assert.equal(made.status, 201);
++ const request = made.json().request;
++ assert.match(request.id, /^clr_[0-9A-Z]{26}$/);
++ assert.equal(request.state, 'queued');
++ assert.equal(request.machine_id, mine.id);
++ assert.equal(request.repo, 'sam/demo');
++ assert.equal(request.clone_url, undefined, 'the page never sees a clone URL');
++
++ const watchURL = new URL(mineURL); watchURL.pathname += '/watch';
++ const watch = await fetch(watchURL);
++ assert.equal(watch.status, 200);
++ const item = (await watch.json()).request;
++ assert.equal(item.kind, 'clone');
++ assert.equal(item.clone_id, request.id);
++ assert.equal(item.repo, 'sam/demo');
++ assert.match(item.url, /^https:\/\/x-access-token:git_/);
++ assert.equal(item.requested_by, 'sam');
++
++ // The page sees each step the computer reports; only that computer can report it.
++ const progressURL = new URL(mineURL); progressURL.pathname += `/clone-requests/${request.id}`;
++ const pageState = async (as) => (await t.get(`/api/v1/repos/sam/demo/clone-to-machine/${request.id}`, { as })).json().request;
++ assert.equal((await pageState(sam)).state, 'queued');
++ const elsewhere = await t.get(`/api/v1/repos/sam/demo/clone-to-machine/${request.id}`, { as: other });
++ assert.equal(elsewhere.status, 404, "another person cannot read sam's request");
++ const wrongPath = await t.get(`/api/v1/repos/sam/other-name/clone-to-machine/${request.id}`, { as: sam });
++ assert.equal(wrongPath.status, 404);
++
++ const report = (state, extra = {}) => fetch(progressURL, {
++ method: 'POST', headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({ state, ...extra }),
++ });
++ const bareURL = new URL(progressURL); bareURL.searchParams.delete('token');
++ assert.equal((await fetch(bareURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) })).status, 404, 'no token: 404');
++ const secondURL = new URL(madeSecond.json().link.url); secondURL.pathname += `/clone-requests/${request.id}`;
++ assert.equal((await fetch(secondURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) })).status, 404, 'another of sam\'s computers has a valid token but is not the one the request went to');
++ assert.equal((await pageState(sam)).state, 'queued');
++ const theirsURL = new URL(madeTheirs.json().link.url); theirsURL.pathname += `/clone-requests/${request.id}`;
++ assert.equal((await fetch(theirsURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) })).status, 404, "another person's computer cannot move it");
++
++ assert.equal((await report('waiting')).status, 200);
++ assert.equal((await pageState(sam)).state, 'waiting');
++ assert.equal((await report('approved')).status, 200);
++ assert.equal((await pageState(sam)).state, 'approved');
++ assert.equal((await report('cloning', { progress: { percent: 42, line: 'Receiving objects: 42%' } })).status, 200);
++ const cloning = await pageState(sam);
++ assert.equal(cloning.state, 'cloning');
++ assert.equal(cloning.progress.percent, 42);
++ assert.equal((await report('done', { path: '/home/sam/OpenVibe/demo', progress: { percent: 100, line: 'Cloned into demo' } })).status, 200);
++ const done = await pageState(sam);
++ assert.equal(done.state, 'done');
++ assert.equal(done.path, '/home/sam/OpenVibe/demo');
++ assert.equal((await report('failed', { reason: 'late' })).status, 200, 'a terminal request is not moved again');
++ assert.equal((await pageState(sam)).state, 'done');
++
++ // ── The panel ───────────────────────────────────────
++ const repoPage = await t.get('/sam/demo', { as: sam });
++ assert.equal(repoPage.status, 200);
++ assert.match(repoPage.text, /Clone to sam-laptop/);
++ assert.match(repoPage.text, /action="\/sam\/demo\/clone-to-machine"/);
++ assert.match(repoPage.text, /sam-desk is offline since/, 'an offline computer is named, not offered');
++ const noMachines = await t.get('/sam/demo', { as: t.asUser('nobody') });
++ assert.match(noMachines.text, /No Link computer is online right now/, 'with none online the panel says so…');
++ assert.match(noMachines.text, /action="\/sam\/demo\/clone"/, '…and keeps the existing link');
++ const withLine = await t.get(`/sam/demo?clone=${request.id}`, { as: sam });
++ assert.match(withLine.text, /data-clone-endpoint="\/api\/v1\/repos\/sam\/demo\/clone-to-machine\//);
++ assert.match(withLine.text, /Cloned into \/home\/sam\/OpenVibe\/demo/);
++
++ // The plain button goes back to the repository with its progress line.
++ const formPost = await t.get('/sam/demo/clone-to-machine', { as: sam, form: { machine: mine.id } });
++ assert.equal(formPost.status, 303);
++ assert.match(formPost.headers.get('location'), /^\/sam\/demo\?clone=clr_[0-9A-Z]{26}#get$/);
++ const progressPage = await t.get(formPost.headers.get('location').split('#')[0], { as: sam });
++ assert.equal(progressPage.status, 200);
++ assert.match(progressPage.text, /data-clone-request=/);
++
++ // ── Two minutes, then it is over ────────────────────
++ const late = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: mine.id } });
++ const lateRequest = late.json().request;
++ nowMs += 3 * 60_000;
++ const expiredPage = await t.get(`/api/v1/repos/sam/demo/clone-to-machine/${lateRequest.id}`, { as: sam });
++ assert.equal(expiredPage.json().request.state, 'expired');
++ const lateURL = new URL(mineURL); lateURL.pathname += `/clone-requests/${lateRequest.id}`;
++ const lateReport = await fetch(lateURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) });
++ assert.equal(lateReport.status, 200);
++ assert.equal((await lateReport.json()).request.state, 'expired', 'an expired request is refused');
++ // …and the computer is not online any more after a gap that long.
++ const stale = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: mine.id } });
++ assert.equal(stale.status, 409);
++ assert.equal(stale.json().code, 'machine.offline');
++
++ process.stdout.write('clone-to-machine: ok\n');
++ } finally {
++ await t.close();
++ }
++})().catch((err) => { process.stderr.write(`${err && err.stack || err}\n`); process.exit(1); });