runlight 0.1.7 → 0.1.8
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 +12 -12
- data/assets/intl.json +1 -1
- data/assets/locales.json +5 -5
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +23 -15
- data/lib/runlight/accounts/crypto.rb +41 -13
- data/lib/runlight/accounts/pages.rb +102 -63
- data/lib/runlight/accounts/web.rb +55 -50
- data/lib/runlight/assistant.rb +9 -1
- data/lib/runlight/core.rb +44 -11
- data/lib/runlight/dates.rb +8 -0
- data/lib/runlight/http/fetcher.rb +12 -0
- data/lib/runlight/http/request.rb +3 -0
- data/lib/runlight/http/url.rb +6 -1
- data/lib/runlight/messages.rb +15 -1
- data/lib/runlight/oauth.rb +60 -41
- data/lib/runlight/routes.rb +47 -29
- data/lib/runlight/server/config.rb +1 -1
- data/lib/runlight/server/standalone.rb +3 -8
- data/lib/runlight/store/sql.rb +13 -0
- data/lib/runlight/store/sql_store.rb +187 -35
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight/zip.rb +3 -1
- metadata +2 -2
|
@@ -28,8 +28,8 @@ module Runlight
|
|
|
28
28
|
|
|
29
29
|
# options: "runlight" (the Core, or anything with store, mail_settings, send_mail, and client_ip), "secret",
|
|
30
30
|
# "base", "now" (a callable giving milliseconds), "firstAccount", "home" (optional, a callable giving the
|
|
31
|
-
# install's own origin or nil), "forgot", and "setupWhere" (optional), which says
|
|
32
|
-
# link with the one-time code
|
|
31
|
+
# install's own origin or nil), "forgot", and "setupWhere" (optional, true), which says the setup
|
|
32
|
+
# link with the one-time code is in the file setup.txt, not in a log.
|
|
33
33
|
def initialize(options)
|
|
34
34
|
@rl = options["runlight"]
|
|
35
35
|
@store = @rl.store
|
|
@@ -58,8 +58,6 @@ module Runlight
|
|
|
58
58
|
# Six-digit codes: five wrong tries an account every fifteen minutes, and five to confirm the first one.
|
|
59
59
|
# Password re-checks in Account: ten.
|
|
60
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)
|
|
63
61
|
@confirm_tries = Throttle.new(@store, "confirm", 5)
|
|
64
62
|
@rechecks = Throttle.new(@store, "recheck", 10)
|
|
65
63
|
end
|
|
@@ -142,7 +140,7 @@ module Runlight
|
|
|
142
140
|
|
|
143
141
|
# The dashboard itself: straight to sign-in, or to setting up the first account.
|
|
144
142
|
if (path == "/" || path == "") && request.method == "GET" && signed_in(request).nil?
|
|
145
|
-
return @first == "open" || @asks_for_token ? redirect("#{@base}/setup") : setup_locked unless has_account?
|
|
143
|
+
return @first == "open" || @asks_for_token ? redirect("#{@base}/setup") : setup_locked(Messages.accepted_language(request)) unless has_account?
|
|
146
144
|
|
|
147
145
|
search = Http::Url.new(request.url).search
|
|
148
146
|
return redirect("#{@base}/login#{search == "" ? "" : "?next=#{Js.encode_uri_component("#{@home_path}#{search}")}"}")
|
|
@@ -270,8 +268,15 @@ module Runlight
|
|
|
270
268
|
end
|
|
271
269
|
|
|
272
270
|
# Why there is no setup form.
|
|
273
|
-
def setup_locked
|
|
274
|
-
html(@first == "locked" ? Pages.setup_needs_token_page(@base) : Pages.setup_locked_page(@base, @setup_where), 403)
|
|
271
|
+
def setup_locked(lang)
|
|
272
|
+
html(@first == "locked" ? Pages.setup_needs_token_page(@base, lang) : Pages.setup_locked_page(@base, Js.truthy?(@setup_where), lang), 403)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# An account's problem in the visitor's language, where the dashboard has words for it; English as it was raised.
|
|
276
|
+
def reason(lang, error)
|
|
277
|
+
key = "error.#{error.is_a?(AccountError) ? error.code : ""}"
|
|
278
|
+
t = Messages.translator(lang)["t"]
|
|
279
|
+
lang == "en" || t.call(key) == key ? error.message : t.call(key, error.is_a?(AccountError) ? error.params : {})
|
|
275
280
|
end
|
|
276
281
|
|
|
277
282
|
# Emails an invite through the mail service when there is one. The link
|
|
@@ -340,11 +345,13 @@ module Runlight
|
|
|
340
345
|
query = url.search_params
|
|
341
346
|
method = request.method
|
|
342
347
|
base = @base
|
|
348
|
+
lang = Messages.accepted_language(request)
|
|
349
|
+
t = Messages.translator(lang)["t"]
|
|
343
350
|
if path == "/auth.css"
|
|
344
351
|
return Http::Response.new(Pages::AUTH_CSS, status: 200, headers: { "content-type" => "text/css; charset=utf-8", "cache-control" => "public, max-age=3600" })
|
|
345
352
|
end
|
|
346
353
|
if path == "/auth.js"
|
|
347
|
-
return Http::Response.new(Pages
|
|
354
|
+
return Http::Response.new(Pages.auth_js(query.get("lang") || "en"), status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "public, max-age=3600" })
|
|
348
355
|
end
|
|
349
356
|
|
|
350
357
|
if path == "/setup"
|
|
@@ -352,43 +359,43 @@ module Runlight
|
|
|
352
359
|
|
|
353
360
|
if method == "GET"
|
|
354
361
|
code = query.get("code") || ""
|
|
355
|
-
return setup_locked if @first == "locked"
|
|
362
|
+
return setup_locked(lang) if @first == "locked"
|
|
356
363
|
# The app's token is typed in; the server's code comes in the link it printed.
|
|
357
|
-
return html(Pages.setup_page(base, { "code" => "", "askCode" => @asks_for_token })) if @asks_for_token || @first == "open"
|
|
364
|
+
return html(Pages.setup_page(base, { "code" => "", "askCode" => @asks_for_token }, lang)) if @asks_for_token || @first == "open"
|
|
358
365
|
|
|
359
|
-
return setup_ok(code) ? html(Pages.setup_page(base, { "code" => code })) : setup_locked
|
|
366
|
+
return setup_ok(code) ? html(Pages.setup_page(base, { "code" => code }, lang)) : setup_locked(lang)
|
|
360
367
|
end
|
|
361
368
|
if method == "POST"
|
|
362
369
|
form = Http::SearchParams.new(request.text)
|
|
363
370
|
code = form.get("code") || ""
|
|
364
371
|
unless setup_ok(code)
|
|
365
372
|
if @asks_for_token
|
|
366
|
-
return html(Pages.setup_page(base, { "code" => "", "askCode" => true, "error" => "
|
|
373
|
+
return html(Pages.setup_page(base, { "code" => "", "askCode" => true, "error" => t.call("auth.tokenWrong"), "email" => form.get("email") || "" }, lang), 403)
|
|
367
374
|
end
|
|
368
375
|
|
|
369
|
-
return setup_locked
|
|
376
|
+
return setup_locked(lang)
|
|
370
377
|
end
|
|
371
378
|
again = ->(error) { { "code" => @asks_for_token ? "" : code, "askCode" => @asks_for_token, "error" => error, "email" => form.get("email") || "" } }
|
|
372
379
|
# Asked twice, since a typo here would lock the first owner out.
|
|
373
|
-
return html(Pages.setup_page(base, again.call("
|
|
380
|
+
return html(Pages.setup_page(base, again.call(t.call("auth.mismatch")), lang), 400) if (form.get("password") || "") != (form.get("again") || "")
|
|
374
381
|
|
|
375
382
|
begin
|
|
376
383
|
user = @accounts.set_password(form.get("email") || "", form.get("password") || "", now)
|
|
377
384
|
@existing = true
|
|
378
385
|
return redirect(@home_path, { "set-cookie" => session_cookie(request, @accounts.session_for(user, now), Accounts::SESSION_MS / 1000) })
|
|
379
386
|
rescue RangeError => e
|
|
380
|
-
return html(Pages.setup_page(base, again.call(e
|
|
387
|
+
return html(Pages.setup_page(base, again.call(reason(lang, e)), lang), 400)
|
|
381
388
|
end
|
|
382
389
|
end
|
|
383
390
|
end
|
|
384
391
|
|
|
385
392
|
if path == "/login"
|
|
386
393
|
unless has_account?
|
|
387
|
-
return setup_locked if @first == "locked"
|
|
394
|
+
return setup_locked(lang) if @first == "locked"
|
|
388
395
|
|
|
389
|
-
return @first == "open" || @asks_for_token ? redirect("#{base}/setup") : setup_locked
|
|
396
|
+
return @first == "open" || @asks_for_token ? redirect("#{base}/setup") : setup_locked(lang)
|
|
390
397
|
end
|
|
391
|
-
return html(Pages.login_page(base, { "next" => safe_next(query.get("next")), "forgot" => @forgot })) if method == "GET"
|
|
398
|
+
return html(Pages.login_page(base, { "next" => safe_next(query.get("next")), "forgot" => @forgot }, lang)) if method == "GET"
|
|
392
399
|
return login(request, context) if method == "POST"
|
|
393
400
|
end
|
|
394
401
|
|
|
@@ -397,9 +404,9 @@ module Runlight
|
|
|
397
404
|
next_path = safe_next(query.get("next"))
|
|
398
405
|
user = @accounts.from_link(query.get("ticket") || "", now)
|
|
399
406
|
if user.nil?
|
|
400
|
-
return html(Pages.login_page(base, { "error" => "
|
|
407
|
+
return html(Pages.login_page(base, { "error" => t.call("auth.linkExpired"), "next" => next_path, "forgot" => @forgot }, lang), 410)
|
|
401
408
|
end
|
|
402
|
-
return html(Pages.code_page(base, { "pending" => @accounts.pending_for(user, now), "next" => next_path })) if user["twoFactor"]
|
|
409
|
+
return html(Pages.code_page(base, { "pending" => @accounts.pending_for(user, now), "next" => next_path }, lang)) if user["twoFactor"]
|
|
403
410
|
|
|
404
411
|
return signed_in_to(request, user, next_path)
|
|
405
412
|
end
|
|
@@ -408,18 +415,16 @@ module Runlight
|
|
|
408
415
|
ready
|
|
409
416
|
form = Http::SearchParams.new(request.text)
|
|
410
417
|
next_path = safe_next(form.get("next"))
|
|
411
|
-
|
|
412
|
-
return redirect("#{base}/login?next=#{Js.encode_uri_component(next_path)}") if
|
|
418
|
+
user = @accounts.from_pending(form.get("pending") || "", now)
|
|
419
|
+
return redirect("#{base}/login?next=#{Js.encode_uri_component(next_path)}") if user.nil?
|
|
413
420
|
|
|
414
|
-
user = pending["user"]
|
|
415
|
-
real = pending["real"]
|
|
416
421
|
# Counted before the check, so a burst cannot get past five.
|
|
417
|
-
unless
|
|
418
|
-
return html(Pages.code_page(base, { "pending" => form.get("pending") || "", "next" => next_path, "error" => "
|
|
422
|
+
unless @code_tries.take(user["id"], now)
|
|
423
|
+
return html(Pages.code_page(base, { "pending" => form.get("pending") || "", "next" => next_path, "error" => t.call("error.too_many_tries") }, lang), 429)
|
|
419
424
|
end
|
|
420
|
-
|
|
425
|
+
unless @accounts.check_second_factor(user["id"], form.get("code") || "", now)
|
|
421
426
|
return html(Pages.code_page(base, { "pending" => form.get("pending") || "", "next" => next_path,
|
|
422
|
-
"error" => "
|
|
427
|
+
"error" => t.call("auth.codeWrong") }, lang), 401)
|
|
423
428
|
end
|
|
424
429
|
|
|
425
430
|
@code_tries.clear(user["id"], now)
|
|
@@ -432,29 +437,29 @@ module Runlight
|
|
|
432
437
|
if method == "GET"
|
|
433
438
|
code = query.get("code") || ""
|
|
434
439
|
invite = @accounts.invite_by_code(code, now)
|
|
435
|
-
return html(Pages.invite_gone_page(base), 410) if invite.nil?
|
|
440
|
+
return html(Pages.invite_gone_page(base, lang), 410) if invite.nil?
|
|
436
441
|
|
|
437
|
-
return html(Pages.invite_page(base, { "code" => code, "email" => invite["email"], "role" => invite["role"], "site" => invite["site"], "host" => url.host }))
|
|
442
|
+
return html(Pages.invite_page(base, { "code" => code, "email" => invite["email"], "role" => invite["role"], "site" => invite["site"], "host" => url.host }, lang))
|
|
438
443
|
end
|
|
439
444
|
if method == "POST"
|
|
440
445
|
form = Http::SearchParams.new(request.text)
|
|
441
446
|
code = form.get("code") || ""
|
|
442
447
|
invite = @accounts.invite_by_code(code, now)
|
|
443
|
-
return html(Pages.invite_gone_page(base), 410) if invite.nil?
|
|
448
|
+
return html(Pages.invite_gone_page(base, lang), 410) if invite.nil?
|
|
444
449
|
|
|
445
450
|
again = lambda do |error|
|
|
446
|
-
html(Pages.invite_page(base, { "code" => code, "email" => invite["email"], "role" => invite["role"], "site" => invite["site"], "host" => url.host, "error" => error }), 400)
|
|
451
|
+
html(Pages.invite_page(base, { "code" => code, "email" => invite["email"], "role" => invite["role"], "site" => invite["site"], "host" => url.host, "error" => error }, lang), 400)
|
|
447
452
|
end
|
|
448
|
-
return again.call("
|
|
453
|
+
return again.call(t.call("auth.mismatch")) if (form.get("password") || "") != (form.get("again") || "")
|
|
449
454
|
|
|
450
455
|
begin
|
|
451
456
|
user = @accounts.accept_invite(code, form.get("password") || "", now)
|
|
452
457
|
@existing = true
|
|
453
458
|
return redirect(@home_path, { "set-cookie" => session_cookie(request, @accounts.session_for(user, now), Accounts::SESSION_MS / 1000) })
|
|
454
459
|
rescue RangeError => e
|
|
455
|
-
return html(Pages.invite_gone_page(base), 410) if e.is_a?(AccountError) && e.code == "invite_gone"
|
|
460
|
+
return html(Pages.invite_gone_page(base, lang), 410) if e.is_a?(AccountError) && e.code == "invite_gone"
|
|
456
461
|
|
|
457
|
-
return again.call(e
|
|
462
|
+
return again.call(reason(lang, e))
|
|
458
463
|
end
|
|
459
464
|
end
|
|
460
465
|
end
|
|
@@ -471,8 +476,10 @@ module Runlight
|
|
|
471
476
|
account = Js.lower(Js.trim(email))
|
|
472
477
|
ip = @rl.client_ip(request, context)
|
|
473
478
|
pair = "#{account}\n#{ip == "" ? "unknown" : ip}"
|
|
474
|
-
|
|
475
|
-
|
|
479
|
+
lang = Messages.accepted_language(request)
|
|
480
|
+
t = Messages.translator(lang)["t"]
|
|
481
|
+
login_page = ->(opts) { Pages.login_page(base, opts.merge("next" => next_path, "forgot" => @forgot) { |_k, mine, _theirs| mine }, lang) }
|
|
482
|
+
too_many = -> { html(login_page.call({ "error" => t.call("error.too_many_tries"), "email" => email }), 429) }
|
|
476
483
|
return too_many.call if ip != "" && !@per_ip.take(ip, now)
|
|
477
484
|
return too_many.call unless @per_address.take(pair, now)
|
|
478
485
|
|
|
@@ -480,10 +487,11 @@ module Runlight
|
|
|
480
487
|
known = @accounts.by_email(account)
|
|
481
488
|
trusted = !known.nil? && @accounts.trusts_device(read_cookie(request, DEVICE_COOKIE), known)
|
|
482
489
|
over = !trusted && !@per_account.take(account, now)
|
|
483
|
-
# Past the account's limit, a right password and a wrong one get the same answer, so
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
|
|
490
|
+
# Past the account's limit, a right password and a wrong one get the same answer, and so does every
|
|
491
|
+
# account, with two-factor on or not, and an email with no account, so guessing from many addresses
|
|
492
|
+
# learns nothing about either. The owner still gets in: a right password emails a sign-in link, which
|
|
493
|
+
# leads to the code step when two-factor is on.
|
|
494
|
+
if over
|
|
487
495
|
home = home_origin
|
|
488
496
|
return too_many.call if !@rl.mail_settings || home.nil? || home == ""
|
|
489
497
|
|
|
@@ -497,27 +505,24 @@ module Runlight
|
|
|
497
505
|
warn "Runlight: could not send a sign-in link #{e.message}"
|
|
498
506
|
end
|
|
499
507
|
end
|
|
500
|
-
return html(login_page.call({ "error" => "
|
|
501
|
-
"email" => email }), 429)
|
|
508
|
+
return html(login_page.call({ "error" => t.call("auth.tooManyLink"), "email" => email }), 429)
|
|
502
509
|
end
|
|
503
510
|
user = @accounts.sign_in(email, password)
|
|
504
|
-
if user.nil?
|
|
505
|
-
|
|
511
|
+
return html(login_page.call({ "error" => t.call("auth.wrongPassword"), "email" => email }), 401) if user.nil?
|
|
512
|
+
|
|
506
513
|
|
|
507
|
-
return html(login_page.call({ "error" => "That email and password do not match an account.", "email" => email }), 401)
|
|
508
|
-
end
|
|
509
514
|
@per_address.clear(pair, now)
|
|
510
515
|
@per_ip.forgive(ip, now) if ip != ""
|
|
511
|
-
@per_account.forgive(account, now)
|
|
516
|
+
@per_account.forgive(account, now) unless trusted
|
|
512
517
|
# With two-factor on, the password only earns the second step.
|
|
513
|
-
return html(Pages.code_page(base, { "pending" => @accounts.pending_for(user, now), "next" => next_path })) if user["twoFactor"]
|
|
518
|
+
return html(Pages.code_page(base, { "pending" => @accounts.pending_for(user, now), "next" => next_path }, lang)) if user["twoFactor"]
|
|
514
519
|
|
|
515
520
|
signed_in_to(request, user, next_path)
|
|
516
521
|
rescue AccountError => e
|
|
517
522
|
# Too many password checks already waiting, so this one was never run: try again in a moment.
|
|
518
523
|
raise unless e.code == "busy"
|
|
519
524
|
|
|
520
|
-
html(login_page.call({ "error" => e
|
|
525
|
+
html(login_page.call({ "error" => reason(lang, e), "email" => email }), 503, { "retry-after" => "5" })
|
|
521
526
|
end
|
|
522
527
|
|
|
523
528
|
def recheck(user, input, name, wrong)
|
data/lib/runlight/assistant.rb
CHANGED
|
@@ -28,6 +28,8 @@ module Runlight
|
|
|
28
28
|
{ "id" => "custom", "name" => "Another OpenAI-compatible service", "protocol" => "openai", "baseUrl" => "", "model" => "", "key" => "optional" },
|
|
29
29
|
].freeze
|
|
30
30
|
|
|
31
|
+
# The most text of earlier turns a question carries, about six thousand tokens.
|
|
32
|
+
MAX_HISTORY_CHARS = 24_000
|
|
31
33
|
MAX_ROUNDS = 8
|
|
32
34
|
# However many rounds a question takes, the answer comes within this long or the assistant stops.
|
|
33
35
|
DEADLINE_MS = 120_000
|
|
@@ -201,6 +203,12 @@ module Runlight
|
|
|
201
203
|
history << { "role" => m["role"], "content" => content }
|
|
202
204
|
end
|
|
203
205
|
end
|
|
206
|
+
# The browser sends the history, so its whole length is held too, the oldest turns dropped first: each
|
|
207
|
+
# tool round sends it again, and the limits on questions are there to keep the owner's AI credit safe.
|
|
208
|
+
while history.length > 1 && history.sum { |h| Js.length(h["content"]) } > MAX_HISTORY_CHARS
|
|
209
|
+
history.shift
|
|
210
|
+
history.shift while history.length > 1 && history[0]["role"] != "user"
|
|
211
|
+
end
|
|
204
212
|
|
|
205
213
|
if provider["protocol"] == "anthropic"
|
|
206
214
|
tools = (Mcp.tools + [Json.decode(File.read(File.expand_path("../../assets/assistant-action.json", __dir__)))]).map { |t| { "name" => t["name"], "description" => t["description"], "input_schema" => t["inputSchema"] } }
|
|
@@ -412,6 +420,6 @@ module Runlight
|
|
|
412
420
|
end
|
|
413
421
|
|
|
414
422
|
private_class_method :provider, :system_prompt, :in_time, :service_message, :post, :tool_text, :clock, :first_message, :unreadable, :blank?,
|
|
415
|
-
:
|
|
423
|
+
:collation_key, :refused
|
|
416
424
|
end
|
|
417
425
|
end
|
data/lib/runlight/core.rb
CHANGED
|
@@ -180,6 +180,8 @@ module Runlight
|
|
|
180
180
|
@checking = false
|
|
181
181
|
# When planner statistics were last gathered.
|
|
182
182
|
@optimized_at = 0
|
|
183
|
+
# When statistics were last checked for staleness, which reports do at most hourly.
|
|
184
|
+
@freshened_at = 0
|
|
183
185
|
@link_domain_cache = nil
|
|
184
186
|
# Each timezone's salts for its current day, so a lookup is a map read until midnight there.
|
|
185
187
|
@salts = {}
|
|
@@ -193,6 +195,15 @@ module Runlight
|
|
|
193
195
|
@managed_sites
|
|
194
196
|
end
|
|
195
197
|
|
|
198
|
+
# Gathers planner statistics again if the events have outgrown them, at most once an hour, so an app
|
|
199
|
+
# that never runs the scheduled check still reads its reports with statistics that fit its data.
|
|
200
|
+
def freshen_statistics
|
|
201
|
+
return if now - @freshened_at < 3_600_000
|
|
202
|
+
|
|
203
|
+
@freshened_at = now
|
|
204
|
+
@store.optimize(true)
|
|
205
|
+
end
|
|
206
|
+
|
|
196
207
|
# The clock, in milliseconds.
|
|
197
208
|
def now
|
|
198
209
|
@clock.call.to_i
|
|
@@ -732,13 +743,15 @@ module Runlight
|
|
|
732
743
|
months = retention(site)
|
|
733
744
|
return nil if months.nil?
|
|
734
745
|
|
|
735
|
-
#
|
|
746
|
+
# Six months before August 31st is February 28th (or 29th), not March 2nd: the day is kept within its month.
|
|
736
747
|
at = now
|
|
737
748
|
ms = at % 1000
|
|
738
749
|
time = Time.at((at - ms) / 1000).utc
|
|
739
750
|
total = (time.year * 12) + (time.month - 1) - months
|
|
740
|
-
|
|
741
|
-
|
|
751
|
+
year = total.div(12)
|
|
752
|
+
month = (total % 12) + 1
|
|
753
|
+
last = (Time.utc(month == 12 ? year + 1 : year, month == 12 ? 1 : month + 1, 1) - 86_400).day
|
|
754
|
+
(Time.utc(year, month, [time.day, last].min, time.hour, time.min, time.sec).to_i * 1000) + ms
|
|
742
755
|
end
|
|
743
756
|
|
|
744
757
|
def site(id = nil)
|
|
@@ -1014,11 +1027,10 @@ module Runlight
|
|
|
1014
1027
|
}
|
|
1015
1028
|
end
|
|
1016
1029
|
|
|
1017
|
-
# Sites in name order, as TS sorts them with localeCompare
|
|
1030
|
+
# Sites in name order, as TS sorts them with localeCompare, in the order the assistant's collation gives.
|
|
1018
1031
|
def by_name(sites)
|
|
1019
1032
|
sites.each_with_index.sort do |(a, i), (b, j)|
|
|
1020
|
-
order =
|
|
1021
|
-
order = Js.compare(b["name"], a["name"]) if order.zero?
|
|
1033
|
+
order = Assistant.locale_compare(a["name"], b["name"])
|
|
1022
1034
|
order.zero? ? i <=> j : order
|
|
1023
1035
|
end.map(&:first)
|
|
1024
1036
|
end
|
|
@@ -1282,11 +1294,28 @@ module Runlight
|
|
|
1282
1294
|
@salts[timezone] = { "day" => day, "today" => today, "yesterday" => yesterday }
|
|
1283
1295
|
end
|
|
1284
1296
|
|
|
1285
|
-
# Deletes
|
|
1286
|
-
#
|
|
1287
|
-
#
|
|
1297
|
+
# Deletes every salt no visit can still use. A day's salt counts visitors while it is that day in a
|
|
1298
|
+
# site's timezone, and after midnight only while a visit that began that day there is still going, since
|
|
1299
|
+
# it carries on under the visitor it began with. So a salt lasts its day and the visits that run past it.
|
|
1288
1300
|
def drop_old_salts(at)
|
|
1289
|
-
|
|
1301
|
+
zones = {}
|
|
1302
|
+
sites.each { |site| (zones[site["timezone"]] ||= []) << site["id"] }
|
|
1303
|
+
return if zones.empty?
|
|
1304
|
+
|
|
1305
|
+
needed = []
|
|
1306
|
+
zones.each do |timezone, ids|
|
|
1307
|
+
today = Dates.local_date(at, timezone)
|
|
1308
|
+
needed << today
|
|
1309
|
+
yesterday = Dates.add_days(today, -1)
|
|
1310
|
+
if @store.visit_open(ids, Dates.start_of(yesterday, timezone), Dates.start_of(today, timezone), at - SESSION_IDLE_MS)
|
|
1311
|
+
needed << yesterday
|
|
1312
|
+
else
|
|
1313
|
+
# Nor is it kept here: no visit is left for it to find.
|
|
1314
|
+
cached = @salts[timezone]
|
|
1315
|
+
cached["yesterday"] = nil if !cached.nil? && cached["day"] == today
|
|
1316
|
+
end
|
|
1317
|
+
end
|
|
1318
|
+
@store.drop_salts_except(needed)
|
|
1290
1319
|
end
|
|
1291
1320
|
|
|
1292
1321
|
# The host a proxy says the request was for, read only when proxy headers are trusted, as the client's address is.
|
|
@@ -1387,7 +1416,8 @@ module Runlight
|
|
|
1387
1416
|
"utmContent" => page["utm"]["content"],
|
|
1388
1417
|
).merge(location).merge(parsed).merge("screen" => client["screen"], "language" => client["language"])
|
|
1389
1418
|
@store.insert_session(row)
|
|
1390
|
-
|
|
1419
|
+
# Another process may have opened this visitor's visit at the same moment: both go on with the earlier.
|
|
1420
|
+
@store.settle_open_session(site["id"], candidates, at - SESSION_IDLE_MS, session)
|
|
1391
1421
|
end
|
|
1392
1422
|
|
|
1393
1423
|
# The link domains, read at most every 30 seconds. Every request to a
|
|
@@ -1455,6 +1485,9 @@ module Runlight
|
|
|
1455
1485
|
if now - @optimized_at >= 86_400_000
|
|
1456
1486
|
@optimized_at = now
|
|
1457
1487
|
@store.optimize
|
|
1488
|
+
else
|
|
1489
|
+
# Between the daily runs, a large import or a busy first day still gets statistics that fit it.
|
|
1490
|
+
@store.optimize(true)
|
|
1458
1491
|
end
|
|
1459
1492
|
build_rollups
|
|
1460
1493
|
{ "ok" => true, "reports" => send_reports }
|
data/lib/runlight/dates.rb
CHANGED
|
@@ -211,6 +211,14 @@ module Runlight
|
|
|
211
211
|
|
|
212
212
|
at += 15 * 60_000
|
|
213
213
|
end
|
|
214
|
+
# Where clocks fall back across that hour (midnight in Amman in 2019, three hours at Casey station), it
|
|
215
|
+
# happens twice; the day begins at the first.
|
|
216
|
+
return at if offset(at - (3 * 3_600_000), timezone) == offset(at, timezone)
|
|
217
|
+
|
|
218
|
+
(3 * 60).step(15, -15) do |back|
|
|
219
|
+
ly, lm, ld, lh = parts(at - (back * 60_000), timezone)
|
|
220
|
+
return at - (back * 60_000) if utc(ly, lm - 1, ld, lh) == guess
|
|
221
|
+
end
|
|
214
222
|
at
|
|
215
223
|
end
|
|
216
224
|
|
|
@@ -26,6 +26,8 @@ module Runlight
|
|
|
26
26
|
MAX_REDIRECTS = 20
|
|
27
27
|
REDIRECTS = [301, 302, 303, 307, 308].freeze
|
|
28
28
|
TIMED_OUT = "The operation was aborted due to timeout"
|
|
29
|
+
# Sent only to the origin they were given to.
|
|
30
|
+
CREDENTIALS = %w[authorization cookie proxy-authorization x-api-key].freeze
|
|
29
31
|
|
|
30
32
|
def fetch(url, init = {})
|
|
31
33
|
deadline = monotonic + ((init["timeoutMs"] || 30_000).to_i / 1000.0)
|
|
@@ -45,6 +47,16 @@ module Runlight
|
|
|
45
47
|
target = Url.parse(location, url)
|
|
46
48
|
raise FetchError, "fetch failed" if target.nil? || !%w[http: https:].include?(target.protocol)
|
|
47
49
|
|
|
50
|
+
here = Url.parse(url)
|
|
51
|
+
# Never from an encrypted address to a plain one, where anything still sent could be read on the way.
|
|
52
|
+
raise FetchError, "fetch failed: a redirect from https to http" if here.protocol == "https:" && target.protocol == "http:"
|
|
53
|
+
|
|
54
|
+
# Credentials are for the address they were given to, so they go no further than its origin, as
|
|
55
|
+
# fetch() drops them.
|
|
56
|
+
if target.origin != here.origin
|
|
57
|
+
headers = headers.dup
|
|
58
|
+
CREDENTIALS.each { |name| headers.delete(name) }
|
|
59
|
+
end
|
|
48
60
|
url = target.href
|
|
49
61
|
next unless response.status == 303 || ([301, 302].include?(response.status) && method == "POST")
|
|
50
62
|
|
|
@@ -32,6 +32,9 @@ module Runlight
|
|
|
32
32
|
# the path's limit raises TooLarge once one byte past it has been read.
|
|
33
33
|
def self.from_rack(env, read_body: true)
|
|
34
34
|
scheme = env["rack.url_scheme"] || "http"
|
|
35
|
+
# The first X-Forwarded-Proto of http or https names the scheme, as the Node server reads it.
|
|
36
|
+
proto = env["HTTP_X_FORWARDED_PROTO"].to_s.split(",", -1)[0].to_s.strip.downcase
|
|
37
|
+
scheme = proto if %w[http https].include?(proto)
|
|
35
38
|
# A Host header no URL can hold (a space, a bad port, a userinfo @) is read as the server's own name, as
|
|
36
39
|
# an absent one is, so the request still has a URL; the header itself is kept as it came.
|
|
37
40
|
host = env["HTTP_HOST"].to_s
|
data/lib/runlight/http/url.rb
CHANGED
|
@@ -273,12 +273,17 @@ module Runlight
|
|
|
273
273
|
def ipv4(host)
|
|
274
274
|
parts = host.split(".", -1)
|
|
275
275
|
parts.pop if parts.last == ""
|
|
276
|
-
return nil if parts.empty?
|
|
276
|
+
return nil if parts.empty?
|
|
277
277
|
return nil unless parts.last.match?(/\A(0x[0-9a-f]*|[0-9]+)\z/)
|
|
278
278
|
|
|
279
|
+
# A host ending in a number is an address, so too many parts, or a digit past 7 in a part that starts with
|
|
280
|
+
# a 0 (octal), is no host at all, as new URL() finds.
|
|
281
|
+
raise Invalid, "Invalid URL" if parts.length > 4
|
|
282
|
+
|
|
279
283
|
numbers = parts.map do |part|
|
|
280
284
|
if (m = part.match(/\A0x([0-9a-f]*)\z/)) then m[1].empty? ? 0 : m[1].to_i(16)
|
|
281
285
|
elsif part.match?(/\A0[0-7]+\z/) then part.to_i(8)
|
|
286
|
+
elsif part.match?(/\A0[0-9]+\z/) then raise Invalid, "Invalid URL"
|
|
282
287
|
elsif part.match?(/\A[0-9]+\z/) then part.to_i
|
|
283
288
|
else raise Invalid, "Invalid URL"
|
|
284
289
|
end
|
data/lib/runlight/messages.rb
CHANGED
|
@@ -43,7 +43,21 @@ module Runlight
|
|
|
43
43
|
own = table(code)["#{key}_#{form}"] || table(code)["#{key}_other"]
|
|
44
44
|
!own.nil? && own != "" ? fill.call(own, vars) : t.call("#{key}_other", vars)
|
|
45
45
|
end
|
|
46
|
-
|
|
46
|
+
# A message for a page: its own text escaped, then each {name} filled with HTML the caller made safe.
|
|
47
|
+
html = lambda do |key, vars = {}|
|
|
48
|
+
t.call(key).gsub(/[&<>]/, "&" => "&", "<" => "<", ">" => ">")
|
|
49
|
+
.gsub(/\{(\w+)\}/) { vars.key?(Regexp.last_match(1)) ? vars[Regexp.last_match(1)] : Regexp.last_match(0) }
|
|
50
|
+
end
|
|
51
|
+
{ "t" => t, "tn" => tn, "html" => html, "lang" => code }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The first language a browser asks for that the dashboard speaks, else English.
|
|
55
|
+
def accepted_language(request)
|
|
56
|
+
(request.headers.get("accept-language") || "").split(",", -1).each do |part|
|
|
57
|
+
code = Js.lower(Js.slice(Js.trim(part.split(";", -1).first.to_s), 0, 2))
|
|
58
|
+
return code if languages.include?(code)
|
|
59
|
+
end
|
|
60
|
+
"en"
|
|
47
61
|
end
|
|
48
62
|
|
|
49
63
|
# Intl.PluralRules(lang).select(n) for the dashboard's languages, by CLDR's cardinal rules. As there, the
|