runlight 0.1.6 → 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.
@@ -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 where to find the setup
32
- # link with the one-time code, when it is not in a log (HTML).
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::AUTH_JS, status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "public, max-age=3600" })
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" => "That is not this app's RUNLIGHT_TOKEN.", "email" => form.get("email") || "" }), 403)
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("The two passwords are not the same.")), 400) if (form.get("password") || "") != (form.get("again") || "")
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.message)), 400)
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" => "That sign-in link has run out. Sign in again.", "next" => next_path, "forgot" => @forgot }), 410)
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
- pending = @accounts.from_pending(form.get("pending") || "", now)
412
- return redirect("#{base}/login?next=#{Js.encode_uri_component(next_path)}") if pending.nil?
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 (real ? @code_tries : @decoy_tries).take(user["id"], now)
418
- return html(Pages.code_page(base, { "pending" => form.get("pending") || "", "next" => next_path, "error" => "Too many tries. Wait fifteen minutes and try again." }), 429)
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
- if !real || !@accounts.check_second_factor(user["id"], form.get("code") || "", now)
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" => "That code is not right. Check the time on your phone, or use a recovery code." }), 401)
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("The two passwords are not the same.") if (form.get("password") || "") != (form.get("again") || "")
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.message)
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
- login_page = ->(opts) { Pages.login_page(base, opts.merge("next" => next_path, "forgot" => @forgot) { |_k, mine, _theirs| mine }) }
475
- too_many = -> { html(login_page.call({ "error" => "Too many tries. Wait fifteen minutes and try again.", "email" => email }), 429) }
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 guessing from many
484
- # addresses learns nothing, and the owner still gets in. With two-factor on, both reach the code step,
485
- # where a wrong password's ticket never passes. Without it, a right password emails a sign-in link.
486
- if over && !(known && known["twoFactor"])
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" => "Too many tries for this account. If the password was right, a link to sign in is on its way to its email address.",
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
- return html(Pages.code_page(base, { "pending" => @accounts.decoy_for(known, now), "next" => next_path })) if over && !known.nil?
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) if !over && !trusted
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.message, "email" => email }), 503, { "retry-after" => "5" })
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)
@@ -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
- :locale_compare, :collation_key, :refused
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
- # setUTCMonth: the same day and time that many months back, a day past the month's end running on.
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
- first = Time.utc(total.div(12), (total % 12) + 1, 1, time.hour, time.min, time.sec)
741
- ((first.to_i + ((time.day - 1) * 86_400)) * 1000) + ms
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: letters before case, as a collator puts them.
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 = Js.lower(a["name"]) <=> Js.lower(b["name"])
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 salts whose day has ended everywhere. The earliest timezone is a
1286
- # day behind UTC and still needs its yesterday, so a salt goes two UTC days
1287
- # after its date.
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
- @store.drop_salts_before(Time.at((at - (2 * 86_400_000)).div(1000)).utc.strftime("%Y-%m-%d"))
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
- session
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 }
@@ -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
@@ -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? || parts.length > 4
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
@@ -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
- { "t" => t, "tn" => tn, "lang" => code }
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(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;")
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