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
@@ -44,16 +44,22 @@ module Runlight
44
44
  @home_path = "#{@base}/"
45
45
  @asks_for_token = @first.is_a?(Hash) && @first.key?("token")
46
46
  @existing = false
47
- # Wrong passwords are counted twice. Per account and address, ten tries;
47
+ # Wrong passwords are counted three times. Per account and address, ten tries;
48
48
  # per account from anywhere, fifty, so a caller who invents a new address
49
49
  # for every try still cannot guess on and on. Addresses come from
50
- # forwarding headers a client can write, so they never stand alone.
50
+ # forwarding headers a client can write, so they never stand alone. And per
51
+ # address across every account, thirty, so one caller trying made-up emails
52
+ # cannot keep the server hashing; a request with no known address skips that one,
53
+ # since it would hold up everyone at once.
51
54
  # Each try counts before the password is checked, and a right one is taken back.
52
55
  @per_address = Throttle.new(@store, "address", 10)
53
56
  @per_account = Throttle.new(@store, "account", 50)
57
+ @per_ip = Throttle.new(@store, "ip", 30)
54
58
  # Six-digit codes: five wrong tries an account every fifteen minutes, and five to confirm the first one.
55
59
  # Password re-checks in Account: ten.
56
60
  @code_tries = Throttle.new(@store, "code", 5)
61
+ # A decoy ticket's tries count apart, the same way, so someone guessing passwords cannot use up the owner's five.
62
+ @decoy_tries = Throttle.new(@store, "decoy", 5)
57
63
  @confirm_tries = Throttle.new(@store, "confirm", 5)
58
64
  @rechecks = Throttle.new(@store, "recheck", 10)
59
65
  end
@@ -70,21 +76,26 @@ module Runlight
70
76
  # Only a path on this install, so a sign-in can never send someone elsewhere.
71
77
  # Browsers drop tabs and newlines from a URL and read a backslash as a slash,
72
78
  # so "/\t/evil.example" would leave; anything with those is refused outright,
73
- # and what is left must resolve to this origin.
79
+ # and what is left must resolve to this origin. A path that resolves to one
80
+ # starting "//", as "/..//evil.example" does, would leave from the Location
81
+ # header, so that goes home too.
74
82
  def safe_next(value)
75
83
  return @home_path if value.nil? || value == "" || !value.start_with?("/") || value.match?(/[\x00-\x1f\x7f\\]/)
76
84
 
77
85
  url = Http::Url.parse(value, "http://runlight.invalid")
78
- !url.nil? && url.origin == "http://runlight.invalid" ? "#{url.pathname}#{url.search}#{url.hash}" : @home_path
86
+ return @home_path if url.nil? || url.origin != "http://runlight.invalid"
87
+
88
+ path = "#{url.pathname}#{url.search}#{url.hash}"
89
+ path.start_with?("//") || path.start_with?("/\\") ? @home_path : path
79
90
  end
80
91
 
81
92
  def signed_in(request)
82
93
  value = read_cookie(request, Accounts::SESSION_COOKIE)
83
94
  return nil if value == ""
84
95
 
96
+ # A cookie that is not valid percent-encoding is nobody's session, never an error.
85
97
  decoded = Js.decode_uri_component(value)
86
- # decodeURIComponent throws a URIError here in TypeScript.
87
- raise ArgumentError, "URI malformed" if decoded.nil?
98
+ return nil if decoded.nil?
88
99
 
89
100
  @accounts.from_session(decoded, now)
90
101
  end
@@ -143,6 +154,12 @@ module Runlight
143
154
  @now.call
144
155
  end
145
156
 
157
+ # The store's tables, settings among them, which the throttles keep their counts in (TypeScript keeps them
158
+ # in memory), so a fresh install's first sign-in or two-factor change finds them there.
159
+ def ready
160
+ @rl.init if @rl.respond_to?(:init)
161
+ end
162
+
146
163
  def home_origin
147
164
  @home&.call
148
165
  end
@@ -378,6 +395,7 @@ module Runlight
378
395
  end
379
396
 
380
397
  if path == "/login/code" && method == "POST"
398
+ ready
381
399
  form = Http::SearchParams.new(request.text)
382
400
  next_path = safe_next(form.get("next"))
383
401
  pending = @accounts.from_pending(form.get("pending") || "", now)
@@ -386,7 +404,7 @@ module Runlight
386
404
  user = pending["user"]
387
405
  real = pending["real"]
388
406
  # Counted before the check, so a burst cannot get past five.
389
- unless @code_tries.take(user["id"], now)
407
+ unless (real ? @code_tries : @decoy_tries).take(user["id"], now)
390
408
  return html(Pages.code_page(base, { "pending" => form.get("pending") || "", "next" => next_path, "error" => "Too many tries. Wait fifteen minutes and try again." }), 429)
391
409
  end
392
410
  if !real || !@accounts.check_second_factor(user["id"], form.get("code") || "", now)
@@ -394,7 +412,7 @@ module Runlight
394
412
  "error" => "That code is not right. Check the time on your phone, or use a recovery code." }), 401)
395
413
  end
396
414
 
397
- @code_tries.clear(user["id"])
415
+ @code_tries.clear(user["id"], now)
398
416
  return signed_in_to(request, user, next_path)
399
417
  end
400
418
 
@@ -424,6 +442,8 @@ module Runlight
424
442
  @existing = true
425
443
  return redirect(@home_path, { "set-cookie" => session_cookie(request, @accounts.session_for(user, now), Accounts::SESSION_MS / 1000) })
426
444
  rescue RangeError => e
445
+ return html(Pages.invite_gone_page(base), 410) if e.is_a?(AccountError) && e.code == "invite_gone"
446
+
427
447
  return again.call(e.message)
428
448
  end
429
449
  end
@@ -432,6 +452,7 @@ module Runlight
432
452
  end
433
453
 
434
454
  def login(request, context)
455
+ ready
435
456
  base = @base
436
457
  form = Http::SearchParams.new(request.text)
437
458
  email = form.get("email") || ""
@@ -442,6 +463,7 @@ module Runlight
442
463
  pair = "#{account}\n#{ip == "" ? "unknown" : ip}"
443
464
  login_page = ->(opts) { Pages.login_page(base, opts.merge("next" => next_path, "forgot" => @forgot) { |_k, mine, _theirs| mine }) }
444
465
  too_many = -> { html(login_page.call({ "error" => "Too many tries. Wait fifteen minutes and try again.", "email" => email }), 429) }
466
+ return too_many.call if ip != "" && !@per_ip.take(ip, now)
445
467
  return too_many.call unless @per_address.take(pair, now)
446
468
 
447
469
  # A browser that signed in to the account before is never held up by others' failures.
@@ -474,12 +496,18 @@ module Runlight
474
496
 
475
497
  return html(login_page.call({ "error" => "That email and password do not match an account.", "email" => email }), 401)
476
498
  end
477
- @per_address.clear(pair)
478
- @per_account.forgive(account) if !over && !trusted
499
+ @per_address.clear(pair, now)
500
+ @per_ip.forgive(ip, now) if ip != ""
501
+ @per_account.forgive(account, now) if !over && !trusted
479
502
  # With two-factor on, the password only earns the second step.
480
503
  return html(Pages.code_page(base, { "pending" => @accounts.pending_for(user, now), "next" => next_path })) if user["twoFactor"]
481
504
 
482
505
  signed_in_to(request, user, next_path)
506
+ rescue AccountError => e
507
+ # Too many password checks already waiting, so this one was never run: try again in a moment.
508
+ raise unless e.code == "busy"
509
+
510
+ html(login_page.call({ "error" => e.message, "email" => email }), 503, { "retry-after" => "5" })
483
511
  end
484
512
 
485
513
  # Your own account, and for the owner and admins, everyone else's.
@@ -487,6 +515,7 @@ module Runlight
487
515
  user = signed_in(request)
488
516
  return coded("Sign in first", "sign_in", 401) if user.nil?
489
517
 
518
+ ready
490
519
  now = self.now
491
520
  method = request.method
492
521
  # Writes must be JSON, which a form on another page cannot send, even those with no body.
@@ -495,9 +524,16 @@ module Runlight
495
524
 
496
525
  recheck = lambda do |input, name, wrong|
497
526
  return coded("Too many tries. Wait fifteen minutes and try again.", "too_many_tries", 429) unless @rechecks.take(user["id"], now)
498
- return coded(wrong[0], wrong[1], 400) if @accounts.sign_in(user["email"], field(input, name)).nil?
527
+ begin
528
+ right = @accounts.sign_in(user["email"], field(input, name))
529
+ rescue AccountError => e
530
+ raise unless e.code == "busy"
531
+
532
+ return coded("Runlight is busy checking other sign-ins. Try again in a moment.", "busy", 503)
533
+ end
534
+ return coded(wrong[0], wrong[1], 400) if right.nil?
499
535
 
500
- @rechecks.forgive(user["id"])
536
+ @rechecks.forgive(user["id"], now)
501
537
  nil
502
538
  end
503
539
  fresh = ->(updated) { { "set-cookie" => session_cookie(request, @accounts.session_for(updated, now), Accounts::SESSION_MS / 1000) } }
@@ -532,7 +568,7 @@ module Runlight
532
568
  codes = @accounts.confirm_two_factor(user["id"], field(input, "code").gsub(/[#{Js::SPACE}]/o, ""), now)
533
569
  return coded("That code is not right. Check the time on your phone and try the next one.", "code_wrong", 400) if codes.nil?
534
570
 
535
- @confirm_tries.clear(user["id"])
571
+ @confirm_tries.clear(user["id"], now)
536
572
  # Turning it on signs out every other browser; this one gets a new session.
537
573
  updated = @accounts.by_id(user["id"])
538
574
  return reply({ "recovery" => codes }, 200, fresh.call(updated))
@@ -541,7 +577,7 @@ module Runlight
541
577
  return refused unless refused.nil?
542
578
 
543
579
  if action == "/start"
544
- @confirm_tries.clear(user["id"])
580
+ @confirm_tries.clear(user["id"], now)
545
581
  secret = @accounts.start_two_factor(user["id"])
546
582
  return reply({ "secret" => secret, "uri" => Crypto.otpauth_uri(secret, user["email"], Http::Url.new(request.url).host) })
547
583
  end
@@ -85,24 +85,52 @@ module Runlight
85
85
  message.is_a?(String) ? message : ""
86
86
  end
87
87
 
88
+ # A service at a link-local or cloud metadata address, refused there or at a redirect. One on this computer
89
+ # (Ollama, LM Studio) or a private network is allowed, as the person setting it up means it to be.
90
+ def refused(error)
91
+ AssistantError.new(error.message, "address_refused", { "host" => error.host })
92
+ end
93
+
94
+ # A service that sent the request to another origin, which would have carried the key, and the question with it.
95
+ def redirected(error)
96
+ AssistantError.new(error.message, "redirect_refused", { "host" => error.host, "to" => error.to })
97
+ end
98
+
99
+ # The most of a model's answer, or its list of models, read; past it the answer counts as unreadable. One byte
100
+ # more is asked of the fetcher, which hands back what it read, so that an answer past the limit is seen as such.
101
+ MAX_ANSWER_BYTES = 4 * 1024 * 1024
102
+
103
+ # The answer's JSON, or nil when it is not JSON or runs past MAX_ANSWER_BYTES.
104
+ def read_answer(answer)
105
+ parsed, data = Js.parse_json(Body.read_text_capped(answer, MAX_ANSWER_BYTES))
106
+ parsed ? data : nil
107
+ rescue Http::BodyTooLong
108
+ nil
109
+ end
110
+
88
111
  def post(fetcher, url, headers, body, deadline, now, cancelled)
89
112
  in_time(deadline, now, cancelled)
90
113
  left = deadline - now.call
91
114
  begin
92
- answer = fetcher.fetch(url, {
115
+ answer = Safefetch.service_fetch(url, {
93
116
  "method" => "POST",
94
117
  "headers" => { "content-type" => "application/json" }.merge(headers),
95
118
  "body" => Json.encode(body),
96
119
  "timeoutMs" => [90_000, left].min,
97
- })
120
+ "maxBytes" => MAX_ANSWER_BYTES + 1,
121
+ "truncate" => true,
122
+ }, fetcher)
123
+ rescue MetadataAddressError => e
124
+ raise refused(e)
125
+ rescue RedirectRefusedError => e
126
+ raise redirected(e)
98
127
  rescue StandardError => e
99
128
  host = Http::Url.new(url).host
100
129
  raise AssistantError.new("Could not reach #{host}: it took too long to answer", "assistant_timeout", { "host" => host }) if e.is_a?(Http::FetchError) && e.timed_out?
101
130
 
102
131
  raise AssistantError.new("Could not reach #{host}: the connection failed", "unreachable", { "host" => host })
103
132
  end
104
- parsed, data = Js.parse_json(answer.text)
105
- data = nil unless parsed
133
+ data = read_answer(answer)
106
134
  unless answer.ok?
107
135
  message = service_message(data)
108
136
  host = Http::Url.new(url).host
@@ -294,13 +322,17 @@ module Runlight
294
322
  else {}
295
323
  end
296
324
  begin
297
- answer = fetcher.fetch("#{base}/models#{provider["protocol"] == "anthropic" ? "?limit=100" : ""}", { "headers" => headers, "timeoutMs" => 20_000 })
325
+ answer = Safefetch.service_fetch("#{base}/models#{provider["protocol"] == "anthropic" ? "?limit=100" : ""}", { "headers" => headers, "timeoutMs" => 20_000, "maxBytes" => MAX_ANSWER_BYTES + 1, "truncate" => true },
326
+ fetcher)
327
+ rescue MetadataAddressError => e
328
+ raise refused(e)
329
+ rescue RedirectRefusedError => e
330
+ raise redirected(e)
298
331
  rescue StandardError
299
332
  host = Http::Url.new(base).host
300
333
  raise AssistantError.new("Could not reach #{host}", "unreachable", { "host" => host })
301
334
  end
302
- parsed, data = Js.parse_json(answer.text)
303
- data = nil unless parsed
335
+ data = read_answer(answer)
304
336
  unless answer.ok?
305
337
  message = service_message(data)
306
338
  host = Http::Url.new(base).host
@@ -378,6 +410,6 @@ module Runlight
378
410
  end
379
411
 
380
412
  private_class_method :provider, :system_prompt, :in_time, :service_message, :post, :tool_text, :clock, :first_message, :unreadable, :blank?,
381
- :locale_compare, :collation_key
413
+ :locale_compare, :collation_key, :refused
382
414
  end
383
415
  end
@@ -136,9 +136,8 @@ module Runlight
136
136
  def finish_connect(runlight, params)
137
137
  state = params.get("state") || ""
138
138
  key = "connect:#{state}"
139
- stored = state.match?(/\A[a-f0-9]{32}\z/) ? runlight.store.setting(key) : nil
140
- # Each attempt works once.
141
- runlight.store.set_setting(key, nil) if !stored.nil? && stored != ""
139
+ # Each attempt works once, even when it comes back twice at once.
140
+ stored = state.match?(/\A[a-f0-9]{32}\z/) ? runlight.store.take_setting(key) : nil
142
141
  pending = !stored.nil? && stored != "" ? pending_from(stored, runlight.now) : nil
143
142
  if pending.nil?
144
143
  raise ConnectError.new("That connection took too long or was already used. Start again.", "expired")
data/lib/runlight/core.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  module Runlight
4
6
  # Runlight in an app: the sites it counts, the tracker endpoint's work, short links, email reports, and the
5
7
  # scheduled upkeep. A port of the TypeScript SDK's runlight.ts; `Runlight.new(options)` makes one.
@@ -22,8 +24,16 @@ module Runlight
22
24
  # in front of. Left unset, it warns once when a request comes straight from a public address with none of
23
25
  # those headers.
24
26
  # - linkPath: where short links on the app's own domain live, as `{linkPath}/{slug}`. Default "/go".
25
- # - mail: the mail service for email reports, in code (a Transports config plus `from` and `fromName`).
26
- # When set, the dashboard shows it and cannot change it. Otherwise it is set up in Settings.
27
+ # - mail: how email reports, invites, and sign-in links are sent. Either a mail service in code (a Transports
28
+ # config plus `from` and `fromName`), which the dashboard shows and cannot change, or a mailer of the app's
29
+ # own, such as a CMS's: `{ send: ->(message) { ... }, from:, from_name:, via: }`. The app's mailer is a
30
+ # default: a service saved in the dashboard takes its place, and removing that service goes back to it.
31
+ # `via` names it there, such as "WordPress (WP Mail SMTP)". The message has to, from, fromName (when there
32
+ # is one), subject, html, text, and headers; `send` raises to say it failed. Without either, the service is
33
+ # set up in Settings.
34
+ # - mailServices: the ids of the mail services Settings offers and saves, such as every service but "smtp" in
35
+ # an app that cannot keep connections of its own open. Default every service. A service saved earlier that
36
+ # is not in the list counts as none, so the app's mailer, if there is one, sends instead.
27
37
  # - secret: encrypts the keys kept in the database: the mail service's, the AI Assistant's, and the tokens
28
38
  # for connected installs. Default the RUNLIGHT_SECRET environment variable, then RUNLIGHT_TOKEN.
29
39
  # - rateLimit: tracker requests allowed per visitor address per minute. Default 120, which a real visitor
@@ -59,7 +69,9 @@ module Runlight
59
69
  # On a database that caps statements per request (Cloudflare D1), fewer days a check, about 30 statements.
60
70
  METERED_ROLLUP_BATCH = 4
61
71
  ROLLUP_DELAY_MS = 2 * 3_600_000
62
- private_constant :ROLLUP_VERSION, :ROLLUP_BATCH, :REMOTE_MAX_BYTES, :METERED_ROLLUP_BATCH, :ROLLUP_DELAY_MS
72
+ # The longest a tracker hit waits for the tables while another process creates or upgrades them.
73
+ TRACKER_WAIT_MS = 10_000
74
+ private_constant :ROLLUP_VERSION, :ROLLUP_BATCH, :REMOTE_MAX_BYTES, :METERED_ROLLUP_BATCH, :ROLLUP_DELAY_MS, :TRACKER_WAIT_MS
63
75
 
64
76
  BUSY = /timeout exceeded when trying to connect|connection timeout|no MySQL connection was free|SQLITE_BUSY|database is locked/i
65
77
  private_constant :BUSY
@@ -76,7 +88,12 @@ module Runlight
76
88
  # Whether a connected install may be on this machine, at http://localhost or http://127.0.0.1.
77
89
  :local_installs,
78
90
  # Every outgoing request goes through it.
79
- :fetcher
91
+ :fetcher,
92
+ # The app's own mailer, used while no service is saved in the dashboard: { "send", "from",
93
+ # "fromName" (when there is one), "via" }. Nil when there is none.
94
+ :app_mailer,
95
+ # The mail services Settings offers and saves: every one, unless `mailServices` names fewer.
96
+ :mail_services
80
97
 
81
98
  # Where routes() serves the dashboard and API, which a link domain leaves alone, as a list without
82
99
  # repeats. Middleware often runs apart from the routes, where none were made, so the default "/runlight"
@@ -119,7 +136,30 @@ module Runlight
119
136
  @fetcher = options["fetcher"] || Http::NetFetcher.new
120
137
  @links = Links.new(self)
121
138
  @link_path = "/#{(options["linkPath"].nil? ? "/go" : options["linkPath"]).to_s.gsub(%r{\A/+|/+\z}, "")}"
122
- @mail_in_code = options["mail"]
139
+ mail = options["mail"]
140
+ if mail.is_a?(Hash) && mail["send"].respond_to?(:call)
141
+ unless Js.string(mail["from"].nil? ? "" : mail["from"]).match?(EMAIL)
142
+ raise ArgumentError, "Runlight: the app's mailer needs a from address, like reports@example.com"
143
+ end
144
+
145
+ @app_mailer = { "send" => mail["send"], "from" => mail["from"].to_s }
146
+ @app_mailer["fromName"] = mail["fromName"].to_s unless mail["fromName"].nil? || mail["fromName"].to_s == ""
147
+ @app_mailer["via"] = Js.string(mail["via"].nil? ? "" : mail["via"])
148
+ @app_mailer.freeze
149
+ @mail_in_code = nil
150
+ else
151
+ @app_mailer = nil
152
+ @mail_in_code = mail
153
+ end
154
+ offered = options["mailServices"]&.then { |ids| Array(ids).map(&:to_s) }
155
+ offered&.each do |id|
156
+ unless Mail::Transports::SERVICES.any? { |s| s["id"] == id }
157
+ raise ArgumentError, "Runlight: mail_services names \"#{id}\", which is not a mail service"
158
+ end
159
+ end
160
+ @mail_services = if offered.nil? then Mail::Transports::SERVICES
161
+ else Mail::Transports::SERVICES.select { |s| offered.include?(s["id"]) }.freeze
162
+ end
123
163
  @local_installs = options["localInstalls"] ? true : false
124
164
  @secret = if options.key?("secret") && !options["secret"].nil?
125
165
  options["secret"].to_s
@@ -132,6 +172,11 @@ module Runlight
132
172
  @remote_seen = {}
133
173
  @overrides = {}
134
174
  @ready = false
175
+ # init runs once however many threads ask at once; reentrant, as init may ask for itself.
176
+ @init_lock = Monitor.new
177
+ # The thread running init for tracker hits, which wait for it a moment at most.
178
+ @init_thread = nil
179
+ @init_thread_lock = Mutex.new
135
180
  @checking = false
136
181
  # When planner statistics were last gathered.
137
182
  @optimized_at = 0
@@ -153,18 +198,27 @@ module Runlight
153
198
  @clock.call.to_i
154
199
  end
155
200
 
156
- # The mail service: from code, or as saved in the dashboard. Nil when there is none.
201
+ # The mail Runlight sends with: a service from code, or as saved in the
202
+ # dashboard, or else the app's own mailer, as service "app" with its
203
+ # `via`. Nil when there is none.
157
204
  def mail_settings
158
205
  return @mail_in_code.merge("source" => "code") unless @mail_in_code.nil?
159
206
 
160
207
  init
161
208
  sealed = @store.setting("mail")
162
- return nil if sealed.nil? || sealed == ""
209
+ opened = sealed.nil? || sealed == "" ? nil : Mail::Secret.unseal(sealed, @secret)
210
+ saved = opened.nil? || opened == "" ? nil : Json.decode(opened)
211
+ # A service no longer offered counts as none, so it is never sent through.
212
+ if saved && (@mail_services.equal?(Mail::Transports::SERVICES) || @mail_services.any? { |s| s["id"] == saved["service"] })
213
+ return saved.merge("source" => "dashboard")
214
+ end
163
215
 
164
- opened = Mail::Secret.unseal(sealed, @secret)
165
- return nil if opened.nil? || opened == ""
216
+ app = @app_mailer
217
+ return nil if app.nil?
166
218
 
167
- Json.decode(opened).merge("source" => "dashboard")
219
+ settings = { "service" => "app", "from" => app["from"] }
220
+ settings["fromName"] = app["fromName"] if app.key?("fromName")
221
+ settings.merge("via" => app["via"], "source" => "app")
168
222
  end
169
223
 
170
224
  # Saves the mail service from the dashboard. A secret field left blank
@@ -176,9 +230,13 @@ module Runlight
176
230
  @store.set_setting("mail", nil)
177
231
  return
178
232
  end
179
- before = mail_settings || {}
233
+ current = mail_settings
234
+ before = current && current["source"] == "dashboard" ? current : {}
180
235
  service = Mail::Transports::SERVICES.find { |s| s["id"] == input["service"] }
181
236
  raise Mail::MailError.new("Pick a mail service", "mail_service", {}) if service.nil?
237
+ unless @mail_services.include?(service)
238
+ raise Mail::MailError.new("#{service["name"]} is not offered here. Pick another mail service", "mail_service", { "service" => service["id"] })
239
+ end
182
240
 
183
241
  settings = { "service" => service["id"] }
184
242
  service["fields"].each do |f|
@@ -211,17 +269,33 @@ module Runlight
211
269
  config = settings.merge("from" => from)
212
270
  config["fromName"] = from_name unless from_name == ""
213
271
  Mail::Transports.check_config(config)
272
+ if config["service"] == "smtp" && Js.truthy?(config["host"]) && Safefetch.metadata_host?(config["host"].to_s)
273
+ raise Mail::MailError.new("#{config["host"]} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused",
274
+ { "host" => config["host"] })
275
+ end
214
276
  @store.set_setting("mail", Mail::Secret.seal(Json.encode(config), @secret))
215
277
  end
216
278
 
217
- # Sends one email through the mail service: { "to", "subject", "html", "text", "headers" (optional) }.
279
+ # Sends one email through the mail service, or the app's own mailer when no service is saved: { "to",
280
+ # "subject", "html", "text", "headers" (optional) }.
218
281
  def send_mail(message)
219
282
  settings = mail_settings
220
283
  raise Mail::MailError.new("Set up a mail service first", "mail_unset", {}) if settings.nil?
221
284
 
222
285
  full = message.merge("from" => settings["from"])
223
286
  full["fromName"] = settings["fromName"] unless settings["fromName"].nil?
224
- Mail::Transports.deliver(settings, full, @fetcher, now)
287
+ return Mail::Transports.deliver(settings, full, @fetcher, now) unless settings["source"] == "app"
288
+
289
+ begin
290
+ @app_mailer["send"].call(full.merge("headers" => message["headers"] || {}))
291
+ rescue Mail::MailError
292
+ raise
293
+ rescue StandardError => e
294
+ detail = e.message
295
+ via = settings["via"]
296
+ raise Mail::MailError.new("#{via} could not send it#{": #{detail}" unless detail == ""}", "mail_app_failed",
297
+ { "via" => via, "detail" => detail })
298
+ end
225
299
  end
226
300
 
227
301
  # Sends every report that is due: last week's on Monday from 8am, last
@@ -275,6 +349,30 @@ module Runlight
275
349
  def init
276
350
  return if @ready
277
351
 
352
+ @init_lock.synchronize { init_now unless @ready }
353
+ end
354
+
355
+ # Whether init finished within `ms`; false while it is still waiting, such as on another process creating
356
+ # the tables, and it goes on in a thread of its own for later requests. Its errors are raised.
357
+ private def ready_within(ms)
358
+ return true if @ready
359
+
360
+ runner = @init_thread_lock.synchronize do
361
+ # One that failed is started again, as init is by the next request after a failure.
362
+ @init_thread = nil unless @init_thread&.alive? || @init_thread&.status == false
363
+ @init_thread ||= Thread.new { init }.tap { |t| t.report_on_exception = false }
364
+ end
365
+ return false if runner.join(ms / 1000.0).nil?
366
+
367
+ true
368
+ end
369
+
370
+ # Whether init has run, so middleware can leave the app's own requests alone until it has.
371
+ def ready?
372
+ @ready
373
+ end
374
+
375
+ private def init_now
278
376
  @store.migrate
279
377
  # A database that never had its statistics gathered gets them now, before any report is read, rather
280
378
  # than at the first scheduled check, which an app may never run.
@@ -434,9 +532,11 @@ module Runlight
434
532
  nil
435
533
  end
436
534
 
437
- # Changes a site's name or timezone from the dashboard. Stored apart from
535
+ # Changes a site's name, timezone, or retention from the dashboard. Stored apart from
438
536
  # the settings in code, which keep being written on every start. A managed
439
537
  # site has no settings in code, so its changes, hostnames too, go to its row.
538
+ # Every field is checked before anything changes, since a new timezone clears the
539
+ # built days and a shorter retention deletes visits, and pruning starts only once saved.
440
540
  # A key left out of `patch` is left alone, as TS's undefined is.
441
541
  def update_site(id, patch)
442
542
  patch = Options.normalize(patch)
@@ -451,6 +551,7 @@ module Runlight
451
551
 
452
552
  nxt["name"] = name
453
553
  end
554
+ timezone = nil
454
555
  if given?(patch, "timezone")
455
556
  timezone = Js.string(patch["timezone"])
456
557
  unless Dates.timezone?(timezone)
@@ -458,16 +559,20 @@ module Runlight
458
559
  end
459
560
 
460
561
  nxt["timezone"] = timezone
461
- zone_changed(id, timezone) if timezone != site(id)&.[]("timezone")
462
562
  end
563
+ if @managed_sites && given?(patch, "hostnames") && !@remotes.key?(id)
564
+ nxt["hostnames"] = hostnames_for(patch["hostnames"], id)
565
+ end
566
+ check_retention(id, patch["retentionMonths"]) if given?(patch, "retentionMonths")
567
+ zone_changed(id, timezone) if !timezone.nil? && timezone != site(id)&.[]("timezone")
463
568
  if @managed_sites
464
- nxt["hostnames"] = hostnames_for(patch["hostnames"], id) if given?(patch, "hostnames") && !@remotes.key?(id)
465
569
  @store.upsert_site(nxt, now)
466
570
  @configured = @configured.map { |site| site["id"] == id ? nxt : site }
467
- return site(id)
571
+ else
572
+ @store.set_site_overrides(id, nxt)
573
+ @overrides[id] = nxt
468
574
  end
469
- @store.set_site_overrides(id, nxt)
470
- @overrides[id] = nxt
575
+ set_retention(id, patch["retentionMonths"]) if given?(patch, "retentionMonths")
471
576
  site(id)
472
577
  end
473
578
 
@@ -480,13 +585,7 @@ module Runlight
480
585
  # Sets how many months of visits a site keeps. Deleting a long history takes a while, so it runs in
481
586
  # pieces in idle(), after the answer, with tracking going on between them, as TS runs it after answering.
482
587
  def set_retention(site, months)
483
- raise SettingsError.new("Unknown site", "unknown_site") if site(site).nil? || @remotes.key?(site)
484
-
485
- unless months.nil? || (months.is_a?(Numeric) && RETENTION_MONTHS.include?(months))
486
- list = RETENTION_MONTHS.join(", ")
487
- raise SettingsError.new("Keep visits for #{list} months, or forever", "retention_bad", { "months" => list })
488
- end
489
-
588
+ check_retention(site, months)
490
589
  @store.set_setting("retention:#{site}", months.nil? ? nil : Json.number(months))
491
590
  @pruning << site
492
591
  nil
@@ -601,6 +700,10 @@ module Runlight
601
700
  if parsed.nil? || (parsed.protocol != "https:" && parsed.protocol != "http:")
602
701
  raise SettingsError.new("Enter the service's address, starting with https://", "assistant_address_bad")
603
702
  end
703
+ if Safefetch.metadata_host?(parsed.hostname)
704
+ raise SettingsError.new("#{parsed.hostname} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused",
705
+ { "host" => parsed.hostname })
706
+ end
604
707
  end
605
708
  raise SettingsError.new("Enter the service's address", "assistant_address") if base_url == "" && provider["baseUrl"] == ""
606
709
 
@@ -710,6 +813,10 @@ module Runlight
710
813
  return if !Ua.ai_agent(ua).nil? || Ua.bot?(ua)
711
814
  return if !@limit.nil? && !@limit.allow(client_ip(request, context))
712
815
 
816
+ # While another process creates or upgrades the tables (an index on a big table takes a while), a hit
817
+ # waits a moment at most and is then dropped, rather than holding its request until that is done.
818
+ return unless ready_within(TRACKER_WAIT_MS)
819
+
713
820
  # A database too busy to take the hit right now (every pooled connection held by long reports, or
714
821
  # another process writing the SQLite file) gets it a little later, at the time it arrived.
715
822
  at = now
@@ -737,7 +844,8 @@ module Runlight
737
844
  def link_handler
738
845
  lambda do |request, context = {}|
739
846
  path = Http::Url.new(request.url).pathname
740
- slug = path.start_with?("#{@link_path}/") ? decode(path[(@link_path.length + 1)..]) : ""
847
+ # A slug with broken escapes is one no link has.
848
+ slug = path.start_with?("#{@link_path}/") ? Js.decode_uri_component(path[(@link_path.length + 1)..]) || "" : ""
741
849
  found = slug != "" && !slug.include?("/") ? redirect(request, slug, "", context) : nil
742
850
  found || not_found
743
851
  end
@@ -764,7 +872,7 @@ module Runlight
764
872
  (@route_bases.empty? ? ["/runlight"] : @route_bases).each do |base|
765
873
  return nil if base != "/" && (url.pathname == base || url.pathname.start_with?("#{base}/"))
766
874
  end
767
- slug = decode(url.pathname[1..].to_s)
875
+ slug = Js.decode_uri_component(url.pathname[1..].to_s) || ""
768
876
  found = slug != "" && !slug.include?("/") ? redirect(request, slug, host, context) : nil
769
877
  found || not_found
770
878
  end
@@ -933,11 +1041,6 @@ module Runlight
933
1041
  Http::Response.new("Not found", status: 404, headers: { "content-type" => "text/plain; charset=utf-8" })
934
1042
  end
935
1043
 
936
- # decodeURIComponent, raising where it throws a URIError.
937
- def decode(text)
938
- Js.decode_uri_component(text) || raise(ArgumentError, "URI malformed")
939
- end
940
-
941
1044
  # True for a database that could not take a statement just now and may a moment later.
942
1045
  def busy?(error)
943
1046
  error.message.match?(BUSY)
@@ -955,6 +1058,15 @@ module Runlight
955
1058
  end
956
1059
 
957
1060
  # Checks a list of hostnames for a managed site: at least one, each a domain, none taken.
1061
+ def check_retention(site, months)
1062
+ raise SettingsError.new("Unknown site", "unknown_site") if site(site).nil? || @remotes.key?(site)
1063
+
1064
+ unless months.nil? || (months.is_a?(Numeric) && RETENTION_MONTHS.include?(months))
1065
+ list = RETENTION_MONTHS.join(", ")
1066
+ raise SettingsError.new("Keep visits for #{list} months, or forever", "retention_bad", { "months" => list })
1067
+ end
1068
+ end
1069
+
958
1070
  def hostnames_for(input, except = nil)
959
1071
  items = input.is_a?(Array) ? input : Js.string(input.nil? ? "" : input).split(/[#{Js::SPACE},]+/)
960
1072
  hostnames = []
@@ -1260,7 +1372,7 @@ module Runlight
1260
1372
  "mobile" => request.headers.get("sec-ch-ua-mobile"),
1261
1373
  "platform" => request.headers.get("sec-ch-ua-platform"),
1262
1374
  }, client["screenWidth"])
1263
- location = Geo.locate(request.headers, ip, @geo)
1375
+ location = Geo.locate(request.headers, ip, @geo, Js.truthy?(@trust_proxy))
1264
1376
  row = {
1265
1377
  "id" => session["id"],
1266
1378
  "site" => site["id"],
@@ -1335,6 +1447,8 @@ module Runlight
1335
1447
  @salts = {}
1336
1448
  sites.map { |site| site["timezone"] }.uniq.each { |timezone| current_salts(now, timezone) }
1337
1449
  drop_old_salts(now)
1450
+ # Sign-in counts that have run out, some keyed by an address, and the hashing keys of days gone.
1451
+ Accounts::Throttle.sweep(@store, now)
1338
1452
  # Every site's retention covers any one site's that is still waiting.
1339
1453
  @pruning = []
1340
1454
  begin