runlight 0.2.0 → 0.3.1

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.
@@ -13,8 +13,8 @@ module Runlight
13
13
  # stats and changes nothing.
14
14
  #
15
15
  # A user is a Hash with the TypeScript User's keys: id, email, hash, role ("owner", "admin", "member", or
16
- # "viewer"), createdAt, twoFactor (whether sign-in also asks for a code), and recoveryLeft (recovery codes not
17
- # yet used). An invite has id, email, role, invitedBy, createdAt, and expiresAt.
16
+ # "viewer"), createdAt, twoFactor (whether sign-in also asks for a code), recoveryLeft (recovery codes not
17
+ # yet used), and signedOut (a mark signing out replaces, which every session is signed with, when set). An invite has id, email, role, invitedBy, createdAt, and expiresAt.
18
18
  class Accounts
19
19
  SESSION_COOKIE = "runlight_session"
20
20
  # Thirty days, renewed on every sign-in.
@@ -401,6 +401,13 @@ module Runlight
401
401
  Crypto.same_text(sign("#{id}.#{expires}", session_key(user)), signature) ? user : nil
402
402
  end
403
403
 
404
+ # Ends every session an account has, in every browser, as signing out does.
405
+ def end_sessions(id)
406
+ init
407
+ @store.db.run("UPDATE rl_users SET signed_out = ? WHERE id = ?", [Hashing.random_id(8), id])
408
+ nil
409
+ end
410
+
404
411
  # A long-lived mark for a browser that signed in to an account. With it, failed tries by others
405
412
  # against that account cannot lock this browser out; the per-address limit still applies.
406
413
  # A new password withdraws it.
@@ -455,7 +462,8 @@ module Runlight
455
462
  names = columns.map { |c| (c["name"] || c["NAME"] || "").to_s }
456
463
  db.run("ALTER TABLE rl_users ADD COLUMN role #{str.call(20)} NOT NULL DEFAULT 'owner'") unless names.include?("role")
457
464
  # Two-factor came later still: the secret (sealed), one being set up, recovery code hashes, and the last code's step.
458
- [%w[totp_secret TEXT], %w[totp_pending TEXT], %w[totp_recovery TEXT], %w[totp_step BIGINT]].each do |name, type|
465
+ # And the mark signing out replaces, later again.
466
+ [%w[totp_secret TEXT], %w[totp_pending TEXT], %w[totp_recovery TEXT], %w[totp_step BIGINT], %w[signed_out TEXT]].each do |name, type|
459
467
  db.run("ALTER TABLE rl_users ADD COLUMN #{name} #{type}") unless names.include?(name)
460
468
  end
461
469
  db.run(
@@ -486,6 +494,7 @@ module Runlight
486
494
  "createdAt" => Js.number(r["created_at"]),
487
495
  "twoFactor" => Js.truthy?(r["totp_secret"]),
488
496
  "recoveryLeft" => recovery.length,
497
+ **(Js.truthy?(r["signed_out"]) ? { "signedOut" => r["signed_out"].to_s } : {}),
489
498
  }
490
499
  end
491
500
 
@@ -580,9 +589,10 @@ module Runlight
580
589
  { "invite" => invite, "code" => code }
581
590
  end
582
591
 
583
- # What a session is signed with: the password hash, and whether two-factor is on, so changing either ends other sessions.
592
+ # What a session is signed with: the password hash, whether two-factor is on, and a mark that signing out
593
+ # replaces, so changing any of them ends other sessions.
584
594
  def session_key(user)
585
- "#{user["hash"]}#{user["twoFactor"] ? ".2fa" : ""}"
595
+ "#{user["hash"]}#{user["twoFactor"] ? ".2fa" : ""}#{user["signedOut"] ? ".#{user["signedOut"]}" : ""}"
586
596
  end
587
597
 
588
598
  def sign(body, hash)
@@ -132,6 +132,8 @@ module Runlight
132
132
  n, r, p = parts[1, 3].map { |part| Js.number(part) }
133
133
  whole = ->(v, max) { v.is_a?(Integer) && v >= 1 && v <= max }
134
134
  return nil unless whole.call(n, 1 << 20) && n >= 2 && (n & (n - 1)).zero? && whole.call(r, 32) && whole.call(p, 16)
135
+ # N must also be below 2^(16r), as scrypt itself requires, so a stored hash past that fails to match rather than raises.
136
+ return nil if Math.log2(n) >= 16 * r
135
137
 
136
138
  { n: n, r: r, p: p }
137
139
  end
@@ -192,9 +192,10 @@ module Runlight
192
192
  save(id, entry)
193
193
  end
194
194
 
195
- # Expired entries go first, then the oldest that are not blocked, and blocked ones last, so the counts
196
- # have a hard ceiling and a flood of made-up names cannot wipe out a real block. Run when a new entry is
197
- # made, since only then can there be more.
195
+ # Expired entries go first, then those with the fewest tries, oldest first, down to nine tenths of the
196
+ # ceiling so this runs seldom. A flood of made-up names has one try each, so it cannot wipe out a block,
197
+ # or the count of a name someone is close to blocking. Run when a new entry is made, since only then can
198
+ # there be more.
198
199
  def prune(now)
199
200
  entries = []
200
201
  @store.settings_starting_with(prefix).each do |row|
@@ -204,23 +205,13 @@ module Runlight
204
205
  @store.set_setting(row["key"], nil)
205
206
  next
206
207
  end
207
- entries << { "key" => row["key"], "until" => until_ms, "blocked" => entry.is_a?(Hash) && int(entry["count"]) >= @limit }
208
- end
209
- size = entries.length
210
- return if size <= Accounts::MAX_THROTTLED
211
-
212
- # Oldest first: each entry's window started windowMs before its end.
213
- entries = entries.each_with_index.sort_by { |e, i| [e["until"], i] }.map(&:first)
214
- [false, true].each do |blocked|
215
- entries.each do |e|
216
- return if size <= Accounts::MAX_THROTTLED
217
-
218
- if e["blocked"] == blocked
219
- @store.set_setting(e["key"], nil)
220
- size -= 1
221
- end
222
- end
208
+ entries << { "key" => row["key"], "until" => until_ms, "count" => int(entry["count"]) }
223
209
  end
210
+ return if entries.length <= Accounts::MAX_THROTTLED
211
+
212
+ # Fewest tries first, and among equals the oldest: each entry's window started windowMs before its end.
213
+ fewest = entries.each_with_index.sort_by { |e, i| [e["count"], e["until"], i] }.map(&:first)
214
+ fewest.first(entries.length - (Accounts::MAX_THROTTLED * 0.9).floor).each { |e| @store.set_setting(e["key"], nil) }
224
215
  end
225
216
  end
226
217
  end
@@ -127,6 +127,11 @@ module Runlight
127
127
  true
128
128
  end
129
129
 
130
+ # The ids of the tokens an account made.
131
+ def tokens_of(by)
132
+ @store.settings_starting_with(MADE_BY).select { |row| row["value"] == by }.map { |row| row["key"][MADE_BY.length..] }.to_set
133
+ end
134
+
130
135
  # Answers an account page or API request at a path under the base, or nil for anything else.
131
136
  # context: {"ip" => the connection's address}, optional.
132
137
  def handle(request, path, context = {})
@@ -293,7 +298,7 @@ module Runlight
293
298
  "to" => invite["email"],
294
299
  "subject" => "#{invite["invitedBy"]} invited you to Runlight",
295
300
  "text" => "#{invite["invitedBy"]} invited you to the Runlight at #{host} as #{what}.\n\nChoose a password to join:\n#{link}\n\nThe link works for seven days.\n",
296
- "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\"><p>" \
301
+ "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><div role=\"img\" aria-label=\"Runlight\" style=\"width:48px;height:48px;margin-bottom:24px;border-radius:10px;background:#111827;color:#fff;font:700 26px/48px -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;text-align:center\">R</div><p>" \
297
302
  "#{esc(invite["invitedBy"])} invited you to the Runlight at #{esc(host)} as #{esc(what)}.</p><p><a href=\"#{esc(link)}\" " \
298
303
  "style=\"display:inline-block;padding:10px 18px;border-radius:8px;background:#111827;color:#ffffff;text-decoration:none;font-weight:600\">" \
299
304
  "Choose a password and join</a></p><p style=\"color:#6b7280;font-size:13px\">The link works for seven days. If you were not expecting this, " \
@@ -331,7 +336,7 @@ module Runlight
331
336
  "subject" => "Sign in to Runlight",
332
337
  "text" => "Someone, most likely you, signed in to Runlight at #{host} with your password while your account was held up by too many failed tries.\n\n" \
333
338
  "Sign in with this link within fifteen minutes:\n#{link}\n\nIf this was not you, change your password, since someone knows it.\n",
334
- "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\">" \
339
+ "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><div role=\"img\" aria-label=\"Runlight\" style=\"width:48px;height:48px;margin-bottom:24px;border-radius:10px;background:#111827;color:#fff;font:700 26px/48px -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;text-align:center\">R</div>" \
335
340
  "<p>Someone, most likely you, signed in to Runlight at #{esc(host)} with your password while your account was held up by too many failed tries.</p>" \
336
341
  "<p><a href=\"#{esc(link)}\" style=\"display:inline-block;padding:10px 18px;border-radius:8px;background:#111827;color:#ffffff;" \
337
342
  "text-decoration:none;font-weight:600\">Sign in</a></p><p style=\"color:#6b7280;font-size:13px\">The link works for fifteen minutes. " \
@@ -353,6 +358,12 @@ module Runlight
353
358
  if path == "/auth.js"
354
359
  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" })
355
360
  end
361
+ # A form posted from another site, or a sibling one, could sign someone in to an account that is not theirs or
362
+ # plant a session, so these pages take forms only from their own. The browser says where a request came from.
363
+ cross_site = %w[cross-site same-site].include?(request.headers.get("sec-fetch-site") || "")
364
+ if cross_site && (method == "POST" || path == "/logout") && %w[/setup /login /login/code /invite /logout].include?(path)
365
+ return html(Pages.login_page(base, { "error" => t.call("oauth.anotherSiteTitle"), "next" => @home_path, "forgot" => @forgot }, lang), 403)
366
+ end
356
367
 
357
368
  if path == "/setup"
358
369
  return redirect("#{base}/login") if has_account?
@@ -431,7 +442,10 @@ module Runlight
431
442
  return signed_in_to(request, user, next_path)
432
443
  end
433
444
 
434
- return redirect("#{base}/login", { "set-cookie" => session_cookie(request, "", 0) }) if path == "/logout"
445
+ # Signing out ends this browser's session; the account page can end them everywhere.
446
+ if path == "/logout"
447
+ return redirect("#{base}/login", { "set-cookie" => session_cookie(request, "", 0) })
448
+ end
435
449
 
436
450
  if path == "/invite"
437
451
  if method == "GET"
@@ -552,6 +566,12 @@ module Runlight
552
566
  return coded("Send JSON", "send_json", 415) if method == "POST" && media_type(request) != "application/json"
553
567
  return reply({ "account" => person(user) }) if path == "/api/account" && method == "GET"
554
568
 
569
+ # Ends the account's sessions in every browser, this one too, so a cookie copied before stops working.
570
+ if path == "/api/account/sign-out-everywhere" && method == "POST"
571
+ @accounts.end_sessions(user["id"])
572
+ return reply({ "ok" => true }, 200, { "set-cookie" => session_cookie(request, "", 0) })
573
+ end
574
+
555
575
  fresh = ->(updated) { { "set-cookie" => session_cookie(request, @accounts.session_for(updated, now), Accounts::SESSION_MS / 1000) } }
556
576
  if path == "/api/account/password" && method == "POST"
557
577
  input = body(request)
@@ -562,6 +582,8 @@ module Runlight
562
582
 
563
583
  begin
564
584
  updated = @accounts.set_password(user["email"], field(input, "next"), now)
585
+ # Asked for after a password got out: the tokens and apps the account made could still read on.
586
+ drop_tokens_of(user["id"]) if input["revokeTokens"] == true
565
587
  # The new password ends every other sign-in; this browser gets a fresh one.
566
588
  return reply({ "ok" => true }, 200, fresh.call(updated))
567
589
  rescue AccountError => e
data/lib/runlight/core.rb CHANGED
@@ -17,12 +17,13 @@ module Runlight
17
17
  # standalone server does. `site` and `sites` are ignored.
18
18
  # - geo: a callable taking an IP and giving { "country", "region", "city" } or nil, a location for an IP when
19
19
  # the platform sends no location headers.
20
- # - trustProxy: true (default), false, or one of "x-forwarded-for", "x-real-ip", "cf-connecting-ip". Read the
21
- # client IP from forwarding headers: the last X-Forwarded-For entry, which the nearest proxy wrote, then
22
- # X-Real-IP, then CF-Connecting-IP. Name one of them to read only that header, such as "cf-connecting-ip"
23
- # behind Cloudflare and another proxy. False reads only the connection's address, for an app nothing sits
24
- # in front of. Left unset, it warns once when a request comes straight from a public address with none of
25
- # those headers.
20
+ # - trustProxy: true, false, or one of "x-forwarded-for", "x-real-ip", "cf-connecting-ip". Read the client IP
21
+ # from forwarding headers: the last X-Forwarded-For entry, which the nearest proxy wrote, then X-Real-IP,
22
+ # then CF-Connecting-IP. Name one of them to read only that header, such as "cf-connecting-ip" behind
23
+ # Cloudflare and another proxy. Left unset, the headers count only on a connection from this computer or a
24
+ # private network, where proxies and load balancers connect from, so a client that reaches the app straight
25
+ # from the internet cannot choose its own address. True reads them from anywhere, for a CDN that connects
26
+ # from public addresses. False reads only the connection's address.
26
27
  # - linkPath: where short links on the app's own domain live, as `{linkPath}/{slug}`. Default "/go".
27
28
  # - mail: how email reports, invites, and sign-in links are sent. Either a mail service in code (a Transports
28
29
  # config plus `from` and `fromName`), which the dashboard shows and cannot change, or a mailer of the app's
@@ -124,8 +125,10 @@ module Runlight
124
125
 
125
126
  @geo = options["geo"]
126
127
  @trust_proxy = options["trustProxy"].nil? ? true : options["trustProxy"]
127
- # True until trustProxy left at its default has been seen answering a public address directly, and warned about once.
128
- @warn_direct = options["trustProxy"].nil?
128
+ # True when trustProxy was left at its default, which believes forwarding headers only from a private address.
129
+ @trust_private = options["trustProxy"].nil?
130
+ # True until a public address has sent forwarding headers that the default did not believe, and been warned about once.
131
+ @warn_direct = @trust_private
129
132
  per_minute = options["rateLimit"].nil? ? 120 : options["rateLimit"]
130
133
  # false, 0, or anything that is not a positive number means no limit, never a limit of nothing.
131
134
  number = per_minute == false ? Float::NAN : Js.number(per_minute)
@@ -781,7 +784,7 @@ module Runlight
781
784
  # Cloudflare before nginx) reads only that one. Otherwise it is the connection's address: the
782
785
  # context's "ip", or else the request's own remote_address.
783
786
  def client_ip(request, context = {})
784
- if @trust_proxy != false
787
+ if trusts?(request, context)
785
788
  h = request.headers
786
789
  last = lambda do |name|
787
790
  value = h.get(name)
@@ -799,16 +802,27 @@ module Runlight
799
802
  end
800
803
  return Js.trim(forwarded) if !forwarded.nil? && Js.trim(forwarded) != ""
801
804
  end
802
- ip = context_ip(context)
803
- ip = (ip.nil? ? request.remote_address : ip).to_s
804
- # A public address with no forwarding header means nothing sits in front, and then any client
805
- # could name its own address in one. Said once, only when trustProxy was left at its default.
806
- if @warn_direct && Safefetch.public_address?(ip)
805
+ connection_ip(request, context).to_s
806
+ end
807
+
808
+ # Whether a request's forwarding headers count. Left at its default, trustProxy believes them only on a
809
+ # connection from this computer or a private network, or one whose address the app did not pass on, since a
810
+ # client reaching the server straight from the internet could write them itself.
811
+ def trusts?(request, context = {})
812
+ return false if @trust_proxy == false
813
+
814
+ ip = connection_ip(request, context)
815
+ return true if !@trust_private || ip.nil? || ip.to_s == "" || !Safefetch.public_address?(ip.to_s)
816
+
817
+ # Said once: a CDN that connects from public addresses needs trustProxy set, or its visitors all look alike.
818
+ h = request.headers
819
+ if @warn_direct && (h.has?("x-forwarded-for") || h.has?("x-real-ip") || h.has?("cf-connecting-ip"))
807
820
  @warn_direct = false
808
- warn("Runlight: a request came straight from a public address with no proxy in front, but trustProxy is on by default, so a client could send X-Forwarded-For and choose its own address, getting round the rate limits. Set trust_proxy: false when nothing sits in front of this server, or put a proxy in front that sets the header.")
821
+ warn("Runlight: a request came from a public address with forwarding headers, which are not believed by default, since a client could write them itself and choose its own address. Behind a CDN that connects from public addresses, such as Cloudflare in front of this server, set trust_proxy to the header it sets (cf-connecting-ip) or to true.")
809
822
  end
810
- ip
823
+ false
811
824
  end
825
+ private :trusts?
812
826
 
813
827
  # Handles one tracker request. Bad input is dropped quietly; only a database that keeps failing raises.
814
828
  def collect(request, context = {})
@@ -834,8 +848,11 @@ module Runlight
834
848
  # another process writing the SQLite file) gets it a little later, at the time it arrived.
835
849
  at = now
836
850
  attempt = 1
851
+ # How far recording the hit got: its visit, and which of its writes went in. A hit tried again after a write
852
+ # failed skips the ones before it, so a visit's counts never take the same hit twice.
853
+ done = {}
837
854
  begin
838
- record(payload, request, context, at)
855
+ record(payload, request, context, at, done)
839
856
  rescue StandardError => e
840
857
  raise if attempt >= 3 || !busy?(e)
841
858
 
@@ -872,7 +889,7 @@ module Runlight
872
889
  def link_domain_response(request, context = {})
873
890
  url = Http::Url.new(request.url)
874
891
  # A forwarded host only counts behind a proxy that sets it; otherwise any client could pick one.
875
- given = forwarded_host(request) || request.headers.get("host") || url.host
892
+ given = forwarded_host(request, context) || request.headers.get("host") || url.host
876
893
  host = Sources.strip_www(Js.trim(given.split(",", -1).first.to_s).split(":", -1).first.to_s)
877
894
  return nil unless link_domain_set.key?(host)
878
895
 
@@ -899,7 +916,7 @@ module Runlight
899
916
  def redirect(request, slug, domain, context = {})
900
917
  init
901
918
  url = Http::Url.new(request.url)
902
- host = Sources.strip_www((forwarded_host(request) || request.headers.get("host") || url.host).split(":", -1).first.to_s)
919
+ host = Sources.strip_www((forwarded_host(request, context) || request.headers.get("host") || url.host).split(":", -1).first.to_s)
903
920
  link = @store.link_by_slug(slug)
904
921
  # The app's own link path answers for every link, so a link whose domain
905
922
  # was removed keeps working; a link domain answers only for its own links.
@@ -1290,8 +1307,16 @@ module Runlight
1290
1307
 
1291
1308
  today = @store.salt(day, Hashing.random_salt)
1292
1309
  yesterday = @store.salt_if_exists(Dates.add_days(day, -1))
1293
- drop_old_salts(at)
1294
- @salts[timezone] = { "day" => day, "today" => today, "yesterday" => yesterday }
1310
+ salts = { "day" => day, "today" => today, "yesterday" => yesterday }
1311
+ # Kept before old salts go, so dropping yesterday's from the store drops it from here too.
1312
+ @salts[timezone] = salts
1313
+ begin
1314
+ drop_old_salts(at)
1315
+ rescue StandardError
1316
+ @salts.delete(timezone)
1317
+ raise
1318
+ end
1319
+ salts
1295
1320
  end
1296
1321
 
1297
1322
  # Deletes every salt no visit can still use. A day's salt counts visitors while it is that day in a
@@ -1319,11 +1344,17 @@ module Runlight
1319
1344
  end
1320
1345
 
1321
1346
  # The host a proxy says the request was for, read only when proxy headers are trusted, as the client's address is.
1322
- def forwarded_host(request)
1323
- @trust_proxy == false ? nil : request.headers.get("x-forwarded-host")
1347
+ def forwarded_host(request, context = {})
1348
+ trusts?(request, context) ? request.headers.get("x-forwarded-host") : nil
1324
1349
  end
1325
1350
 
1326
- def record(payload, request, context, at)
1351
+ # The connection's own address: the context's "ip", or else the request's remote_address, or nil.
1352
+ def connection_ip(request, context)
1353
+ ip = context_ip(context)
1354
+ ip.nil? ? request.remote_address : ip
1355
+ end
1356
+
1357
+ def record(payload, request, context, at, done = {})
1327
1358
  # Managed sites load from the database in init(), so it must come first.
1328
1359
  init
1329
1360
  url = payload["url"]
@@ -1331,19 +1362,19 @@ module Runlight
1331
1362
  return if site.nil?
1332
1363
 
1333
1364
  if payload["kind"] == "engagement"
1334
- engagement(site, payload, at)
1365
+ engagement(site, payload, at, done)
1335
1366
  return
1336
1367
  end
1337
1368
 
1338
1369
  page = Sources.parse_page(url)
1339
- session = nil
1370
+ session = done["session"]
1340
1371
  reopen = true
1341
1372
  if payload["kind"] == "event" && payload["pageviewId"] != ""
1342
1373
  pageview = @store.pageview(site["id"], payload["pageviewId"])
1343
1374
  # An event joins its page's visit unless that visit began longer ago than reports look for its rows
1344
1375
  # (a tab left open for days); it then starts a visit of its own, as any later activity would.
1345
1376
  if !pageview.nil? && at - pageview["startedAt"] < Store::SqlStore::EVENT_TAIL_MS
1346
- session = { "id" => pageview["session"], "visitor" => pageview["visitor"] }
1377
+ session ||= { "id" => pageview["session"], "visitor" => pageview["visitor"] }
1347
1378
  # A visit idle past the 30 minutes stays ended: the event counts in it without reopening it.
1348
1379
  reopen = at - pageview["lastAt"] <= SESSION_IDLE_MS
1349
1380
  if at - pageview["startedAt"] > 3_600_000
@@ -1359,8 +1390,12 @@ module Runlight
1359
1390
  session ||= session_for(site, request, context, page, payload["referrer"], at, {
1360
1391
  "screenWidth" => payload["screenWidth"], "screen" => screen, "language" => payload["language"],
1361
1392
  })
1393
+ done["session"] = session
1362
1394
 
1363
- @store.touch_session(session["id"], at, payload["kind"], page["path"], reopen)
1395
+ unless done["touched"]
1396
+ @store.touch_session(session["id"], at, payload["kind"], page["path"], reopen)
1397
+ done["touched"] = true
1398
+ end
1364
1399
  @store.insert_event({
1365
1400
  "site" => site["id"],
1366
1401
  "ts" => at,
@@ -1401,7 +1436,7 @@ module Runlight
1401
1436
  "mobile" => request.headers.get("sec-ch-ua-mobile"),
1402
1437
  "platform" => request.headers.get("sec-ch-ua-platform"),
1403
1438
  }, client["screenWidth"])
1404
- location = Geo.locate(request.headers, ip, @geo, Js.truthy?(@trust_proxy))
1439
+ location = Geo.locate(request.headers, ip, @geo, trusts?(request, context))
1405
1440
  row = {
1406
1441
  "id" => session["id"],
1407
1442
  "site" => site["id"],
@@ -1434,14 +1469,17 @@ module Runlight
1434
1469
  domains
1435
1470
  end
1436
1471
 
1437
- def engagement(site, payload, at)
1472
+ def engagement(site, payload, at, done = {})
1438
1473
  return if payload["engagedMs"] <= 0
1439
1474
 
1440
1475
  pageview = @store.pageview(site["id"], payload["pageviewId"])
1441
1476
  # Reports look for a visit's rows only so long after it began, so later time on it is let go.
1442
1477
  return if pageview.nil? || at - pageview["startedAt"] >= Store::SqlStore::EVENT_TAIL_MS
1443
1478
 
1444
- @store.add_engagement(pageview["session"], payload["engagedMs"])
1479
+ unless done["engaged"]
1480
+ @store.add_engagement(pageview["session"], payload["engagedMs"])
1481
+ done["engaged"] = true
1482
+ end
1445
1483
  # Only a visit that began more than an hour ago can belong to a day that is already added up.
1446
1484
  if at - pageview["startedAt"] > 3_600_000
1447
1485
  @store.touched_old_visit(site["id"], pageview["startedAt"].to_i, at - ROLLUP_DELAY_MS + 3_600_000)
@@ -46,6 +46,44 @@ module Runlight
46
46
  "systemv/hst10" => "Etc/GMT+10",
47
47
  # Names the database dropped and ICU kept.
48
48
  "canada/east-saskatchewan" => "America/Regina", "us/pacific-new" => "America/Los_Angeles",
49
+ # Old names the time zone database keeps as links in its backward file. Debian, and Ubuntu from 26.04, ship
50
+ # them apart (tzdata-legacy), so a system may not have them; ICU reads them as the links. From tzdata 2026e.
51
+ "africa/asmera" => "Africa/Asmara", "america/argentina/comodrivadavia" => "America/Argentina/Catamarca",
52
+ "america/buenos_aires" => "America/Argentina/Buenos_Aires", "america/catamarca" => "America/Argentina/Catamarca",
53
+ "america/cordoba" => "America/Argentina/Cordoba", "america/fort_wayne" => "America/Indiana/Indianapolis",
54
+ "america/godthab" => "America/Nuuk", "america/indianapolis" => "America/Indiana/Indianapolis",
55
+ "america/jujuy" => "America/Argentina/Jujuy", "america/knox_in" => "America/Indiana/Knox",
56
+ "america/louisville" => "America/Kentucky/Louisville", "america/mendoza" => "America/Argentina/Mendoza",
57
+ "america/rosario" => "America/Argentina/Cordoba", "antarctica/south_pole" => "Antarctica/McMurdo",
58
+ "asia/ashkhabad" => "Asia/Ashgabat", "asia/calcutta" => "Asia/Kolkata", "asia/choibalsan" => "Asia/Ulaanbaatar",
59
+ "asia/chungking" => "Asia/Shanghai", "asia/dacca" => "Asia/Dhaka", "asia/katmandu" => "Asia/Kathmandu",
60
+ "asia/macao" => "Asia/Macau", "asia/rangoon" => "Asia/Yangon", "asia/saigon" => "Asia/Ho_Chi_Minh",
61
+ "asia/thimbu" => "Asia/Thimphu", "asia/ujung_pandang" => "Asia/Makassar", "asia/ulan_bator" => "Asia/Ulaanbaatar",
62
+ "atlantic/faeroe" => "Atlantic/Faroe", "australia/act" => "Australia/Sydney", "australia/lhi" => "Australia/Lord_Howe",
63
+ "australia/north" => "Australia/Darwin", "australia/nsw" => "Australia/Sydney",
64
+ "australia/queensland" => "Australia/Brisbane", "australia/south" => "Australia/Adelaide",
65
+ "australia/tasmania" => "Australia/Hobart", "australia/victoria" => "Australia/Melbourne",
66
+ "australia/west" => "Australia/Perth", "brazil/acre" => "America/Rio_Branco", "brazil/denoronha" => "America/Noronha",
67
+ "brazil/east" => "America/Sao_Paulo", "brazil/west" => "America/Manaus", "canada/atlantic" => "America/Halifax",
68
+ "canada/central" => "America/Winnipeg", "canada/eastern" => "America/Toronto", "canada/mountain" => "America/Edmonton",
69
+ "canada/newfoundland" => "America/St_Johns", "canada/pacific" => "America/Vancouver",
70
+ "canada/saskatchewan" => "America/Regina", "canada/yukon" => "America/Whitehorse",
71
+ "chile/continental" => "America/Santiago", "chile/easterisland" => "Pacific/Easter", "cst6cdt" => "America/Chicago",
72
+ "cuba" => "America/Havana", "egypt" => "Africa/Cairo", "eire" => "Europe/Dublin", "est5edt" => "America/New_York",
73
+ "europe/kiev" => "Europe/Kyiv", "europe/uzhgorod" => "Europe/Kyiv", "europe/zaporozhye" => "Europe/Kyiv",
74
+ "gb" => "Europe/London", "gb-eire" => "Europe/London", "gmt0" => "Etc/GMT", "greenwich" => "Etc/GMT",
75
+ "hongkong" => "Asia/Hong_Kong", "iceland" => "Atlantic/Reykjavik", "iran" => "Asia/Tehran", "israel" => "Asia/Jerusalem",
76
+ "jamaica" => "America/Jamaica", "japan" => "Asia/Tokyo", "kwajalein" => "Pacific/Kwajalein", "libya" => "Africa/Tripoli",
77
+ "mexico/bajanorte" => "America/Tijuana", "mexico/bajasur" => "America/Mazatlan", "mexico/general" => "America/Mexico_City",
78
+ "mst7mdt" => "America/Denver", "navajo" => "America/Denver", "nz" => "Pacific/Auckland", "nz-chat" => "Pacific/Chatham",
79
+ "pacific/enderbury" => "Pacific/Kanton", "pacific/ponape" => "Pacific/Pohnpei", "pacific/truk" => "Pacific/Chuuk",
80
+ "poland" => "Europe/Warsaw", "portugal" => "Europe/Lisbon", "prc" => "Asia/Shanghai", "pst8pdt" => "America/Los_Angeles",
81
+ "roc" => "Asia/Taipei", "rok" => "Asia/Seoul", "singapore" => "Asia/Singapore", "turkey" => "Europe/Istanbul",
82
+ "universal" => "Etc/UTC", "us/alaska" => "America/Anchorage", "us/aleutian" => "America/Adak",
83
+ "us/arizona" => "America/Phoenix", "us/central" => "America/Chicago", "us/east-indiana" => "America/Indiana/Indianapolis",
84
+ "us/eastern" => "America/New_York", "us/hawaii" => "Pacific/Honolulu", "us/indiana-starke" => "America/Indiana/Knox",
85
+ "us/michigan" => "America/Detroit", "us/mountain" => "America/Denver", "us/pacific" => "America/Los_Angeles",
86
+ "us/samoa" => "Pacific/Pago_Pago", "w-su" => "Europe/Moscow", "zulu" => "Etc/UTC",
49
87
  }.freeze
50
88
 
51
89
  OFFSET = /\A([+\-]|−)([01][0-9]|2[0-3])(?::?([0-5][0-9]))?\z/
@@ -27,7 +27,7 @@ module Runlight
27
27
  REDIRECTS = [301, 302, 303, 307, 308].freeze
28
28
  TIMED_OUT = "The operation was aborted due to timeout"
29
29
  # Sent only to the origin they were given to.
30
- CREDENTIALS = %w[authorization cookie proxy-authorization x-api-key].freeze
30
+ CREDENTIALS = %w[authorization cookie proxy-authorization x-api-key apikey].freeze
31
31
 
32
32
  def fetch(url, init = {})
33
33
  deadline = monotonic + ((init["timeoutMs"] || 30_000).to_i / 1000.0)
@@ -14,11 +14,9 @@ module Runlight
14
14
  ISO_DATE = /\A([+-]\d{6}|\d{4})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2})?)?\z/i
15
15
  private_constant :ISO_DATE
16
16
 
17
- # sleep: a callable that waits this many milliseconds. owner: true for a service at an address the owner
18
- # typed (a self-hosted Umami), fetched only on the public internet, as Safefetch.owner_fetch allows.
19
- def initialize(fetcher = nil, sleep = nil, owner: false)
17
+ # sleep: a callable that waits this many milliseconds.
18
+ def initialize(fetcher = nil, sleep = nil)
20
19
  @fetcher = fetcher || Http::NetFetcher.new
21
- @owner = owner
22
20
  @sleep = sleep || ->(ms) { Kernel.sleep(ms / 1000.0) if ms.positive? }
23
21
  end
24
22
 
@@ -26,7 +24,9 @@ module Runlight
26
24
  @sleep.call(ms)
27
25
  end
28
26
 
29
- # Fetches JSON, decoded. `init` may hold "headers", "method", and "body".
27
+ # Fetches JSON, decoded. `init` may hold "headers", "method", and "body". The address can come from whoever
28
+ # runs an import (a self-hosted Umami), so every service, a hosted one too, is asked only at public https
29
+ # addresses, with no redirect followed, which would carry the key somewhere else, as public_fetch allows.
30
30
  def get_json(url, init = {})
31
31
  attempt = 1
32
32
  loop do
@@ -35,7 +35,7 @@ module Runlight
35
35
  options["method"] = init["method"] unless init["method"].nil?
36
36
  options["body"] = init["body"] unless init["body"].nil?
37
37
  begin
38
- response = @owner ? Safefetch.owner_fetch(url, options, @fetcher) : @fetcher.fetch(url, options)
38
+ response = Safefetch.public_fetch(url, options.merge("redirects" => 0), @fetcher)
39
39
  rescue PrivateAddressError, Http::BodyTooLong
40
40
  # An address off the public internet, or an answer past the cap, is the same on every try.
41
41
  host = Http::Url.new(url).host
@@ -25,8 +25,9 @@ module Runlight
25
25
  raise ImportError.new("Runlight cannot import from #{source}", "import_source", { "source" => source }) if name.nil?
26
26
 
27
27
  runlight.init
28
- # Umami is at an address the owner typed; the others are their services' own.
29
- client = Client.new(runlight.fetcher, owner: source == "umami")
28
+ # Umami is at an address the owner typed, and the others are their services' own; each is asked only on the
29
+ # public internet.
30
+ client = Client.new(runlight.fetcher)
30
31
  importer = Importers.const_get(name).new(client, -> { runlight.now })
31
32
  known = lambda do |source_id, slug = nil, url = nil|
32
33
  return true unless runlight.store.link_by_id(Write.imported_link_id(source, Js.string(source_id))).nil?
@@ -36,7 +36,7 @@ module Runlight
36
36
  # The websites an Umami account can see, to pick which one becomes this site's history: a list of
37
37
  # { "id", "name", "domain" }.
38
38
  def umami_websites(credentials, fetcher = nil)
39
- http = Client.new(fetcher, owner: true)
39
+ http = Client.new(fetcher)
40
40
  login = Umami.umami_sign_in(http, credentials)
41
41
  headers = { "authorization" => "Bearer #{Client.str(login["token"])}" }
42
42
  out = []
@@ -74,7 +74,7 @@ module Runlight
74
74
  raise ImportError.new("Unknown site", "unknown_site") if site.nil?
75
75
  raise ImportError.new("Pick the Umami website to import", "import_website") unless website.match?(/\A[A-Za-z0-9-]{1,64}\z/)
76
76
 
77
- http = Client.new(runlight.fetcher, owner: true)
77
+ http = Client.new(runlight.fetcher)
78
78
 
79
79
  saved = !cursor.nil? && cursor != "" ? Json.decode(cursor) : nil
80
80
  login = Umami.umami_sign_in(http, credentials, saved.nil? ? nil : saved["token"])
@@ -23,7 +23,8 @@ module Runlight
23
23
  #
24
24
  # The context the routes pass is a Hash: "runlight", "base", "isOwner" (callable(request) giving a bool), and
25
25
  # optionally "signIn" (String), "isReader" (callable(request) giving a bool), "accountOf" (callable(request)
26
- # giving an account id or nil), and "tokenMade" (callable(token, by) giving a bool).
26
+ # giving an account id or nil), "tokenMade" (callable(token, by) giving a bool), and "csrf" (callable giving the
27
+ # token the consent form sends back in its csrf field).
27
28
  module OAuth
28
29
  CODE_MS = 5 * 60_000
29
30
  # An app stored before client ids were signed, which never finished connecting within a day, is removed.
@@ -206,6 +207,7 @@ module Runlight
206
207
  %w[response_type client_id redirect_uri code_challenge code_challenge_method state scope resource].each do |k|
207
208
  hidden << "<input type=\"hidden\" name=\"#{k}\" value=\"#{esc(form.get(k))}\">" unless form.get(k).nil?
208
209
  end
210
+ hidden << "<input type=\"hidden\" name=\"csrf\" value=\"#{esc(ctx["csrf"].call.to_s)}\">" if ctx["csrf"]
209
211
  # The app names itself, so the page also shows where the answer goes, which it cannot fake.
210
212
  sends_to = "<p class=\"note\">#{h.call("oauth.sendsTo", { "host" => "<strong>#{esc(Http::Url.new(redirect).host)}</strong>" })}</p>"
211
213
  buttons = "<div class=\"buttons\"><button type=\"submit\" name=\"decision\" value=\"deny\" class=\"ghost\">#{h.call("oauth.deny")}</button>" \
@@ -55,9 +55,8 @@ module Runlight
55
55
  else
56
56
  next
57
57
  end
58
- # Assigning out["__proto__"] in JavaScript sets the prototype, which a string cannot be, so
59
- # nothing is kept; it still counts.
60
- out[k] = text if k != "__proto__"
58
+ # A prop named __proto__ is kept like any other, as the SDK keeps it in an object with no prototype.
59
+ out[k] = text
61
60
  count += 1
62
61
  end
63
62
  return nil if count.zero?
@@ -67,6 +66,20 @@ module Runlight
67
66
 
68
67
  # An object's entries in JavaScript's order: keys that are array indexes first, ascending, then the
69
68
  # rest in the order they were added.
69
+ # The events the tracker sends on its own for a link that was followed, each with the link as `url`.
70
+ LINK_EVENTS = ["Outbound link", "File download"].freeze
71
+ private_constant :LINK_EVENTS
72
+
73
+ # An automatic link event's `url` without its query string or fragment, as page paths are kept, since tokens and
74
+ # email addresses ride in them. Done here rather than in the tracker, which has no bytes to spare, and so a
75
+ # tracker cached from before is held to it too.
76
+ def link_props(name, given)
77
+ return given if given.nil? || !LINK_EVENTS.include?(name) || !given.key?("url")
78
+
79
+ given["url"] = given["url"].sub(/[?#].*\z/m, "")
80
+ given
81
+ end
82
+
70
83
  def entries(object)
71
84
  indexes = []
72
85
  names = []
@@ -124,13 +137,13 @@ module Runlight
124
137
  "screenHeight" => int(get.call("h"), 0, 20_000),
125
138
  "language" => str(get.call("l"), 35),
126
139
  "name" => name,
127
- "props" => kind == "event" ? props(get.call("p")) : nil,
140
+ "props" => kind == "event" ? link_props(name, props(get.call("p"))) : nil,
128
141
  "pageviewId" => pageview_id,
129
142
  "engagedMs" => kind == "engagement" ? int(get.call("e"), 0, MAX_ENGAGED_MS) || 0 : 0,
130
143
  "scroll" => int(get.call("d"), 0, 100),
131
144
  }
132
145
  end
133
146
 
134
- private_class_method :str, :int, :props, :entries, :parse
147
+ private_class_method :str, :int, :props, :link_props, :entries, :parse
135
148
  end
136
149
  end
@@ -183,7 +183,7 @@ module Runlight
183
183
  u = Http::Url.parse(links["dashboard"])
184
184
  where = u.nil? ? links["dashboard"] : u.host + u.pathname.sub(%r{/\z}, "")
185
185
  at = t.call("email.at", { "where" => where })
186
- mark = "<img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\">"
186
+ mark = "<div role=\"img\" aria-label=\"Runlight\" style=\"width:48px;height:48px;margin-bottom:24px;border-radius:10px;background:#111827;color:#fff;font:700 26px/48px -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;text-align:center\">R</div>"
187
187
 
188
188
  footer = t.call("email.footer", { "frequency" => t.call(monthly ? "email.monthly" : "email.weekly"), "site" => site["name"] })
189
189
  at_line = esc(t.call("email.at", { "where" => "\0" }))