runlight 0.1.0 → 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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/assets/build.json +4 -4
  3. data/assets/dashboard.css +1 -1
  4. data/assets/dashboard.js +71 -22
  5. data/assets/locales.json +5 -5
  6. data/assets/pick-return.js +1 -0
  7. data/assets/picker.js +1 -1
  8. data/assets/tracker.js +1 -1
  9. data/lib/runlight/accounts/accounts.rb +90 -19
  10. data/lib/runlight/accounts/throttle.rb +123 -38
  11. data/lib/runlight/accounts/web.rb +50 -14
  12. data/lib/runlight/assistant.rb +40 -8
  13. data/lib/runlight/connect.rb +2 -3
  14. data/lib/runlight/core.rb +148 -34
  15. data/lib/runlight/dates.rb +31 -8
  16. data/lib/runlight/db/connect.rb +32 -6
  17. data/lib/runlight/db/db.rb +129 -9
  18. data/lib/runlight/db/prefixed.rb +92 -0
  19. data/lib/runlight/geo.rb +4 -3
  20. data/lib/runlight/http/fetcher.rb +31 -6
  21. data/lib/runlight/http/request.rb +14 -3
  22. data/lib/runlight/importers/csv_visits.rb +1 -1
  23. data/lib/runlight/importers/index.rb +10 -2
  24. data/lib/runlight/importers/visits.rb +17 -6
  25. data/lib/runlight/importers/write.rb +21 -6
  26. data/lib/runlight/links.rb +14 -9
  27. data/lib/runlight/mail/ses.rb +3 -2
  28. data/lib/runlight/mail/smtp.rb +2 -0
  29. data/lib/runlight/mail/smtp_session.rb +22 -1
  30. data/lib/runlight/mail/transports.rb +19 -2
  31. data/lib/runlight/mcp.rb +8 -1
  32. data/lib/runlight/metadata_address_error.rb +13 -0
  33. data/lib/runlight/oauth.rb +85 -18
  34. data/lib/runlight/rack_app.rb +64 -10
  35. data/lib/runlight/redirect_refused_error.rb +14 -0
  36. data/lib/runlight/routes.rb +306 -144
  37. data/lib/runlight/safefetch.rb +133 -1
  38. data/lib/runlight/server/agents.rb +1 -1
  39. data/lib/runlight/server/cli.rb +1 -1
  40. data/lib/runlight/server/config.rb +64 -19
  41. data/lib/runlight/server/standalone.rb +7 -6
  42. data/lib/runlight/store/sql.rb +10 -0
  43. data/lib/runlight/store/sql_store.rb +75 -13
  44. data/lib/runlight/stores.rb +19 -15
  45. data/lib/runlight/tables_busy_error.rb +11 -0
  46. data/lib/runlight/version.rb +1 -1
  47. data/lib/runlight.rb +4 -0
  48. metadata +7 -2
@@ -18,8 +18,14 @@ module Runlight
18
18
  # - cronSecret: also accepted as a bearer token on POST /api/check. Defaults to CRON_SECRET.
19
19
  # - observeKey: lets another site report AI agent fetches to POST /api/observe. Defaults to RUNLIGHT_OBSERVE_KEY.
20
20
  # - signOut, signIn: links the dashboard shows. The standalone server sets them.
21
- # - accounts: true for sign-in accounts, or a Runlight::Accounts::Web of your own.
21
+ # - accounts: true for sign-in accounts, or a Runlight::Accounts::Web of your own. Sessions are signed with the
22
+ # Core's secret (RUNLIGHT_SECRET, else RUNLIGHT_TOKEN). With the token left nil and no secret, only
23
+ # development makes one up (a restart signs everyone out); anywhere else that raises ArgumentError, since
24
+ # each process of an app would make its own and sign the others' sessions out.
22
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.
23
29
  # - origin: the address people open the app at, such as https://example.com.
24
30
  # - ownHosts: a callable returning more names the dashboard is reached at.
25
31
  # - accountOf: a callable taking the Request and returning the account it comes from, or nil (internal, for the
@@ -44,6 +50,14 @@ module Runlight
44
50
  PICK_TARGET_PLACEHOLDER = '"__RUNLIGHT_PICK_TARGET__"'
45
51
  # Where the hostnames of the site its ticket names go, as JSON inside a string.
46
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/
47
61
  # How long a picker ticket works: long enough to find the element, not to be kept.
48
62
  PICK_TICKET_MS = 30 * 60_000
49
63
  # Questions one person may put to the assistant in an hour, and at once.
@@ -59,10 +73,14 @@ module Runlight
59
73
  PATH_DIMENSIONS = %w[page entry exit ai_page].freeze
60
74
  # runlight.ts's LINK_DOMAIN_CHECK: the path on every link domain that answers when the domain reaches this Runlight.
61
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
62
79
  # runlight.ts's RETENTION_MONTHS: the choices for how long a site keeps its visits.
63
80
  RETENTION_MONTHS = [6, 12, 24, 36, 60].freeze
64
- private_constant :RULES_PLACEHOLDER, :PICK_TARGET_PLACEHOLDER, :PICK_HOSTS_PLACEHOLDER, :SHARE_ID,
65
- :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
66
84
 
67
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'"
68
86
 
@@ -73,9 +91,7 @@ module Runlight
73
91
  EMAIL = /\A[^#{Js::SPACE}@<>"]+@[^#{Js::SPACE}@<>"]+\.[^#{Js::SPACE}@<>"]+\z/
74
92
  # A dashboard's origin, which a picker ticket names.
75
93
  ORIGIN = %r{\Ahttps?://[^/?\##{Js::SPACE}]+\z}
76
- # An address a report's links may point back to.
77
- HOME = %r{\Ahttps?://[^#{Js::SPACE}]+\z}
78
- private_constant :EMAIL, :ORIGIN, :HOME
94
+ private_constant :EMAIL, :ORIGIN
79
95
 
80
96
  # The files in assets/, read once per process.
81
97
  @assets = {}
@@ -118,11 +134,17 @@ module Runlight
118
134
 
119
135
  # Accounts: the standalone server passes its own, and an app turns them on with true. Sessions need a secret
120
136
  # that outlives the process; in development without one, a made-up one does, so a restart signs everyone out.
121
- # An app left open on purpose (token: nil) is treated like development here.
137
+ # An app left open on purpose (token: nil) is treated like development for the first account, but outside
138
+ # development it still needs a real secret: TypeScript runs as one process, where a made-up one only lasts
139
+ # until a restart, but a Ruby app usually runs several, and each would sign the others' sessions out.
122
140
  token = @token
123
141
  open_setup = token.nil? || (token == "" && development?)
124
- account_secret = runlight.secret || (open_setup ? Hashing.random_id(32) : nil)
125
142
  accounts = options["accounts"]
143
+ if accounts == true && token.nil? && runlight.secret.to_s.empty? && !development?
144
+ raise ArgumentError, "Runlight: accounts need a secret every process shares; set RUNLIGHT_SECRET (or pass secret:) to a long random string"
145
+ end
146
+
147
+ account_secret = runlight.secret || (open_setup ? Hashing.random_id(32) : nil)
126
148
  @web = if accounts.is_a?(Accounts::Web)
127
149
  accounts
128
150
  elsif accounts == true && !account_secret.nil? && account_secret != ""
@@ -236,17 +258,22 @@ module Runlight
236
258
  Js.scrub(value).match?(EMAIL)
237
259
  end
238
260
 
239
- # A plain page in a visitor's language, for unsubscribing and for a share link that is gone.
240
- 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 = "")
241
264
  Http::Response.new(
242
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" \
243
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>' \
244
- "#{body}</main></body></html>",
267
+ "#{body}</main>#{script == "" ? "" : "<script src=\"#{escape_html(script)}\"></script>"}</body></html>",
245
268
  status: status,
246
269
  headers: {
247
270
  "content-type" => "text/html; charset=utf-8",
248
271
  "cache-control" => "no-store",
249
- "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,
250
277
  "referrer-policy" => "no-referrer",
251
278
  },
252
279
  )
@@ -366,7 +393,7 @@ module Runlight
366
393
  # The dashboard's page, which holds no data: the API it calls checks access. `embed` is the dashboard inside a
367
394
  # CMS's admin pages: { "session", "origin" }, its session (empty once its ticket was used or ran out) and the
368
395
  # admin origin that frames it.
369
- 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 = "")
370
397
  b = escape_attr(base)
371
398
  hash = build_hash("dashboardHash")
372
399
  attributes = (share != "" ? " data-share=\"#{escape_attr(share)}\"" : "") +
@@ -374,6 +401,7 @@ module Runlight
374
401
  (sign_in != "" ? " data-sign-in=\"#{escape_attr(sign_in)}\"" : "") +
375
402
  (geo_credit ? ' data-geo-credit=""' : "") +
376
403
  (accounts ? ' data-accounts=""' : "") +
404
+ (cms != "" ? " data-cms=\"#{escape_attr(cms)}\"" : "") +
377
405
  (embed.nil? ? "" : " data-embed=\"#{escape_attr(embed["session"])}\" data-embed-origin=\"#{escape_attr(embed["origin"])}\"")
378
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" \
379
407
  "<link rel=\"icon\" href=\"#{Brand.runlight_icon}\">\n" \
@@ -406,12 +434,10 @@ module Runlight
406
434
  Js.scrub(value).match?(ORIGIN)
407
435
  end
408
436
 
409
- # decodeURIComponent, which throws on a broken escape, as the TypeScript does (an internal error there).
410
- def decode(text)
411
- decoded = Js.decode_uri_component(text)
412
- raise ArgumentError, "URI malformed" if decoded.nil?
413
-
414
- 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)
415
441
  end
416
442
 
417
443
  # `String(body[key] ?? fallback)`.
@@ -504,7 +530,7 @@ module Runlight
504
530
  extend Helpers
505
531
  include Helpers
506
532
 
507
- PUBLIC_HELPERS = %i[coded json json? host_name cookie_value read_cookie bearer normalise_base dashboard manage_path].freeze
533
+ PUBLIC_HELPERS = %i[coded json json? host_name cookie_value read_cookie bearer normalise_base dashboard manage_path oauth_document?].freeze
508
534
  private_constant :PUBLIC_HELPERS
509
535
  private(*Helpers.instance_methods(false))
510
536
  private_class_method(*(Helpers.instance_methods(false) - PUBLIC_HELPERS))
@@ -518,6 +544,8 @@ module Runlight
518
544
  if base != "" && oauth_document?(url.pathname)
519
545
  begin
520
546
  return OAuth.oauth_response(@oauth, request, url.pathname, url, context) || coded("Not found", "not_found", 404)
547
+ rescue TablesBusyError
548
+ return tables_busy
521
549
  rescue StandardError => e
522
550
  warn("Runlight: #{e.full_message(highlight: false)}")
523
551
  return coded("Internal error", "internal", 500)
@@ -530,13 +558,16 @@ module Runlight
530
558
 
531
559
  begin
532
560
  route(request, path, url, context)
561
+ rescue TablesBusyError
562
+ tables_busy
533
563
  rescue StandardError => e
534
564
  warn("Runlight: #{e.full_message(highlight: false)}")
535
565
  coded("Internal error", "internal", 500)
536
566
  end
537
567
  end
538
568
 
539
- # The routes as a Rack app. The answer to a HEAD has no body.
569
+ # The routes as a Rack app. The answer to a HEAD has no body. Once the answer is sent, the work left for
570
+ # later (a retention change's deletions) runs in the Core's idle, as RackApp runs it.
540
571
  def call(env)
541
572
  begin
542
573
  request = Http::Request.from_rack(env)
@@ -545,11 +576,21 @@ module Runlight
545
576
  [Json.encode({ "error" => "That request is too large" })]]
546
577
  end
547
578
  status, headers, body = handle(request).to_rack
548
- [status, headers, request.method == "HEAD" ? [] : body]
579
+ idle = lambda do
580
+ @rl.idle
581
+ rescue StandardError => e
582
+ warn("Runlight: #{e.message}")
583
+ end
584
+ [status, headers, RackApp::Body.new(request.method == "HEAD" ? [] : body, idle)]
549
585
  end
550
586
 
551
587
  private
552
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
+
553
594
  def sites
554
595
  @rl.sites
555
596
  end
@@ -672,7 +713,7 @@ module Runlight
672
713
 
673
714
  # Answers a read for a site counted by another install by asking that install,
674
715
  # with its token and its own id for the site, and handing back what it says.
675
- def pass_through(remote, path, url, request = nil)
716
+ def pass_through(remote, path, url, request = nil, max_bytes = nil)
676
717
  target = Http::Url.new("#{remote["url"]}#{path}")
677
718
  query = target.search_params
678
719
  url.search_params.each { |key, value| query.append(key.to_s, value) }
@@ -694,6 +735,8 @@ module Runlight
694
735
  "timeoutMs" => write ? 30_000 : 120_000,
695
736
  }
696
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?
697
740
  answer = Safefetch.owner_fetch(target.href, init, @rl.fetcher, @rl.local_installs)
698
741
  rescue StandardError => e
699
742
  if e.is_a?(Http::FetchError) && e.timed_out?
@@ -764,6 +807,44 @@ module Runlight
764
807
  Http::Response.new(answer.text, status: answer.status, headers: back)
765
808
  end
766
809
 
810
+ # The checks a link domain passes before a site gets it, from Settings or from an import: the name as it
811
+ # is kept, or the refusal to send.
812
+ def check_link_domain(request, url, site, value)
813
+ domain = Js.lower(Js.trim(value))
814
+ domain = domain.sub(%r{\Ahttps?://}, "")
815
+ domain = domain.sub(%r{/.*\z}, "")
816
+ domain = domain.sub(/\.+\z/, "")
817
+ domain = domain.sub(/\Awww\./, "")
818
+ return coded("That is not a domain name", "domain_invalid", 400) unless domain.match?(DOMAIN_NAME)
819
+
820
+ if private_name?(domain) || Safefetch.resolves_privately?(domain)
821
+ return coded("#{domain} is not a public domain name. Use one that browsers anywhere can reach.", "domain_not_public", 400, { "domain" => domain })
822
+ end
823
+
824
+ # A link domain answers every path on it, so it must never be where the dashboard or a counted site lives.
825
+ # The request's own Host is the caller's to choose, so the configured address and the names people
826
+ # signed in from count too. A hub cannot know every name this app answers on, so it adds none until
827
+ # the app knows its own address.
828
+ return origin_needed if @managed.key?(request) && @origin.nil?
829
+
830
+ here = [request.headers.get("host"), request.headers.get("x-forwarded-host"), url.host].select { |h| Js.truthy?(h) }
831
+ own = [*(@origin.nil? ? [] : [Http::Url.new(@origin).host]), *here]
832
+ @options["ownHosts"]&.call&.each { |host| own << host.to_s }
833
+ taken = own.map { |h| host_name(h) }
834
+ sites.each do |s|
835
+ taken.concat(s["hostnames"])
836
+ taken.concat(@rl.remote(s["id"])&.[]("hostnames") || [])
837
+ end
838
+ if taken.include?(domain)
839
+ return coded("#{domain} is where this dashboard or one of your sites lives. Use a separate domain or subdomain for short links, such as go.#{domain}.", "domain_in_use", 400, { "domain" => domain })
840
+ end
841
+
842
+ owner = store.link_domains.find { |d| d["domain"] == domain }
843
+ return coded("#{domain} already belongs to another site", "domain_taken", 409, { "domain" => domain }) if !owner.nil? && owner["site"] != site
844
+
845
+ domain
846
+ end
847
+
767
848
  def links_api(request, path, url)
768
849
  @rl.init
769
850
  site = query_site(url)
@@ -778,49 +859,17 @@ module Runlight
778
859
  body = read_json(request)
779
860
  return body if body.is_a?(Http::Response)
780
861
 
781
- domain = Js.lower(Js.trim(text(body, "domain")))
782
- domain = domain.sub(%r{\Ahttps?://}, "")
783
- domain = domain.sub(%r{/.*\z}, "")
784
- domain = domain.sub(/\.+\z/, "")
785
- domain = domain.sub(/\Awww\./, "")
786
- return coded("That is not a domain name", "domain_invalid", 400) unless domain.match?(DOMAIN_NAME)
787
-
788
- if private_name?(domain) || Safefetch.resolves_privately?(domain)
789
- return coded("#{domain} is not a public domain name. Use one that browsers anywhere can reach.", "domain_not_public", 400, { "domain" => domain })
790
- end
791
-
792
- # A link domain answers every path on it, so it must never be where the dashboard or a counted site lives.
793
- # The request's own Host is the caller's to choose, so the configured address and the names people
794
- # signed in from count too. A hub cannot know every name this app answers on, so it adds none until
795
- # the app knows its own address.
796
- return origin_needed if @managed.key?(request) && @origin.nil?
797
-
798
- here = [request.headers.get("host"), request.headers.get("x-forwarded-host"), url.host].select { |h| Js.truthy?(h) }
799
- own = [*(@origin.nil? ? [] : [Http::Url.new(@origin).host]), *here]
800
- @options["ownHosts"]&.call&.each { |host| own << host.to_s }
801
- taken = own.map { |h| host_name(h) }
802
- sites.each do |s|
803
- taken.concat(s["hostnames"])
804
- taken.concat(@rl.remote(s["id"])&.[]("hostnames") || [])
805
- end
806
- if taken.include?(domain)
807
- return coded("#{domain} is where this dashboard or one of your sites lives. Use a separate domain or subdomain for short links, such as go.#{domain}.", "domain_in_use", 400, { "domain" => domain })
808
- end
862
+ domain = check_link_domain(request, url, site["id"], text(body, "domain"))
863
+ return domain if domain.is_a?(Http::Response)
809
864
 
810
- store.link_domains.each do |d|
811
- next unless d["domain"] == domain
812
- return coded("#{domain} already belongs to another site", "domain_taken", 409, { "domain" => domain }) if d["site"] != site["id"]
813
-
814
- break
815
- end
816
865
  store.add_link_domain(domain, site["id"], @rl.now)
817
866
  @rl.forget_link_domains
818
867
  return json({ "domain" => domain }, 201)
819
868
  end
820
869
  end
821
870
  if (check_match = path.match(%r{\A/api/link-domains/([^/]+)/check\z})) && request.method == "GET"
822
- domain = decode(check_match[1])
823
- 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)
824
873
 
825
874
  # One added before names inside private networks were refused is never fetched.
826
875
  # What the check found, as a code the dashboard says in its own words, beside the English reason.
@@ -841,9 +890,11 @@ module Runlight
841
890
  begin
842
891
  # Only a public address is fetched, whatever the name resolves to now, so the check cannot be pointed
843
892
  # into a private network.
844
- 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)
845
896
  parsed, body = begin
846
- Js.parse_json(answer.text)
897
+ Js.parse_json(Body.read_text_capped(answer, LINK_DOMAIN_MAX_BYTES))
847
898
  rescue StandardError
848
899
  [false, nil]
849
900
  end
@@ -860,8 +911,8 @@ module Runlight
860
911
  end
861
912
 
862
913
  if (domain_match = path.match(%r{\A/api/link-domains/([^/]+)\z})) && request.method == "DELETE"
863
- domain = decode(domain_match[1])
864
- 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)
865
916
 
866
917
  store.remove_link_domain(domain)
867
918
  @rl.forget_link_domains
@@ -903,6 +954,11 @@ module Runlight
903
954
  credentials(Js.get(body, "credentials")),
904
955
  cursor.is_a?(String) ? cursor : nil,
905
956
  done.is_a?(Float) && done.nan? ? 0 : done,
957
+ # An imported link's domain passes the same checks as one added in Settings, or the link comes over without it.
958
+ lambda do |domain|
959
+ checked = check_link_domain(request, url, site["id"], domain)
960
+ checked.is_a?(Http::Response) ? nil : checked
961
+ end,
906
962
  )
907
963
  return json(step)
908
964
  rescue Importers::ImportError => e
@@ -976,40 +1032,41 @@ module Runlight
976
1032
  # The key picker tickets are signed with, made on first use and kept in the database for every process.
977
1033
  def pick_key
978
1034
  @rl.init
979
- saved = store.setting("pick-key")
980
- return saved if !saved.nil? && saved != ""
981
-
982
- made = Hashing.random_id(32)
983
- store.set_setting("pick-key", made)
984
- 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) }
985
1037
  end
986
1038
 
987
- # A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half an hour.
988
- 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)
989
1043
  payload = "#{@rl.now + PICK_TICKET_MS}.#{site.unpack1("H*")}.#{origin.unpack1("H*")}"
1044
+ payload += ".#{back["url"].unpack1("H*")}.#{back["nonce"]}" unless back.nil?
990
1045
  "#{payload}.#{Hashing.hmac(pick_key, payload)}"
991
1046
  end
992
1047
 
993
- # 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.
994
1049
  def pick_target(ticket)
995
- 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/)
996
1051
  return nil if parts.nil? || Js.number(parts[1]) < @rl.now
997
- 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(".")]))
998
1053
 
999
1054
  unhex = ->(text) { Js.scrub([text[0, text.length - (text.length % 2)]].pack("H*")) }
1000
1055
  origin = unhex.call(parts[3])
1001
- 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] } }
1002
1063
  end
1003
1064
 
1004
1065
  # The key embed tickets and sessions are signed with, made on first use and kept in the database for every process.
1005
1066
  def embed_key
1006
1067
  @rl.init
1007
- saved = store.setting("embed-key")
1008
- return saved if !saved.nil? && saved != ""
1009
-
1010
- made = Hashing.random_id(32)
1011
- store.set_setting("embed-key", made)
1012
- 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) }
1013
1070
  end
1014
1071
 
1015
1072
  # An embed token that still exists, for a site that still does.
@@ -1096,10 +1153,10 @@ module Runlight
1096
1153
  return site if site.is_a?(Http::Response)
1097
1154
 
1098
1155
  existing = store.goals(site["id"])
1099
- id = path == "/api/goals" ? nil : decode(path["/api/goals/".length..])
1156
+ id = path == "/api/goals" ? nil : decode_part(path["/api/goals/".length..])
1100
1157
  before = nil
1101
1158
  existing.each { |g| before = g if g["id"] == id }
1102
- 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?)
1103
1160
 
1104
1161
  @trackers = {}
1105
1162
  if request.method == "DELETE"
@@ -1125,7 +1182,7 @@ module Runlight
1125
1182
  if request.method == "GET"
1126
1183
  settings = @rl.mail_settings
1127
1184
  known = settings || {}
1128
- 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"] }
1129
1186
  # Secret fields come back only as "saved", never as their value.
1130
1187
  fields = {}
1131
1188
  saved = []
@@ -1139,16 +1196,23 @@ module Runlight
1139
1196
  end
1140
1197
  # A hub with a manage token learns which service sends the reports and from where, nothing more.
1141
1198
  via_manage = @managed.key?(request)
1142
- return json({
1199
+ answer = {
1143
1200
  "source" => known["source"],
1144
- "service" => known["service"] || "",
1201
+ "service" => known["source"] == "app" ? "" : known["service"] || "",
1145
1202
  "from" => known["from"] || "",
1146
1203
  "fromName" => known["fromName"] || "",
1147
1204
  "fields" => via_manage ? {} : fields,
1148
1205
  "saved" => via_manage ? [] : saved,
1149
1206
  "encrypted" => !@rl.secret.nil?,
1150
- "services" => Mail::Transports::SERVICES,
1151
- })
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)
1152
1216
  end
1153
1217
  if request.method == "PUT"
1154
1218
  body = read_json(request)
@@ -1175,7 +1239,11 @@ module Runlight
1175
1239
  return coded("Set up a mail service first", "mail_unset", 400) if settings.nil?
1176
1240
 
1177
1241
  t = Messages.translator(text(body, "lang", "en"))["t"]
1178
- 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
1179
1247
  @rl.send_mail({
1180
1248
  "to" => to,
1181
1249
  "subject" => t.call("email.test.subject"),
@@ -1208,13 +1276,13 @@ module Runlight
1208
1276
  end
1209
1277
  return coded("A site can send to at most 50 addresses", "report_limit", 400) if existing.length >= 50
1210
1278
 
1211
- # Links in the email point back to the configured address, or else to this dashboard as the
1212
- # browser sees it. A report made from a hub needs the configured address, where its unsubscribe
1213
- # link answers, since the Host its request names is the hub's to choose.
1279
+ # Links in the email point back to the configured address, or else to this dashboard at the address
1280
+ # the request came to. An address in the body is never used, so whoever adds a report cannot point
1281
+ # its links somewhere else. A report made from a hub needs the configured address, where its
1282
+ # unsubscribe link answers, since the Host its request names is the hub's to choose.
1214
1283
  return origin_needed if @managed.key?(request) && @origin.nil?
1215
1284
 
1216
- given = @origin.nil? ? text(body, "origin") : ""
1217
- home = Js.scrub(given).match?(HOME) ? given.sub(%r{/+\z}, "") : "#{@origin || url.origin}#{@base}"
1285
+ home = "#{@origin || url.origin}#{@base}"
1218
1286
  # A period already due counts as sent, so a report added mid-week first goes out on the next Monday, as the form says.
1219
1287
  now = @rl.now
1220
1288
  due = Reports.last_period(frequency, now, site["timezone"])
@@ -1308,8 +1376,8 @@ module Runlight
1308
1376
  return coded("Method not allowed", "method_not_allowed", 405)
1309
1377
  end
1310
1378
 
1311
- id = decode(path["/api/shares/".length..])
1312
- 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
1313
1381
  return coded("Unknown share", "unknown_share", 404) if share.nil? || share["site"] != site["id"]
1314
1382
 
1315
1383
  if request.method == "PATCH"
@@ -1338,39 +1406,72 @@ module Runlight
1338
1406
  # As in TypeScript, the hour's questions are counted in this process; the day's, in the settings.
1339
1407
  def ask_turn(who, owner)
1340
1408
  now = @rl.now
1409
+ # Taken in one step, before anything slow, so questions sent at once each see the others.
1341
1410
  mine = @lock.synchronize do
1342
- held = @asked[who] || { "at" => [], "open" => 0 }
1411
+ held = (@asked[who] ||= { "at" => [], "open" => 0 })
1343
1412
  held["at"] = held["at"].select { |at| now - at < 3_600_000 }
1344
- held
1345
- end
1346
- if mine["at"].length >= ASK_PER_HOUR || mine["open"] >= ASK_AT_ONCE
1347
- 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
1348
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?
1349
1422
 
1423
+ release = -> { @lock.synchronize { mine["open"] -= 1 } }
1350
1424
  unless owner
1351
- daily = viewer_daily
1352
- day = "assistant-asked:#{Time.at(now.div(1000)).utc.strftime("%Y-%m-%d")}"
1353
- counts = Json.decode(store.setting(day) || "{}")
1354
- if (counts[who] || 0) >= daily
1355
- return coded("Viewers can ask #{Js.string(daily)} questions a day. Ask again tomorrow.", "assistant_daily", 429, { "limit" => Js.string(daily) })
1356
- 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?
1357
1438
 
1358
- counts[who] = (counts[who] || 0) + 1
1359
- store.set_setting(day, Json.encode(counts))
1360
- store.settings_starting_with("assistant-asked:").each do |entry|
1361
- 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) })
1362
1440
  end
1363
1441
  end
1442
+ # People who stopped asking are dropped, so the map holds only the last hour's.
1364
1443
  @lock.synchronize do
1365
- mine["at"] << now
1366
- mine["open"] += 1
1367
- @asked[who] = mine
1368
- # People who stopped asking are dropped, so the map holds only the last hour's.
1369
1444
  if @asked.size > 1000
1370
1445
  @asked.delete_if { |_key, value| value["open"].zero? && value["at"].none? { |at| now - at < 3_600_000 } }
1371
1446
  end
1372
1447
  end
1373
- -> { @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
1374
1475
  end
1375
1476
 
1376
1477
  def tokens_api(request, path)
@@ -1394,7 +1495,7 @@ module Runlight
1394
1495
  asked_scope = Js.get(body, "scope")
1395
1496
  scope = %w[manage embed].include?(asked_scope) ? asked_scope : "read"
1396
1497
  return coded("A token that changes settings is for one site. Pick the site.", "token_site", 400) if scope == "manage" && site == ""
1397
- 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 == ""
1398
1499
 
1399
1500
  secret = "#{TOKEN_PREFIX}#{Hashing.random_id(20)}"
1400
1501
  row = { "id" => Hashing.random_id, "name" => name, "site" => site, "scope" => scope, "hash" => Hashing.sha256(secret),
@@ -1508,7 +1609,7 @@ module Runlight
1508
1609
  if path == "/api/embed" && method == "POST"
1509
1610
  token = api_token(request)
1510
1611
  return denied(false) if token.nil?
1511
- 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"
1512
1613
 
1513
1614
  site = token["site"] != "" ? rl.site(token["site"]) : nil
1514
1615
  return coded("Unknown site", "unknown_site", 404) if site.nil?
@@ -1520,7 +1621,15 @@ module Runlight
1520
1621
  parsed = origin?(origin) && Js.length(origin) <= 200 ? Http::Url.parse(origin) : nil
1521
1622
  return coded("Send the admin page's origin, such as https://example.com", "embed_origin", 400) if parsed.nil? || parsed.origin != origin
1522
1623
 
1523
- host = host_name(parsed.host)
1624
+ # Only https and the default port, so a plain http page or another service on the same name cannot frame
1625
+ # the dashboard; a page on this computer, for development, may use http and any port. The name must be
1626
+ # one of the site's own, which are kept without www, so www. is the only difference allowed.
1627
+ local = ["localhost", "127.0.0.1", "[::1]"].include?(parsed.hostname)
1628
+ if !local && (parsed.protocol != "https:" || parsed.port != "")
1629
+ return coded("Send the admin page's origin, such as https://example.com", "embed_origin", 400)
1630
+ end
1631
+
1632
+ host = parsed.hostname.sub(/\Awww\./, "")
1524
1633
  domains = ((rl.remote(site["id"]) || {})["hostnames"] || site["hostnames"] || []).map { |h| host_name(h) }
1525
1634
  unless domains.include?(host)
1526
1635
  return coded("#{host} is not one of this site's domains. Add it to the site's domains in Runlight's settings.", "embed_host", 400, { "host" => host })
@@ -1536,7 +1645,7 @@ module Runlight
1536
1645
  if !token.nil? && token["scope"] == "manage" && manage_path(method, path)
1537
1646
  asked = url.search_params.get("site")
1538
1647
  site_match = path.match(%r{\A/api/sites/([^/]+)\z})
1539
- 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"])
1540
1649
  return coded("Unknown site", "unknown_site", 404)
1541
1650
  end
1542
1651
 
@@ -1621,13 +1730,13 @@ module Runlight
1621
1730
  return site if site.is_a?(Http::Response)
1622
1731
 
1623
1732
  begin
1624
- return json(Importers::Visits.import_csv_visits(rl, site["id"], plain(Js.get(body, "rows"))))
1733
+ return json(Importers::Visits.import_csv_visits(rl, site["id"], plain(Js.get(body, "rows")), Js.get(body, "secret")))
1625
1734
  rescue Importers::ImportError => e
1626
1735
  return refused(e, "import_failed")
1627
1736
  end
1628
1737
  end
1629
1738
 
1630
- # 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.
1631
1740
  if (path == "/api/observe-key" && method == "GET") || (path == "/api/observe-key/new" && method == "POST")
1632
1741
  access = can_read(request)
1633
1742
  return denied(access) if access != true
@@ -1636,13 +1745,7 @@ module Runlight
1636
1745
  site = query_site(url)
1637
1746
  return site if site.is_a?(Http::Response)
1638
1747
 
1639
- name = "observe-key:#{site["id"]}"
1640
- key = path.end_with?("/new") ? nil : store.setting(name)
1641
- if key.nil? || key == ""
1642
- key = "rlo_#{Hashing.random_id(20)}"
1643
- store.set_setting(name, key)
1644
- end
1645
- return json({ "key" => key })
1748
+ return json({ "key" => OAuth.agents_key(rl, site["id"], path.end_with?("/new")) })
1646
1749
  end
1647
1750
 
1648
1751
  # Making, changing, and deleting funnels; reading them is with the other reports.
@@ -1826,13 +1929,25 @@ module Runlight
1826
1929
  origin = text(body, "origin")
1827
1930
  return coded("Send the dashboard's origin, such as https://stats.example.com", "pick_origin", 400) unless origin?(origin)
1828
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
1829
1944
  # A hub's ticket only ever sends to the hub it connected from, never to an origin it names now.
1830
1945
  hub = @managed[request]
1831
1946
  if !hub.nil? && store.setting("token-origin:#{hub["id"]}") != origin
1832
1947
  return coded("This hub's address is not the one it connected from. Connect the site again from here.", "pick_hub", 403)
1833
1948
  end
1834
1949
 
1835
- return json({ "ticket" => pick_ticket(origin, site["id"]) })
1950
+ return json({ "ticket" => pick_ticket(origin, site["id"], back) })
1836
1951
  end
1837
1952
 
1838
1953
  if (path == "/api/goals" && method == "POST") || (path.match?(%r{\A/api/goals/[^/]+\z}) && %w[PATCH DELETE].include?(method))
@@ -1869,8 +1984,11 @@ module Runlight
1869
1984
  access = can_read(request)
1870
1985
  return denied(access) if access != true
1871
1986
 
1987
+ id = decode_part(site_match[1])
1988
+ return coded("Unknown site", "unknown_site", 404) if id.nil?
1989
+
1872
1990
  begin
1873
- rl.delete_site(decode(site_match[1]))
1991
+ rl.delete_site(id)
1874
1992
  return json({ "ok" => true })
1875
1993
  rescue RangeError => e
1876
1994
  return e.message == "Unknown site" ? coded(e.message, "unknown_site", 404) : refused(e, "site_invalid")
@@ -2036,8 +2154,10 @@ module Runlight
2036
2154
  months = RETENTION_MONTHS.join(", ")
2037
2155
  return coded("Keep visits for #{months} months, or forever", "retention_bad", 400, { "months" => months })
2038
2156
  end
2157
+ id = decode_part(raw_id)
2158
+ return coded("Unknown site", "unknown_site", 404) if id.nil?
2159
+
2039
2160
  begin
2040
- id = decode(raw_id)
2041
2161
  remote = rl.remote(id)
2042
2162
  # How long a connected site keeps visits, and the timezone its days follow, are the install's
2043
2163
  # settings: this server passes them on, and changes its own row only once the install took them.
@@ -2058,14 +2178,14 @@ module Runlight
2058
2178
  return answer unless answer.ok?
2059
2179
 
2060
2180
  rl.forget_remote_info(id)
2061
- elsif remote.nil? && !retention.equal?(UNDEFINED)
2062
- rl.set_retention(id, retention.nil? ? nil : Js.number(retention))
2063
2181
  end
2182
+ # update_site checks every field, hostnames too, before it changes anything.
2064
2183
  patch = {}
2184
+ patch["retentionMonths"] = retention.nil? ? nil : Js.number(retention) if remote.nil? && !retention.equal?(UNDEFINED)
2065
2185
  patch["name"] = Js.string(Js.get(body, "name")) if given?(body, "name")
2066
2186
  patch["timezone"] = Js.string(Js.get(body, "timezone")) if given?(body, "timezone")
2067
2187
  patch["hostnames"] = plain(Js.get(body, "hostnames")) if given?(body, "hostnames") && rl.managed_sites
2068
- site = rl.update_site(decode(raw_id), patch).dup
2188
+ site = rl.update_site(id, patch).dup
2069
2189
  # A connected site answers as the list shows it, so the dashboard keeps its install and domains.
2070
2190
  unless remote.nil?
2071
2191
  site["remote"] = remote["url"]
@@ -2153,7 +2273,24 @@ module Runlight
2153
2273
  return coded("Unknown site", "unknown_site", 404) if site.nil? || (!only.nil? && site["id"] != only)
2154
2274
 
2155
2275
  remote = rl.remote(site["id"])
2156
- return pass_through(remote, path, url, request) unless remote.nil?
2276
+ # A share link or an embedded dashboard sees how many are here now and what they read, but not the feed of
2277
+ # each visit, with its time and city, since anyone with the link could watch one person's way through the site.
2278
+ unfeed = ->(live) { shared.nil? ? live : live.merge("recent" => []) }
2279
+ unless remote.nil?
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?
2283
+
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
2289
+ return json(unfeed.call(live)) if parsed && live.is_a?(Hash)
2290
+
2291
+ host = Http::Url.new(remote["url"].to_s).host
2292
+ return coded("Could not reach #{host}", "unreachable", 502, { "host" => host })
2293
+ end
2157
2294
 
2158
2295
  if path == "/api/icon"
2159
2296
  host = site["hostnames"][0]
@@ -2170,7 +2307,7 @@ module Runlight
2170
2307
  })
2171
2308
  end
2172
2309
 
2173
- return json(store.realtime(site["id"], rl.now)) if path == "/api/realtime"
2310
+ return json(unfeed.call(store.realtime(site["id"], rl.now))) if path == "/api/realtime"
2174
2311
 
2175
2312
  read = read_query(url, site)
2176
2313
  return read if read.is_a?(Http::Response)
@@ -2284,10 +2421,12 @@ module Runlight
2284
2421
 
2285
2422
  keys = store.event_prop_keys(query, event)
2286
2423
  asked = params.get("key")
2287
- # A property name goes into a JSON path on SQLite, so quotes and backslashes are refused.
2288
- return coded("Bad property name", "property_bad", 400) if !asked.nil? && !asked.match?(/\A[^"\\]{1,64}\z/)
2424
+ # A property name goes into a JSON path on SQLite, so quotes and backslashes are refused, and without
2425
+ # one asked for, the most used name that has none is shown.
2426
+ safe = ->(name) { name.match?(/\A[^"\\]{1,64}\z/) }
2427
+ return coded("Bad property name", "property_bad", 400) if !asked.nil? && !safe.call(asked)
2289
2428
 
2290
- key = asked || keys.first&.[]("key")
2429
+ key = asked || keys.find { |k| safe.call(k["key"].to_s) }&.[]("key")
2291
2430
  count = limit(params.get("limit"), 100)
2292
2431
  rows = !key.nil? && key != "" ? store.event_prop_values(query, event, key, whole(count)) : []
2293
2432
  return json({ "site" => site["id"], "range" => range_out, "event" => event, "keys" => keys, "key" => key, "rows" => rows })
@@ -2373,11 +2512,32 @@ module Runlight
2373
2512
  target = pick_target(url.search_params.get("runlight_ticket") || "")
2374
2513
  rl.init unless target.nil?
2375
2514
  hosts = target.nil? ? [] : rl.site(target["site"])&.[]("hostnames")
2376
- 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"]))
2377
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"]))
2378
2521
  return Http::Response.new(script, status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
2379
2522
  end
2380
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
+
2381
2541
  if path == "/assets/world.#{build_hash("worldHash")}.json" && method == "GET"
2382
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" })
2383
2543
  end
@@ -2499,11 +2659,13 @@ module Runlight
2499
2659
  end
2500
2660
  # The page itself holds no data; the API it calls checks access and
2501
2661
  # the page explains how to sign in when it is refused.
2502
- 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: {
2503
2665
  "content-type" => "text/html; charset=utf-8",
2504
2666
  "cache-control" => "no-store",
2505
- "content-security-policy" => DASHBOARD_CSP,
2506
- "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",
2507
2669
  "referrer-policy" => "same-origin",
2508
2670
  })
2509
2671
  end