Skip to main content

runlight/routes/
mod.rs

1//! The dashboard, its HTTP API, the tracker endpoint, and the pages around
2//! them (routes.ts), framework-free: `Routes::handle(Request) -> Response`.
3
4mod api;
5pub(crate) mod glue;
6mod parts;
7mod wired;
8
9use std::collections::HashMap;
10use std::sync::{Arc, Mutex};
11
12use crate::assets::{self, BUILD_INFO, DASHBOARD_CSS, DASHBOARD_JS, PICK_RELAY, PICKER, TRACKER, WORLD_JSON};
13use crate::goals::{CodedError, click_rules};
14use crate::hash::{hmac, random_id, sha256};
15use crate::http::{Headers, Request, Response, Url};
16use crate::js::{self, Value};
17use crate::re::{js_re, test};
18use crate::runlight::{Runlight, env_value};
19use crate::store::{TablesBusyError, TokenRow};
20use crate::{BoxFuture, obj};
21
22pub use glue::RoutesExtras;
23
24/// What a check of the app's own says a request may do.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum Access {
27    /// Full access.
28    Full,
29    /// Everything but the install-wide controls.
30    Member,
31    /// Read every site's stats and change nothing.
32    Read,
33    /// Refused.
34    Denied,
35}
36
37/// The token routes() checks.
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub enum TokenOption {
40    /// RUNLIGHT_TOKEN, the default.
41    #[default]
42    FromEnv,
43    /// Open everywhere, for example behind the app's own auth middleware (the SDK's `null`).
44    Open,
45    /// This token; empty means none.
46    Given(String),
47}
48
49/// Checks a request the app's own way.
50pub type Authorize = Arc<dyn Fn(&Request) -> BoxFuture<'static, Access> + Send + Sync>;
51
52/// How routes() is set up. The option names are the SDK's.
53#[derive(Clone, Default)]
54pub struct RoutesOptions {
55    /// Where the routes are mounted. Default "/runlight".
56    pub base_path: Option<String>,
57    /// Required to read stats.
58    pub token: TokenOption,
59    /// The app's own check instead of a token.
60    pub authorize: Option<Authorize>,
61    /// Also accepted as a bearer token on POST /api/check. `None` reads CRON_SECRET.
62    pub cron_secret: Option<String>,
63    /// Lets another site report AI agent fetches to POST /api/observe. `None` reads RUNLIGHT_OBSERVE_KEY.
64    pub observe_key: Option<String>,
65    /// A link to sign out, shown in the dashboard's footer.
66    pub sign_out: Option<String>,
67    /// Where to sign in.
68    pub sign_in: Option<String>,
69    /// Sign-in accounts for the dashboard.
70    pub accounts: bool,
71    /// Credits DB-IP in the dashboard's footer.
72    pub geo_credit: bool,
73    /// The CMS that adds the script to every page, such as "WordPress", when Runlight runs inside one. The
74    /// dashboard then says so in place of the steps to add the script, and pages of the same origin, the CMS's
75    /// own admin, may show the whole dashboard in a frame. The WordPress, Drupal, and Craft plugins set it.
76    pub cms: Option<String>,
77    /// The address people open the app at, such as https://example.com.
78    pub origin: Option<String>,
79    /// What the standalone server adds: more names the dashboard is reached at, and its accounts' hooks.
80    pub extras: RoutesExtras,
81}
82
83pub(crate) const COOKIE: &str = "runlight_token";
84/// API tokens start with this, so they are told apart from the main token.
85pub(crate) const TOKEN_PREFIX: &str = "rl_";
86/// The header a shared dashboard sends its share id in.
87pub(crate) const SHARE_HEADER: &str = "x-runlight-share";
88/// The header an embedded dashboard sends its session in.
89pub(crate) const EMBED_HEADER: &str = "x-runlight-embed";
90/// How long an embed ticket works: long enough for the admin page to load its frame, never to be kept.
91const EMBED_TICKET_MS: i64 = 5 * 60_000;
92/// How long an embedded dashboard reads before the admin page has to be loaded again for a new ticket.
93const EMBED_SESSION_MS: i64 = 60 * 60_000;
94/// Where the tracker's click rules go; the script ships with this string in their place.
95const RULES_PLACEHOLDER: &str = "\"__RUNLIGHT_RULES__\"";
96/// Where the picker's one allowed receiver goes.
97const PICK_TARGET_PLACEHOLDER: &str = "\"__RUNLIGHT_PICK_TARGET__\"";
98/// Where the hostnames of the site its ticket names go, as JSON inside a string.
99const PICK_HOSTS_PLACEHOLDER: &str = "\"__RUNLIGHT_PICK_HOSTS__\"";
100/// Where the relay page the picker sends its choice to goes, the return address its ticket names.
101const PICK_RETURN_PLACEHOLDER: &str = "\"__RUNLIGHT_PICK_RETURN__\"";
102/// Where the nonce the dashboard made for this pick goes, which the relay page hands back to it.
103const PICK_NONCE_PLACEHOLDER: &str = "\"__RUNLIGHT_PICK_NONCE__\"";
104/// How long a picker ticket works.
105const PICK_TICKET_MS: i64 = 30 * 60_000;
106pub(crate) const DASHBOARD_CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
107
108/// HTML's special characters escaped.
109pub(crate) fn escape_html(value: &str) -> String {
110    let mut out = String::with_capacity(value.len());
111    for c in value.chars() {
112        match c {
113            '&' => out.push_str("&amp;"),
114            '<' => out.push_str("&lt;"),
115            '>' => out.push_str("&gt;"),
116            '"' => out.push_str("&quot;"),
117            '\'' => out.push_str("&#39;"),
118            _ => out.push(c),
119        }
120    }
121    out
122}
123
124fn escape_attr(value: &str) -> String {
125    let mut out = String::with_capacity(value.len());
126    for c in value.chars() {
127        match c {
128            '&' | '"' | '<' | '>' => out.push_str(&format!("&#{};", c as u32)),
129            _ => out.push(c),
130        }
131    }
132    out
133}
134
135/// The discovery documents OAuth clients read.
136pub(crate) fn is_oauth_document(path: &str) -> bool {
137    path.starts_with("/.well-known/oauth-") || path.starts_with("/.well-known/openid-configuration")
138}
139
140pub(crate) fn is_development() -> bool {
141    env_value("NODE_ENV").as_deref() == Some("development")
142}
143
144/// A JSON answer, never cached or sniffed.
145pub(crate) fn json(body: &Value, status: u16, extra: &[(&str, &str)]) -> Response {
146    let mut headers = Headers::new()
147        .with("content-type", "application/json; charset=utf-8")
148        .with("cache-control", "no-store")
149        .with("x-content-type-options", "nosniff");
150    for (k, v) in extra {
151        headers.set(k, v);
152    }
153    Response::new(js::stringify(body), status, headers)
154}
155
156/// An error the dashboard can show in its own language: `code` names it and `params` fill its
157/// placeholders, while `error` stays the English message.
158pub(crate) fn coded(error: &str, code: &str, status: u16, params: Option<&[(&str, &str)]>) -> Response {
159    coded_with(error, code, status, params, &[])
160}
161
162/// As coded(), with more headers. Public for the tests that replay the TypeScript's answers.
163#[doc(hidden)]
164pub fn coded_with(
165    error: &str,
166    code: &str,
167    status: u16,
168    params: Option<&[(&str, &str)]>,
169    extra: &[(&str, &str)],
170) -> Response {
171    let mut o = js::Object::new();
172    o.set("error", error);
173    o.set("code", code);
174    if let Some(params) = params {
175        let mut p = js::Object::new();
176        for (k, v) in params {
177            p.set(*k, *v);
178        }
179        o.set("params", p);
180    }
181    json(&Value::Object(o), status, extra)
182}
183
184/// A coded error's own code and params as an answer.
185pub(crate) fn coded_error(error: &CodedError, status: u16) -> Response {
186    let mut o = js::Object::new();
187    o.set("error", error.message.clone());
188    o.set("code", error.code.clone());
189    o.set("params", error.params_value());
190    json(&Value::Object(o), status, &[])
191}
192
193/// A refusal from a check elsewhere, with its own code and params.
194pub(crate) fn refused(error: &CodedError, status: u16) -> Response {
195    coded_error(error, status)
196}
197
198/// A refusal whose error carries no code: `fallback` with its English words as `detail`.
199pub(crate) fn refused_plain(message: &str, fallback: &str, status: u16) -> Response {
200    coded(message, fallback, status, Some(&[("detail", message)]))
201}
202
203/// Whether a request's body is JSON by its media type.
204pub(crate) fn is_json(request: &Request) -> bool {
205    js::trim(request.headers.get("content-type").unwrap_or_default().split(';').next().unwrap_or("")).to_lowercase()
206        == "application/json"
207}
208
209/// A Host or X-Forwarded-Host value as a bare name: lowercase, with no port, no final dot, and no www.
210pub fn host_name(value: &str) -> String {
211    let first = js::trim(value.split(',').next().unwrap_or("")).to_lowercase();
212    let name = if first.starts_with('[') {
213        match first.find(']') {
214            Some(i) => first[..=i].to_string(),
215            None => String::new(),
216        }
217    } else {
218        crate::re::replace_first(js_re!(r":\d*$"), &first, "")
219    };
220    let name = name.trim_end_matches('.');
221    name.strip_prefix("www.").unwrap_or(name).to_string()
222}
223
224/// Whether a domain name is one kept for private networks or tests, or has an IPv4 address inside it.
225pub(crate) fn private_name(domain: &str) -> bool {
226    if test(js_re!(r"(^|\.)\d{1,3}(\.\d{1,3}){3}(\.|$)"), domain) {
227        return true;
228    }
229    test(
230        js_re!(
231            r"\.(internal|intranet|private|local|localhost|localdomain|lan|home|corp|home\.arpa|arpa|test|invalid|example)$"
232        ),
233        domain,
234    )
235}
236
237/// A plain page in a visitor's language, for unsubscribing, for a share link that is gone, and for the picker's relay
238/// page, which alone runs a script: one file from this server, never inline.
239pub(crate) fn small_page(lang: &str, body: &str, status: u16, script: &str) -> Response {
240    let tag =
241        if script.is_empty() { String::new() } else { format!("<script src=\"{}\"></script>", escape_html(script)) };
242    Response::new(
243        format!(
244            "<!doctype html><html lang=\"{lang}\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"robots\" content=\"noindex\"><title>Runlight</title>
245<style>body{{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f5;font:16px/1.5 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif;color:#111827}}main{{max-width:420px;margin:24px;padding:32px;background:#fff;border:1px solid #e5e7eb;border-radius:14px}}h1{{font-size:20px;margin:0 0 12px}}p{{margin:0 0 20px;color:#4b5563}}button{{height:40px;padding:0 18px;border:0;border-radius:8px;background:#111827;color:#fff;font:inherit;font-weight:600;cursor:pointer}}</style></head><body><main>{body}</main>{tag}</body></html>"
246        ),
247        status,
248        Headers::new()
249            .with("content-type", "text/html; charset=utf-8")
250            .with("cache-control", "no-store")
251            .with(
252                "content-security-policy",
253                if script.is_empty() {
254                    "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"
255                } else {
256                    "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
257                },
258            )
259            .with("referrer-policy", "no-referrer"),
260    )
261}
262
263/// The first language a browser asks for that the dashboard speaks, else English.
264pub(crate) fn accepted_language(request: &Request) -> String {
265    let languages = glue::languages();
266    for part in request.headers.get("accept-language").unwrap_or_default().split(',') {
267        let code = js::head16(js::trim(part.split(';').next().unwrap_or("")), 2).to_lowercase();
268        if languages.contains(&code) {
269            return code;
270        }
271    }
272    "en".into()
273}
274
275/// A file to save, never shown in the browser or kept in a shared cache.
276pub(crate) fn download(name: &str, body: Vec<u8>, kind: &str) -> Response {
277    let safe = crate::re::replace_all(js_re!(r"[^A-Za-z0-9._-]"), name, "-");
278    Response::new(
279        body,
280        200,
281        Headers::new()
282            .with("content-type", kind)
283            .with("content-disposition", format!("attachment; filename=\"{safe}\""))
284            .with("cache-control", "private, no-store"),
285    )
286}
287
288/// Compares two strings in time that does not depend on where they differ, as UTF-16 units.
289pub(crate) fn constant_time_equal(a: &str, b: &str) -> bool {
290    let (a, b): (Vec<u16>, Vec<u16>) = (a.encode_utf16().collect(), b.encode_utf16().collect());
291    if a.len() != b.len() {
292        return false;
293    }
294    a.iter().zip(&b).fold(0u16, |d, (x, y)| d | (x ^ y)) == 0
295}
296
297pub(crate) fn cookie_value(token: &str) -> String {
298    sha256(&format!("runlight-cookie:{token}"))
299}
300
301pub(crate) fn read_cookie(request: &Request, name: &str) -> String {
302    for part in request.headers.get("cookie").unwrap_or_default().split(';') {
303        let mut pieces = js::trim(part).split('=');
304        if pieces.next() == Some(name) {
305            return pieces.collect::<Vec<_>>().join("=");
306        }
307    }
308    String::new()
309}
310
311pub(crate) fn bearer(request: &Request) -> String {
312    let header = request.headers.get("authorization").unwrap_or_default();
313    if header.to_lowercase().starts_with("bearer ") {
314        js::trim(&js::slice16(&header, 7, i64::MAX)).to_string()
315    } else {
316        String::new()
317    }
318}
319
320fn normalise_base(path: &str) -> String {
321    let trimmed = format!("/{}", path.trim_matches('/'));
322    if trimmed == "/" { String::new() } else { trimmed }
323}
324
325fn locale_urls(base: &str) -> String {
326    let mut o = js::Object::new();
327    for (code, _) in assets::locales() {
328        o.set(code.clone(), format!("{base}/assets/locale.{code}.{}.json", BUILD_INFO.locales_hash));
329    }
330    o.to_json()
331}
332
333/// The dashboard inside a CMS's admin pages: its session (empty once its ticket was used or ran out) and the admin
334/// origin that frames it.
335struct Embedded<'a> {
336    session: &'a str,
337    origin: &'a str,
338}
339
340#[allow(clippy::too_many_arguments)]
341fn dashboard_page(
342    base: &str,
343    share: &str,
344    sign_out: &str,
345    geo_credit: bool,
346    accounts: bool,
347    sign_in: &str,
348    embed: Option<Embedded<'_>>,
349    cms: &str,
350) -> String {
351    let b = escape_attr(base);
352    let hash = &BUILD_INFO.dashboard_hash;
353    let mut attrs = String::new();
354    if !share.is_empty() {
355        attrs.push_str(&format!(" data-share=\"{}\"", escape_attr(share)));
356    }
357    if !sign_out.is_empty() {
358        attrs.push_str(&format!(" data-sign-out=\"{}\"", escape_attr(sign_out)));
359    }
360    if !sign_in.is_empty() {
361        attrs.push_str(&format!(" data-sign-in=\"{}\"", escape_attr(sign_in)));
362    }
363    if geo_credit {
364        attrs.push_str(" data-geo-credit=\"\"");
365    }
366    if accounts {
367        attrs.push_str(" data-accounts=\"\"");
368    }
369    if !cms.is_empty() {
370        attrs.push_str(&format!(" data-cms=\"{}\"", escape_attr(cms)));
371    }
372    if let Some(embed) = embed {
373        attrs.push_str(&format!(
374            " data-embed=\"{}\" data-embed-origin=\"{}\"",
375            escape_attr(embed.session),
376            escape_attr(embed.origin)
377        ));
378    }
379    format!(
380        "<!doctype html>
381<html lang=\"en\">
382<head>
383<meta charset=\"utf-8\">
384<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
385<meta name=\"robots\" content=\"noindex\">
386<title>Runlight</title>
387<link rel=\"icon\" href=\"{}\">
388<link rel=\"stylesheet\" href=\"{b}/assets/app.{hash}.css\">
389</head>
390<body>
391<div id=\"app\" data-base=\"{b}\"{attrs} data-world=\"{b}/assets/world.{}.json\" data-locales=\"{}\"></div>
392<script type=\"module\" src=\"{b}/assets/app.{hash}.js\"></script>
393</body>
394</html>
395",
396        crate::brand::RUNLIGHT_ICON,
397        BUILD_INFO.world_hash,
398        escape_attr(&locale_urls(base))
399    )
400}
401
402/// What a manage token, held by a Runlight hub, may read and change.
403pub fn manage_path(method: &str, path: &str) -> bool {
404    if path.starts_with("/api/links/import") {
405        return false;
406    }
407    if test(js_re!(r"^/api/(links|link-domains|reports|goals|funnels|shares)(/|$)"), path) {
408        return true;
409    }
410    if path == "/api/pick" {
411        return method == "POST";
412    }
413    if path == "/api/mail" {
414        return method == "GET";
415    }
416    if test(js_re!(r"^/api/sites/[^/]+$"), path) {
417        return method == "PATCH";
418    }
419    false
420}
421
422/// What a share, or the dashboard inside a CMS, can read: one site's reports, nothing that changes anything.
423pub(crate) fn shared_path(path: &str) -> bool {
424    matches!(
425        path,
426        "/api/sites"
427            | "/api/icon"
428            | "/api/realtime"
429            | "/api/stats"
430            | "/api/series"
431            | "/api/rhythm"
432            | "/api/breakdown"
433            | "/api/goals"
434            | "/api/event-props"
435            | "/api/export"
436            | "/api/funnels"
437            | "/api/journeys"
438    ) || test(js_re!(r"^/api/goals/[a-f0-9]{24}$"), path)
439}
440
441/// Who a request is, worked out once per request: a manage token's row, and whether it is a member.
442#[derive(Default)]
443pub(crate) struct Call {
444    pub(crate) managed: Mutex<Option<TokenRow>>,
445    pub(crate) member: std::sync::atomic::AtomicBool,
446}
447
448impl Call {
449    pub(crate) fn managed(&self) -> Option<TokenRow> {
450        self.managed.lock().unwrap_or_else(|e| e.into_inner()).clone()
451    }
452    pub(crate) fn is_managed(&self) -> bool {
453        self.managed().is_some()
454    }
455    pub(crate) fn is_member(&self) -> bool {
456        self.member.load(std::sync::atomic::Ordering::Relaxed)
457    }
458}
459
460/// What a reader of stats is: the owner, an API token (or a read-only sign-in, as a token with no id), or nobody.
461#[derive(Clone, Debug, PartialEq, Eq)]
462pub(crate) enum Reader {
463    Owner,
464    Token(TokenRow),
465    No,
466    Unconfigured,
467}
468
469/// What can_read answers.
470#[derive(Clone, Copy, Debug, PartialEq, Eq)]
471pub(crate) enum CanRead {
472    Yes,
473    No,
474    Unconfigured,
475    Read,
476}
477
478pub(crate) struct Tracker {
479    body: String,
480    etag: String,
481    at: i64,
482}
483
484pub(crate) struct Inner {
485    pub(crate) runlight: Runlight,
486    pub(crate) base: String,
487    pub(crate) token: Option<String>,
488    pub(crate) open: bool,
489    pub(crate) cron_secret: Option<String>,
490    pub(crate) observe_key: Option<String>,
491    pub(crate) origin: Option<String>,
492    pub(crate) options: RoutesOptions,
493    pub(crate) web: Option<glue::Web>,
494    pub(crate) sign_in: Option<String>,
495    pub(crate) sign_out: Option<String>,
496    warned: std::sync::atomic::AtomicBool,
497    pub(crate) sample_sent: Mutex<HashMap<String, i64>>,
498    pub(crate) asked: Mutex<HashMap<String, (Vec<i64>, i64)>>,
499    trackers: Mutex<HashMap<String, Tracker>>,
500}
501
502/// The routes: one handler for every request under the base path.
503#[derive(Clone)]
504pub struct Routes(pub(crate) Arc<Inner>);
505
506impl Runlight {
507    /// The dashboard, its API, the tracker endpoint, and the pages around them.
508    pub fn routes(&self, options: RoutesOptions) -> Result<Routes, String> {
509        Routes::new(self.clone(), options)
510    }
511}
512
513impl Routes {
514    /// Routes over a Runlight. Refuses an origin that is not a URL.
515    pub fn new(runlight: Runlight, options: RoutesOptions) -> Result<Routes, String> {
516        let base = normalise_base(options.base_path.as_deref().unwrap_or("/runlight"));
517        let (token, open) = match &options.token {
518            TokenOption::FromEnv => (env_value("RUNLIGHT_TOKEN"), false),
519            TokenOption::Open => (None, true),
520            TokenOption::Given(t) => (Some(t.clone()), false),
521        };
522        let cron_secret = match &options.cron_secret {
523            None => env_value("CRON_SECRET"),
524            Some(s) => Some(s.clone()),
525        }
526        .filter(|s| !s.is_empty());
527        let observe_key = match &options.observe_key {
528            None => env_value("RUNLIGHT_OBSERVE_KEY"),
529            Some(s) => Some(s.clone()),
530        }
531        .filter(|s| !s.is_empty());
532        let origin = match &options.origin {
533            Some(o) if !o.is_empty() => {
534                Some(Url::parse(o).ok_or_else(|| "TypeError: Invalid URL".to_string())?.origin())
535            }
536            _ => None,
537        };
538        // A link domain leaves these paths to the app, so the dashboard stays reachable on every name.
539        {
540            let mut bases = runlight.0.route_bases.lock().unwrap_or_else(|e| e.into_inner());
541            let b = if base.is_empty() { "/".to_string() } else { base.clone() };
542            if !bases.contains(&b) {
543                bases.push(b);
544            }
545        }
546        let token_set = token.as_deref().is_some_and(|t| !t.is_empty());
547        // An app left open on purpose (token: null) is treated like development here.
548        let open_setup = open || (!token_set && is_development());
549        let account_secret = runlight.secret().map(str::to_string).or_else(|| open_setup.then(|| random_id(32)));
550        let web = if options.accounts {
551            account_secret.map(|secret| {
552                glue::web(
553                    &runlight,
554                    &secret,
555                    &base,
556                    token.as_deref().filter(|t| !t.is_empty()),
557                    open_setup,
558                    options.origin.as_deref(),
559                )
560            })
561        } else {
562            None
563        };
564        let sign_in = options.sign_in.clone().or_else(|| web.as_ref().map(|_| format!("{base}/login")));
565        let sign_out = options.sign_out.clone().or_else(|| web.as_ref().map(|_| format!("{base}/logout")));
566        Ok(Routes(Arc::new(Inner {
567            runlight,
568            base,
569            token,
570            open,
571            cron_secret,
572            observe_key,
573            origin,
574            options,
575            web,
576            sign_in,
577            sign_out,
578            warned: std::sync::atomic::AtomicBool::new(false),
579            sample_sent: Mutex::new(HashMap::new()),
580            asked: Mutex::new(HashMap::new()),
581            trackers: Mutex::new(HashMap::new()),
582        })))
583    }
584
585    /// Where the routes are mounted, such as "/runlight", or "" at the root.
586    pub fn base_path(&self) -> &str {
587        &self.0.base
588    }
589
590    /// The token in use, if any.
591    pub fn token(&self) -> Option<&str> {
592        self.0.token.as_deref().filter(|t| !t.is_empty())
593    }
594
595    pub(crate) fn rl(&self) -> &Runlight {
596        &self.0.runlight
597    }
598
599    /// Whether this request acts as the owner.
600    pub(crate) async fn session_scope(&self, request: &Request) -> Result<Option<crate::accounts::User>, crate::Error> {
601        if let Some(web) = &self.0.web {
602            return web.signed_in(request).await;
603        }
604        Ok(None)
605    }
606
607    pub(crate) async fn can_read(&self, request: &Request, call: &Call) -> CanRead {
608        if call.is_managed() {
609            return CanRead::Yes;
610        }
611        let inner = &self.0;
612        if inner.options.authorize.is_some() || inner.web.is_some() {
613            // A script's bearer token still has full access beside the sign-ins.
614            let given = bearer(request);
615            if inner.options.authorize.is_none()
616                && let Some(token) = self.token()
617                && !given.is_empty()
618                && constant_time_equal(&given, token)
619            {
620                return CanRead::Yes;
621            }
622            match self.session_scope(request).await {
623                Err(_) => return CanRead::No,
624                Ok(Some(user)) if user.site.is_some() && request.url.contains("/oauth/") => return CanRead::No,
625                _ => {}
626            }
627            let answer = match &inner.options.authorize {
628                Some(authorize) => authorize(request).await,
629                None => glue::web_access(inner.web.as_ref().expect("accounts"), request).await,
630            };
631            // A member changes things like an owner, apart from the few controls admin_only() names.
632            if answer == Access::Member {
633                call.member.store(true, std::sync::atomic::Ordering::Relaxed);
634            }
635            return match answer {
636                Access::Read => CanRead::Read,
637                Access::Full | Access::Member => CanRead::Yes,
638                Access::Denied => CanRead::No,
639            };
640        }
641        if inner.open {
642            return CanRead::Yes;
643        }
644        let Some(token) = self.token() else {
645            // Fails closed: only a process that says it is in development runs open.
646            if !is_development() {
647                return CanRead::Unconfigured;
648            }
649            if !inner.warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
650                eprintln!(
651                    "Runlight: no RUNLIGHT_TOKEN set, so the dashboard is open because NODE_ENV is development. Anywhere else it answers 503 until a token is set."
652                );
653            }
654            return CanRead::Yes;
655        };
656        let given = bearer(request);
657        if !given.is_empty() && constant_time_equal(&given, token) {
658            return CanRead::Yes;
659        }
660        let cookie = read_cookie(request, COOKIE);
661        if !cookie.is_empty() && constant_time_equal(&cookie, &cookie_value(token)) {
662            CanRead::Yes
663        } else {
664            CanRead::No
665        }
666    }
667
668    /// An API token from the bearer header: read-only, and maybe limited to one site.
669    pub(crate) async fn api_token(&self, request: &Request) -> Result<Option<TokenRow>, crate::Error> {
670        let given = bearer(request);
671        if !given.starts_with(TOKEN_PREFIX) {
672            return Ok(None);
673        }
674        self.rl().init().await?;
675        let store = self.rl().store();
676        let Some(row) = store.token_by_hash(&sha256(&given)).await? else { return Ok(None) };
677        let now = self.rl().now();
678        // At most once a minute, so a busy assistant does not write on every call.
679        if row.last_used_at.is_none_or(|l| now - l > 60_000) {
680            store.touch_token(&row.id, now).await?;
681        }
682        Ok(Some(row))
683    }
684
685    /// Who may read stats: the owner, an API token or a read-only sign-in, or nobody.
686    pub(crate) async fn reader(&self, request: &Request, call: &Call) -> Result<Reader, crate::Error> {
687        if let Some(token) = self.api_token(request).await? {
688            // A key for the dashboard inside a CMS gets tickets and reads nothing itself.
689            return Ok(if token.scope == "embed" { Reader::No } else { Reader::Token(token) });
690        }
691        let access = self.can_read(request, call).await;
692        if self.0.options.authorize.is_some() || self.0.web.is_some() {
693            // A read-only sign-in reads like an API token for every site.
694            return Ok(match access {
695                CanRead::Read => Reader::Token(TokenRow {
696                    id: String::new(),
697                    name: String::new(),
698                    site: String::new(),
699                    scope: "read".into(),
700                    hash: String::new(),
701                    hint: String::new(),
702                    created_at: 0,
703                    last_used_at: None,
704                }),
705                CanRead::Yes => Reader::Owner,
706                _ => Reader::No,
707            });
708        }
709        Ok(match access {
710            CanRead::Yes => Reader::Owner,
711            CanRead::Unconfigured => Reader::Unconfigured,
712            _ => Reader::No,
713        })
714    }
715
716    pub(crate) fn denied(&self, result: CanRead) -> Response {
717        match result {
718            CanRead::Read => coded("Only an owner can change this", "owner_only", 403, None),
719            CanRead::Unconfigured => coded(
720                "Set RUNLIGHT_TOKEN, or pass token or authorize to routes(). Without one, Runlight only runs open when NODE_ENV is development.",
721                "token_unset",
722                503,
723                None,
724            ),
725            _ => coded("Unauthorized", "unauthorized", 401, None),
726        }
727    }
728
729    pub(crate) fn denied_reader(&self, reader: &Reader) -> Response {
730        self.denied(if *reader == Reader::Unconfigured { CanRead::Unconfigured } else { CanRead::No })
731    }
732
733    /// The key picker tickets are signed with, made on first use and kept in the database.
734    async fn pick_key(&self) -> Result<String, crate::Error> {
735        self.rl().init().await?;
736        // Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
737        Ok(self.rl().store().setting_or_add("pick-key", || random_id(32)).await?)
738    }
739
740    /// A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half
741    /// an hour. With a return address and a nonce it sends it there, to the relay page, which hands it to the dashboard
742    /// tab that made the nonce; without them, as an older dashboard asks, to the tab that opened the site.
743    pub(crate) async fn pick_ticket(
744        &self,
745        origin: &str,
746        site: &str,
747        back: Option<(&str, &str)>,
748    ) -> Result<String, crate::Error> {
749        let mut payload = format!(
750            "{}.{}.{}",
751            self.rl().now() + PICK_TICKET_MS,
752            crate::hash::hex(site.as_bytes()),
753            crate::hash::hex(origin.as_bytes())
754        );
755        if let Some((url, nonce)) = back {
756            payload.push_str(&format!(".{}.{nonce}", crate::hash::hex(url.as_bytes())));
757        }
758        let sig = hmac(&self.pick_key().await?, &payload);
759        Ok(format!("{payload}.{sig}"))
760    }
761
762    /// What a picker ticket names, or `None` when it is not one this install signed or has run out.
763    async fn pick_target(&self, ticket: &str) -> Result<Option<PickTarget>, crate::Error> {
764        let re = js_re!(
765            r"^(\d+)\.([a-f0-9]{2,512})\.([a-f0-9]{2,512})(?:\.([a-f0-9]{2,2048})\.([a-f0-9]{32}))?\.([a-f0-9]{64})$"
766        );
767        let Some(caps) = re.captures(ticket.as_bytes()) else { return Ok(None) };
768        let part = |i: usize| caps.get(i).map(|m| String::from_utf8_lossy(m.as_bytes()).into_owned());
769        if js::text_number(&part(1).unwrap_or_default()) < self.rl().now() as f64 {
770            return Ok(None);
771        }
772        let signed = &ticket[..ticket.rfind('.').unwrap_or(0)];
773        if !constant_time_equal(&part(6).unwrap_or_default(), &hmac(&self.pick_key().await?, signed)) {
774            return Ok(None);
775        }
776        let origin = unhex(&part(3).unwrap_or_default());
777        if !test(js_re!(r"^https?://[^/?#\s]+$"), &origin) {
778            return Ok(None);
779        }
780        let site = unhex(&part(2).unwrap_or_default());
781        let Some(url) = part(4).map(|u| unhex(&u)) else { return Ok(Some(PickTarget { origin, site, back: None })) };
782        if !is_pick_return(&url, &origin) {
783            return Ok(None);
784        }
785        Ok(Some(PickTarget { origin, site, back: Some((url, part(5).unwrap_or_default())) }))
786    }
787
788    /// The key embed tickets and sessions are signed with, made on first use and kept in the database for every process.
789    async fn embed_key(&self) -> Result<String, crate::Error> {
790        self.rl().init().await?;
791        // Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
792        Ok(self.rl().store().setting_or_add("embed-key", || random_id(32)).await?)
793    }
794
795    /// An embed token that still exists, for a site that still does.
796    pub(crate) async fn embed_token(&self, id: &str) -> Result<Option<TokenRow>, crate::Error> {
797        let tokens = self.rl().store().tokens().await?;
798        Ok(tokens
799            .into_iter()
800            .find(|t| t.id == id)
801            .filter(|t| t.scope == "embed" && self.rl().site(Some(&t.site)).is_some()))
802    }
803
804    /// A ticket for one load of the embedded dashboard, signed with when it runs out, a nonce, and the admin origin
805    /// that may frame it. The nonce is kept, with the token it was made for, until the ticket is used.
806    pub(crate) async fn embed_ticket(&self, origin: &str, token: &str) -> Result<(String, i64), crate::Error> {
807        let store = self.rl().store();
808        let now = self.rl().now();
809        // Tickets nobody used are cleared as new ones are made.
810        for (key, value) in store.settings_starting_with("embed-ticket:").await? {
811            if js::text_number(value.split('.').next().unwrap_or("")) < now as f64 {
812                store.set_setting(&key, None).await?;
813            }
814        }
815        let expires_at = now + EMBED_TICKET_MS;
816        let nonce = random_id(16);
817        store.set_setting(&format!("embed-ticket:{nonce}"), Some(&format!("{expires_at}.{token}"))).await?;
818        let payload = format!("{expires_at}.{nonce}.{}", crate::hash::hex(origin.as_bytes()));
819        let sig = hmac(&self.embed_key().await?, &format!("ticket.{payload}"));
820        Ok((format!("{payload}.{sig}"), expires_at))
821    }
822
823    /// What a ticket this install signed names: always its origin, and its token only the first time it is used
824    /// before it runs out. `None` for anything else.
825    async fn redeem_embed(&self, ticket: &str) -> Result<Option<(String, Option<TokenRow>)>, crate::Error> {
826        let re = js_re!(r"^(\d{1,15})\.([a-f0-9]{32})\.([a-f0-9]{2,512})\.([a-f0-9]{64})$");
827        let Some(caps) = re.captures(ticket.as_bytes()) else { return Ok(None) };
828        let part = |i: usize| String::from_utf8_lossy(caps.get(i).map_or(&[][..], |m| m.as_bytes())).into_owned();
829        let expected = hmac(&self.embed_key().await?, &format!("ticket.{}.{}.{}", part(1), part(2), part(3)));
830        if !constant_time_equal(&part(4), &expected) {
831            return Ok(None);
832        }
833        let origin = unhex(&part(3));
834        if !test(js_re!(r"^https?://[^/?#\s]+$"), &origin) || origin.chars().any(js::is_space) {
835            return Ok(None);
836        }
837        // A ticket works once: it is gone before anything else is checked.
838        let kept = self.rl().store().take_setting(&format!("embed-ticket:{}", part(2))).await?;
839        let token = match kept.filter(|k| !k.is_empty()) {
840            Some(kept) if js::text_number(&part(1)) >= self.rl().now() as f64 => {
841                self.embed_token(kept.split('.').nth(1).unwrap_or("")).await?
842            }
843            _ => None,
844        };
845        Ok(Some((origin, token)))
846    }
847
848    /// A session for an embedded dashboard, which its page sends with every read, signed with when it runs out
849    /// and its token.
850    async fn embed_session(&self, token: &str) -> Result<String, crate::Error> {
851        let payload = format!("{}.{token}", self.rl().now() + EMBED_SESSION_MS);
852        let sig = hmac(&self.embed_key().await?, &format!("session.{payload}"));
853        Ok(format!("{payload}.{sig}"))
854    }
855
856    /// The embed token a session this install signed was made for, while it lasts and the token still exists.
857    pub(crate) async fn embed_reader(&self, session: &str) -> Result<Option<TokenRow>, crate::Error> {
858        let re = js_re!(r"^(\d{1,15})\.([a-f0-9]{24})\.([a-f0-9]{64})$");
859        let Some(caps) = re.captures(session.as_bytes()) else { return Ok(None) };
860        let part = |i: usize| String::from_utf8_lossy(caps.get(i).map_or(&[][..], |m| m.as_bytes())).into_owned();
861        if js::text_number(&part(1)) < self.rl().now() as f64 {
862            return Ok(None);
863        }
864        let expected = hmac(&self.embed_key().await?, &format!("session.{}.{}", part(1), part(2)));
865        if !constant_time_equal(&part(3), &expected) {
866            return Ok(None);
867        }
868        self.embed_token(&part(2)).await
869    }
870
871    /// The tracker with click rules inside, rebuilt when goals change.
872    async fn tracker_script(&self, site_id: Option<String>) -> Result<(String, String), crate::Error> {
873        let key = site_id.clone().unwrap_or_default();
874        let now = self.rl().now();
875        if let Some(t) = self.0.trackers.lock().unwrap_or_else(|e| e.into_inner()).get(&key)
876            && now - t.at < 60_000
877        {
878            return Ok((t.body.clone(), t.etag.clone()));
879        }
880        self.rl().init().await?;
881        let sites = match &site_id {
882            Some(id) => self.rl().sites().into_iter().filter(|s| &s.id == id).collect(),
883            None if self.rl().managed_sites() => vec![],
884            None => self.rl().sites(),
885        };
886        let rules = click_rules(&sites, &self.rl().store().goals(None).await?).to_json();
887        let body = TRACKER.replacen(RULES_PLACEHOLDER, &rules, 1);
888        let etag = format!("\"{}-{}\"", BUILD_INFO.tracker_hash, &sha256(&rules)[..8]);
889        // One entry per site at most; a query naming no real site gets the empty script without filling the map.
890        if site_id.is_none() || !sites.is_empty() {
891            self.0
892                .trackers
893                .lock()
894                .unwrap_or_else(|e| e.into_inner())
895                .insert(key, Tracker { body: body.clone(), etag: etag.clone(), at: self.rl().now() });
896        }
897        Ok((body, etag))
898    }
899
900    pub(crate) fn forget_trackers(&self) {
901        self.0.trackers.lock().unwrap_or_else(|e| e.into_inner()).clear();
902    }
903
904    /// Answers one request.
905    pub async fn handle(&self, request: Request) -> Response {
906        let Some(url) = Url::parse(&request.url) else { return coded("Internal error", "internal", 500, None) };
907        let base = self.0.base.clone();
908        let pathname = url.pathname();
909        // OAuth clients look for these at the site's root; an app routes them here when it wants OAuth.
910        if !base.is_empty() && is_oauth_document(&pathname) {
911            return match glue::oauth_response(self, &request, &pathname, &url).await {
912                Ok(Some(answer)) => answer,
913                Ok(None) => coded("Not found", "not_found", 404, None),
914                Err(error) => internal(error),
915            };
916        }
917        if !base.is_empty() && pathname != base && !pathname.starts_with(&format!("{base}/")) {
918            return coded("Not found", "not_found", 404, None);
919        }
920        let path = {
921            let rest = &pathname[base.len().min(pathname.len())..];
922            if rest.is_empty() { "/".to_string() } else { rest.to_string() }
923        };
924        match self.route(&request, &path, url).await {
925            Ok(response) => response,
926            Err(error) => internal(error),
927        }
928    }
929
930    async fn route(&self, request: &Request, path: &str, mut url: Url) -> Result<Response, crate::Error> {
931        let call = Call::default();
932        let method = request.method.as_str();
933        // Checked before any route, so a connected site's pass-through to its install is held to it too.
934        if admin_only(path, method) && self.can_read(request, &call).await == CanRead::Yes && call.is_member() {
935            return Ok(coded("Only an owner or admin can change this", "admin_only", 403, None));
936        }
937        // Sign-in, setup, invites, and the Account and People APIs.
938        if let Some(web) = &self.0.web
939            && let Some(answered) = glue::web_handle(web, request, path).await?
940        {
941            return Ok(answered);
942        }
943        if path == "/s.js" && method == "GET" {
944            let (body, etag) = self.tracker_script(url.search_params().get("site").map(str::to_string)).await?;
945            let headers = Headers::new()
946                .with("content-type", "application/javascript; charset=utf-8")
947                // Short, so a new click goal reaches visitors within minutes; the etag makes rechecks cheap.
948                .with("cache-control", "public, max-age=300")
949                .with("etag", &etag);
950            if request.headers.get("if-none-match").as_deref() == Some(etag.as_str()) {
951                return Ok(Response::new(Vec::new(), 304, headers));
952            }
953            return Ok(Response::new(body, 200, headers));
954        }
955        if path == "/pick.js" && method == "GET" {
956            // The picker sends what it picked only to the dashboard its ticket names.
957            let target = self.pick_target(url.search_params().get("runlight_ticket").unwrap_or("")).await?;
958            if target.is_some() {
959                self.rl().init().await?;
960            }
961            let hosts: Option<Vec<String>> = match &target {
962                Some(t) => self.rl().site(Some(&t.site)).map(|s| s.hostnames),
963                None => Some(vec![]),
964            };
965            let named = if hosts.is_some() { target.as_ref() } else { None };
966            let back = named.and_then(|t| t.back.as_ref());
967            let hosts_json =
968                js::quote(&Value::Array(hosts.unwrap_or_default().into_iter().map(Value::from).collect()).to_json());
969            let script = PICKER
970                .replacen(PICK_TARGET_PLACEHOLDER, &js::quote(named.map_or("", |t| t.origin.as_str())), 1)
971                .replacen(PICK_HOSTS_PLACEHOLDER, &hosts_json, 1)
972                .replacen(PICK_RETURN_PLACEHOLDER, &js::quote(back.map_or("", |b| b.0.as_str())), 1)
973                .replacen(PICK_NONCE_PLACEHOLDER, &js::quote(back.map_or("", |b| b.1.as_str())), 1);
974            return Ok(Response::new(
975                script,
976                200,
977                Headers::new()
978                    .with("content-type", "application/javascript; charset=utf-8")
979                    .with("cache-control", "no-store"),
980            ));
981        }
982        // The relay page the picker opens with its choice in the fragment, which never reaches a server. Its
983        // script hands the choice to the dashboard tab on this origin that started the pick, and nothing else.
984        if path == "/pick-return" && method == "GET" {
985            let (t, lang) = glue::translator(&accepted_language(request));
986            return Ok(small_page(
987                &lang,
988                &format!(
989                    "<div id=\"done\" hidden><h1>{}</h1><p>{}</p></div><div id=\"gone\" hidden><h1>{}</h1><p>{}</p></div>",
990                    escape_html(&t("pick.doneTitle", &[])),
991                    escape_html(&t("pick.done", &[])),
992                    escape_html(&t("pick.goneTitle", &[])),
993                    escape_html(&t("pick.gone", &[]))
994                ),
995                200,
996                &format!("{}/pick-return.js", self.0.base),
997            ));
998        }
999        if path == "/pick-return.js" && method == "GET" {
1000            return Ok(Response::new(
1001                PICK_RELAY,
1002                200,
1003                Headers::new()
1004                    .with("content-type", "application/javascript; charset=utf-8")
1005                    .with("cache-control", "no-store"),
1006            ));
1007        }
1008        if path == format!("/assets/world.{}.json", BUILD_INFO.world_hash) && method == "GET" {
1009            return Ok(Response::new(
1010                WORLD_JSON,
1011                200,
1012                Headers::new()
1013                    .with("content-type", "application/json; charset=utf-8")
1014                    .with("cache-control", "public, max-age=31536000, immutable"),
1015            ));
1016        }
1017        if let Some(caps) = js_re!(r"^/assets/locale\.([a-z]{2,3})\.([a-f0-9]+)\.json$").captures(path.as_bytes()) {
1018            let code = String::from_utf8_lossy(&caps[1]).into_owned();
1019            let hash = String::from_utf8_lossy(&caps[2]).into_owned();
1020            if hash == BUILD_INFO.locales_hash
1021                && method == "GET"
1022                && let Some((_, text)) = assets::locales().find(|(k, _)| *k == code)
1023            {
1024                return Ok(Response::new(
1025                    text.clone(),
1026                    200,
1027                    Headers::new()
1028                        .with("content-type", "application/json; charset=utf-8")
1029                        .with("cache-control", "public, max-age=31536000, immutable"),
1030                ));
1031            }
1032        }
1033        if path.starts_with("/assets/app.") && method == "GET" {
1034            let hash = &BUILD_INFO.dashboard_hash;
1035            let asset = if path == format!("/assets/app.{hash}.js") {
1036                DASHBOARD_JS
1037            } else if path == format!("/assets/app.{hash}.css") {
1038                DASHBOARD_CSS
1039            } else {
1040                return Ok(coded("Not found", "not_found", 404, None));
1041            };
1042            return Ok(Response::new(
1043                asset,
1044                200,
1045                Headers::new()
1046                    .with(
1047                        "content-type",
1048                        if path.ends_with(".js") {
1049                            "application/javascript; charset=utf-8"
1050                        } else {
1051                            "text/css; charset=utf-8"
1052                        },
1053                    )
1054                    .with("cache-control", "public, max-age=31536000, immutable"),
1055            ));
1056        }
1057        if path == "/e" {
1058            if method == "OPTIONS" {
1059                return Ok(Response::new(
1060                    Vec::new(),
1061                    204,
1062                    Headers::new()
1063                        .with("access-control-allow-origin", "*")
1064                        .with("access-control-allow-methods", "POST")
1065                        .with("access-control-max-age", "86400"),
1066                ));
1067            }
1068            if method != "POST" {
1069                return Ok(coded("Method not allowed", "method_not_allowed", 405, None));
1070            }
1071            if let Err(error) = self.rl().collect(request).await {
1072                eprintln!("Runlight: could not record an event {error}");
1073            }
1074            // The same answer whatever happened, so the endpoint reveals nothing.
1075            return Ok(Response::new(Vec::new(), 202, Headers::new().with("access-control-allow-origin", "*")));
1076        }
1077        if path == "/api" || path.starts_with("/api/") {
1078            return self.api(request, path, &mut url, &call).await;
1079        }
1080        if (path.starts_with("/oauth/") || is_oauth_document(path))
1081            && let Some(answer) = glue::oauth_response(self, request, path, &url).await?
1082        {
1083            return Ok(answer);
1084        }
1085        if path == "/mcp" {
1086            // No server-sent stream and no sessions: every message is one POST.
1087            if method != "POST" {
1088                return Ok(coded_with("Method not allowed", "method_not_allowed", 405, None, &[("allow", "POST")]));
1089            }
1090            let reader = self.reader(request, &call).await?;
1091            if matches!(reader, Reader::No | Reader::Unconfigured) {
1092                let mut refused = self.denied_reader(&reader);
1093                // Points an OAuth client at the metadata that starts the sign-in.
1094                refused.headers.set(
1095                    "www-authenticate",
1096                    &format!(
1097                        "Bearer realm=\"runlight\", resource_metadata=\"{}\"",
1098                        glue::resource_metadata_url(&url.origin(), &self.0.base)
1099                    ),
1100                );
1101                return Ok(refused);
1102            }
1103            return glue::mcp_response(self, request, &url).await;
1104        }
1105        if let Some(token) = crate::re::group(js_re!(r"^/unsubscribe/([^/]+)/?$"), path, 1)
1106            && (method == "GET" || method == "POST")
1107        {
1108            return self.unsubscribe_page(request, &token).await;
1109        }
1110        // The dashboard inside a CMS's admin pages, opened with a ticket its plugin just got. Only the admin origin
1111        // the ticket names may frame it. A ticket used already or run out opens it with no session, so it says it
1112        // has expired and offers to reload the admin page; anything else is refused and never framed.
1113        if path == "/embed" && method == "GET" {
1114            self.rl().init().await?;
1115            let Some((origin, token)) = self.redeem_embed(url.search_params().get("ticket").unwrap_or("")).await?
1116            else {
1117                let lang = accepted_language(request);
1118                let (t, lang) = glue::translator(&lang);
1119                return Ok(small_page(
1120                    &lang,
1121                    &format!(
1122                        "<h1>{}</h1><p>{}</p>",
1123                        escape_html(&t("embed.goneTitle", &[])),
1124                        escape_html(&t("embed.gone", &[]))
1125                    ),
1126                    404,
1127                    "",
1128                ));
1129            };
1130            let session = match &token {
1131                Some(token) => self.embed_session(&token.id).await?,
1132                None => String::new(),
1133            };
1134            let page = dashboard_page(
1135                &self.0.base,
1136                "",
1137                "",
1138                self.0.options.geo_credit,
1139                false,
1140                "",
1141                Some(Embedded { session: &session, origin: &origin }),
1142                "",
1143            );
1144            return Ok(Response::new(
1145                page,
1146                if session.is_empty() { 410 } else { 200 },
1147                Headers::new()
1148                    .with("content-type", "text/html; charset=utf-8")
1149                    .with("cache-control", "no-store")
1150                    .with(
1151                        "content-security-policy",
1152                        DASHBOARD_CSP.replacen("frame-ancestors 'none'", &format!("frame-ancestors {origin}"), 1),
1153                    )
1154                    .with("referrer-policy", "no-referrer")
1155                    .with("x-robots-tag", "noindex"),
1156            ));
1157        }
1158        if let Some(id) = crate::re::group(js_re!(r"^/share/([^/]+)/?$"), path, 1)
1159            && method == "GET"
1160        {
1161            self.rl().init().await?;
1162            let share =
1163                if test(js_re!(r"^[a-f0-9]{32}$"), &id) { self.rl().store().share_by_id(&id).await? } else { None };
1164            let Some(share) = share else {
1165                let lang = accepted_language(request);
1166                let (t, lang) = glue::translator(&lang);
1167                return Ok(small_page(
1168                    &lang,
1169                    &format!(
1170                        "<h1>{}</h1><p>{}</p>",
1171                        escape_html(&t("share.goneTitle", &[])),
1172                        escape_html(&t("share.gone", &[]))
1173                    ),
1174                    404,
1175                    "",
1176                ));
1177            };
1178            return Ok(Response::new(
1179                dashboard_page(&self.0.base, &share.id, "", self.0.options.geo_credit, false, "", None, ""),
1180                200,
1181                Headers::new()
1182                    .with("content-type", "text/html; charset=utf-8")
1183                    .with("cache-control", "no-store")
1184                    .with("content-security-policy", DASHBOARD_CSP)
1185                    .with("x-frame-options", "DENY")
1186                    // The share id is the key; never send it on to another site.
1187                    .with("referrer-policy", "no-referrer")
1188                    .with("x-robots-tag", "noindex"),
1189            ));
1190        }
1191        if (path == "/" || path.is_empty()) && method == "GET" {
1192            let mut params = url.search_params();
1193            if let Some(given) = params.get("token").map(str::to_string)
1194                && !given.is_empty()
1195                && let Some(token) = self.token()
1196                && constant_time_equal(&given, token)
1197            {
1198                params.delete("token");
1199                url.set_search_params(&params);
1200                let secure = if url.protocol() == "https:" { "; Secure" } else { "" };
1201                let base = if self.0.base.is_empty() { "/" } else { self.0.base.as_str() };
1202                return Ok(Response::new(
1203                    Vec::new(),
1204                    303,
1205                    Headers::new().with("location", format!("{}{}", url.pathname(), url.search())).with(
1206                        "set-cookie",
1207                        format!(
1208                            "{COOKIE}={}; Path={base}; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}",
1209                            cookie_value(token)
1210                        ),
1211                    ),
1212                ));
1213            }
1214            // The page itself holds no data; the API it calls checks access.
1215            // Inside a CMS, its admin, on this same origin, shows the whole dashboard in a frame; nothing else may.
1216            let cms = self.0.options.cms.as_deref().unwrap_or("");
1217            let framed = !cms.is_empty();
1218            return Ok(Response::new(
1219                dashboard_page(
1220                    &self.0.base,
1221                    "",
1222                    self.0.sign_out.as_deref().unwrap_or(""),
1223                    self.0.options.geo_credit,
1224                    self.0.web.is_some(),
1225                    self.0.sign_in.as_deref().unwrap_or(""),
1226                    None,
1227                    cms,
1228                ),
1229                200,
1230                Headers::new()
1231                    .with("content-type", "text/html; charset=utf-8")
1232                    .with("cache-control", "no-store")
1233                    .with(
1234                        "content-security-policy",
1235                        if framed {
1236                            DASHBOARD_CSP.replacen("frame-ancestors 'none'", "frame-ancestors 'self'", 1)
1237                        } else {
1238                            DASHBOARD_CSP.to_string()
1239                        },
1240                    )
1241                    .with("x-frame-options", if framed { "SAMEORIGIN" } else { "DENY" })
1242                    .with("referrer-policy", "same-origin"),
1243            ));
1244        }
1245        Ok(coded("Not found", "not_found", 404, None))
1246    }
1247}
1248
1249/// The controls a member cannot change: the mail service and its keys, the assistant's settings, and deleting a site.
1250fn admin_only(path: &str, method: &str) -> bool {
1251    (path == "/api/mail" && (method == "PUT" || method == "DELETE"))
1252        || (path == "/api/assistant" && (method == "PUT" || method == "DELETE"))
1253        || (path == "/api/assistant/limits" && method == "PUT")
1254        || (path == "/api/assistant/models" && method == "POST")
1255        || (test(js_re!(r"^/api/sites/[^/]+$"), path) && method == "DELETE")
1256}
1257
1258fn internal(error: crate::Error) -> Response {
1259    // Another process is building the tables, such as an index after an upgrade: worth asking again soon.
1260    if let crate::Error::Db(db) = &error
1261        && TablesBusyError::of(db).is_some()
1262    {
1263        return coded_with(
1264            "Runlight is creating or upgrading its tables. Try again in a minute.",
1265            "tables_busy",
1266            503,
1267            None,
1268            &[("retry-after", "30")],
1269        );
1270    }
1271    eprintln!("Runlight: {error}");
1272    coded("Internal error", "internal", 500, None)
1273}
1274
1275/// What a picker ticket names: the dashboard's origin, the site, and, for a newer dashboard, the relay page on that
1276/// origin and the nonce its tab listens for.
1277struct PickTarget {
1278    origin: String,
1279    site: String,
1280    back: Option<(String, String)>,
1281}
1282
1283/// Whether `url` is the relay page on the dashboard's `origin`: its address with /pick-return on the end.
1284pub(crate) fn is_pick_return(url: &str, origin: &str) -> bool {
1285    test(js_re!(r"^https?://[^/?#\s]+(/[^?#\s]*)?/pick-return$"), url)
1286        && !url.chars().any(js::is_space)
1287        && url.starts_with(&format!("{origin}/"))
1288}
1289
1290fn unhex(text: &str) -> String {
1291    let bytes: Vec<u8> =
1292        (0..text.len() / 2).filter_map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).ok()).collect();
1293    crate::http::utf8(&bytes)
1294}
1295
1296/// A JSON body as an object, or the answer refusing it.
1297pub(crate) fn read_json(request: &Request) -> Result<js::Object, Response> {
1298    // A form posted from another site cannot carry this content type without CORS.
1299    if !is_json(request) {
1300        return Err(coded("Send JSON", "send_json", 415, None));
1301    }
1302    match request.json() {
1303        Ok(Value::Object(o)) => Ok(o),
1304        _ => Err(coded("Send a JSON object", "send_object", 400, None)),
1305    }
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310    use super::*;
1311
1312    #[test]
1313    fn dashboard_names_the_cms_only_when_set() {
1314        let plain = dashboard_page("/rl", "", "", false, true, "", None, "");
1315        assert!(!plain.contains("data-cms"));
1316        let page = dashboard_page("/rl", "", "", false, true, "", None, "Word\"Press");
1317        assert!(page.contains(" data-accounts=\"\" data-cms=\"Word&#34;Press\" data-world="));
1318    }
1319}