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.
@@ -26,11 +26,17 @@ module Runlight
26
26
  # - cms: the CMS that adds the script to every page, such as "WordPress", when Runlight runs inside one. The
27
27
  # dashboard then says so in place of the steps to add the script, and pages of the same origin, the CMS's own
28
28
  # admin, may show the whole dashboard in a frame. The WordPress, Drupal, and Craft plugins set it.
29
+ # - csrf: a callable returning a token the dashboard sends back with every change it makes, in an x-runlight-csrf
30
+ # header, and the OAuth consent form in a csrf field, for an authorize that checks where a change comes from, as
31
+ # a CMS checks its own forms. Asked for each time the dashboard or the form is shown. The WordPress plugin gives
32
+ # a nonce.
29
33
  # - origin: the address people open the app at, such as https://example.com.
30
34
  # - ownHosts: a callable returning more names the dashboard is reached at.
31
35
  # - accountOf: a callable taking the Request and returning the account it comes from, or nil (internal, for the
32
36
  # standalone server).
33
37
  # - tokenMade: a callable taking a token row and who made it, returning whether that is allowed (internal).
38
+ # - tokensOf: a callable taking an account id, returning the ids of the tokens it made, so a member sees and
39
+ # revokes only their own (internal).
34
40
  class Routes
35
41
  COOKIE = "runlight_token"
36
42
  IMPLEMENTATION = { "library" => "runlight", "language" => "ruby" }.freeze
@@ -169,6 +175,7 @@ module Runlight
169
175
  @sign_out = options["signOut"].nil? ? (web ? "#{@base}/logout" : nil) : options["signOut"].to_s
170
176
  @account_of = options["accountOf"] || (web ? ->(r) { web.account_of(r) } : nil)
171
177
  @token_made = options["tokenMade"] || (web ? ->(row, by) { web.token_made(row, by) } : nil)
178
+ @tokens_of = options["tokensOf"] || (web ? ->(by) { web.tokens_of(by) } : nil)
172
179
  authorize = options["authorize"]
173
180
  oauth = {
174
181
  "runlight" => runlight,
@@ -179,6 +186,7 @@ module Runlight
179
186
  oauth["signIn"] = @sign_in if !@sign_in.nil? && @sign_in != ""
180
187
  oauth["accountOf"] = @account_of unless @account_of.nil?
181
188
  oauth["tokenMade"] = @token_made unless @token_made.nil?
189
+ oauth["csrf"] = options["csrf"] unless options["csrf"].nil?
182
190
  @oauth = oauth
183
191
  end
184
192
 
@@ -384,7 +392,7 @@ module Runlight
384
392
  # The dashboard's page, which holds no data: the API it calls checks access. `embed` is the dashboard inside a
385
393
  # CMS's admin pages: { "session", "origin" }, its session (empty once its ticket was used or ran out) and the
386
394
  # admin origin that frames it.
387
- def dashboard(base, share = "", sign_out = "", geo_credit = false, accounts = false, sign_in = "", embed = nil, cms = "")
395
+ def dashboard(base, share = "", sign_out = "", geo_credit = false, accounts = false, sign_in = "", embed = nil, cms = "", csrf = "")
388
396
  b = escape_attr(base)
389
397
  hash = build_hash("dashboardHash")
390
398
  attributes = (share != "" ? " data-share=\"#{escape_attr(share)}\"" : "") +
@@ -393,6 +401,7 @@ module Runlight
393
401
  (geo_credit ? ' data-geo-credit=""' : "") +
394
402
  (accounts ? ' data-accounts=""' : "") +
395
403
  (cms != "" ? " data-cms=\"#{escape_attr(cms)}\"" : "") +
404
+ (csrf != "" ? " data-csrf=\"#{escape_attr(csrf)}\"" : "") +
396
405
  (embed.nil? ? "" : " data-embed=\"#{escape_attr(embed["session"])}\" data-embed-origin=\"#{escape_attr(embed["origin"])}\"")
397
406
  "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<title>Runlight</title>\n" \
398
407
  "<link rel=\"icon\" href=\"#{Brand.runlight_icon}\">\n" \
@@ -471,12 +480,14 @@ module Runlight
471
480
  n.is_a?(Float) ? (n.finite? ? n.to_i : 0) : n.to_i
472
481
  end
473
482
 
474
- # The controls a member cannot change: the mail service and its keys, the assistant's settings, and deleting a site.
483
+ # The controls a member cannot change: the mail service and its keys, the assistant's settings, a site's agents key,
484
+ # and deleting a site.
475
485
  def admin_only?(path, method)
476
486
  (path == "/api/mail" && %w[PUT DELETE].include?(method)) ||
477
487
  (path == "/api/assistant" && %w[PUT DELETE].include?(method)) ||
478
488
  (path == "/api/assistant/limits" && method == "PUT") ||
479
489
  (path == "/api/assistant/models" && method == "POST") ||
490
+ (path == "/api/observe-key/new" && method == "POST") ||
480
491
  (path.match?(%r{\A/api/sites/[^/]+\z}) && method == "DELETE")
481
492
  end
482
493
 
@@ -1253,7 +1264,7 @@ module Runlight
1253
1264
  "to" => to,
1254
1265
  "subject" => t.call("email.test.subject"),
1255
1266
  "text" => t.call("email.test.body", { "service" => name }),
1256
- "html" => "<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 style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
1267
+ "html" => "<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 style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
1257
1268
  })
1258
1269
  # Counted once it went out, so a test that failed can be tried again as soon as the setting is fixed.
1259
1270
  @lock.synchronize { @sample_sent[tested] = @rl.now }
@@ -1481,13 +1492,23 @@ module Runlight
1481
1492
  nil
1482
1493
  end
1483
1494
 
1495
+ # The tokens the account behind a request made: none when nothing notes who made what.
1496
+ def own_tokens(request)
1497
+ by = @account_of&.call(request)
1498
+ !by.nil? && by != "" && @tokens_of ? @tokens_of.call(by) : Set.new
1499
+ end
1500
+
1484
1501
  def tokens_api(request, path)
1485
1502
  @rl.init
1486
1503
  view = lambda do |t|
1487
1504
  { "id" => t["id"], "name" => t["name"], "site" => t["site"], "scope" => t["scope"], "hint" => t["hint"],
1488
1505
  "createdAt" => t["createdAt"], "lastUsedAt" => t["lastUsedAt"] }
1489
1506
  end
1490
- return json({ "tokens" => store.tokens.map(&view) }) if path == "/api/tokens" && request.method == "GET"
1507
+ # A member makes tokens like anyone who manages sites, but sees and revokes only the ones they made.
1508
+ mine = @members.key?(request) ? own_tokens(request) : nil
1509
+ if path == "/api/tokens" && request.method == "GET"
1510
+ return json({ "tokens" => store.tokens.select { |t| mine.nil? || mine.include?(t["id"]) }.map(&view) })
1511
+ end
1491
1512
 
1492
1513
  if path == "/api/tokens" && request.method == "POST"
1493
1514
  body = read_json(request)
@@ -1517,6 +1538,8 @@ module Runlight
1517
1538
  return json({ "token" => view.call(row), "secret" => secret }, 201)
1518
1539
  end
1519
1540
  if (match = path.match(%r{\A/api/tokens/([a-f0-9]{24})\z})) && request.method == "DELETE"
1541
+ return coded("Unknown token", "unknown_token", 404) if mine && !mine.include?(match[1])
1542
+
1520
1543
  return store.delete_token(match[1]) ? json({ "ok" => true }) : coded("Unknown token", "unknown_token", 404)
1521
1544
  end
1522
1545
 
@@ -1898,7 +1921,7 @@ module Runlight
1898
1921
  end
1899
1922
  return chat(request, url) if path == "/api/assistant/chat" && method == "POST"
1900
1923
 
1901
- # Only the owner manages tokens: an API token cannot make or revoke one.
1924
+ # Only people at the dashboard manage tokens, a member only their own: an API token cannot make or revoke one.
1902
1925
  if path == "/api/tokens" || path.start_with?("/api/tokens/")
1903
1926
  access = can_read(request)
1904
1927
  return denied(access) if access != true
@@ -2456,8 +2479,10 @@ module Runlight
2456
2479
  return json({ "site" => site["id"], "funnels" => store.funnels(site["id"]) }) if params.get("definitions") == "1"
2457
2480
  # One funnel at a time, so a page of funnels never takes every database connection at once.
2458
2481
  rows = store.funnels(site["id"]).map do |funnel|
2459
- counts = store.funnel_counts(query, funnel)
2460
- funnel.merge("steps" => funnel["steps"].each_with_index.map { |step, i| step.merge("visits" => counts[i]) })
2482
+ counted = store.funnel_counts(query, funnel)
2483
+ counts = counted["counts"]
2484
+ row = funnel.merge("steps" => funnel["steps"].each_with_index.map { |step, i| step.merge("visits" => counts[i]) })
2485
+ counted["sampled"] ? row.merge("sampled" => store.funnel_visits) : row
2461
2486
  end
2462
2487
  return json({ "site" => site["id"], "range" => range_out, "funnels" => rows })
2463
2488
  end
@@ -2711,7 +2736,7 @@ module Runlight
2711
2736
  # the page explains how to sign in when it is refused.
2712
2737
  # Inside a CMS, its admin, on this same origin, shows the whole dashboard in a frame; nothing else may.
2713
2738
  cms = @options["cms"].to_s
2714
- return Http::Response.new(dashboard(base, "", @sign_out || "", Js.truthy?(@options["geoCredit"]), !@web.nil?, @sign_in || "", nil, cms), status: 200, headers: {
2739
+ return Http::Response.new(dashboard(base, "", @sign_out || "", Js.truthy?(@options["geoCredit"]), !@web.nil?, @sign_in || "", nil, cms, @options["csrf"].nil? ? "" : @options["csrf"].call.to_s), status: 200, headers: {
2715
2740
  "content-type" => "text/html; charset=utf-8",
2716
2741
  "cache-control" => "no-store",
2717
2742
  "content-security-policy" => cms != "" ? DASHBOARD_CSP.sub("frame-ancestors 'none'", "frame-ancestors 'self'") : DASHBOARD_CSP,
@@ -80,22 +80,30 @@ module Runlight
80
80
  return false if g.nil?
81
81
 
82
82
  embedded = ->(hi, lo) { [hi >> 8, hi & 255, lo >> 8, lo & 255] }
83
- zero = ->(groups) { groups.all?(&:zero?) }
84
- # IPv4 inside IPv6: mapped (::ffff:0:0/96), the old compatible form (::/96), and NAT64 (64:ff9b::/96).
85
- if zero.call(g[0, 5]) && (g[5] == 0xffff || g[5].zero?)
86
- return g[5].zero? && g[6].zero? && g[7] <= 1 ? false : public_v4?(embedded.call(g[6], g[7]))
87
- end
88
- return public_v4?(embedded.call(g[6], g[7])) if g[0] == 0x64 && g[1] == 0xff9b && zero.call(g[2, 4])
83
+ # IPv4 inside IPv6: mapped (::ffff:0:0/96), the old compatible form (::/96), translated (::ffff:0:0:0/96),
84
+ # and NAT64 (64:ff9b::/96).
85
+ inside = embedded_v4(g)
86
+ return g[5].zero? && g[4].zero? && g[6].zero? && g[7] <= 1 ? false : public_v4?(inside) unless inside.nil?
89
87
  # 6to4 carries an IPv4 address in its second and third groups.
90
88
  return public_v4?(embedded.call(g[1], g[2])) if g[0] == 0x2002
91
- return false if (g[0] & 0xfe00) == 0xfc00 || (g[0] & 0xffc0) == 0xfe80 || (g[0] & 0xff00) == 0xff00
92
- # Teredo, documentation, and discard prefixes.
93
- return false if g[0] == 0x2001 && (g[1].zero? || g[1] == 0xdb8)
94
- return false if g[0] == 0x100 && zero.call(g[1, 3])
89
+ # Only global unicast (2000::/3) is public, which leaves out unique local, link-local, the old site-local,
90
+ # multicast, discard, and local-use NAT64 (64:ff9b:1::/48), which may carry any address.
91
+ return false if (g[0] & 0xe000) != 0x2000
92
+ # The protocol assignments (2001::/23, with Teredo and ORCHID) and the documentation prefixes.
93
+ return false if g[0] == 0x2001 && (g[1] < 0x200 || g[1] == 0xdb8)
94
+ return false if (g[0] & 0xfff0) == 0x3ff0
95
95
 
96
96
  true
97
97
  end
98
98
 
99
+ # The IPv4 address inside a mapped, compatible, translated, or NAT64 IPv6 address, or nil.
100
+ def embedded_v4(g)
101
+ inside = (g[0, 5].all?(&:zero?) && (g[5] == 0xffff || g[5].zero?)) ||
102
+ (g[0, 4].all?(&:zero?) && g[4] == 0xffff && g[5].zero?) ||
103
+ (g[0] == 0x64 && g[1] == 0xff9b && g[2, 4].all?(&:zero?))
104
+ inside ? [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255] : nil
105
+ end
106
+
99
107
  # Names clouds give their metadata services.
100
108
  METADATA_NAMES = %w[metadata metadata.google.internal metadata.goog instance-data instance-data.ec2.internal].freeze
101
109
 
@@ -105,10 +113,8 @@ module Runlight
105
113
  def metadata_address?(ip)
106
114
  four = v4(ip)
107
115
  g = four.nil? ? v6(ip) : nil
108
- # IPv4 inside IPv6, mapped (::ffff:0:0/96), compatible (::/96), or NAT64 (64:ff9b::/96), is read as IPv4.
109
- if four.nil? && !g.nil? && ((g[0, 5].all?(&:zero?) && (g[5] == 0xffff || g[5].zero?)) || (g[0] == 0x64 && g[1] == 0xff9b && g[2, 4].all?(&:zero?)))
110
- four = [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255]
111
- end
116
+ # IPv4 inside IPv6, mapped, compatible, translated, or NAT64, is read as IPv4.
117
+ four = embedded_v4(g) if four.nil? && !g.nil?
112
118
  return (four[0] == 169 && four[1] == 254) || four == [100, 100, 100, 200] unless four.nil?
113
119
  return false if g.nil?
114
120
  return true if (g[0] & 0xffc0) == 0xfe80
@@ -16,7 +16,8 @@ module Runlight
16
16
  # RUNLIGHT_SECRET signs sessions and encrypts saved keys (made and kept in DATA_DIR if unset)
17
17
  # RUNLIGHT_TOKEN also accepted as a bearer token on the API, and makes the first account
18
18
  # RUNLIGHT_URL the dashboard's public address, which can never become a link domain
19
- # TRUST_PROXY "false" when no proxy sits in front, so forwarded addresses are ignored
19
+ # TRUST_PROXY unset believes forwarded addresses only from a private network; "false" never,
20
+ # "true" from anywhere, or the one header a CDN sets, such as cf-connecting-ip
20
21
  # RUNLIGHT_GEO city (the default), country, off, or the path to an MMDB file
21
22
  # CRON_SECRET lets a scheduler run the check over HTTP, at POST /api/check
22
23
  # RUNLIGHT_OBSERVE_KEY one key for every site's AI agent reports
@@ -115,7 +116,7 @@ module Runlight
115
116
  end
116
117
 
117
118
  # false with nothing in front, or the one header your proxy sets, such as cf-connecting-ip behind Cloudflare.
118
- # Unset stays nil, so the library's default applies and it can warn when nothing sits in front.
119
+ # Unset stays nil, so the library's default applies: forwarding headers count only from a private address.
119
120
  def trust_proxy
120
121
  value = (get("TRUST_PROXY") || "").downcase
121
122
  return nil if value == ""
@@ -27,6 +27,8 @@ module Runlight
27
27
 
28
28
  # The most visits journeys reads, newest first.
29
29
  JOURNEY_VISITS = 20_000
30
+ # The most visits a funnel is counted over: the newest in the range, so its rows fit in memory over any range.
31
+ FUNNEL_VISITS = 100_000
30
32
 
31
33
  # How long after a visit starts its events are looked for: far past any real visit.
32
34
  EVENT_TAIL_MS = 2 * 86_400_000
@@ -25,6 +25,7 @@ module Runlight
25
25
  class SqlStore
26
26
  BOUNCE_MS = Sql::BOUNCE_MS
27
27
  JOURNEY_VISITS = Sql::JOURNEY_VISITS
28
+ FUNNEL_VISITS = Sql::FUNNEL_VISITS
28
29
  EVENT_TAIL_MS = Sql::EVENT_TAIL_MS
29
30
  MYSQL_COLLATION = Sql::MYSQL_COLLATION
30
31
 
@@ -34,6 +35,8 @@ module Runlight
34
35
  attr_reader :db,
35
36
  # What the store's table and index names start with: `rl_` unless it was given another.
36
37
  :prefix
38
+ # The most visits a funnel is counted over; smaller in tests, to try the cap.
39
+ attr_accessor :funnel_visits
37
40
 
38
41
  # `prefix` takes the place of `rl_` at the start of every table and index name, so two apps can each keep
39
42
  # Runlight's tables in one database. A database that is already prefixed keeps its own.
@@ -45,6 +48,7 @@ module Runlight
45
48
  end
46
49
  @db = db
47
50
  @prefix = prefix
51
+ @funnel_visits = FUNNEL_VISITS
48
52
  @ready = false
49
53
  @checked_all = false
50
54
  end
@@ -575,7 +579,18 @@ module Runlight
575
579
  # How many visits reached each step, in order, within the same visit. Step
576
580
  # one is the first matching row in the range; each later step must come
577
581
  # after the step before it. Filters choose which visits enter the funnel.
582
+ # {"counts" => Array<Integer>, "sampled" => bool}
578
583
  def funnel_counts(query, funnel)
584
+ # Past funnel_visits visits in the range, only the newest are counted: the range starts where the oldest
585
+ # of them began.
586
+ scope = Sql.visit_scope(query["filters"], query["site"], query["from"], query["to"], @db.dialect)
587
+ first = @db.all(
588
+ "SELECT COUNT(*) AS n, MIN(started_at) AS t FROM (SELECT s.started_at AS started_at FROM rl_sessions s WHERE s.site = ? AND s.started_at >= ? AND s.started_at < ? AND #{Sql::IS_VISIT}#{scope["sql"]}
589
+ ORDER BY s.started_at DESC, s.id LIMIT #{@funnel_visits + 1}) x",
590
+ [query["site"], query["from"], query["to"], *scope["params"]],
591
+ )[0] || {}
592
+ sampled = Sql.num(first["n"]) > @funnel_visits
593
+ query = query.merge("from" => [query["from"], Sql.num(first["t"]) + 1].max) if sampled
579
594
  # The rows of the picked visits that match any step, in order, read once and walked here: a join from
580
595
  # each step to the next is planned badly by Postgres, which cannot guess how many visits go on.
581
596
  v = Sql.visit_rows(query["filters"], query["site"], query["from"], query["to"], @db.dialect)
@@ -611,7 +626,7 @@ module Runlight
611
626
  reached += 1 if reached < counts.length && Sql.num(row["m#{reached}"]) == 1
612
627
  end
613
628
  close.call
614
- counts
629
+ { "counts" => counts, "sampled" => sampled }
615
630
  end
616
631
 
617
632
  # Each visit's pageviews in order, at most `per_visit` of them, for journeys.
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Runlight
6
6
  # The gem's own version, which the release keeps equal to the npm package's.
7
- GEM_VERSION = "0.2.0"
7
+ GEM_VERSION = "0.3.1"
8
8
 
9
9
  # The SDK's version and the HTTP API's, read from assets/build.json, which
10
10
  # scripts/ruby-assets.mts writes from the TypeScript SDK, so the two always
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: runlight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-10-18 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -33,7 +32,6 @@ dependencies:
33
32
  description: 'Mount the Rails engine or the Rack app, add a script tag, and read your
34
33
  stats at /runlight. The Ruby port of the runlight npm package: same tables, same
35
34
  answers, same dashboard.'
36
- email:
37
35
  executables:
38
36
  - runlight
39
37
  extensions: []
@@ -157,7 +155,6 @@ metadata:
157
155
  bug_tracker_uri: https://github.com/runlightsh/runlight/issues
158
156
  documentation_uri: https://runlight.sh/docs/ruby/
159
157
  rubygems_mfa_required: 'true'
160
- post_install_message:
161
158
  rdoc_options: []
162
159
  require_paths:
163
160
  - lib
@@ -172,8 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
172
169
  - !ruby/object:Gem::Version
173
170
  version: '0'
174
171
  requirements: []
175
- rubygems_version: 3.0.3.1
176
- signing_key:
172
+ rubygems_version: 3.6.9
177
173
  specification_version: 4
178
174
  summary: Privacy friendly web analytics that lives inside your Ruby app.
179
175
  test_files: []