samagotchi 0.108.0 → 0.109.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.
@@ -57,10 +57,10 @@ module Samagotchi
57
57
 
58
58
  issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
59
59
  ttl: ttl, cap: cap, state_dir: default_state_dir)
60
- link = participant_link(req, id, issued.token)
60
+ link = participant_link(req, id, issued.token, expires_at: issued.grant.expires_at)
61
61
  warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
62
62
  json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
63
- link: link, lan: !@lan.nil?, qr_svg: QR.svg(link), warning: warning })
63
+ link: link, lan: !@lan.nil? || !@public_url.nil?, qr_svg: QR.svg(link), warning: warning })
64
64
  rescue Grants::Invalid => e
65
65
  error_response(400, "invalid_participant", e.message)
66
66
  end
@@ -115,10 +115,17 @@ module Samagotchi
115
115
  error_response(400, "invalid_participant", e.message)
116
116
  end
117
117
 
118
- # The link a participant opens: this chi web's LAN address when it has
119
- # one (the owner may be asking on loopback), else the Host asked; the
120
- # token traded for the cookie, the session in the fragment.
121
- def participant_link(req, id, token)
118
+ # The link a participant opens: web.public_url's with a one-time code
119
+ # for the token in the fragment (LinkCodes, until the grant expires;
120
+ # the page trades it, POST /api/token); else this chi web's LAN address
121
+ # when it has one (the owner may be asking on loopback), else the Host
122
+ # asked; the token traded for the cookie, the session in the fragment.
123
+ def participant_link(req, id, token, expires_at: nil)
124
+ if @public_url
125
+ ttl = expires_at ? [Time.iso8601(expires_at) - @grants.now, 1].max : LinkCodes::OWNER_TTL
126
+ return @public_url.link(@link_codes.issue(kind: :grant, token: token, ttl: ttl), "s=#{id}")
127
+ end
128
+
122
129
  host = req.get_header("HTTP_HOST").to_s
123
130
  port = host[/:(\d+)\z/, 1] || req.port.to_s
124
131
  name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "link_codes"
4
+ require_relative "principal"
5
+ require_relative "public_url"
6
+ require_relative "../log"
7
+ require_relative "../proxy_trace"
8
+
9
+ module Samagotchi
10
+ module Web
11
+ # chi web behind an auth proxy (web.public_url), mixed into App. With a
12
+ # PublicUrl every request needs a token (this machine's too: a proxy
13
+ # with no trace and a rewritten Host looks local). A request is
14
+ # *proxied* when it carries a proxy trace (ProxyTrace: X-Forwarded-*,
15
+ # Forwarded, X-Real-IP, a known identity header…), the configured
16
+ # identity header, or the public Host. A proxied request:
17
+ # - is the owner with the owner token, and, when web.proxy.identity_header
18
+ # is set, only if that header is one clean value equal to
19
+ # web.proxy.owner (D-a: identity AND token);
20
+ # - is a participant with a live grant's token (a grant bound to an
21
+ # identity only for that identity, and only proxied);
22
+ # - else nil: the gate's 401, its reason in the detail (only the
23
+ # requester's own identity) and the log.
24
+ # The identity header is refused when repeated, with a comma, empty, or
25
+ # sent under two names or with an underscore: WEBrick folds
26
+ # X_Forwarded_Email into the same Rack key as X-Forwarded-Email, the
27
+ # last one winning, so the gate reads the raw names (RawHeaderNames,
28
+ # installed by Server). See docs/configuration.md "Behind an auth proxy".
29
+ module ProxyGate
30
+ # The header names a request came with, as WEBrick read them
31
+ # (lower-cased, "_" kept), in the Rack env. Prepended to
32
+ # WEBrick::HTTPRequest by Server.
33
+ module RawHeaderNames
34
+ def meta_vars
35
+ super.merge(RAW_NAMES_ENV => header.keys)
36
+ end
37
+ end
38
+
39
+ RAW_NAMES_ENV = "samagotchi.raw_header_names"
40
+ # Why the gate refused a proxied request, for the 401's detail.
41
+ REFUSED_ENV = "samagotchi.proxy_refused"
42
+ # The token cookie on a proxied (https) request: Secure, Path=/, no
43
+ # Domain (other host names under the domain, a pod's other ports, don't
44
+ # get it). Cookies aren't port-scoped: a server on the same host name
45
+ # and another port does.
46
+ SECURE_COOKIE = "__Host-chi_token"
47
+
48
+ private
49
+
50
+ def init_proxy_gate(public_url, token)
51
+ @public_url = public_url
52
+ @public_token = token
53
+ @link_codes = LinkCodes.for_grants(@grants.path)
54
+ @proxy_hint_mutex = Mutex.new
55
+ @proxy_hint_logged = false
56
+ end
57
+
58
+ def proxied?(req)
59
+ return false unless @public_url
60
+
61
+ env = req.env
62
+ return true if @public_url.host?(env["HTTP_HOST"])
63
+ return true if @public_url.identity_env && env.key?(@public_url.identity_env)
64
+
65
+ names = env[RAW_NAMES_ENV] || env.keys.filter_map { |key| ProxyTrace.env_name(key) }
66
+ names.any? { |name| ProxyTrace.header?(name) }
67
+ end
68
+
69
+ # [:ok, email] | [:none, nil] | [:bad, reason]: the configured identity
70
+ # header of a proxied request; :none without one configured.
71
+ def proxy_identity(req)
72
+ env_key = @public_url.identity_env
73
+ return [:none, nil] unless env_key
74
+
75
+ raw = req.env[RAW_NAMES_ENV]
76
+ return [:bad, "the server gave no raw header names to check the identity header against"] unless raw
77
+
78
+ names = raw.select { |name| PublicUrl.env_key(name) == env_key }.uniq
79
+ return [:bad, "identity header sent under #{names.size} names (#{names.join(", ")})"] if names.size > 1
80
+ return [:bad, "identity header sent with an underscore (#{names.first})"] if names.first&.include?("_")
81
+
82
+ value = req.env[env_key]
83
+ return [:none, nil] if value.nil?
84
+ return [:bad, "identity header not among the raw header names"] if names.empty?
85
+
86
+ value = value.strip
87
+ return [:bad, "identity header empty"] if value.empty?
88
+ return [:bad, "identity header repeated or with a comma"] if value.include?(",")
89
+
90
+ [:ok, value.downcase]
91
+ end
92
+
93
+ # The principal of a proxied request, or nil (the reason in the env).
94
+ def proxied_principal(req, given)
95
+ state, identity = proxy_identity(req)
96
+ return proxy_refuse(req, identity) if state == :bad
97
+
98
+ notice_identity_headers(req) if state == :none
99
+ shown = identity || "(none)"
100
+ if owner_token?(given)
101
+ return Principal::OWNER if @public_url.identity_env.nil?
102
+ return Principal::OWNER if @public_url.owner && identity == @public_url.owner
103
+
104
+ return proxy_refuse(req, "owner token with identity #{shown}#{identity_hint(req)}")
105
+ end
106
+ grant_tokens = given.select { |value| Grants.token?(value) }
107
+ return proxy_refuse(req, "no token (identity #{shown})") if grant_tokens.empty?
108
+
109
+ participant_principal(req, grant_tokens, identity: identity, proxied: true)
110
+ end
111
+
112
+ # chi's own probe of a chi web behind a proxy (InfoProbe: a second chi
113
+ # web, chi share, chi update) sends no token: GET /api/info from this
114
+ # machine, not through the proxy, with no token at all, gets the
115
+ # fields it checks (nothing secret), so no probe ever hands the token
116
+ # to whatever listens on the port.
117
+ def probe_info?(req)
118
+ return false unless @public_url && req.get? && req.path_info == "/api/info"
119
+ return false unless App::LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) && !proxied?(req)
120
+
121
+ req.get_header("HTTP_AUTHORIZATION").nil? && req.get_header("HTTP_COOKIE").nil?
122
+ end
123
+
124
+ def probe_info
125
+ json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, features: App::INFO_FEATURES,
126
+ lan: @lan && @lan[:ip] })
127
+ end
128
+
129
+ def proxy_refuse(req, reason)
130
+ req.env[REFUSED_ENV] = reason
131
+ nil
132
+ end
133
+
134
+ # The known identity headers (ProxyTrace::IDENTITY_HEADERS) a proxied
135
+ # request carries other than the configured one.
136
+ def other_identity_headers(req)
137
+ ProxyTrace::IDENTITY_HEADERS.select do |name|
138
+ key = PublicUrl.env_key(name)
139
+ key != @public_url.identity_env && req.env.key?(key)
140
+ end
141
+ end
142
+
143
+ def identity_hint(req)
144
+ return "" if @public_url.identity_env.nil? || req.env.key?(@public_url.identity_env)
145
+
146
+ others = other_identity_headers(req)
147
+ others.empty? ? "" : "; the proxy sends #{others.join(", ")}, not #{@public_url.identity_header}"
148
+ end
149
+
150
+ # Once per start: a proxied request without the configured identity
151
+ # header (or with none configured) but with a header a known setup
152
+ # puts the identity in. A wrong header locks the owner out, and one the
153
+ # proxy doesn't replace is a client-chosen string: the line says which.
154
+ def notice_identity_headers(req)
155
+ return if @proxy_hint_logged
156
+
157
+ others = other_identity_headers(req)
158
+ return if others.empty?
159
+ return unless @proxy_hint_mutex.synchronize { !@proxy_hint_logged && (@proxy_hint_logged = true) }
160
+
161
+ if @public_url.identity_env
162
+ Log.warn(:web, "proxy_identity_header", configured: @public_url.identity_header, sent: others.join(","),
163
+ echo: "Warning: chi web: the proxy sends #{others.join(", ")}, not " \
164
+ "web.proxy.identity_header #{@public_url.identity_header}")
165
+ else
166
+ Log.info(:web, "proxy_identity_header", configured: "none", sent: others.join(","),
167
+ echo: "chi web: the proxy sends #{others.join(", ")}; with " \
168
+ "web.proxy.identity_header and web.proxy.owner it is checked " \
169
+ "next to the token (only a header the proxy replaces)")
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -24,7 +24,10 @@ import {
24
24
  addContext,
25
25
  renderText,
26
26
  getWorkingSet,
27
+ onAuthLost,
27
28
  } from "./data.js";
29
+ import { authProbe, createAuthLost } from "./auth_lost.js";
30
+ import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
28
31
  import { createContextChips } from "./context_chips.js";
29
32
  import { createLlmContextControl } from "./llm_context_chip.js";
30
33
  import { createParticipantsControl } from "./participants_panel.js";
@@ -96,6 +99,16 @@ import { createSessionActions } from "./session_actions.js";
96
99
  import { createRename } from "./session_rename.js";
97
100
  import { createSendTurn } from "./send_turn.js";
98
101
 
102
+ // A link's #code= (behind an auth proxy): traded for the cookie before
103
+ // anything else runs; the page reloads without it.
104
+ // This page has a session already: the trade would replace it (a link
105
+ // someone sent to swap this browser's session for theirs), so ask first.
106
+ if (fragmentCode(globalThis.location?.hash)
107
+ && await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
108
+ // Signed out (the proxy's session or chi's token): a banner, or one reload.
109
+ const authLost = createAuthLost();
110
+ onAuthLost(authLost);
111
+
99
112
  const $ = (s) => document.querySelector(s);
100
113
  const topStripEl = $("#topStrip");
101
114
  const allListEl = $("#allList");
@@ -449,9 +462,20 @@ function openSessionEvents() {
449
462
  eventsControl = null;
450
463
  refresh();
451
464
  },
465
+ // The browser gave up after frames: signed out (the proxy's sign-in
466
+ // page answered the reconnect), or chi web went away for a while.
467
+ onClosed: () => {
468
+ eventsControl = null;
469
+ authProbe().then((state) => {
470
+ if (state === "lost") authLost("events");
471
+ else setTimeout(openSessionEvents, EVENTS_REOPEN_MS);
472
+ });
473
+ },
452
474
  });
453
475
  }
454
476
 
477
+ const EVENTS_REOPEN_MS = 3000;
478
+
455
479
  // Which chi serves this page now and which is the newest installed (the
456
480
  // events snapshot, and `chi` frames when that changes). A toast says when
457
481
  // chi web was upgraded while this tab stayed open (offering the reload,
@@ -0,0 +1,76 @@
1
+ // Signed out: the auth proxy's session (Okta…) or chi's token is gone. An
2
+ // /api answer says so in one of these ways: a 401 (chi's unauthorized, or
3
+ // the proxy's own 401 {} for Accept: application/json), a fetch that was
4
+ // redirected or came back as HTML (a sign-in page), or, from the probe,
5
+ // a redirect fetch can't follow (real IdPs send no CORS: a TypeError, so
6
+ // the probe asks with redirect: "manual"). The page then says so in a
7
+ // banner with Reload, and reloads by itself at most once a minute (the
8
+ // proxy's sign-in brings the user back), never when it can't keep the time.
9
+
10
+ export const AUTH_RELOAD_KEY = "chi.authReloadAt";
11
+ export const AUTH_RELOAD_MS = 60_000;
12
+
13
+ // Whether a fetch Response says the sign-in is gone.
14
+ export function lostAnswer(res) {
15
+ if (!res) return false;
16
+ if (res.status === 401 || res.redirected || res.type === "opaqueredirect") return true;
17
+ const type = res.headers?.get?.("content-type") || "";
18
+ return type.includes("text/html");
19
+ }
20
+
21
+ // "lost" | "ok" | "down" (chi web unreachable: no banner, the page's own
22
+ // retries go on).
23
+ export async function authProbe(fetchImpl = globalThis.fetch) {
24
+ try {
25
+ const res = await fetchImpl("/api/info", {
26
+ credentials: "same-origin", redirect: "manual", headers: { Accept: "application/json" },
27
+ });
28
+ return lostAnswer(res) ? "lost" : "ok";
29
+ } catch (_) {
30
+ return "down";
31
+ }
32
+ }
33
+
34
+ export const AUTH_LOST_TEXT = "Signed out (the proxy's sign-in or chi's token ended).";
35
+
36
+ // The page's authLost(reason): once, a banner (doc) and, unless one ran in
37
+ // the last minute, a reload. storage: sessionStorage (may be missing or throw).
38
+ export function createAuthLost({
39
+ doc = globalThis.document, storage = () => globalThis.sessionStorage, reload = () => globalThis.location.reload(),
40
+ now = () => Date.now(), log = (reason) => console.warn("chi: signed out", reason),
41
+ } = {}) {
42
+ let shown = false;
43
+ return function authLost(reason) {
44
+ if (shown) return;
45
+ shown = true;
46
+ log(reason);
47
+ // The reload only when its stamp can be kept: without storage (a
48
+ // private window) a page that keeps losing its sign-in would reload
49
+ // forever, so it gets the banner.
50
+ let due = false;
51
+ try {
52
+ due = now() - (Number(storage().getItem(AUTH_RELOAD_KEY)) || 0) >= AUTH_RELOAD_MS;
53
+ if (due) storage().setItem(AUTH_RELOAD_KEY, String(now()));
54
+ } catch (_) {
55
+ due = false;
56
+ }
57
+ if (due) {
58
+ reload();
59
+ return;
60
+ }
61
+ const bar = doc.createElement("div");
62
+ bar.id = "authLost";
63
+ bar.setAttribute("role", "alert");
64
+ bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:10000;padding:8px 16px;font-size:14px;"
65
+ + "color:var(--warn-text);background:var(--warn-bg);border-bottom:1px solid var(--warn-border);"
66
+ + "display:flex;gap:12px;align-items:center;justify-content:center";
67
+ const text = doc.createElement("span");
68
+ text.textContent = AUTH_LOST_TEXT;
69
+ const button = doc.createElement("button");
70
+ button.type = "button";
71
+ button.textContent = "Reload";
72
+ button.addEventListener("click", () => reload());
73
+ bar.append(text, button);
74
+ doc.body.append(bar);
75
+ };
76
+ }
@@ -1,9 +1,27 @@
1
+ import { authProbe, lostAnswer } from "./auth_lost.js";
2
+
3
+ // What the page does when an answer says the sign-in is gone (auth_lost.js;
4
+ // app.js sets it). A no-op until then.
5
+ let authLostHandler = () => {};
6
+ export function onAuthLost(handler) {
7
+ authLostHandler = typeof handler === "function" ? handler : () => {};
8
+ }
9
+
10
+ // Accept: application/json: an auth proxy whose session ended answers 401
11
+ // {} (oauth2-proxy) rather than a redirect to its sign-in. A fetch that
12
+ // fails outright asks authProbe whether that was a sign-in redirect.
1
13
  export function api(path, opts = {}) {
2
14
  const { fetchImpl = globalThis.fetch } = opts;
3
15
  return fetchImpl(path, {
4
- headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
5
16
  ...opts,
17
+ headers: { "Content-Type": "application/json", Accept: "application/json", ...(opts.headers || {}) },
18
+ }).catch((error) => {
19
+ if (error instanceof TypeError) {
20
+ authProbe(fetchImpl).then((state) => { if (state === "lost") authLostHandler(`fetch ${path}`); }, () => {});
21
+ }
22
+ throw error;
6
23
  }).then(async (r) => {
24
+ if (lostAnswer(r)) authLostHandler(`${r.status} ${path}`);
7
25
  const j = await r.json().catch(() => ({}));
8
26
  if (!r.ok) {
9
27
  // The server's words as they are: what the page shows the user. The
@@ -279,10 +297,12 @@ export function openStream(id, fromSeq, handlers = {}, opts = {}) {
279
297
  // only when the browser gave up before the first frame (a chi web without
280
298
  // a hub answers 503, a bad ?dir 400): the caller falls back to fetching.
281
299
  // Later errors are left to EventSource's own reconnect, and a reconnect
282
- // always starts with a fresh snapshot.
300
+ // always starts with a fresh snapshot, unless the browser gave up for good
301
+ // (readyState CLOSED: an auth proxy answered the reconnect with its sign-in
302
+ // page): onClosed then, and the caller probes and opens it again.
283
303
  // @return {{close(): void}}
284
304
  export function openEvents(handlers = {}, opts = {}) {
285
- const { EventSourceImpl = globalThis.EventSource, dir, onError } = opts;
305
+ const { EventSourceImpl = globalThis.EventSource, dir, onError, onClosed } = opts;
286
306
  const url = dir ? `/api/events?dir=${encodeURIComponent(dir)}` : "/api/events";
287
307
  const es = new EventSourceImpl(url);
288
308
  let frameReceived = false;
@@ -290,10 +310,14 @@ export function openEvents(handlers = {}, opts = {}) {
290
310
  let live = true;
291
311
  es.onerror = () => {
292
312
  // readyState 2 (CLOSED): the browser won't retry on its own.
293
- if (frameReceived || !live || es.readyState !== 2) return;
313
+ if (!live || es.readyState !== 2) return;
294
314
  live = false;
295
315
  try { es.close(); } catch (_) {}
296
- if (typeof onError === "function") onError();
316
+ if (!frameReceived) {
317
+ if (typeof onError === "function") onError();
318
+ } else if (typeof onClosed === "function") {
319
+ onClosed();
320
+ }
297
321
  };
298
322
  return {
299
323
  close() {
@@ -9,6 +9,16 @@ import {
9
9
  authorColor, participantAuthor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
10
10
  liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries,
11
11
  } from "./participant_model.js";
12
+ import { createAuthLost, lostAnswer } from "./auth_lost.js";
13
+ import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
14
+
15
+ // A link's #code= opened in a browser that already has a cookie: traded
16
+ // first, the page reloads without it.
17
+ // This page has a session already: the trade would replace it (a link
18
+ // someone sent to swap this browser's session for theirs), so ask first.
19
+ if (fragmentCode(globalThis.location?.hash)
20
+ && await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
21
+ const authLost = createAuthLost();
12
22
 
13
23
  const $ = (id) => document.getElementById(id);
14
24
  const body = document.body.dataset;
@@ -47,17 +57,21 @@ function el(tag, cls, text) {
47
57
  }
48
58
 
49
59
  // A request's answer; a network failure is status 0 (the page says it
50
- // can't reach the session), never a throw.
60
+ // can't reach the session), never a throw. A 401 with chi's words is the
61
+ // link ending (revoked, expired); one without (an auth proxy's own 401 {}),
62
+ // a redirect or an HTML answer is the sign-in ending (auth_lost.js).
51
63
  async function api(path, options = {}) {
52
64
  let res;
53
65
  try {
54
- res = await fetch(path, { credentials: "same-origin", ...options });
66
+ res = await fetch(path, { credentials: "same-origin", ...options,
67
+ headers: { Accept: "application/json", ...(options.headers || {}) } });
55
68
  } catch {
56
69
  return { status: 0, ok: false, body: null };
57
70
  }
58
71
  let json = null;
59
72
  try { json = await res.json(); } catch { json = null; }
60
- if (res.status === 401) showEnded(json);
73
+ if (lostAnswer(res) && !(res.status === 401 && json?.error)) authLost(`${res.status} ${path}`);
74
+ else if (res.status === 401) showEnded(json);
61
75
  return { status: res.status, ok: res.ok, body: json };
62
76
  }
63
77
 
@@ -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
+ }
@@ -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