samagotchi 0.108.0 → 0.110.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +7 -2
- data/docs/cli.md +40 -1
- data/docs/configuration.md +177 -2
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +28 -8
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/internals/remote-sessions.md +6 -2
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +25 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +20 -9
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/proxy_trace.rb +45 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +52 -10
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +246 -32
- data/lib/samagotchi/web/grants.rb +6 -3
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +9 -2
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/participants_admin.rb +13 -6
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/data.js +29 -5
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +47 -10
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/token_fragment.js +57 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/public_url.rb +87 -0
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +138 -22
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- metadata +12 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// A link's one-time code in its fragment (https://<public url>/#code=…&s=ID,
|
|
2
|
+
// what chi web makes behind an auth proxy: never a token, since a fragment
|
|
3
|
+
// outlives the sign-in redirects and stays in the browser's history). The
|
|
4
|
+
// page posts it to POST /api/token, which uses it up and sets the cookie,
|
|
5
|
+
// then loads itself again without it. The 401 page runs it (chi web serves
|
|
6
|
+
// this file without the token); the owner's and participant's pages do on
|
|
7
|
+
// load, asking first, since the trade replaces the browser's session. Plain
|
|
8
|
+
// module, no imports.
|
|
9
|
+
|
|
10
|
+
// {code, rest}: the fragment's code and what else it says ("s=ID"), or
|
|
11
|
+
// null without one.
|
|
12
|
+
export function fragmentCode(hash) {
|
|
13
|
+
const params = new URLSearchParams(String(hash || "").replace(/^#/, ""));
|
|
14
|
+
const code = params.get("code");
|
|
15
|
+
if (!code) return null;
|
|
16
|
+
params.delete("code");
|
|
17
|
+
return { code, rest: params.toString() };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const REPLACE_SESSION_TEXT = "This link signs this browser in as its holder, in place of the session it has now. Open it?";
|
|
21
|
+
|
|
22
|
+
// Trades the fragment's code, if there is one: the URL loses it at once
|
|
23
|
+
// (history.replaceState), the page reloads once the cookie is set. ask():
|
|
24
|
+
// whether to go ahead (a page that already has a session); declined, the
|
|
25
|
+
// code is dropped unused. Resolves true when it reloads, false when there
|
|
26
|
+
// was nothing to trade, it was declined, or the code was refused
|
|
27
|
+
// (onRefused gets the server's words).
|
|
28
|
+
export async function tradeFragmentCode({
|
|
29
|
+
loc = globalThis.location, hist = globalThis.history, fetchImpl = globalThis.fetch, onRefused = () => {},
|
|
30
|
+
ask = () => true,
|
|
31
|
+
} = {}) {
|
|
32
|
+
const found = fragmentCode(loc?.hash);
|
|
33
|
+
if (!found) return false;
|
|
34
|
+
try {
|
|
35
|
+
hist.replaceState(null, "", `${loc.pathname}${loc.search}${found.rest ? `#${found.rest}` : ""}`);
|
|
36
|
+
} catch (_) {}
|
|
37
|
+
if (!ask()) return false;
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetchImpl("/api/token", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
credentials: "same-origin",
|
|
43
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
44
|
+
body: JSON.stringify({ code: found.code }),
|
|
45
|
+
});
|
|
46
|
+
} catch (_) {
|
|
47
|
+
onRefused("chi web can't be reached: reload to try again.");
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (res.ok) {
|
|
51
|
+
loc.reload();
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const body = await res.json().catch(() => ({}));
|
|
55
|
+
onRefused(body.detail || `That link was refused (HTTP ${res.status}): it works once, and an owner's link for 10 minutes.`);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
--glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
|
|
4
4
|
--diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
|
|
5
5
|
--code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
|
|
6
|
-
--card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3)}
|
|
6
|
+
--card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3);--time-gap:var(--text-faint)}
|
|
7
7
|
/* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
|
|
8
8
|
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0b5cd5;--accent-strong:#0550ae;--accent-bg:#e2ecfd;--accent-ring:rgba(11,92,213,.2);--primary:#1f6feb;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b8cef5;--pick-border:#0969da;
|
|
9
9
|
--glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
|
|
10
10
|
--diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
|
|
11
11
|
--code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
|
|
12
|
-
--card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14);--ws-stale-opacity:.55;--ws-rule:var(--border);--ws-muted:var(--text-muted);--ws-faint:var(--text-faint);--ws-act-border:var(--border-strong);--author-1:#0550ae;--author-2:#8250df;--author-3:#116329;--author-4:#953800;--author-5:#a40e26;--author-6:#7d4e00;--author-owner:var(--text-3)}}
|
|
12
|
+
--card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14);--ws-stale-opacity:.55;--ws-rule:var(--border);--ws-muted:var(--text-muted);--ws-faint:var(--text-faint);--ws-act-border:var(--border-strong);--author-1:#0550ae;--author-2:#8250df;--author-3:#116329;--author-4:#953800;--author-5:#a40e26;--author-6:#7d4e00;--author-owner:var(--text-3);--time-gap:var(--text-faint)}}
|
|
@@ -340,7 +340,9 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
|
|
|
340
340
|
const cutAfter = turn.cut_after != null ? { cut_after: turn.cut_after } : {};
|
|
341
341
|
// A participant's turn names them (TurnAccumulator keeps author, author_name).
|
|
342
342
|
const author = turn.author ? { author: turn.author, ...(turn.author_name ? { author_name: turn.author_name } : {}) } : {};
|
|
343
|
-
|
|
343
|
+
// Its wall time (TurnAccumulator keeps turn_started's at): the gap divider before it.
|
|
344
|
+
const at = typeof turn.at === "string" ? { at: turn.at } : {};
|
|
345
|
+
events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...at, ...cutAfter, ...author }, turn.images));
|
|
344
346
|
let textIteration = null; // a text bubble is open for this iteration
|
|
345
347
|
const closeText = () => {
|
|
346
348
|
if (textIteration !== null) events.push({ type: "generation_completed" });
|
|
@@ -8,7 +8,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
|
|
|
8
8
|
import { commandBlockTemplate, rowHover } from "./command_view.js";
|
|
9
9
|
import { copySource } from "./copy.js";
|
|
10
10
|
import { activityDiffTemplate } from "./diff_view.js";
|
|
11
|
-
import { clip, steerSender } from "./format.js";
|
|
11
|
+
import { clip, fullTime, steerSender } from "./format.js";
|
|
12
12
|
import { rowTitleRef } from "./refs.js";
|
|
13
13
|
import { readLinesTemplate } from "./read_view.js";
|
|
14
14
|
import { handedOverId } from "./task_stop.js";
|
|
@@ -219,9 +219,15 @@ export function shellCommandMessage(content) {
|
|
|
219
219
|
// A `!cmd`'s history bubble: the same markup the live page draws for a
|
|
220
220
|
// command (app.js's drawCommandBubble): the line, the output in a
|
|
221
221
|
// pre.command-output.
|
|
222
|
-
export function commandBubbleTemplate({ line, text }) {
|
|
222
|
+
export function commandBubbleTemplate({ line, text, at = null }) {
|
|
223
223
|
const body = text ? html`<pre class="command-output">${text}</pre>` : nothing;
|
|
224
|
-
return html`<div class="bubble command"><div class="command-line">${line}</div>${body}</div>`;
|
|
224
|
+
return html`<div class="bubble command" title=${timeTitle(at)}><div class="command-line">${line}</div>${body}</div>`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A bubble's hover: when it happened (its message's `at`, MessageTime), in
|
|
228
|
+
// full local time; no title when unknown.
|
|
229
|
+
export function timeTitle(at) {
|
|
230
|
+
return fullTime(at) || nothing;
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
// A user message's body: runs of `>` lines become <blockquote>s, the rest
|
|
@@ -257,9 +263,10 @@ export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
|
|
|
257
263
|
const klass = `bubble output${cls}${rendered ? " markdown" : ""}`;
|
|
258
264
|
const answerAt = at ?? nothing;
|
|
259
265
|
const source = rendered || user ? copySource(message) : nothing;
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source}
|
|
266
|
+
const title = timeTitle(message?.at);
|
|
267
|
+
if (rendered) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .innerHTML=${message.html}></div>`;
|
|
268
|
+
if (user) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
|
|
269
|
+
return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
|
|
263
270
|
}
|
|
264
271
|
|
|
265
272
|
// A context note's bubble (background the user or another session pushed
|
|
@@ -267,7 +274,7 @@ export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
|
|
|
267
274
|
// text.
|
|
268
275
|
export function noteBubbleTemplate(note) {
|
|
269
276
|
const from = note?.label ? `note from ${note.label}` : "note";
|
|
270
|
-
return html`<div class="bubble note"><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
|
|
277
|
+
return html`<div class="bubble note" title=${timeTitle(note?.at)}><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
|
|
271
278
|
}
|
|
272
279
|
|
|
273
280
|
// A plugin's steer (a nudge put into the running turn): one line that
|
|
@@ -295,7 +302,7 @@ export function userBubbleTemplate(message, { label = null, state = null, step =
|
|
|
295
302
|
const from = author
|
|
296
303
|
? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name)}</span>`
|
|
297
304
|
: label ? html`<span class="origin-label">${label}</span>` : nothing;
|
|
298
|
-
return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
|
|
305
|
+
return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
// The notice of a turn that ended with no answer (the server's
|
|
@@ -42,6 +42,7 @@ import { createHold } from "./hold.js";
|
|
|
42
42
|
import { linkedLine } from "./notice_links.js";
|
|
43
43
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
44
44
|
import { footprintTemplate, groupItems, stampHistory } from "./working_set_view.js";
|
|
45
|
+
import { groupGaps, timeGapTemplate } from "./time_gaps.js";
|
|
45
46
|
|
|
46
47
|
// ---- The templates: one step, its thinking, its label ----
|
|
47
48
|
|
|
@@ -847,7 +848,7 @@ function failedLineTemplate(record) {
|
|
|
847
848
|
// web.working_set, never the turn view's): each block stamped with its
|
|
848
849
|
// turn's ordinal and its rows with theirs (working_set_view.js), and the
|
|
849
850
|
// turn's footprint line after it.
|
|
850
|
-
export function turnHistoryNodes(items, timing, { thumbs, workingSet = false }) {
|
|
851
|
+
export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, now = Date.now() }) {
|
|
851
852
|
const timingTemplate = (group) => {
|
|
852
853
|
const record = group.record;
|
|
853
854
|
// Numbered by its place in the turn records, as live (a failed turn
|
|
@@ -868,7 +869,7 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false })
|
|
|
868
869
|
// as its command bubble, as live does — not as a user bubble.
|
|
869
870
|
const userTemplate = (m, opts = {}) => {
|
|
870
871
|
const shell = shellCommandMessage(m?.content);
|
|
871
|
-
if (shell) return commandBubbleTemplate(shell);
|
|
872
|
+
if (shell) return commandBubbleTemplate({ ...shell, at: m?.at });
|
|
872
873
|
return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), author: authorBadge(m?.author, m?.author_name), ...opts });
|
|
873
874
|
};
|
|
874
875
|
// The first text after a delegate report merged mid-turn, unless it is
|
|
@@ -886,7 +887,11 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false })
|
|
|
886
887
|
const outlines = [];
|
|
887
888
|
const blockTurns = [];
|
|
888
889
|
let turnOrdinal = -1;
|
|
889
|
-
|
|
890
|
+
const groups = turnGroups(items, timing);
|
|
891
|
+
// A quiet stretch before a group: its divider first (time_gaps.js).
|
|
892
|
+
const gaps = groupGaps(groups, items, now);
|
|
893
|
+
for (const [k, group] of groups.entries()) {
|
|
894
|
+
if (gaps[k]) parts.push(timeGapTemplate(gaps[k]));
|
|
890
895
|
if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
|
|
891
896
|
if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
|
|
892
897
|
if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "../redacted_inspect"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Web
|
|
8
|
+
# chi web behind an auth proxy (web.public_url, docs/configuration.md
|
|
9
|
+
# "Behind an auth proxy"): the https origin people open, the request
|
|
10
|
+
# header the proxy puts the signed-in user's email in (nil: none is
|
|
11
|
+
# trusted, the chi token alone decides) and the owner's email. Set, it
|
|
12
|
+
# turns on proxied mode (Web::ProxyGate): every request needs a token.
|
|
13
|
+
PublicUrl = Data.define(:origin, :host, :identity_header, :owner) do
|
|
14
|
+
# The Rack env key of the identity header (HTTP_X_FORWARDED_EMAIL),
|
|
15
|
+
# nil without one.
|
|
16
|
+
def identity_env = identity_header && PublicUrl.env_key(identity_header)
|
|
17
|
+
|
|
18
|
+
# Whether a Host header names the public host: any case, a trailing
|
|
19
|
+
# dot, and an explicit :443 for a 443 origin are the same host.
|
|
20
|
+
def host?(header)
|
|
21
|
+
name, port = header.to_s.downcase.match(/\A(.*?)(?::(\d+))?\z/).captures
|
|
22
|
+
name = name.delete_suffix(".")
|
|
23
|
+
port = nil if port == "443"
|
|
24
|
+
host == (port ? "#{name}:#{port}" : name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The header's name as people write it, or "none".
|
|
28
|
+
def identity_label = identity_header || "none"
|
|
29
|
+
|
|
30
|
+
# https://host[:port]/#code=CODE[&FRAGMENT]: a link with a one-time
|
|
31
|
+
# code (LinkCodes) in its fragment, never a token: a fragment outlives
|
|
32
|
+
# the sign-in redirects and stays in the browser's history.
|
|
33
|
+
def link(code, fragment = nil) = "#{origin}/##{["code=#{code}", fragment].compact.join("&")}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class PublicUrl
|
|
37
|
+
class Invalid < StandardError; end
|
|
38
|
+
|
|
39
|
+
# An HTTP header name (RFC 9110 token characters chi accepts).
|
|
40
|
+
HEADER_NAME = /\A[A-Za-z0-9][A-Za-z0-9-]*\z/
|
|
41
|
+
|
|
42
|
+
# @return [PublicUrl, nil] nil when +url+ is unset
|
|
43
|
+
# @raise [Invalid] a url that isn't an https origin, a bad header name,
|
|
44
|
+
# or an owner without an identity header to check it against
|
|
45
|
+
def self.from_config(url:, identity_header: nil, owner: nil)
|
|
46
|
+
text = url.to_s.strip
|
|
47
|
+
return nil if text.empty?
|
|
48
|
+
|
|
49
|
+
origin, host = parse_origin(text)
|
|
50
|
+
header = identity_header.to_s.strip
|
|
51
|
+
header = nil if header.empty? || header.casecmp?("none")
|
|
52
|
+
if header && !HEADER_NAME.match?(header)
|
|
53
|
+
raise Invalid, "web.proxy.identity_header must be a header name (letters, digits and -), not #{header}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
owner = owner.to_s.strip.downcase
|
|
57
|
+
owner = nil if owner.empty?
|
|
58
|
+
if owner && header.nil?
|
|
59
|
+
raise Invalid, "web.proxy.owner needs web.proxy.identity_header (the header the proxy puts #{owner} in); " \
|
|
60
|
+
"without one only the chi token decides, so leave owner unset"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
new(origin: origin, host: host, identity_header: header, owner: owner)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.parse_origin(text)
|
|
67
|
+
uri = URI.parse(text)
|
|
68
|
+
bare = uri.path.to_s.empty? || uri.path == "/"
|
|
69
|
+
unless uri.scheme&.downcase == "https" && uri.host && !uri.host.empty? && bare && uri.query.nil? &&
|
|
70
|
+
uri.fragment.nil? && uri.userinfo.nil?
|
|
71
|
+
raise Invalid, invalid_url(text)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
name = uri.host.downcase
|
|
75
|
+
host = uri.port == 443 ? name : "#{name}:#{uri.port}"
|
|
76
|
+
["https://#{host}", host]
|
|
77
|
+
rescue URI::InvalidURIError
|
|
78
|
+
raise Invalid, invalid_url(text)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.invalid_url(text) = "web.public_url must be an https origin (https://host or https://host:port), not #{text}"
|
|
82
|
+
|
|
83
|
+
# The Rack env key WEBrick makes of a header name.
|
|
84
|
+
def self.env_key(name) = "HTTP_#{name.upcase.tr("-", "_")}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -4,18 +4,23 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "../atomic_file"
|
|
6
6
|
require_relative "../session"
|
|
7
|
+
require_relative "info_probe"
|
|
8
|
+
require_relative "public_url"
|
|
7
9
|
|
|
8
10
|
module Samagotchi
|
|
9
11
|
module Web
|
|
10
12
|
# Where this machine's chi web listens: $XDG_STATE_HOME/samagotchi/web.json
|
|
11
|
-
# ({pid, port}
|
|
13
|
+
# ({pid, port}, and public_url when it runs with one: config's or
|
|
14
|
+
# --tunnel's), written once its port is bound and removed when it
|
|
12
15
|
# stops (by that process only). A command that names chi web in what it
|
|
13
16
|
# prints (chi share's link) reads the port here before config's
|
|
14
17
|
# web.port, which a chi web started with --port or another config
|
|
15
18
|
# doesn't follow. The file is a hint: a chi web killed hard leaves it,
|
|
16
|
-
# so a reader probes the port (InfoProbe) before trusting it.
|
|
19
|
+
# so a reader probes the port (InfoProbe) before trusting it. One slot:
|
|
20
|
+
# a second chi web on another port takes it over.
|
|
17
21
|
module Running
|
|
18
22
|
FILE = "web.json"
|
|
23
|
+
PROBE_TIMEOUT = 1.0
|
|
19
24
|
|
|
20
25
|
module_function
|
|
21
26
|
|
|
@@ -24,9 +29,10 @@ module Samagotchi
|
|
|
24
29
|
end
|
|
25
30
|
|
|
26
31
|
# @return [String] +path+
|
|
27
|
-
|
|
32
|
+
# @param public_url [String, nil] the https origin it runs behind
|
|
33
|
+
def record(port:, path: self.path, pid: Process.pid, public_url: nil)
|
|
28
34
|
FileUtils.mkdir_p(File.dirname(path))
|
|
29
|
-
AtomicFile.write(path, JSON.generate({ pid: pid, port: port }))
|
|
35
|
+
AtomicFile.write(path, JSON.generate({ pid: pid, port: port, public_url: public_url }.compact))
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
# Removes the file when this process wrote it (a second chi web's
|
|
@@ -41,6 +47,35 @@ module Samagotchi
|
|
|
41
47
|
value.is_a?(Integer) && value.positive? ? value : nil
|
|
42
48
|
end
|
|
43
49
|
|
|
50
|
+
# The public_url the running chi web recorded (a --tunnel's, which no
|
|
51
|
+
# config has), for a link that names it: only while the chi web that
|
|
52
|
+
# answers /api/info on the recorded port is the process that wrote the
|
|
53
|
+
# file (its pid), and only an https origin. A record left by a chi web
|
|
54
|
+
# killed hard, with a reused pid or another chi web (another state dir)
|
|
55
|
+
# on that port, is nil. The origin is this file's, never the probe's.
|
|
56
|
+
# @param probe [#call] (port) → InfoProbe's answer
|
|
57
|
+
# @return [PublicUrl, nil]
|
|
58
|
+
def live_public_url(path: self.path, probe: ->(port) { InfoProbe.call("127.0.0.1", port, timeout: PROBE_TIMEOUT) })
|
|
59
|
+
data = read(path) or return nil
|
|
60
|
+
pid, port, url = data.values_at("pid", "port", "public_url")
|
|
61
|
+
return nil unless pid.is_a?(Integer) && pid.positive? && port.is_a?(Integer) && port.positive? && url.is_a?(String)
|
|
62
|
+
return nil unless alive?(pid)
|
|
63
|
+
|
|
64
|
+
info = probe.call(port)
|
|
65
|
+
return nil unless info.is_a?(Hash) && info["pid"] == pid
|
|
66
|
+
|
|
67
|
+
PublicUrl.from_config(url: url)
|
|
68
|
+
rescue PublicUrl::Invalid
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def alive?(pid)
|
|
73
|
+
Process.kill(0, pid)
|
|
74
|
+
true
|
|
75
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
44
79
|
def read(path)
|
|
45
80
|
data = JSON.parse(File.read(path))
|
|
46
81
|
data.is_a?(Hash) ? data : nil
|
|
@@ -9,10 +9,14 @@ require "webrick"
|
|
|
9
9
|
require_relative "app"
|
|
10
10
|
require_relative "info_probe"
|
|
11
11
|
require_relative "lan"
|
|
12
|
+
require_relative "link_codes"
|
|
13
|
+
require_relative "proxy_gate"
|
|
14
|
+
require_relative "public_url"
|
|
12
15
|
require_relative "qr"
|
|
13
16
|
require_relative "running"
|
|
14
17
|
require_relative "session_hub"
|
|
15
18
|
require_relative "token"
|
|
19
|
+
require_relative "tunnel"
|
|
16
20
|
require_relative "../config"
|
|
17
21
|
require_relative "../project_scope"
|
|
18
22
|
require_relative "../log"
|
|
@@ -58,24 +62,34 @@ module Samagotchi
|
|
|
58
62
|
# How long `chi web` waits for a server already on its port to answer.
|
|
59
63
|
PROBE_TIMEOUT = 0.3
|
|
60
64
|
|
|
65
|
+
# The raw header names next to Rack's env (ProxyGate's identity check):
|
|
66
|
+
# chi web is the only WEBrick server chi runs.
|
|
67
|
+
WEBrick::HTTPRequest.prepend(ProxyGate::RawHeaderNames) unless WEBrick::HTTPRequest < ProxyGate::RawHeaderNames
|
|
68
|
+
|
|
61
69
|
# `chi web`: one server serves every project, the scope is in the page
|
|
62
70
|
# URL. If a chi web already runs on the port, print (and with --open,
|
|
63
71
|
# open) its page for +dir+ and leave it be; else start one.
|
|
64
72
|
# @param scope ["project", "all"] "all": the plain page, every session
|
|
65
73
|
# @param new_token [Boolean] replace the LAN access token first
|
|
66
74
|
# (--new-token): a running server takes the new one at once
|
|
75
|
+
# @param tunnel [String, nil] --tunnel's provider (Tunnel.for): this
|
|
76
|
+
# run's public_url is the tunnel's, never config's, and the tunnel is
|
|
77
|
+
# on while the server runs
|
|
67
78
|
# @return [Integer] the exit status
|
|
68
79
|
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
|
|
69
80
|
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
70
81
|
editor: Config::BY_KEY["web.editor"].default, new_token: false,
|
|
71
|
-
working_set: Config::BY_KEY["web.working_set"].default)
|
|
82
|
+
working_set: Config::BY_KEY["web.working_set"].default, tunnel: nil)
|
|
72
83
|
rotate_token if new_token
|
|
84
|
+
public_url = public_url_setting unless tunnel
|
|
73
85
|
setting = host_setting(host)
|
|
74
86
|
lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
|
|
75
87
|
host = resolve_host(setting)
|
|
76
88
|
port = resolve_port(port)
|
|
77
89
|
url = scope_url(host, port, dir: dir, scope: scope)
|
|
78
90
|
found = probe(host, port)
|
|
91
|
+
raise Tunnel::AlreadyRunning, port if tunnel && found.is_a?(Hash)
|
|
92
|
+
|
|
79
93
|
case found
|
|
80
94
|
when Hash
|
|
81
95
|
if lan && !found["lan"]
|
|
@@ -85,6 +99,8 @@ module Samagotchi
|
|
|
85
99
|
end
|
|
86
100
|
puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
|
|
87
101
|
puts running_lan_lines(found["lan"], port) if found["lan"]
|
|
102
|
+
running_public = public_url || Running.live_public_url(probe: ->(at) { probe(host, at) })
|
|
103
|
+
puts running_public_lines(running_public) if running_public
|
|
88
104
|
puts lan_off_line(setting) if new_token && !found["lan"]
|
|
89
105
|
open_url(url) if open_browser
|
|
90
106
|
0
|
|
@@ -92,20 +108,30 @@ module Samagotchi
|
|
|
92
108
|
warn in_use_message(port)
|
|
93
109
|
1
|
|
94
110
|
else
|
|
95
|
-
if
|
|
111
|
+
if tunnel
|
|
112
|
+
provider = Tunnel.for(tunnel)
|
|
113
|
+
plan = provider.prepare(port: port, config_owner: Samagotchi::Config.get("web.proxy.owner"),
|
|
114
|
+
config_public_url: Samagotchi::Config.get("web.public_url"),
|
|
115
|
+
config_identity_header: Samagotchi::Config.get("web.proxy.identity_header"))
|
|
116
|
+
public_url = plan.public_url
|
|
117
|
+
end
|
|
118
|
+
if new_token && !lan && !public_url
|
|
96
119
|
puts lan_off_line(setting)
|
|
97
120
|
return 0
|
|
98
121
|
end
|
|
99
122
|
# The participants' grants: never served from a store others can read.
|
|
100
123
|
Grants.check_permissions!(Grants.path)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
options = { port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
|
|
125
|
+
annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
|
|
126
|
+
public_url: public_url }
|
|
127
|
+
started = if plan
|
|
128
|
+
with_tunnel(provider, plan) { start(**options, public_notes: provider.start_lines(plan)) }
|
|
129
|
+
else
|
|
130
|
+
start(**options)
|
|
131
|
+
end
|
|
132
|
+
started ? 0 : 1
|
|
107
133
|
end
|
|
108
|
-
rescue Lan::Error, Grants::Insecure => e
|
|
134
|
+
rescue Lan::Error, Grants::Insecure, PublicUrl::Invalid, Tunnel::Refused => e
|
|
109
135
|
warn "Error: #{e.message}"
|
|
110
136
|
1
|
|
111
137
|
rescue Interrupt
|
|
@@ -115,16 +141,52 @@ module Samagotchi
|
|
|
115
141
|
CLI::Exit::INTERRUPTED
|
|
116
142
|
end
|
|
117
143
|
|
|
144
|
+
# The tunnel on while the block runs (the server), off after it
|
|
145
|
+
# whatever ends it (Ctrl-C, SIGTERM, SIGHUP, a port taken), and only
|
|
146
|
+
# once #up succeeded: a refusal before it, or a failed #up, turns off
|
|
147
|
+
# nothing (a live tunnel of another chi web stays).
|
|
148
|
+
def self.with_tunnel(provider, plan)
|
|
149
|
+
provider.up(plan)
|
|
150
|
+
upped = true
|
|
151
|
+
yield
|
|
152
|
+
ensure
|
|
153
|
+
tunnel_down(provider, plan) if upped
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The tunnel's teardown, before any line (a hung-up terminal can't
|
|
157
|
+
# write): a failure is one warning line, never a crash or another exit
|
|
158
|
+
# status, and a second Ctrl-C during it only cuts it short.
|
|
159
|
+
def self.tunnel_down(provider, plan)
|
|
160
|
+
line = begin
|
|
161
|
+
provider.down_line(plan, provider.down(plan))
|
|
162
|
+
rescue Tunnel::Refused, SignalException => e
|
|
163
|
+
provider.down_warning(plan, e)
|
|
164
|
+
end
|
|
165
|
+
say(line) if line
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A line that may meet a terminal gone (SIGHUP): never an error.
|
|
169
|
+
def self.say(line)
|
|
170
|
+
puts line
|
|
171
|
+
$stdout.flush
|
|
172
|
+
rescue IOError, SystemCallError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
118
176
|
# @param hub [SessionHub, nil] the session projection the page streams
|
|
119
177
|
# from; built over the app's state dir unless given
|
|
120
178
|
# @param lan [Lan::Choice, nil] also listen on this LAN address, where
|
|
121
179
|
# every request but this machine's needs the access token
|
|
122
180
|
# @param token_path [String] the LAN access token's file
|
|
181
|
+
# @param public_url [PublicUrl, nil] behind an auth proxy
|
|
182
|
+
# (web.public_url, or --tunnel's): every request needs the access token
|
|
183
|
+
# @param public_notes [Array<String>] lines after the proxied link's
|
|
184
|
+
# (the tunnel's)
|
|
123
185
|
# @return [Boolean] false when the port was taken (said so on stderr)
|
|
124
186
|
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
125
187
|
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
|
|
126
188
|
editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
|
|
127
|
-
working_set: Config::BY_KEY["web.working_set"].default)
|
|
189
|
+
working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [])
|
|
128
190
|
config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
|
|
129
191
|
Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
|
|
130
192
|
end
|
|
@@ -133,14 +195,18 @@ module Samagotchi
|
|
|
133
195
|
url ||= "http://#{url_host(host)}:#{port}/"
|
|
134
196
|
|
|
135
197
|
hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
|
|
136
|
-
if lan
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
end
|
|
198
|
+
Token.load_or_create(token_path) if lan || public_url
|
|
199
|
+
lan_option = { ip: lan.ip, token: Token::Source.new(token_path) } if lan
|
|
200
|
+
public_token = Token::Source.new(token_path) if public_url
|
|
140
201
|
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
|
|
141
|
-
annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set
|
|
202
|
+
annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set,
|
|
203
|
+
public_url: public_url, public_token: public_token)
|
|
142
204
|
Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
|
|
143
205
|
Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
|
|
206
|
+
if public_url
|
|
207
|
+
Samagotchi::Log.info(:web, "public_url", origin: public_url.origin, identity_header: public_url.identity_label,
|
|
208
|
+
owner: public_url.owner ? "set" : "none")
|
|
209
|
+
end
|
|
144
210
|
# Subscribed before the first scan, which makes the first check.
|
|
145
211
|
hub.subscribe(method(:on_hub_event))
|
|
146
212
|
hub.start
|
|
@@ -148,15 +214,20 @@ module Samagotchi
|
|
|
148
214
|
# the in-use line instead.
|
|
149
215
|
running_path = Running.path(state_dir: state_dir)
|
|
150
216
|
started = lambda do
|
|
151
|
-
record_running(running_path, port)
|
|
217
|
+
record_running(running_path, port, public_url)
|
|
152
218
|
puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
|
|
153
219
|
puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
|
|
220
|
+
if public_url
|
|
221
|
+
puts public_lines(public_url, token: Token.read(token_path), codes: owner_codes(state_dir), notes: public_notes)
|
|
222
|
+
end
|
|
154
223
|
puts "Press Ctrl-C to stop."
|
|
155
224
|
$stdout.flush # a log file isn't line-buffered
|
|
156
225
|
if open_browser
|
|
226
|
+
# With web.public_url the page opens through the proxy.
|
|
227
|
+
open_target = public_url ? public_url.link(owner_codes(state_dir).issue(kind: :owner, ttl: LinkCodes::OWNER_TTL)) : url
|
|
157
228
|
Thread.new do
|
|
158
229
|
sleep 0.8
|
|
159
|
-
open_url(
|
|
230
|
+
open_url(open_target)
|
|
160
231
|
end
|
|
161
232
|
end
|
|
162
233
|
end
|
|
@@ -184,11 +255,11 @@ module Samagotchi
|
|
|
184
255
|
|
|
185
256
|
class LanListenError < StandardError; end
|
|
186
257
|
|
|
187
|
-
# Where this chi web listens,
|
|
188
|
-
#
|
|
189
|
-
# web.port.
|
|
190
|
-
def self.record_running(path, port)
|
|
191
|
-
Running.record(port: port, path: path)
|
|
258
|
+
# Where this chi web listens, and its public_url (config's or the
|
|
259
|
+
# tunnel's), for chi share's link (Web::Running); a write that fails
|
|
260
|
+
# leaves the server running and the readers on web.port and config.
|
|
261
|
+
def self.record_running(path, port, public_url = nil)
|
|
262
|
+
Running.record(port: port, path: path, public_url: public_url&.origin)
|
|
192
263
|
rescue SystemCallError => e
|
|
193
264
|
Samagotchi::Log.warn(:web, "running_record_failed", error: e.class.name)
|
|
194
265
|
end
|
|
@@ -224,6 +295,51 @@ module Samagotchi
|
|
|
224
295
|
raise LanListenError, e.message
|
|
225
296
|
end
|
|
226
297
|
|
|
298
|
+
# web.public_url as Server runs it, nil when unset.
|
|
299
|
+
# @raise [PublicUrl::Invalid]
|
|
300
|
+
def self.public_url_setting
|
|
301
|
+
PublicUrl.from_config(url: Samagotchi::Config.get("web.public_url"),
|
|
302
|
+
identity_header: Samagotchi::Config.get("web.proxy.identity_header"),
|
|
303
|
+
owner: Samagotchi::Config.get("web.proxy.owner"))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# What web.public_url adds to the start lines: the proxied link (a
|
|
307
|
+
# one-time code in its fragment), who the owner is, +notes+ (the
|
|
308
|
+
# tunnel's) and, at a terminal, the link's QR code. On this machine the
|
|
309
|
+
# page opens through the proxy too; a direct request takes the token
|
|
310
|
+
# as Authorization: Bearer only (no cookie for 127.0.0.1, which every
|
|
311
|
+
# local port would get).
|
|
312
|
+
def self.public_lines(public_url, token:, codes: owner_codes, notes: [], qr: $stdout.tty?)
|
|
313
|
+
unless token
|
|
314
|
+
return ["Proxied: #{public_url.origin}/ (no access token: its file is gone; chi web --new-token makes one)",
|
|
315
|
+
*notes]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
who = if public_url.identity_header.nil? then "token only, no identity header"
|
|
319
|
+
elsif public_url.owner then "owner #{public_url.owner} by #{public_url.identity_header}, and the token"
|
|
320
|
+
else "nobody is the owner by identity: web.proxy.owner is unset"
|
|
321
|
+
end
|
|
322
|
+
link = public_url.link(codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL))
|
|
323
|
+
lines = ["Proxied: #{link} ← opens chi web as you once, within #{LinkCodes::OWNER_TTL / 60} minutes " \
|
|
324
|
+
"(chi web again prints a new one)",
|
|
325
|
+
" (#{who}; on this machine the page opens through the proxy too)", *notes]
|
|
326
|
+
lines.concat(QR.lines(link)) if qr
|
|
327
|
+
lines
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# The owner's one-time link codes, next to the grants.
|
|
331
|
+
def self.owner_codes(state_dir = nil) = LinkCodes.for_grants(Grants.path(state_dir: state_dir))
|
|
332
|
+
|
|
333
|
+
# A second chi web, when this machine's config has web.public_url (or
|
|
334
|
+
# the running chi web recorded one: Running.live_public_url): the link
|
|
335
|
+
# from there and the token file, never from what answered on the port.
|
|
336
|
+
def self.running_public_lines(public_url, token_path: Token.path, codes: owner_codes)
|
|
337
|
+
return ["Proxied: #{public_url.origin}/"] unless Token.read(token_path)
|
|
338
|
+
|
|
339
|
+
["Proxied: #{public_url.link(codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL))} (once, within " \
|
|
340
|
+
"#{LinkCodes::OWNER_TTL / 60} minutes)"]
|
|
341
|
+
end
|
|
342
|
+
|
|
227
343
|
def self.host_setting(host)
|
|
228
344
|
(host || Samagotchi::Config.get("web.host")).to_s.strip
|
|
229
345
|
end
|