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.
- checksums.yaml +4 -4
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +71 -22
- data/assets/locales.json +5 -5
- data/assets/pick-return.js +1 -0
- data/assets/picker.js +1 -1
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +90 -19
- data/lib/runlight/accounts/throttle.rb +123 -38
- data/lib/runlight/accounts/web.rb +50 -14
- data/lib/runlight/assistant.rb +40 -8
- data/lib/runlight/connect.rb +2 -3
- data/lib/runlight/core.rb +148 -34
- data/lib/runlight/dates.rb +31 -8
- data/lib/runlight/db/connect.rb +32 -6
- data/lib/runlight/db/db.rb +129 -9
- data/lib/runlight/db/prefixed.rb +92 -0
- data/lib/runlight/geo.rb +4 -3
- data/lib/runlight/http/fetcher.rb +31 -6
- data/lib/runlight/http/request.rb +14 -3
- data/lib/runlight/importers/csv_visits.rb +1 -1
- data/lib/runlight/importers/index.rb +10 -2
- data/lib/runlight/importers/visits.rb +17 -6
- data/lib/runlight/importers/write.rb +21 -6
- data/lib/runlight/links.rb +14 -9
- data/lib/runlight/mail/ses.rb +3 -2
- data/lib/runlight/mail/smtp.rb +2 -0
- data/lib/runlight/mail/smtp_session.rb +22 -1
- data/lib/runlight/mail/transports.rb +19 -2
- data/lib/runlight/mcp.rb +8 -1
- data/lib/runlight/metadata_address_error.rb +13 -0
- data/lib/runlight/oauth.rb +85 -18
- data/lib/runlight/rack_app.rb +64 -10
- data/lib/runlight/redirect_refused_error.rb +14 -0
- data/lib/runlight/routes.rb +306 -144
- data/lib/runlight/safefetch.rb +133 -1
- data/lib/runlight/server/agents.rb +1 -1
- data/lib/runlight/server/cli.rb +1 -1
- data/lib/runlight/server/config.rb +64 -19
- data/lib/runlight/server/standalone.rb +7 -6
- data/lib/runlight/store/sql.rb +10 -0
- data/lib/runlight/store/sql_store.rb +75 -13
- data/lib/runlight/stores.rb +19 -15
- data/lib/runlight/tables_busy_error.rb +11 -0
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight.rb +4 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
data/lib/runlight/assistant.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
data/lib/runlight/connect.rb
CHANGED
|
@@ -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
|
-
|
|
140
|
-
|
|
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:
|
|
26
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
return nil if
|
|
216
|
+
app = @app_mailer
|
|
217
|
+
return nil if app.nil?
|
|
166
218
|
|
|
167
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
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
|
-
|
|
571
|
+
else
|
|
572
|
+
@store.set_site_overrides(id, nxt)
|
|
573
|
+
@overrides[id] = nxt
|
|
468
574
|
end
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|