runlight 0.1.1 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/assets/assistant-action.json +1 -0
  3. data/assets/build.json +4 -4
  4. data/assets/dashboard.css +1 -1
  5. data/assets/dashboard.js +65 -23
  6. data/assets/locales.json +5 -5
  7. data/assets/pick-return.js +1 -0
  8. data/assets/picker.js +1 -1
  9. data/assets/tracker.js +1 -1
  10. data/lib/runlight/accounts/accounts.rb +30 -17
  11. data/lib/runlight/accounts/pages.rb +67 -3
  12. data/lib/runlight/accounts/web.rb +45 -30
  13. data/lib/runlight/assistant.rb +41 -18
  14. data/lib/runlight/core.rb +126 -39
  15. data/lib/runlight/dates.rb +31 -8
  16. data/lib/runlight/db/connect.rb +27 -4
  17. data/lib/runlight/db/db.rb +52 -4
  18. data/lib/runlight/db/prefixed.rb +92 -0
  19. data/lib/runlight/geo.rb +4 -3
  20. data/lib/runlight/http/fetcher.rb +27 -5
  21. data/lib/runlight/mail/ses.rb +3 -2
  22. data/lib/runlight/mail/transports.rb +19 -2
  23. data/lib/runlight/metadata_address_error.rb +13 -0
  24. data/lib/runlight/oauth.rb +83 -15
  25. data/lib/runlight/rack_app.rb +11 -4
  26. data/lib/runlight/redirect_refused_error.rb +14 -0
  27. data/lib/runlight/reports.rb +1 -6
  28. data/lib/runlight/routes.rb +244 -96
  29. data/lib/runlight/safefetch.rb +98 -1
  30. data/lib/runlight/server/agents.rb +1 -1
  31. data/lib/runlight/server/cli.rb +1 -1
  32. data/lib/runlight/server/config.rb +64 -19
  33. data/lib/runlight/server/standalone.rb +7 -6
  34. data/lib/runlight/store/sql.rb +10 -0
  35. data/lib/runlight/store/sql_store.rb +76 -14
  36. data/lib/runlight/stores.rb +19 -15
  37. data/lib/runlight/tables_busy_error.rb +11 -0
  38. data/lib/runlight/version.rb +1 -1
  39. data/lib/runlight.rb +4 -0
  40. metadata +8 -2
@@ -23,6 +23,9 @@ module Runlight
23
23
  # development makes one up (a restart signs everyone out); anywhere else that raises ArgumentError, since
24
24
  # each process of an app would make its own and sign the others' sessions out.
25
25
  # - geoCredit: credits DB-IP in the dashboard's footer.
26
+ # - cms: the CMS that adds the script to every page, such as "WordPress", when Runlight runs inside one. The
27
+ # dashboard then says so in place of the steps to add the script, and pages of the same origin, the CMS's own
28
+ # admin, may show the whole dashboard in a frame. The WordPress, Drupal, and Craft plugins set it.
26
29
  # - origin: the address people open the app at, such as https://example.com.
27
30
  # - ownHosts: a callable returning more names the dashboard is reached at.
28
31
  # - accountOf: a callable taking the Request and returning the account it comes from, or nil (internal, for the
@@ -47,6 +50,14 @@ module Runlight
47
50
  PICK_TARGET_PLACEHOLDER = '"__RUNLIGHT_PICK_TARGET__"'
48
51
  # Where the hostnames of the site its ticket names go, as JSON inside a string.
49
52
  PICK_HOSTS_PLACEHOLDER = '"__RUNLIGHT_PICK_HOSTS__"'
53
+ # Where the relay page the picker sends its choice to goes, the return address its ticket names.
54
+ PICK_RETURN_PLACEHOLDER = '"__RUNLIGHT_PICK_RETURN__"'
55
+ # Where the nonce the dashboard made for this pick goes, which the relay page hands back to it.
56
+ PICK_NONCE_PLACEHOLDER = '"__RUNLIGHT_PICK_NONCE__"'
57
+ # The relay page on a dashboard's origin: the dashboard's address with /pick-return on the end.
58
+ PICK_RETURN = %r{\Ahttps?://[^/?#\s]+(/[^?#\s]*)?/pick-return\z}
59
+ # What the dashboard names one pick by, so its relay page answers only the tab that started it.
60
+ PICK_NONCE = /\A[a-f0-9]{32}\z/
50
61
  # How long a picker ticket works: long enough to find the element, not to be kept.
51
62
  PICK_TICKET_MS = 30 * 60_000
52
63
  # Questions one person may put to the assistant in an hour, and at once.
@@ -62,10 +73,14 @@ module Runlight
62
73
  PATH_DIMENSIONS = %w[page entry exit ai_page].freeze
63
74
  # runlight.ts's LINK_DOMAIN_CHECK: the path on every link domain that answers when the domain reaches this Runlight.
64
75
  LINK_DOMAIN_CHECK = "/.well-known/runlight-link-domain"
76
+ # The most read of a link domain's answer to the check, and of an install's live answer for a share or embed.
77
+ LINK_DOMAIN_MAX_BYTES = 16_384
78
+ REALTIME_MAX_BYTES = 4 * 1024 * 1024
65
79
  # runlight.ts's RETENTION_MONTHS: the choices for how long a site keeps its visits.
66
80
  RETENTION_MONTHS = [6, 12, 24, 36, 60].freeze
67
- private_constant :RULES_PLACEHOLDER, :PICK_TARGET_PLACEHOLDER, :PICK_HOSTS_PLACEHOLDER, :SHARE_ID,
68
- :PATH_DIMENSIONS, :LINK_DOMAIN_CHECK, :RETENTION_MONTHS
81
+ private_constant :RULES_PLACEHOLDER, :PICK_TARGET_PLACEHOLDER, :PICK_HOSTS_PLACEHOLDER, :PICK_RETURN_PLACEHOLDER,
82
+ :PICK_NONCE_PLACEHOLDER, :PICK_RETURN, :PICK_NONCE, :SHARE_ID,
83
+ :PATH_DIMENSIONS, :LINK_DOMAIN_CHECK, :LINK_DOMAIN_MAX_BYTES, :REALTIME_MAX_BYTES, :RETENTION_MONTHS
69
84
 
70
85
  DASHBOARD_CSP = "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'"
71
86
 
@@ -243,17 +258,22 @@ module Runlight
243
258
  Js.scrub(value).match?(EMAIL)
244
259
  end
245
260
 
246
- # A plain page in a visitor's language, for unsubscribing and for a share link that is gone.
247
- def small_page(lang, body, status = 200)
261
+ # A plain page in a visitor's language, for unsubscribing, for a share link that is gone, and for the picker's
262
+ # relay page, which alone runs a script: one file from this server, never inline.
263
+ def small_page(lang, body, status = 200, script = "")
248
264
  Http::Response.new(
249
265
  "<!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>\n" \
250
266
  '<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>' \
251
- "#{body}</main></body></html>",
267
+ "#{body}</main>#{script == "" ? "" : "<script src=\"#{escape_html(script)}\"></script>"}</body></html>",
252
268
  status: status,
253
269
  headers: {
254
270
  "content-type" => "text/html; charset=utf-8",
255
271
  "cache-control" => "no-store",
256
- "content-security-policy" => "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
272
+ "content-security-policy" => if script == ""
273
+ "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"
274
+ else
275
+ "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
276
+ end,
257
277
  "referrer-policy" => "no-referrer",
258
278
  },
259
279
  )
@@ -373,7 +393,7 @@ module Runlight
373
393
  # The dashboard's page, which holds no data: the API it calls checks access. `embed` is the dashboard inside a
374
394
  # CMS's admin pages: { "session", "origin" }, its session (empty once its ticket was used or ran out) and the
375
395
  # admin origin that frames it.
376
- def dashboard(base, share = "", sign_out = "", geo_credit = false, accounts = false, sign_in = "", embed = nil)
396
+ def dashboard(base, share = "", sign_out = "", geo_credit = false, accounts = false, sign_in = "", embed = nil, cms = "")
377
397
  b = escape_attr(base)
378
398
  hash = build_hash("dashboardHash")
379
399
  attributes = (share != "" ? " data-share=\"#{escape_attr(share)}\"" : "") +
@@ -381,6 +401,7 @@ module Runlight
381
401
  (sign_in != "" ? " data-sign-in=\"#{escape_attr(sign_in)}\"" : "") +
382
402
  (geo_credit ? ' data-geo-credit=""' : "") +
383
403
  (accounts ? ' data-accounts=""' : "") +
404
+ (cms != "" ? " data-cms=\"#{escape_attr(cms)}\"" : "") +
384
405
  (embed.nil? ? "" : " data-embed=\"#{escape_attr(embed["session"])}\" data-embed-origin=\"#{escape_attr(embed["origin"])}\"")
385
406
  "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<title>Runlight</title>\n" \
386
407
  "<link rel=\"icon\" href=\"#{Brand.runlight_icon}\">\n" \
@@ -413,12 +434,10 @@ module Runlight
413
434
  Js.scrub(value).match?(ORIGIN)
414
435
  end
415
436
 
416
- # decodeURIComponent, which throws on a broken escape, as the TypeScript does (an internal error there).
417
- def decode(text)
418
- decoded = Js.decode_uri_component(text)
419
- raise ArgumentError, "URI malformed" if decoded.nil?
420
-
421
- decoded
437
+ # One part of a URL path with its percent escapes decoded, or nil when they are broken (such as %E0 or
438
+ # %zz). A route answers nil as it does a name it does not know, never with an error.
439
+ def decode_part(text)
440
+ Js.decode_uri_component(text)
422
441
  end
423
442
 
424
443
  # `String(body[key] ?? fallback)`.
@@ -525,6 +544,8 @@ module Runlight
525
544
  if base != "" && oauth_document?(url.pathname)
526
545
  begin
527
546
  return OAuth.oauth_response(@oauth, request, url.pathname, url, context) || coded("Not found", "not_found", 404)
547
+ rescue TablesBusyError
548
+ return tables_busy
528
549
  rescue StandardError => e
529
550
  warn("Runlight: #{e.full_message(highlight: false)}")
530
551
  return coded("Internal error", "internal", 500)
@@ -537,6 +558,8 @@ module Runlight
537
558
 
538
559
  begin
539
560
  route(request, path, url, context)
561
+ rescue TablesBusyError
562
+ tables_busy
540
563
  rescue StandardError => e
541
564
  warn("Runlight: #{e.full_message(highlight: false)}")
542
565
  coded("Internal error", "internal", 500)
@@ -563,6 +586,11 @@ module Runlight
563
586
 
564
587
  private
565
588
 
589
+ # Another process is building the tables, such as an index after an upgrade: worth asking again soon.
590
+ def tables_busy
591
+ coded("Runlight is creating or upgrading its tables. Try again in a minute.", "tables_busy", 503, nil, { "retry-after" => "30" })
592
+ end
593
+
566
594
  def sites
567
595
  @rl.sites
568
596
  end
@@ -572,6 +600,10 @@ module Runlight
572
600
  end
573
601
 
574
602
  # Whether this request acts as the owner. "read" is someone signed in who may only read, such as a viewer.
603
+ def session_scope(request)
604
+ @web.signed_in(request) if @web
605
+ end
606
+
575
607
  def can_read(request)
576
608
  return true if @managed.key?(request)
577
609
 
@@ -581,6 +613,8 @@ module Runlight
581
613
  given = bearer(request)
582
614
  return true if authorize.nil? && !@token.nil? && @token != "" && given != "" && constant_time_equal(given, @token)
583
615
 
616
+ scoped = session_scope(request)
617
+ return false if scoped && !scoped["site"].nil? && Http::Url.new(request.url).pathname.delete_prefix(@base).start_with?("/oauth/")
584
618
  answer = authorize.nil? ? @web.access(request) : authorize.call(request)
585
619
  # A member changes things like an owner, apart from the few controls admin_only? names.
586
620
  @members[request] = true if answer == "member"
@@ -685,7 +719,7 @@ module Runlight
685
719
 
686
720
  # Answers a read for a site counted by another install by asking that install,
687
721
  # with its token and its own id for the site, and handing back what it says.
688
- def pass_through(remote, path, url, request = nil)
722
+ def pass_through(remote, path, url, request = nil, max_bytes = nil)
689
723
  target = Http::Url.new("#{remote["url"]}#{path}")
690
724
  query = target.search_params
691
725
  url.search_params.each { |key, value| query.append(key.to_s, value) }
@@ -707,6 +741,8 @@ module Runlight
707
741
  "timeoutMs" => write ? 30_000 : 120_000,
708
742
  }
709
743
  init["body"] = request.text if write
744
+ # One byte more than the most wanted is asked, so an answer past it is seen as such.
745
+ init.merge!("maxBytes" => max_bytes + 1, "truncate" => true) unless max_bytes.nil?
710
746
  answer = Safefetch.owner_fetch(target.href, init, @rl.fetcher, @rl.local_installs)
711
747
  rescue StandardError => e
712
748
  if e.is_a?(Http::FetchError) && e.timed_out?
@@ -838,8 +874,8 @@ module Runlight
838
874
  end
839
875
  end
840
876
  if (check_match = path.match(%r{\A/api/link-domains/([^/]+)/check\z})) && request.method == "GET"
841
- domain = decode(check_match[1])
842
- return coded("Unknown domain", "unknown_domain", 404) unless own_domains.call.include?(domain)
877
+ domain = decode_part(check_match[1])
878
+ return coded("Unknown domain", "unknown_domain", 404) if domain.nil? || !own_domains.call.include?(domain)
843
879
 
844
880
  # One added before names inside private networks were refused is never fetched.
845
881
  # What the check found, as a code the dashboard says in its own words, beside the English reason.
@@ -860,9 +896,11 @@ module Runlight
860
896
  begin
861
897
  # Only a public address is fetched, whatever the name resolves to now, so the check cannot be pointed
862
898
  # into a private network.
863
- answer = Safefetch.public_fetch("https://#{domain}#{LINK_DOMAIN_CHECK}", { "timeoutMs" => 5000 }, @rl.fetcher)
899
+ # Read no further than the check's answer could need; one byte more is asked, so a longer one is seen as such.
900
+ answer = Safefetch.public_fetch("https://#{domain}#{LINK_DOMAIN_CHECK}",
901
+ { "timeoutMs" => 5000, "maxBytes" => LINK_DOMAIN_MAX_BYTES + 1, "truncate" => true }, @rl.fetcher)
864
902
  parsed, body = begin
865
- Js.parse_json(answer.text)
903
+ Js.parse_json(Body.read_text_capped(answer, LINK_DOMAIN_MAX_BYTES))
866
904
  rescue StandardError
867
905
  [false, nil]
868
906
  end
@@ -879,8 +917,8 @@ module Runlight
879
917
  end
880
918
 
881
919
  if (domain_match = path.match(%r{\A/api/link-domains/([^/]+)\z})) && request.method == "DELETE"
882
- domain = decode(domain_match[1])
883
- return coded("Unknown domain", "unknown_domain", 404) unless own_domains.call.include?(domain)
920
+ domain = decode_part(domain_match[1])
921
+ return coded("Unknown domain", "unknown_domain", 404) if domain.nil? || !own_domains.call.include?(domain)
884
922
 
885
923
  store.remove_link_domain(domain)
886
924
  @rl.forget_link_domains
@@ -1000,40 +1038,41 @@ module Runlight
1000
1038
  # The key picker tickets are signed with, made on first use and kept in the database for every process.
1001
1039
  def pick_key
1002
1040
  @rl.init
1003
- saved = store.setting("pick-key")
1004
- return saved if !saved.nil? && saved != ""
1005
-
1006
- made = Hashing.random_id(32)
1007
- store.set_setting("pick-key", made)
1008
- made
1041
+ # Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
1042
+ store.setting_or_add("pick-key") { Hashing.random_id(32) }
1009
1043
  end
1010
1044
 
1011
- # A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half an hour.
1012
- def pick_ticket(origin, site)
1045
+ # A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half
1046
+ # an hour. With a return address and a nonce it sends it there, to the relay page, which hands it to the dashboard
1047
+ # tab that made the nonce; without them, as an older dashboard asks, to the tab that opened the site.
1048
+ def pick_ticket(origin, site, back = nil)
1013
1049
  payload = "#{@rl.now + PICK_TICKET_MS}.#{site.unpack1("H*")}.#{origin.unpack1("H*")}"
1050
+ payload += ".#{back["url"].unpack1("H*")}.#{back["nonce"]}" unless back.nil?
1014
1051
  "#{payload}.#{Hashing.hmac(pick_key, payload)}"
1015
1052
  end
1016
1053
 
1017
- # The dashboard origin and site a picker ticket names, or nil when it is not one this install signed or has run out.
1054
+ # What a picker ticket names, or nil when it is not one this install signed or has run out.
1018
1055
  def pick_target(ticket)
1019
- parts = ticket.match(/\A(\d+)\.([a-f0-9]{2,512})\.([a-f0-9]{2,512})\.([a-f0-9]{64})\z/)
1056
+ parts = ticket.match(/\A(\d+)\.([a-f0-9]{2,512})\.([a-f0-9]{2,512})(?:\.([a-f0-9]{2,2048})\.([a-f0-9]{32}))?\.([a-f0-9]{64})\z/)
1020
1057
  return nil if parts.nil? || Js.number(parts[1]) < @rl.now
1021
- return nil unless constant_time_equal(parts[4], Hashing.hmac(pick_key, "#{parts[1]}.#{parts[2]}.#{parts[3]}"))
1058
+ return nil unless constant_time_equal(parts[6], Hashing.hmac(pick_key, ticket[0, ticket.rindex(".")]))
1022
1059
 
1023
1060
  unhex = ->(text) { Js.scrub([text[0, text.length - (text.length % 2)]].pack("H*")) }
1024
1061
  origin = unhex.call(parts[3])
1025
- origin?(origin) ? { "origin" => origin, "site" => unhex.call(parts[2]) } : nil
1062
+ return nil unless origin?(origin)
1063
+ return { "origin" => origin, "site" => unhex.call(parts[2]) } if parts[4].nil?
1064
+
1065
+ address = unhex.call(parts[4])
1066
+ return nil unless address.match?(PICK_RETURN) && address.start_with?("#{origin}/")
1067
+
1068
+ { "origin" => origin, "site" => unhex.call(parts[2]), "back" => { "url" => address, "nonce" => parts[5] } }
1026
1069
  end
1027
1070
 
1028
1071
  # The key embed tickets and sessions are signed with, made on first use and kept in the database for every process.
1029
1072
  def embed_key
1030
1073
  @rl.init
1031
- saved = store.setting("embed-key")
1032
- return saved if !saved.nil? && saved != ""
1033
-
1034
- made = Hashing.random_id(32)
1035
- store.set_setting("embed-key", made)
1036
- made
1074
+ # Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
1075
+ store.setting_or_add("embed-key") { Hashing.random_id(32) }
1037
1076
  end
1038
1077
 
1039
1078
  # An embed token that still exists, for a site that still does.
@@ -1120,10 +1159,10 @@ module Runlight
1120
1159
  return site if site.is_a?(Http::Response)
1121
1160
 
1122
1161
  existing = store.goals(site["id"])
1123
- id = path == "/api/goals" ? nil : decode(path["/api/goals/".length..])
1162
+ id = path == "/api/goals" ? nil : decode_part(path["/api/goals/".length..])
1124
1163
  before = nil
1125
1164
  existing.each { |g| before = g if g["id"] == id }
1126
- return coded("Unknown goal", "unknown_goal", 404) if !id.nil? && before.nil?
1165
+ return coded("Unknown goal", "unknown_goal", 404) if path != "/api/goals" && (id.nil? || before.nil?)
1127
1166
 
1128
1167
  @trackers = {}
1129
1168
  if request.method == "DELETE"
@@ -1149,7 +1188,7 @@ module Runlight
1149
1188
  if request.method == "GET"
1150
1189
  settings = @rl.mail_settings
1151
1190
  known = settings || {}
1152
- service = Mail::Transports::SERVICES.find { |s| s["id"] == known["service"] }
1191
+ service = known["source"] == "app" ? nil : Mail::Transports::SERVICES.find { |s| s["id"] == known["service"] }
1153
1192
  # Secret fields come back only as "saved", never as their value.
1154
1193
  fields = {}
1155
1194
  saved = []
@@ -1163,16 +1202,23 @@ module Runlight
1163
1202
  end
1164
1203
  # A hub with a manage token learns which service sends the reports and from where, nothing more.
1165
1204
  via_manage = @managed.key?(request)
1166
- return json({
1205
+ answer = {
1167
1206
  "source" => known["source"],
1168
- "service" => known["service"] || "",
1207
+ "service" => known["source"] == "app" ? "" : known["service"] || "",
1169
1208
  "from" => known["from"] || "",
1170
1209
  "fromName" => known["fromName"] || "",
1171
1210
  "fields" => via_manage ? {} : fields,
1172
1211
  "saved" => via_manage ? [] : saved,
1173
1212
  "encrypted" => !@rl.secret.nil?,
1174
- "services" => Mail::Transports::SERVICES,
1175
- })
1213
+ "services" => @rl.mail_services,
1214
+ }
1215
+ # The app's own mailer, named only when there is one, so the answer without it stays as it was.
1216
+ app = @rl.app_mailer
1217
+ unless app.nil?
1218
+ answer["app"] = { "via" => app["via"], "from" => app["from"], "fromName" => app["fromName"] || "",
1219
+ "replaced" => known["source"] != "app" }
1220
+ end
1221
+ return json(answer)
1176
1222
  end
1177
1223
  if request.method == "PUT"
1178
1224
  body = read_json(request)
@@ -1199,12 +1245,16 @@ module Runlight
1199
1245
  return coded("Set up a mail service first", "mail_unset", 400) if settings.nil?
1200
1246
 
1201
1247
  t = Messages.translator(text(body, "lang", "en"))["t"]
1202
- name = Mail::Transports::SERVICES.find { |s| s["id"] == settings["service"] }&.[]("name") || ""
1248
+ name = if settings["source"] == "app"
1249
+ settings["via"] || ""
1250
+ else
1251
+ Mail::Transports::SERVICES.find { |s| s["id"] == settings["service"] }&.[]("name") || ""
1252
+ end
1203
1253
  @rl.send_mail({
1204
1254
  "to" => to,
1205
1255
  "subject" => t.call("email.test.subject"),
1206
1256
  "text" => t.call("email.test.body", { "service" => name }),
1207
- "html" => "<p style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
1257
+ "html" => "<img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\"><p style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
1208
1258
  })
1209
1259
  return json({ "ok" => true })
1210
1260
  end
@@ -1332,8 +1382,8 @@ module Runlight
1332
1382
  return coded("Method not allowed", "method_not_allowed", 405)
1333
1383
  end
1334
1384
 
1335
- id = decode(path["/api/shares/".length..])
1336
- share = id.match?(SHARE_ID) ? store.share_by_id(id) : nil
1385
+ id = decode_part(path["/api/shares/".length..])
1386
+ share = !id.nil? && id.match?(SHARE_ID) ? store.share_by_id(id) : nil
1337
1387
  return coded("Unknown share", "unknown_share", 404) if share.nil? || share["site"] != site["id"]
1338
1388
 
1339
1389
  if request.method == "PATCH"
@@ -1362,39 +1412,72 @@ module Runlight
1362
1412
  # As in TypeScript, the hour's questions are counted in this process; the day's, in the settings.
1363
1413
  def ask_turn(who, owner)
1364
1414
  now = @rl.now
1415
+ # Taken in one step, before anything slow, so questions sent at once each see the others.
1365
1416
  mine = @lock.synchronize do
1366
- held = @asked[who] || { "at" => [], "open" => 0 }
1417
+ held = (@asked[who] ||= { "at" => [], "open" => 0 })
1367
1418
  held["at"] = held["at"].select { |at| now - at < 3_600_000 }
1368
- held
1369
- end
1370
- if mine["at"].length >= ASK_PER_HOUR || mine["open"] >= ASK_AT_ONCE
1371
- return coded("You have asked a lot in a short time. Wait a little and ask again.", "assistant_soon", 429)
1419
+ if held["at"].length >= ASK_PER_HOUR || held["open"] >= ASK_AT_ONCE
1420
+ nil
1421
+ else
1422
+ held["at"] << now
1423
+ held["open"] += 1
1424
+ held
1425
+ end
1372
1426
  end
1427
+ return coded("You have asked a lot in a short time. Wait a little and ask again.", "assistant_soon", 429) if mine.nil?
1373
1428
 
1429
+ release = -> { @lock.synchronize { mine["open"] -= 1 } }
1374
1430
  unless owner
1375
- daily = viewer_daily
1376
- day = "assistant-asked:#{Time.at(now.div(1000)).utc.strftime("%Y-%m-%d")}"
1377
- counts = Json.decode(store.setting(day) || "{}")
1378
- if (counts[who] || 0) >= daily
1379
- return coded("Viewers can ask #{Js.string(daily)} questions a day. Ask again tomorrow.", "assistant_daily", 429, { "limit" => Js.string(daily) })
1380
- end
1431
+ counted = begin
1432
+ count_ask(who, now)
1433
+ rescue StandardError
1434
+ release.call
1435
+ raise
1436
+ end
1437
+ unless counted == true
1438
+ release.call
1439
+ @lock.synchronize do
1440
+ at = mine["at"].index(now)
1441
+ mine["at"].delete_at(at) unless at.nil?
1442
+ end
1443
+ return coded("You have asked a lot in a short time. Wait a little and ask again.", "assistant_soon", 429) if counted.nil?
1381
1444
 
1382
- counts[who] = (counts[who] || 0) + 1
1383
- store.set_setting(day, Json.encode(counts))
1384
- store.settings_starting_with("assistant-asked:").each do |entry|
1385
- store.set_setting(entry["key"], nil) if entry["key"] != day
1445
+ return coded("Viewers can ask #{Js.string(counted)} questions a day. Ask again tomorrow.", "assistant_daily", 429, { "limit" => Js.string(counted) })
1386
1446
  end
1387
1447
  end
1448
+ # People who stopped asking are dropped, so the map holds only the last hour's.
1388
1449
  @lock.synchronize do
1389
- mine["at"] << now
1390
- mine["open"] += 1
1391
- @asked[who] = mine
1392
- # People who stopped asking are dropped, so the map holds only the last hour's.
1393
1450
  if @asked.size > 1000
1394
1451
  @asked.delete_if { |_key, value| value["open"].zero? && value["at"].none? { |at| now - at < 3_600_000 } }
1395
1452
  end
1396
1453
  end
1397
- -> { @lock.synchronize { mine["open"] -= 1 } }
1454
+ release
1455
+ end
1456
+
1457
+ # Counts a viewer's question in their own row for the day, or gives the daily limit when it is reached. The
1458
+ # row only ever moves from the count read to the next one, so of questions sent at once, by one viewer or by
1459
+ # every process on the database, each count is taken once. True when counted, nil when the row kept changing.
1460
+ def count_ask(who, now)
1461
+ limit = viewer_daily
1462
+ return limit if limit < 1
1463
+
1464
+ day = "assistant-asked:#{Time.at(now.div(1000)).utc.strftime("%Y-%m-%d")}:"
1465
+ key = "#{day}#{who}"
1466
+ 20.times do
1467
+ seen = store.setting(key)
1468
+ if seen.nil?
1469
+ next unless store.add_setting(key, "1")
1470
+
1471
+ # A viewer's first question of the day clears the days before.
1472
+ store.settings_starting_with("assistant-asked:").each do |entry|
1473
+ store.set_setting(entry["key"], nil) unless entry["key"].start_with?(day)
1474
+ end
1475
+ return true
1476
+ end
1477
+ return limit if Js.number(seen) >= limit
1478
+ return true if store.swap_setting(key, seen, Js.string(Js.number(seen) + 1))
1479
+ end
1480
+ nil
1398
1481
  end
1399
1482
 
1400
1483
  def tokens_api(request, path)
@@ -1418,7 +1501,7 @@ module Runlight
1418
1501
  asked_scope = Js.get(body, "scope")
1419
1502
  scope = %w[manage embed].include?(asked_scope) ? asked_scope : "read"
1420
1503
  return coded("A token that changes settings is for one site. Pick the site.", "token_site", 400) if scope == "manage" && site == ""
1421
- return coded("A key for the dashboard in a CMS is for one site. Pick the site.", "embed_site", 400) if scope == "embed" && site == ""
1504
+ return coded("A dashboard key is for one site. Pick the site.", "embed_site", 400) if scope == "embed" && site == ""
1422
1505
 
1423
1506
  secret = "#{TOKEN_PREFIX}#{Hashing.random_id(20)}"
1424
1507
  row = { "id" => Hashing.random_id, "name" => name, "site" => site, "scope" => scope, "hash" => Hashing.sha256(secret),
@@ -1458,6 +1541,27 @@ module Runlight
1458
1541
  def api(request, path, url)
1459
1542
  rl = @rl
1460
1543
  method = request.method
1544
+ scoped = session_scope(request)
1545
+ if scoped && !scoped["site"].nil?
1546
+ rl.init
1547
+ site = scoped["site"]
1548
+ return coded("Unknown site", "unknown_site", 404) if rl.site(site).nil?
1549
+ asked = url.search_params.get("site")
1550
+ site_path = path.match(%r{\A/api/sites/([^/]+)\z})
1551
+ return coded("Unknown site", "unknown_site", 404) if (!asked.nil? && asked != site) || (site_path && decode_part(site_path[1]) != site)
1552
+ read = method == "GET" && shared_path?(path)
1553
+ assistant = (path == "/api/assistant" && method == "GET") || (path == "/api/assistant/chat" && method == "POST")
1554
+ manage = scoped["role"] == "member" && manage_path(method, path)
1555
+ return coded("Your account only has access to its assigned site", "site_access", 403) unless read || assistant || manage
1556
+ if path == "/api/assistant/chat"
1557
+ parsed, body = Js.parse_json(request.text)
1558
+ return coded("Unknown site", "unknown_site", 404) unless parsed && Js.object?(body) && Js.get(body, "site") == site
1559
+ end
1560
+ url = Http::Url.new(url.href)
1561
+ query = url.search_params
1562
+ query.set("site", site)
1563
+ url.search_params = query
1564
+ end
1461
1565
  # An embedded dashboard reads what a share link shows and nothing else, whoever else the request comes from.
1462
1566
  if !request.headers.get(EMBED_HEADER).nil? && !(method == "GET" && shared_path?(path))
1463
1567
  return coded("Not available on a shared dashboard", "share_not_available", 403)
@@ -1532,7 +1636,7 @@ module Runlight
1532
1636
  if path == "/api/embed" && method == "POST"
1533
1637
  token = api_token(request)
1534
1638
  return denied(false) if token.nil?
1535
- return coded("Use a key for the dashboard in a CMS, made in Settings, Install", "embed_token", 403) if token["scope"] != "embed"
1639
+ return coded("Use the site's dashboard key, made in Settings, Install", "embed_token", 403) if token["scope"] != "embed"
1536
1640
 
1537
1641
  site = token["site"] != "" ? rl.site(token["site"]) : nil
1538
1642
  return coded("Unknown site", "unknown_site", 404) if site.nil?
@@ -1568,7 +1672,7 @@ module Runlight
1568
1672
  if !token.nil? && token["scope"] == "manage" && manage_path(method, path)
1569
1673
  asked = url.search_params.get("site")
1570
1674
  site_match = path.match(%r{\A/api/sites/([^/]+)\z})
1571
- if (!asked.nil? && asked != "" && asked != token["site"]) || (!site_match.nil? && decode(site_match[1]) != token["site"])
1675
+ if (!asked.nil? && asked != "" && asked != token["site"]) || (!site_match.nil? && decode_part(site_match[1]) != token["site"])
1572
1676
  return coded("Unknown site", "unknown_site", 404)
1573
1677
  end
1574
1678
 
@@ -1659,7 +1763,7 @@ module Runlight
1659
1763
  end
1660
1764
  end
1661
1765
 
1662
- # Each site's key for CMS plugins reporting AI agent fetches: made on first ask, replaced on request.
1766
+ # Each site's agents key, for CMS plugins reporting AI agent fetches: made on first ask, replaced on request.
1663
1767
  if (path == "/api/observe-key" && method == "GET") || (path == "/api/observe-key/new" && method == "POST")
1664
1768
  access = can_read(request)
1665
1769
  return denied(access) if access != true
@@ -1668,13 +1772,7 @@ module Runlight
1668
1772
  site = query_site(url)
1669
1773
  return site if site.is_a?(Http::Response)
1670
1774
 
1671
- name = "observe-key:#{site["id"]}"
1672
- key = path.end_with?("/new") ? nil : store.setting(name)
1673
- if key.nil? || key == ""
1674
- key = "rlo_#{Hashing.random_id(20)}"
1675
- store.set_setting(name, key)
1676
- end
1677
- return json({ "key" => key })
1775
+ return json({ "key" => OAuth.agents_key(rl, site["id"], path.end_with?("/new")) })
1678
1776
  end
1679
1777
 
1680
1778
  # Making, changing, and deleting funnels; reading them is with the other reports.
@@ -1858,13 +1956,25 @@ module Runlight
1858
1956
  origin = text(body, "origin")
1859
1957
  return coded("Send the dashboard's origin, such as https://stats.example.com", "pick_origin", 400) unless origin?(origin)
1860
1958
 
1959
+ # Where the picker sends its choice: the relay page on the same origin, and the nonce the dashboard tab
1960
+ # listens for. An older dashboard sends neither and gets a ticket for the tab that opened the site.
1961
+ back = nil
1962
+ if given?(body, "return") || given?(body, "nonce")
1963
+ address = Js.scrub(text(body, "return"))
1964
+ nonce = text(body, "nonce")
1965
+ if address.unpack1("H*").length > 2048 || !address.match?(PICK_RETURN) || !address.start_with?("#{origin}/") || !nonce.match?(PICK_NONCE)
1966
+ return coded("Send the dashboard's address with /pick-return on the end, on its origin, and a nonce of 32 hex digits", "pick_return", 400)
1967
+ end
1968
+
1969
+ back = { "url" => address, "nonce" => nonce }
1970
+ end
1861
1971
  # A hub's ticket only ever sends to the hub it connected from, never to an origin it names now.
1862
1972
  hub = @managed[request]
1863
1973
  if !hub.nil? && store.setting("token-origin:#{hub["id"]}") != origin
1864
1974
  return coded("This hub's address is not the one it connected from. Connect the site again from here.", "pick_hub", 403)
1865
1975
  end
1866
1976
 
1867
- return json({ "ticket" => pick_ticket(origin, site["id"]) })
1977
+ return json({ "ticket" => pick_ticket(origin, site["id"], back) })
1868
1978
  end
1869
1979
 
1870
1980
  if (path == "/api/goals" && method == "POST") || (path.match?(%r{\A/api/goals/[^/]+\z}) && %w[PATCH DELETE].include?(method))
@@ -1901,8 +2011,15 @@ module Runlight
1901
2011
  access = can_read(request)
1902
2012
  return denied(access) if access != true
1903
2013
 
2014
+ id = decode_part(site_match[1])
2015
+ return coded("Unknown site", "unknown_site", 404) if id.nil?
2016
+
2017
+ if @web && session_scope(request)
2018
+ refused = @web.confirm_password(request)
2019
+ return refused unless refused.nil?
2020
+ end
1904
2021
  begin
1905
- rl.delete_site(decode(site_match[1]))
2022
+ rl.delete_site(id)
1906
2023
  return json({ "ok" => true })
1907
2024
  rescue RangeError => e
1908
2025
  return e.message == "Unknown site" ? coded(e.message, "unknown_site", 404) : refused(e, "site_invalid")
@@ -2068,8 +2185,10 @@ module Runlight
2068
2185
  months = RETENTION_MONTHS.join(", ")
2069
2186
  return coded("Keep visits for #{months} months, or forever", "retention_bad", 400, { "months" => months })
2070
2187
  end
2188
+ id = decode_part(raw_id)
2189
+ return coded("Unknown site", "unknown_site", 404) if id.nil?
2190
+
2071
2191
  begin
2072
- id = decode(raw_id)
2073
2192
  remote = rl.remote(id)
2074
2193
  # How long a connected site keeps visits, and the timezone its days follow, are the install's
2075
2194
  # settings: this server passes them on, and changes its own row only once the install took them.
@@ -2090,14 +2209,14 @@ module Runlight
2090
2209
  return answer unless answer.ok?
2091
2210
 
2092
2211
  rl.forget_remote_info(id)
2093
- elsif remote.nil? && !retention.equal?(UNDEFINED)
2094
- rl.set_retention(id, retention.nil? ? nil : Js.number(retention))
2095
2212
  end
2213
+ # update_site checks every field, hostnames too, before it changes anything.
2096
2214
  patch = {}
2215
+ patch["retentionMonths"] = retention.nil? ? nil : Js.number(retention) if remote.nil? && !retention.equal?(UNDEFINED)
2097
2216
  patch["name"] = Js.string(Js.get(body, "name")) if given?(body, "name")
2098
2217
  patch["timezone"] = Js.string(Js.get(body, "timezone")) if given?(body, "timezone")
2099
2218
  patch["hostnames"] = plain(Js.get(body, "hostnames")) if given?(body, "hostnames") && rl.managed_sites
2100
- site = rl.update_site(decode(raw_id), patch).dup
2219
+ site = rl.update_site(id, patch).dup
2101
2220
  # A connected site answers as the list shows it, so the dashboard keeps its install and domains.
2102
2221
  unless remote.nil?
2103
2222
  site["remote"] = remote["url"]
@@ -2121,7 +2240,8 @@ module Runlight
2121
2240
  share_id = request.headers.get(SHARE_HEADER)
2122
2241
  shared = nil
2123
2242
  # The one site a share or a site's API token may read; nil for every site.
2124
- only = nil
2243
+ scoped = session_scope(request)
2244
+ only = scoped && scoped["site"]
2125
2245
  embed = request.headers.get(EMBED_HEADER)
2126
2246
  if share_id.nil? && !embed.nil?
2127
2247
  token = embed_reader(embed)
@@ -2137,7 +2257,7 @@ module Runlight
2137
2257
  if access != true
2138
2258
  return coded("API tokens can only read", "token_read_only", 403) unless shared_path?(path)
2139
2259
 
2140
- only = access["site"] != "" ? access["site"] : nil
2260
+ only = scoped && scoped["site"] || (access["site"] != "" ? access["site"] : nil)
2141
2261
  end
2142
2262
  else
2143
2263
  shared = share_id.match?(SHARE_ID) ? store.share_by_id(share_id) : nil
@@ -2171,7 +2291,7 @@ module Runlight
2171
2291
  row
2172
2292
  end
2173
2293
  # A share never learns how the install is run.
2174
- return json(shared.nil? ? { "sites" => rows, "managed" => rl.managed_sites } : { "sites" => rows })
2294
+ return json(shared.nil? ? { "sites" => rows, "managed" => !(scoped && scoped["site"]) && rl.managed_sites } : { "sites" => rows })
2175
2295
  end
2176
2296
 
2177
2297
  site = if !shared.nil?
@@ -2189,10 +2309,15 @@ module Runlight
2189
2309
  # each visit, with its time and city, since anyone with the link could watch one person's way through the site.
2190
2310
  unfeed = ->(live) { shared.nil? ? live : live.merge("recent" => []) }
2191
2311
  unless remote.nil?
2192
- answer = pass_through(remote, path, url, request)
2193
- return answer if path != "/api/realtime" || shared.nil? || !answer.ok?
2312
+ live_for_share = path == "/api/realtime" && !shared.nil?
2313
+ answer = pass_through(remote, path, url, request, live_for_share ? REALTIME_MAX_BYTES : nil)
2314
+ return answer if !live_for_share || !answer.ok?
2194
2315
 
2195
- parsed, live = Js.parse_json(answer.text)
2316
+ parsed, live = begin
2317
+ Js.parse_json(Body.read_text_capped(answer, REALTIME_MAX_BYTES))
2318
+ rescue Http::BodyTooLong
2319
+ [false, nil]
2320
+ end
2196
2321
  return json(unfeed.call(live)) if parsed && live.is_a?(Hash)
2197
2322
 
2198
2323
  host = Http::Url.new(remote["url"].to_s).host
@@ -2419,11 +2544,32 @@ module Runlight
2419
2544
  target = pick_target(url.search_params.get("runlight_ticket") || "")
2420
2545
  rl.init unless target.nil?
2421
2546
  hosts = target.nil? ? [] : rl.site(target["site"])&.[]("hostnames")
2422
- script = replace_once(asset("picker.js"), PICK_TARGET_PLACEHOLDER, Json.encode(hosts.nil? || target.nil? ? "" : target["origin"]))
2547
+ named = hosts.nil? ? nil : target
2548
+ back = named&.[]("back")
2549
+ script = replace_once(asset("picker.js"), PICK_TARGET_PLACEHOLDER, Json.encode(named.nil? ? "" : named["origin"]))
2423
2550
  script = replace_once(script, PICK_HOSTS_PLACEHOLDER, Json.encode(Json.encode(hosts || [])))
2551
+ script = replace_once(script, PICK_RETURN_PLACEHOLDER, Json.encode(back.nil? ? "" : back["url"]))
2552
+ script = replace_once(script, PICK_NONCE_PLACEHOLDER, Json.encode(back.nil? ? "" : back["nonce"]))
2424
2553
  return Http::Response.new(script, status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
2425
2554
  end
2426
2555
 
2556
+ # The relay page the picker opens with its choice in the fragment, which never reaches a server. Its
2557
+ # script hands the choice to the dashboard tab on this origin that started the pick, and nothing else.
2558
+ if path == "/pick-return" && method == "GET"
2559
+ translator = Messages.translator(accepted_language(request))
2560
+ t = translator["t"]
2561
+ return small_page(
2562
+ translator["lang"],
2563
+ "<div id=\"done\" hidden><h1>#{escape_html(t.call("pick.doneTitle"))}</h1><p>#{escape_html(t.call("pick.done"))}</p></div>" \
2564
+ "<div id=\"gone\" hidden><h1>#{escape_html(t.call("pick.goneTitle"))}</h1><p>#{escape_html(t.call("pick.gone"))}</p></div>",
2565
+ 200,
2566
+ "#{base}/pick-return.js",
2567
+ )
2568
+ end
2569
+ if path == "/pick-return.js" && method == "GET"
2570
+ return Http::Response.new(asset("pick-return.js"), status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
2571
+ end
2572
+
2427
2573
  if path == "/assets/world.#{build_hash("worldHash")}.json" && method == "GET"
2428
2574
  return Http::Response.new(asset("world.json"), status: 200, headers: { "content-type" => "application/json; charset=utf-8", "cache-control" => "public, max-age=31536000, immutable" })
2429
2575
  end
@@ -2545,11 +2691,13 @@ module Runlight
2545
2691
  end
2546
2692
  # The page itself holds no data; the API it calls checks access and
2547
2693
  # the page explains how to sign in when it is refused.
2548
- return Http::Response.new(dashboard(base, "", @sign_out || "", Js.truthy?(@options["geoCredit"]), !@web.nil?, @sign_in || ""), status: 200, headers: {
2694
+ # Inside a CMS, its admin, on this same origin, shows the whole dashboard in a frame; nothing else may.
2695
+ cms = @options["cms"].to_s
2696
+ return Http::Response.new(dashboard(base, "", @sign_out || "", Js.truthy?(@options["geoCredit"]), !@web.nil?, @sign_in || "", nil, cms), status: 200, headers: {
2549
2697
  "content-type" => "text/html; charset=utf-8",
2550
2698
  "cache-control" => "no-store",
2551
- "content-security-policy" => DASHBOARD_CSP,
2552
- "x-frame-options" => "DENY",
2699
+ "content-security-policy" => cms != "" ? DASHBOARD_CSP.sub("frame-ancestors 'none'", "frame-ancestors 'self'") : DASHBOARD_CSP,
2700
+ "x-frame-options" => cms != "" ? "SAMEORIGIN" : "DENY",
2553
2701
  "referrer-policy" => "same-origin",
2554
2702
  })
2555
2703
  end