runlight 0.1.1 → 0.1.3

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.
@@ -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
@@ -685,7 +713,7 @@ module Runlight
685
713
 
686
714
  # Answers a read for a site counted by another install by asking that install,
687
715
  # 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)
716
+ def pass_through(remote, path, url, request = nil, max_bytes = nil)
689
717
  target = Http::Url.new("#{remote["url"]}#{path}")
690
718
  query = target.search_params
691
719
  url.search_params.each { |key, value| query.append(key.to_s, value) }
@@ -707,6 +735,8 @@ module Runlight
707
735
  "timeoutMs" => write ? 30_000 : 120_000,
708
736
  }
709
737
  init["body"] = request.text if write
738
+ # One byte more than the most wanted is asked, so an answer past it is seen as such.
739
+ init.merge!("maxBytes" => max_bytes + 1, "truncate" => true) unless max_bytes.nil?
710
740
  answer = Safefetch.owner_fetch(target.href, init, @rl.fetcher, @rl.local_installs)
711
741
  rescue StandardError => e
712
742
  if e.is_a?(Http::FetchError) && e.timed_out?
@@ -838,8 +868,8 @@ module Runlight
838
868
  end
839
869
  end
840
870
  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)
871
+ domain = decode_part(check_match[1])
872
+ return coded("Unknown domain", "unknown_domain", 404) if domain.nil? || !own_domains.call.include?(domain)
843
873
 
844
874
  # One added before names inside private networks were refused is never fetched.
845
875
  # What the check found, as a code the dashboard says in its own words, beside the English reason.
@@ -860,9 +890,11 @@ module Runlight
860
890
  begin
861
891
  # Only a public address is fetched, whatever the name resolves to now, so the check cannot be pointed
862
892
  # into a private network.
863
- answer = Safefetch.public_fetch("https://#{domain}#{LINK_DOMAIN_CHECK}", { "timeoutMs" => 5000 }, @rl.fetcher)
893
+ # Read no further than the check's answer could need; one byte more is asked, so a longer one is seen as such.
894
+ answer = Safefetch.public_fetch("https://#{domain}#{LINK_DOMAIN_CHECK}",
895
+ { "timeoutMs" => 5000, "maxBytes" => LINK_DOMAIN_MAX_BYTES + 1, "truncate" => true }, @rl.fetcher)
864
896
  parsed, body = begin
865
- Js.parse_json(answer.text)
897
+ Js.parse_json(Body.read_text_capped(answer, LINK_DOMAIN_MAX_BYTES))
866
898
  rescue StandardError
867
899
  [false, nil]
868
900
  end
@@ -879,8 +911,8 @@ module Runlight
879
911
  end
880
912
 
881
913
  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)
914
+ domain = decode_part(domain_match[1])
915
+ return coded("Unknown domain", "unknown_domain", 404) if domain.nil? || !own_domains.call.include?(domain)
884
916
 
885
917
  store.remove_link_domain(domain)
886
918
  @rl.forget_link_domains
@@ -1000,40 +1032,41 @@ module Runlight
1000
1032
  # The key picker tickets are signed with, made on first use and kept in the database for every process.
1001
1033
  def pick_key
1002
1034
  @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
1035
+ # Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
1036
+ store.setting_or_add("pick-key") { Hashing.random_id(32) }
1009
1037
  end
1010
1038
 
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)
1039
+ # A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half
1040
+ # an hour. With a return address and a nonce it sends it there, to the relay page, which hands it to the dashboard
1041
+ # tab that made the nonce; without them, as an older dashboard asks, to the tab that opened the site.
1042
+ def pick_ticket(origin, site, back = nil)
1013
1043
  payload = "#{@rl.now + PICK_TICKET_MS}.#{site.unpack1("H*")}.#{origin.unpack1("H*")}"
1044
+ payload += ".#{back["url"].unpack1("H*")}.#{back["nonce"]}" unless back.nil?
1014
1045
  "#{payload}.#{Hashing.hmac(pick_key, payload)}"
1015
1046
  end
1016
1047
 
1017
- # The dashboard origin and site a picker ticket names, or nil when it is not one this install signed or has run out.
1048
+ # What a picker ticket names, or nil when it is not one this install signed or has run out.
1018
1049
  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/)
1050
+ 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
1051
  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]}"))
1052
+ return nil unless constant_time_equal(parts[6], Hashing.hmac(pick_key, ticket[0, ticket.rindex(".")]))
1022
1053
 
1023
1054
  unhex = ->(text) { Js.scrub([text[0, text.length - (text.length % 2)]].pack("H*")) }
1024
1055
  origin = unhex.call(parts[3])
1025
- origin?(origin) ? { "origin" => origin, "site" => unhex.call(parts[2]) } : nil
1056
+ return nil unless origin?(origin)
1057
+ return { "origin" => origin, "site" => unhex.call(parts[2]) } if parts[4].nil?
1058
+
1059
+ address = unhex.call(parts[4])
1060
+ return nil unless address.match?(PICK_RETURN) && address.start_with?("#{origin}/")
1061
+
1062
+ { "origin" => origin, "site" => unhex.call(parts[2]), "back" => { "url" => address, "nonce" => parts[5] } }
1026
1063
  end
1027
1064
 
1028
1065
  # The key embed tickets and sessions are signed with, made on first use and kept in the database for every process.
1029
1066
  def embed_key
1030
1067
  @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
1068
+ # Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
1069
+ store.setting_or_add("embed-key") { Hashing.random_id(32) }
1037
1070
  end
1038
1071
 
1039
1072
  # An embed token that still exists, for a site that still does.
@@ -1120,10 +1153,10 @@ module Runlight
1120
1153
  return site if site.is_a?(Http::Response)
1121
1154
 
1122
1155
  existing = store.goals(site["id"])
1123
- id = path == "/api/goals" ? nil : decode(path["/api/goals/".length..])
1156
+ id = path == "/api/goals" ? nil : decode_part(path["/api/goals/".length..])
1124
1157
  before = nil
1125
1158
  existing.each { |g| before = g if g["id"] == id }
1126
- return coded("Unknown goal", "unknown_goal", 404) if !id.nil? && before.nil?
1159
+ return coded("Unknown goal", "unknown_goal", 404) if path != "/api/goals" && (id.nil? || before.nil?)
1127
1160
 
1128
1161
  @trackers = {}
1129
1162
  if request.method == "DELETE"
@@ -1149,7 +1182,7 @@ module Runlight
1149
1182
  if request.method == "GET"
1150
1183
  settings = @rl.mail_settings
1151
1184
  known = settings || {}
1152
- service = Mail::Transports::SERVICES.find { |s| s["id"] == known["service"] }
1185
+ service = known["source"] == "app" ? nil : Mail::Transports::SERVICES.find { |s| s["id"] == known["service"] }
1153
1186
  # Secret fields come back only as "saved", never as their value.
1154
1187
  fields = {}
1155
1188
  saved = []
@@ -1163,16 +1196,23 @@ module Runlight
1163
1196
  end
1164
1197
  # A hub with a manage token learns which service sends the reports and from where, nothing more.
1165
1198
  via_manage = @managed.key?(request)
1166
- return json({
1199
+ answer = {
1167
1200
  "source" => known["source"],
1168
- "service" => known["service"] || "",
1201
+ "service" => known["source"] == "app" ? "" : known["service"] || "",
1169
1202
  "from" => known["from"] || "",
1170
1203
  "fromName" => known["fromName"] || "",
1171
1204
  "fields" => via_manage ? {} : fields,
1172
1205
  "saved" => via_manage ? [] : saved,
1173
1206
  "encrypted" => !@rl.secret.nil?,
1174
- "services" => Mail::Transports::SERVICES,
1175
- })
1207
+ "services" => @rl.mail_services,
1208
+ }
1209
+ # The app's own mailer, named only when there is one, so the answer without it stays as it was.
1210
+ app = @rl.app_mailer
1211
+ unless app.nil?
1212
+ answer["app"] = { "via" => app["via"], "from" => app["from"], "fromName" => app["fromName"] || "",
1213
+ "replaced" => known["source"] != "app" }
1214
+ end
1215
+ return json(answer)
1176
1216
  end
1177
1217
  if request.method == "PUT"
1178
1218
  body = read_json(request)
@@ -1199,7 +1239,11 @@ module Runlight
1199
1239
  return coded("Set up a mail service first", "mail_unset", 400) if settings.nil?
1200
1240
 
1201
1241
  t = Messages.translator(text(body, "lang", "en"))["t"]
1202
- name = Mail::Transports::SERVICES.find { |s| s["id"] == settings["service"] }&.[]("name") || ""
1242
+ name = if settings["source"] == "app"
1243
+ settings["via"] || ""
1244
+ else
1245
+ Mail::Transports::SERVICES.find { |s| s["id"] == settings["service"] }&.[]("name") || ""
1246
+ end
1203
1247
  @rl.send_mail({
1204
1248
  "to" => to,
1205
1249
  "subject" => t.call("email.test.subject"),
@@ -1332,8 +1376,8 @@ module Runlight
1332
1376
  return coded("Method not allowed", "method_not_allowed", 405)
1333
1377
  end
1334
1378
 
1335
- id = decode(path["/api/shares/".length..])
1336
- share = id.match?(SHARE_ID) ? store.share_by_id(id) : nil
1379
+ id = decode_part(path["/api/shares/".length..])
1380
+ share = !id.nil? && id.match?(SHARE_ID) ? store.share_by_id(id) : nil
1337
1381
  return coded("Unknown share", "unknown_share", 404) if share.nil? || share["site"] != site["id"]
1338
1382
 
1339
1383
  if request.method == "PATCH"
@@ -1362,39 +1406,72 @@ module Runlight
1362
1406
  # As in TypeScript, the hour's questions are counted in this process; the day's, in the settings.
1363
1407
  def ask_turn(who, owner)
1364
1408
  now = @rl.now
1409
+ # Taken in one step, before anything slow, so questions sent at once each see the others.
1365
1410
  mine = @lock.synchronize do
1366
- held = @asked[who] || { "at" => [], "open" => 0 }
1411
+ held = (@asked[who] ||= { "at" => [], "open" => 0 })
1367
1412
  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)
1413
+ if held["at"].length >= ASK_PER_HOUR || held["open"] >= ASK_AT_ONCE
1414
+ nil
1415
+ else
1416
+ held["at"] << now
1417
+ held["open"] += 1
1418
+ held
1419
+ end
1372
1420
  end
1421
+ return coded("You have asked a lot in a short time. Wait a little and ask again.", "assistant_soon", 429) if mine.nil?
1373
1422
 
1423
+ release = -> { @lock.synchronize { mine["open"] -= 1 } }
1374
1424
  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
1425
+ counted = begin
1426
+ count_ask(who, now)
1427
+ rescue StandardError
1428
+ release.call
1429
+ raise
1430
+ end
1431
+ unless counted == true
1432
+ release.call
1433
+ @lock.synchronize do
1434
+ at = mine["at"].index(now)
1435
+ mine["at"].delete_at(at) unless at.nil?
1436
+ end
1437
+ return coded("You have asked a lot in a short time. Wait a little and ask again.", "assistant_soon", 429) if counted.nil?
1381
1438
 
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
1439
+ return coded("Viewers can ask #{Js.string(counted)} questions a day. Ask again tomorrow.", "assistant_daily", 429, { "limit" => Js.string(counted) })
1386
1440
  end
1387
1441
  end
1442
+ # People who stopped asking are dropped, so the map holds only the last hour's.
1388
1443
  @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
1444
  if @asked.size > 1000
1394
1445
  @asked.delete_if { |_key, value| value["open"].zero? && value["at"].none? { |at| now - at < 3_600_000 } }
1395
1446
  end
1396
1447
  end
1397
- -> { @lock.synchronize { mine["open"] -= 1 } }
1448
+ release
1449
+ end
1450
+
1451
+ # Counts a viewer's question in their own row for the day, or gives the daily limit when it is reached. The
1452
+ # row only ever moves from the count read to the next one, so of questions sent at once, by one viewer or by
1453
+ # every process on the database, each count is taken once. True when counted, nil when the row kept changing.
1454
+ def count_ask(who, now)
1455
+ limit = viewer_daily
1456
+ return limit if limit < 1
1457
+
1458
+ day = "assistant-asked:#{Time.at(now.div(1000)).utc.strftime("%Y-%m-%d")}:"
1459
+ key = "#{day}#{who}"
1460
+ 20.times do
1461
+ seen = store.setting(key)
1462
+ if seen.nil?
1463
+ next unless store.add_setting(key, "1")
1464
+
1465
+ # A viewer's first question of the day clears the days before.
1466
+ store.settings_starting_with("assistant-asked:").each do |entry|
1467
+ store.set_setting(entry["key"], nil) unless entry["key"].start_with?(day)
1468
+ end
1469
+ return true
1470
+ end
1471
+ return limit if Js.number(seen) >= limit
1472
+ return true if store.swap_setting(key, seen, Js.string(Js.number(seen) + 1))
1473
+ end
1474
+ nil
1398
1475
  end
1399
1476
 
1400
1477
  def tokens_api(request, path)
@@ -1418,7 +1495,7 @@ module Runlight
1418
1495
  asked_scope = Js.get(body, "scope")
1419
1496
  scope = %w[manage embed].include?(asked_scope) ? asked_scope : "read"
1420
1497
  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 == ""
1498
+ return coded("A dashboard key is for one site. Pick the site.", "embed_site", 400) if scope == "embed" && site == ""
1422
1499
 
1423
1500
  secret = "#{TOKEN_PREFIX}#{Hashing.random_id(20)}"
1424
1501
  row = { "id" => Hashing.random_id, "name" => name, "site" => site, "scope" => scope, "hash" => Hashing.sha256(secret),
@@ -1532,7 +1609,7 @@ module Runlight
1532
1609
  if path == "/api/embed" && method == "POST"
1533
1610
  token = api_token(request)
1534
1611
  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"
1612
+ return coded("Use the site's dashboard key, made in Settings, Install", "embed_token", 403) if token["scope"] != "embed"
1536
1613
 
1537
1614
  site = token["site"] != "" ? rl.site(token["site"]) : nil
1538
1615
  return coded("Unknown site", "unknown_site", 404) if site.nil?
@@ -1568,7 +1645,7 @@ module Runlight
1568
1645
  if !token.nil? && token["scope"] == "manage" && manage_path(method, path)
1569
1646
  asked = url.search_params.get("site")
1570
1647
  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"])
1648
+ if (!asked.nil? && asked != "" && asked != token["site"]) || (!site_match.nil? && decode_part(site_match[1]) != token["site"])
1572
1649
  return coded("Unknown site", "unknown_site", 404)
1573
1650
  end
1574
1651
 
@@ -1659,7 +1736,7 @@ module Runlight
1659
1736
  end
1660
1737
  end
1661
1738
 
1662
- # Each site's key for CMS plugins reporting AI agent fetches: made on first ask, replaced on request.
1739
+ # Each site's agents key, for CMS plugins reporting AI agent fetches: made on first ask, replaced on request.
1663
1740
  if (path == "/api/observe-key" && method == "GET") || (path == "/api/observe-key/new" && method == "POST")
1664
1741
  access = can_read(request)
1665
1742
  return denied(access) if access != true
@@ -1668,13 +1745,7 @@ module Runlight
1668
1745
  site = query_site(url)
1669
1746
  return site if site.is_a?(Http::Response)
1670
1747
 
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 })
1748
+ return json({ "key" => OAuth.agents_key(rl, site["id"], path.end_with?("/new")) })
1678
1749
  end
1679
1750
 
1680
1751
  # Making, changing, and deleting funnels; reading them is with the other reports.
@@ -1858,13 +1929,25 @@ module Runlight
1858
1929
  origin = text(body, "origin")
1859
1930
  return coded("Send the dashboard's origin, such as https://stats.example.com", "pick_origin", 400) unless origin?(origin)
1860
1931
 
1932
+ # Where the picker sends its choice: the relay page on the same origin, and the nonce the dashboard tab
1933
+ # listens for. An older dashboard sends neither and gets a ticket for the tab that opened the site.
1934
+ back = nil
1935
+ if given?(body, "return") || given?(body, "nonce")
1936
+ address = Js.scrub(text(body, "return"))
1937
+ nonce = text(body, "nonce")
1938
+ if address.unpack1("H*").length > 2048 || !address.match?(PICK_RETURN) || !address.start_with?("#{origin}/") || !nonce.match?(PICK_NONCE)
1939
+ 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)
1940
+ end
1941
+
1942
+ back = { "url" => address, "nonce" => nonce }
1943
+ end
1861
1944
  # A hub's ticket only ever sends to the hub it connected from, never to an origin it names now.
1862
1945
  hub = @managed[request]
1863
1946
  if !hub.nil? && store.setting("token-origin:#{hub["id"]}") != origin
1864
1947
  return coded("This hub's address is not the one it connected from. Connect the site again from here.", "pick_hub", 403)
1865
1948
  end
1866
1949
 
1867
- return json({ "ticket" => pick_ticket(origin, site["id"]) })
1950
+ return json({ "ticket" => pick_ticket(origin, site["id"], back) })
1868
1951
  end
1869
1952
 
1870
1953
  if (path == "/api/goals" && method == "POST") || (path.match?(%r{\A/api/goals/[^/]+\z}) && %w[PATCH DELETE].include?(method))
@@ -1901,8 +1984,11 @@ module Runlight
1901
1984
  access = can_read(request)
1902
1985
  return denied(access) if access != true
1903
1986
 
1987
+ id = decode_part(site_match[1])
1988
+ return coded("Unknown site", "unknown_site", 404) if id.nil?
1989
+
1904
1990
  begin
1905
- rl.delete_site(decode(site_match[1]))
1991
+ rl.delete_site(id)
1906
1992
  return json({ "ok" => true })
1907
1993
  rescue RangeError => e
1908
1994
  return e.message == "Unknown site" ? coded(e.message, "unknown_site", 404) : refused(e, "site_invalid")
@@ -2068,8 +2154,10 @@ module Runlight
2068
2154
  months = RETENTION_MONTHS.join(", ")
2069
2155
  return coded("Keep visits for #{months} months, or forever", "retention_bad", 400, { "months" => months })
2070
2156
  end
2157
+ id = decode_part(raw_id)
2158
+ return coded("Unknown site", "unknown_site", 404) if id.nil?
2159
+
2071
2160
  begin
2072
- id = decode(raw_id)
2073
2161
  remote = rl.remote(id)
2074
2162
  # How long a connected site keeps visits, and the timezone its days follow, are the install's
2075
2163
  # settings: this server passes them on, and changes its own row only once the install took them.
@@ -2090,14 +2178,14 @@ module Runlight
2090
2178
  return answer unless answer.ok?
2091
2179
 
2092
2180
  rl.forget_remote_info(id)
2093
- elsif remote.nil? && !retention.equal?(UNDEFINED)
2094
- rl.set_retention(id, retention.nil? ? nil : Js.number(retention))
2095
2181
  end
2182
+ # update_site checks every field, hostnames too, before it changes anything.
2096
2183
  patch = {}
2184
+ patch["retentionMonths"] = retention.nil? ? nil : Js.number(retention) if remote.nil? && !retention.equal?(UNDEFINED)
2097
2185
  patch["name"] = Js.string(Js.get(body, "name")) if given?(body, "name")
2098
2186
  patch["timezone"] = Js.string(Js.get(body, "timezone")) if given?(body, "timezone")
2099
2187
  patch["hostnames"] = plain(Js.get(body, "hostnames")) if given?(body, "hostnames") && rl.managed_sites
2100
- site = rl.update_site(decode(raw_id), patch).dup
2188
+ site = rl.update_site(id, patch).dup
2101
2189
  # A connected site answers as the list shows it, so the dashboard keeps its install and domains.
2102
2190
  unless remote.nil?
2103
2191
  site["remote"] = remote["url"]
@@ -2189,10 +2277,15 @@ module Runlight
2189
2277
  # each visit, with its time and city, since anyone with the link could watch one person's way through the site.
2190
2278
  unfeed = ->(live) { shared.nil? ? live : live.merge("recent" => []) }
2191
2279
  unless remote.nil?
2192
- answer = pass_through(remote, path, url, request)
2193
- return answer if path != "/api/realtime" || shared.nil? || !answer.ok?
2280
+ live_for_share = path == "/api/realtime" && !shared.nil?
2281
+ answer = pass_through(remote, path, url, request, live_for_share ? REALTIME_MAX_BYTES : nil)
2282
+ return answer if !live_for_share || !answer.ok?
2194
2283
 
2195
- parsed, live = Js.parse_json(answer.text)
2284
+ parsed, live = begin
2285
+ Js.parse_json(Body.read_text_capped(answer, REALTIME_MAX_BYTES))
2286
+ rescue Http::BodyTooLong
2287
+ [false, nil]
2288
+ end
2196
2289
  return json(unfeed.call(live)) if parsed && live.is_a?(Hash)
2197
2290
 
2198
2291
  host = Http::Url.new(remote["url"].to_s).host
@@ -2419,11 +2512,32 @@ module Runlight
2419
2512
  target = pick_target(url.search_params.get("runlight_ticket") || "")
2420
2513
  rl.init unless target.nil?
2421
2514
  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"]))
2515
+ named = hosts.nil? ? nil : target
2516
+ back = named&.[]("back")
2517
+ script = replace_once(asset("picker.js"), PICK_TARGET_PLACEHOLDER, Json.encode(named.nil? ? "" : named["origin"]))
2423
2518
  script = replace_once(script, PICK_HOSTS_PLACEHOLDER, Json.encode(Json.encode(hosts || [])))
2519
+ script = replace_once(script, PICK_RETURN_PLACEHOLDER, Json.encode(back.nil? ? "" : back["url"]))
2520
+ script = replace_once(script, PICK_NONCE_PLACEHOLDER, Json.encode(back.nil? ? "" : back["nonce"]))
2424
2521
  return Http::Response.new(script, status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
2425
2522
  end
2426
2523
 
2524
+ # The relay page the picker opens with its choice in the fragment, which never reaches a server. Its
2525
+ # script hands the choice to the dashboard tab on this origin that started the pick, and nothing else.
2526
+ if path == "/pick-return" && method == "GET"
2527
+ translator = Messages.translator(accepted_language(request))
2528
+ t = translator["t"]
2529
+ return small_page(
2530
+ translator["lang"],
2531
+ "<div id=\"done\" hidden><h1>#{escape_html(t.call("pick.doneTitle"))}</h1><p>#{escape_html(t.call("pick.done"))}</p></div>" \
2532
+ "<div id=\"gone\" hidden><h1>#{escape_html(t.call("pick.goneTitle"))}</h1><p>#{escape_html(t.call("pick.gone"))}</p></div>",
2533
+ 200,
2534
+ "#{base}/pick-return.js",
2535
+ )
2536
+ end
2537
+ if path == "/pick-return.js" && method == "GET"
2538
+ return Http::Response.new(asset("pick-return.js"), status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
2539
+ end
2540
+
2427
2541
  if path == "/assets/world.#{build_hash("worldHash")}.json" && method == "GET"
2428
2542
  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
2543
  end
@@ -2545,11 +2659,13 @@ module Runlight
2545
2659
  end
2546
2660
  # The page itself holds no data; the API it calls checks access and
2547
2661
  # 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: {
2662
+ # Inside a CMS, its admin, on this same origin, shows the whole dashboard in a frame; nothing else may.
2663
+ cms = @options["cms"].to_s
2664
+ return Http::Response.new(dashboard(base, "", @sign_out || "", Js.truthy?(@options["geoCredit"]), !@web.nil?, @sign_in || "", nil, cms), status: 200, headers: {
2549
2665
  "content-type" => "text/html; charset=utf-8",
2550
2666
  "cache-control" => "no-store",
2551
- "content-security-policy" => DASHBOARD_CSP,
2552
- "x-frame-options" => "DENY",
2667
+ "content-security-policy" => cms != "" ? DASHBOARD_CSP.sub("frame-ancestors 'none'", "frame-ancestors 'self'") : DASHBOARD_CSP,
2668
+ "x-frame-options" => cms != "" ? "SAMEORIGIN" : "DENY",
2553
2669
  "referrer-policy" => "same-origin",
2554
2670
  })
2555
2671
  end