1mod 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum Access {
27 Full,
29 Member,
31 Read,
33 Denied,
35}
36
37#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub enum TokenOption {
40 #[default]
42 FromEnv,
43 Open,
45 Given(String),
47}
48
49pub type Authorize = Arc<dyn Fn(&Request) -> BoxFuture<'static, Access> + Send + Sync>;
51
52#[derive(Clone, Default)]
54pub struct RoutesOptions {
55 pub base_path: Option<String>,
57 pub token: TokenOption,
59 pub authorize: Option<Authorize>,
61 pub cron_secret: Option<String>,
63 pub observe_key: Option<String>,
65 pub sign_out: Option<String>,
67 pub sign_in: Option<String>,
69 pub accounts: bool,
71 pub geo_credit: bool,
73 pub cms: Option<String>,
77 pub origin: Option<String>,
79 pub extras: RoutesExtras,
81}
82
83pub(crate) const COOKIE: &str = "runlight_token";
84pub(crate) const TOKEN_PREFIX: &str = "rl_";
86pub(crate) const SHARE_HEADER: &str = "x-runlight-share";
88pub(crate) const EMBED_HEADER: &str = "x-runlight-embed";
90const EMBED_TICKET_MS: i64 = 5 * 60_000;
92const EMBED_SESSION_MS: i64 = 60 * 60_000;
94const RULES_PLACEHOLDER: &str = "\"__RUNLIGHT_RULES__\"";
96const PICK_TARGET_PLACEHOLDER: &str = "\"__RUNLIGHT_PICK_TARGET__\"";
98const PICK_HOSTS_PLACEHOLDER: &str = "\"__RUNLIGHT_PICK_HOSTS__\"";
100const PICK_RETURN_PLACEHOLDER: &str = "\"__RUNLIGHT_PICK_RETURN__\"";
102const PICK_NONCE_PLACEHOLDER: &str = "\"__RUNLIGHT_PICK_NONCE__\"";
104const 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
108pub(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("&"),
114 '<' => out.push_str("<"),
115 '>' => out.push_str(">"),
116 '"' => out.push_str("""),
117 '\'' => out.push_str("'"),
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
135pub(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
144pub(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
156pub(crate) fn coded(error: &str, code: &str, status: u16, params: Option<&[(&str, &str)]>) -> Response {
159 coded_with(error, code, status, params, &[])
160}
161
162#[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
184pub(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
193pub(crate) fn refused(error: &CodedError, status: u16) -> Response {
195 coded_error(error, status)
196}
197
198pub(crate) fn refused_plain(message: &str, fallback: &str, status: u16) -> Response {
200 coded(message, fallback, status, Some(&[("detail", message)]))
201}
202
203pub(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
209pub 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
224pub(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
237pub(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
263pub(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
275pub(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
288pub(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
333struct 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
402pub 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
422pub(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#[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#[derive(Clone, Debug, PartialEq, Eq)]
462pub(crate) enum Reader {
463 Owner,
464 Token(TokenRow),
465 No,
466 Unconfigured,
467}
468
469#[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#[derive(Clone)]
504pub struct Routes(pub(crate) Arc<Inner>);
505
506impl Runlight {
507 pub fn routes(&self, options: RoutesOptions) -> Result<Routes, String> {
509 Routes::new(self.clone(), options)
510 }
511}
512
513impl Routes {
514 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 {
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 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 pub fn base_path(&self) -> &str {
587 &self.0.base
588 }
589
590 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 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 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 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 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 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 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 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 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 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 async fn pick_key(&self) -> Result<String, crate::Error> {
735 self.rl().init().await?;
736 Ok(self.rl().store().setting_or_add("pick-key", || random_id(32)).await?)
738 }
739
740 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 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 async fn embed_key(&self) -> Result<String, crate::Error> {
790 self.rl().init().await?;
791 Ok(self.rl().store().setting_or_add("embed-key", || random_id(32)).await?)
793 }
794
795 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 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 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 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 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 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 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 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 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 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 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 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 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 .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 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 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 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 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 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 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 .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(¶ms);
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 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
1249fn 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 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
1275struct PickTarget {
1278 origin: String,
1279 site: String,
1280 back: Option<(String, String)>,
1281}
1282
1283pub(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
1296pub(crate) fn read_json(request: &Request) -> Result<js::Object, Response> {
1298 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"Press\" data-world="));
1318 }
1319}