runlight 0.1.0 → 0.1.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.
@@ -60,7 +60,8 @@ module Runlight
60
60
  # MySQL 8.4 or MariaDB 11.4 and later from a URL like mysql://user:pass@host:3306/db (or mariadb://),
61
61
  # through the trilogy gem when it is installed, else mysql2. The session is utf8mb4 with the server's own
62
62
  # SQL mode plus IGNORE_SPACE, as mysql2 opens it for the TypeScript driver (its connect flags ask for
63
- # that, which neither Ruby driver can, so it is set as the session's mode). Runlight's tables carry their
63
+ # that, which neither Ruby driver can, so it is set as the session's mode), less NO_BACKSLASH_ESCAPES, as
64
+ # stores/mysql.ts drops it. Runlight's tables carry their
64
65
  # own binary collation, so text compares and sorts by code point. statement_timeout is set per
65
66
  # connection as stores/mysql.ts sets it; 0 turns it off.
66
67
  def mysql(url, statement_timeout: 120_000)
@@ -71,7 +72,9 @@ module Runlight
71
72
  db = Database.new(Pools.make(config.compact), owned: true, statement_timeout: statement_timeout.to_i, dialect: "mysql")
72
73
  version, mode = db.connection_class.connection_pool.with_connection { |c| c.select_rows("SELECT VERSION(), @@GLOBAL.sql_mode")[0] }
73
74
  db.connection_class.connection_pool.disconnect!
74
- variables = { sql_mode: (mode.to_s.split(",") | ["IGNORE_SPACE"]).join(",") }
75
+ # Less NO_BACKSLASH_ESCAPES, since values are written with backslash escapes (Database drops it from
76
+ # every session too).
77
+ variables = { sql_mode: ((mode.to_s.split(",") - ["NO_BACKSLASH_ESCAPES"]) | ["IGNORE_SPACE"]).join(",") }
75
78
  if statement_timeout.to_i.positive?
76
79
  variables.merge!(version.to_s.match?(/mariadb/i) ? { max_statement_time: statement_timeout.to_i / 1000.0 } : { max_execution_time: statement_timeout.to_i })
77
80
  end
@@ -26,6 +26,14 @@ module Runlight
26
26
  # characters at most.
27
27
  MYSQL_LOCK = "CONCAT('runlight:', LEFT(SHA2(COALESCE(DATABASE(), ''), 256), 48))"
28
28
 
29
+ # The session's sql_mode as it was, less NO_BACKSLASH_ESCAPES, wherever it sits in the list, as
30
+ # stores/mysql.ts sets it.
31
+ NO_BACKSLASH_ESCAPES_OFF = "SET SESSION sql_mode = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', @@SESSION.sql_mode, ','), ',NO_BACKSLASH_ESCAPES,', ','))"
32
+
33
+ # Deleted rows, such as a day's salt once it is gone, are overwritten where that costs no extra writes, so
34
+ # they do not linger in the file's free space. Rails' pragmas setting takes this one only as on or off.
35
+ SECURE_DELETE = "PRAGMA secure_delete = FAST"
36
+
29
37
  attr_reader :connection_class
30
38
 
31
39
  # connection_class: an ActiveRecord::Base subclass whose pool to use (ActiveRecord::Base for the app's
@@ -38,11 +46,16 @@ module Runlight
38
46
  @statement_timeout = statement_timeout
39
47
  @dialect = dialect
40
48
  @held = 0
49
+ # Set on the thread inside a SQLite turn, whose BEGIN IMMEDIATE Rails does not know about.
50
+ @turn_key = :"runlight_turn_#{object_id}"
51
+ # The MySQL sessions, and the SQLite connections this database opened, already set up, by their driver
52
+ # connection, which a reconnect replaces.
53
+ @set_up = ObjectSpace::WeakMap.new
41
54
  end
42
55
 
43
56
  # "sqlite", "postgres", or "mysql".
44
57
  def dialect
45
- @dialect ||= self.class.dialect_of(connection { |c| c.adapter_name })
58
+ @dialect ||= @connection_class.connection_pool.with_connection { |c| self.class.dialect_of(c.adapter_name) }
46
59
  end
47
60
 
48
61
  def self.dialect_of(adapter)
@@ -80,7 +93,7 @@ module Runlight
80
93
  # transaction already open (the store's, or the app's) is joined, not nested. Yields this database.
81
94
  def transaction
82
95
  connection do |c|
83
- if c.transaction_open?
96
+ if c.transaction_open? || Thread.current[@turn_key]
84
97
  yield self
85
98
  elsif dialect == "mysql"
86
99
  # As Postgres does by default: each statement sees what was committed before it began, and InnoDB
@@ -105,6 +118,41 @@ module Runlight
105
118
  end
106
119
  end
107
120
 
121
+ # Runs the block as the only one reading and changing what it reads, across threads and processes, and
122
+ # yields this database. On Postgres and MySQL that is the database-wide lock. On SQLite it is a
123
+ # transaction begun IMMEDIATE, which takes the file's write lock before the block reads anything, so a
124
+ # second turn waits (up to the busy timeout) rather than reading what the first is about to change. A turn
125
+ # inside a turn, or inside a transaction already open, joins it.
126
+ def turn(&block)
127
+ return exclusive(&block) unless dialect == "sqlite"
128
+
129
+ connection do |c|
130
+ next yield(self) if Thread.current[@turn_key] || c.transaction_open?
131
+
132
+ c.execute("BEGIN IMMEDIATE", "Runlight")
133
+ Thread.current[@turn_key] = true
134
+ failed = nil
135
+ begin
136
+ yield self
137
+ rescue Exception => e
138
+ failed = e
139
+ raise
140
+ ensure
141
+ Thread.current[@turn_key] = nil
142
+ if failed
143
+ begin
144
+ c.execute("ROLLBACK", "Runlight")
145
+ rescue StandardError
146
+ # The error that got here matters more than one rolling back.
147
+ end
148
+ else
149
+ # Also when the block leaves early with return, break, or next.
150
+ c.execute("COMMIT", "Runlight")
151
+ end
152
+ end
153
+ end
154
+ end
155
+
108
156
  def close
109
157
  return unless @owned
110
158
 
@@ -115,7 +163,7 @@ module Runlight
115
163
  # seconds and applies it to every statement; MySQL counts milliseconds and applies it to reads.
116
164
  def limit_statements(ms)
117
165
  connection do |c|
118
- @mariadb = c.select_value("SELECT VERSION() AS v").to_s.match?(/mariadb/i) if @mariadb.nil?
166
+ @mariadb = value(c, "SELECT VERSION() AS v").to_s.match?(/mariadb/i) if @mariadb.nil?
119
167
  c.execute(@mariadb ? "SET SESSION max_statement_time = #{Json.number(ms / 1000.0)}" : "SET SESSION max_execution_time = #{ms.to_i}")
120
168
  end
121
169
  end
@@ -184,8 +232,24 @@ module Runlight
184
232
 
185
233
  private
186
234
 
187
- def connection(&block)
188
- @connection_class.connection_pool.with_connection(&block)
235
+ def connection
236
+ @connection_class.connection_pool.with_connection do |c|
237
+ set_up(c) if dialect == "mysql" || (@owned && dialect == "sqlite")
238
+ yield c
239
+ end
240
+ end
241
+
242
+ # Values are escaped with backslashes, which a server set to NO_BACKSLASH_ESCAPES reads as text, so a
243
+ # quote could end the value early. Every MySQL session Runlight uses drops that one mode first, an app's
244
+ # own ActiveRecord connection's too, and again after a reconnect, which starts the session over. A SQLite
245
+ # file Runlight opened itself turns on secure_delete, as stores/sqlite.ts does.
246
+ def set_up(c)
247
+ raw = c.instance_variable_get(:@raw_connection)
248
+ return if raw && @set_up[raw]
249
+
250
+ c.execute(dialect == "sqlite" ? SECURE_DELETE : NO_BACKSLASH_ESCAPES_OFF, "Runlight")
251
+ raw = c.instance_variable_get(:@raw_connection)
252
+ @set_up[raw] = true if raw
189
253
  end
190
254
 
191
255
  # A statement on a connection. A connection the server dropped (a restart, a failover, an idle timeout)
@@ -199,6 +263,7 @@ module Runlight
199
263
  warn "Runlight: a #{dialect == "mysql" ? "MySQL" : "Postgres"} connection was lost; it reconnects on the next query. #{e.message}"
200
264
  connection do |c|
201
265
  c.reconnect!
266
+ set_up(c) if dialect == "mysql"
202
267
  block.call(c)
203
268
  end
204
269
  end
@@ -233,7 +298,7 @@ module Runlight
233
298
  if dialect == "postgres"
234
299
  # Asked for again and again rather than waited on: a waiting statement would hold up an index being
235
300
  # built CONCURRENTLY by whoever has the lock, and the two would wait on each other for good.
236
- sleep 0.1 until truthy(c.select_value("SELECT pg_try_advisory_lock(#{MIGRATION_LOCK}) AS ok"))
301
+ sleep 0.1 until truthy(value(c, "SELECT pg_try_advisory_lock(#{MIGRATION_LOCK}) AS ok"))
237
302
  begin
238
303
  return yield(self)
239
304
  ensure
@@ -245,7 +310,7 @@ module Runlight
245
310
  end
246
311
  end
247
312
  loop do
248
- ok = c.select_value("SELECT GET_LOCK(#{MYSQL_LOCK}, 5) AS ok")
313
+ ok = value(c, "SELECT GET_LOCK(#{MYSQL_LOCK}, 5) AS ok")
249
314
  raise "Runlight: MySQL refused the lock for creating tables" if ok.nil?
250
315
  break if ok.to_i == 1
251
316
  # Not got within 5 seconds: another process is creating the tables. Ask again.
@@ -265,6 +330,13 @@ module Runlight
265
330
  end
266
331
  end
267
332
 
333
+ # The first column of a statement's first row, always asked of the database. select_value goes through
334
+ # Rails' query cache, which a Rails request turns on for every pool, so a poll for a lock would see its
335
+ # first answer again and again; exec_query is never cached.
336
+ def value(c, sql)
337
+ c.exec_query(sql, "Runlight").rows.first&.first
338
+ end
339
+
268
340
  def truthy(value)
269
341
  value == true || value == "t" || value == 1 || value == "1" || value == "true"
270
342
  end
@@ -72,7 +72,10 @@ module Runlight
72
72
  left = deadline - monotonic
73
73
  raise FetchError.new(TIMED_OUT, timed_out: true) if left <= 0
74
74
 
75
- http = Net::HTTP.new(host, port)
75
+ # A pinned request never goes through a proxy from the environment (http_proxy and the rest), which would
76
+ # connect wherever the name leads now rather than to the address that was checked.
77
+ pinned = !Array(init["resolve"]).empty?
78
+ http = pinned ? Net::HTTP.new(host, port, nil) : Net::HTTP.new(host, port)
76
79
  http.use_ssl = https
77
80
  # A pin connects to the checked address, with the name kept for the Host header and the certificate.
78
81
  Array(init["resolve"]).each do |pin|
@@ -32,11 +32,14 @@ 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
- host = env["HTTP_HOST"]
36
- if host.nil? || host.empty?
35
+ # A Host header no URL can hold (a space, a bad port, a userinfo @) is read as the server's own name, as
36
+ # an absent one is, so the request still has a URL; the header itself is kept as it came.
37
+ host = env["HTTP_HOST"].to_s
38
+ unless host?(scheme, host)
37
39
  port = env["SERVER_PORT"].to_s
38
40
  default = (scheme == "https" && port == "443") || (scheme == "http" && port == "80") || port.empty?
39
41
  host = "#{env["SERVER_NAME"] || "localhost"}#{default ? "" : ":#{port}"}"
42
+ host = "localhost" unless host?(scheme, host)
40
43
  end
41
44
  path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
42
45
  path = "/#{path}" unless path.start_with?("/")
@@ -51,7 +54,9 @@ module Runlight
51
54
  headers.set("content-type", type) unless type.empty?
52
55
  length = env["CONTENT_LENGTH"].to_s
53
56
  headers.set("content-length", length) unless length.empty?
54
- method = env["REQUEST_METHOD"].to_s.upcase
57
+ # Rack::MethodOverride (in every Rails app) turns a form's POST with _method=DELETE into a DELETE. Runlight
58
+ # answers the method the browser sent, so a form on another page cannot pass for a write that needs no JSON.
59
+ method = (env["rack.methodoverride.original_method"] || env["REQUEST_METHOD"]).to_s.upcase
55
60
  body = +""
56
61
  input = env["rack.input"]
57
62
  if read_body && !%w[GET HEAD].include?(method) && input
@@ -64,6 +69,12 @@ module Runlight
64
69
  new(url, method: method, headers: headers, body: body, remote_address: env["REMOTE_ADDR"].to_s)
65
70
  end
66
71
 
72
+ # Whether a Host header is a name (or [address]) and maybe a port, and nothing a URL would read as more.
73
+ def self.host?(scheme, host)
74
+ host.match?(%r{\A(?:\[[0-9A-Fa-f:.]+\]|[^\s/?#@\\\[\]:]+)(?::\d{1,5})?\z}) && !Url.parse("#{scheme}://#{host}/").nil?
75
+ end
76
+ private_class_method :host?
77
+
67
78
  # The most a body may hold at this path: little for the collect endpoint, more for everything else.
68
79
  def self.limit_for(path)
69
80
  path.end_with?("/e") ? MAX_COLLECT_BODY : MAX_BODY
@@ -48,7 +48,7 @@ module Runlight
48
48
  ""
49
49
  end
50
50
 
51
- # A row with no visitor is its own visit, keyed by its whole content so a second import gives it the same ids.
51
+ # A row with no visitor is its own visit, keyed by its whole content.
52
52
  def own_key(row)
53
53
  entries = row.map { |k, v| [k.to_s, v] }
54
54
  entries = entries.sort { |a, b| Js.compare(a[0], b[0]).negative? ? -1 : 1 }
@@ -18,7 +18,9 @@ module Runlight
18
18
  #
19
19
  # Returns { "cursor", "done", "total", "links", "clicks", "skipped", "failed" }, each failure
20
20
  # { "slug", "reason", "code"?, "params"? }.
21
- def import_step(runlight, site, source, credentials, cursor, done)
21
+ # link_domain: the domain as a site keeps it once it passes the checks a link domain added by hand does, or nil
22
+ # to drop it.
23
+ def import_step(runlight, site, source, credentials, cursor, done, link_domain = ->(_domain) {})
22
24
  name = IMPORTERS[source]
23
25
  raise ImportError.new("Runlight cannot import from #{source}", "import_source", { "source" => source }) if name.nil?
24
26
 
@@ -37,6 +39,12 @@ module Runlight
37
39
  # A total that is not a number is as good as none.
38
40
  total = result["total"]
39
41
  total = nil unless total.is_a?(Integer) || (total.is_a?(Float) && total.finite?)
42
+ # Each domain is checked once a step, since the check may look it up in DNS.
43
+ checked = {}
44
+ domain_for = lambda do |domain|
45
+ checked[domain] = link_domain.call(domain) unless checked.key?(domain)
46
+ checked[domain]
47
+ end
40
48
  step = { "cursor" => result["cursor"], "done" => done, "total" => total, "links" => 0, "clicks" => 0, "skipped" => 0, "failed" => [] }
41
49
  result["links"].each do |item|
42
50
  if Js.truthy?(item["known"])
@@ -44,7 +52,7 @@ module Runlight
44
52
  step["skipped"] += 1
45
53
  next
46
54
  end
47
- written = Write.write_link(runlight, site, source, item["link"], item)
55
+ written = Write.write_link(runlight, site, source, item["link"], item, domain_for)
48
56
  step["done"] += 1
49
57
  case written["status"]
50
58
  when "created"
@@ -162,11 +162,13 @@ module Runlight
162
162
  store.db.run("DELETE FROM rl_events WHERE site = ? AND ts >= ? AND ts < ? AND kind IN ('pageview', 'event') AND session IN (#{imported})",
163
163
  [site_id, from, to, site_id])
164
164
  # Visits of these days that kept no rows go too. Their rows would come within EVENT_TAIL_MS of the step,
165
- # so the time bounds let the (site, ts) index find them, with no scan of every event.
165
+ # so the time bounds let the (site, ts) index find them, with no scan of every event. That reaches back
166
+ # EVENT_TAIL_MS too, for a visit an earlier import began before this step whose rows were all in it: a
167
+ # CSV imported again has new ids, so its new visit does not take that one's place.
166
168
  store.db.run(
167
169
  "DELETE FROM rl_sessions WHERE site = ? AND imported = 1 AND started_at >= ? AND started_at < ?
168
170
  AND id NOT IN (SELECT e.session FROM rl_events e WHERE e.site = ? AND e.ts >= ? AND e.ts < ?)",
169
- [site_id, from, to, site_id, from, to + tail],
171
+ [site_id, from - tail, to, site_id, from - tail, to + tail],
170
172
  )
171
173
  hits.each do |h|
172
174
  counts["visits"] += 1 if write_event(store, site, h["ns"], h["hit"])
@@ -336,8 +338,15 @@ module Runlight
336
338
  # One batch of a CSV file, sorted oldest first by the dashboard. As with Umami, only rows from before
337
339
  # Runlight's own first visit, and within what the site keeps, are written. A batch can run again: its
338
340
  # time span is cleared first, so batches must not share a moment, which the dashboard sees to.
339
- # Returns { "pageviews", "events", "visits", "skipped" }.
340
- def import_csv_visits(runlight, site_id, rows)
341
+ #
342
+ # A CSV's visitor column can hold anything, an email or an IP address among them, and a plain hash of
343
+ # one is easily undone by trying every address. So each key is an HMAC under a random secret made for
344
+ # the import and never stored: the first batch gets one, the answer carries it, and the caller sends
345
+ # it with every later batch of the same file, so a visit that runs across two batches stays one. A file
346
+ # imported again gets a new secret and new ids; its time span is cleared first, as always, so nothing is
347
+ # counted twice.
348
+ # Returns { "pageviews", "events", "visits", "skipped", "secret" }.
349
+ def import_csv_visits(runlight, site_id, rows, secret = nil)
341
350
  runlight.init
342
351
  raise ImportError.new("Unknown site", "unknown_site") if runlight.site(site_id).nil?
343
352
  if !rows.is_a?(Array) || rows.length > CsvVisits::CSV_BATCH
@@ -356,6 +365,7 @@ module Runlight
356
365
  format = CsvVisits.csv_format(clean.empty? ? [] : clean[0].keys)
357
366
  raise ImportError.new("This CSV is not an Umami export or Runlight's visit format", "import_csv_format") if format.nil?
358
367
 
368
+ key = secret.is_a?(String) && secret.match?(/\A[A-Za-z0-9_-]{22,128}\z/) ? secret : Accounts::Crypto.base64url(Accounts::Crypto.random_bytes(24))
359
369
  cutoff = runlight.retention_cutoff(site_id) || 0
360
370
  own = runlight.store.first_own_visit(site_id)
361
371
  finish = [own.nil? ? Float::INFINITY : own, runlight.now].min
@@ -369,10 +379,11 @@ module Runlight
369
379
  end
370
380
  hits = hits.each_with_index.sort_by { |h, i| [h["hit"]["ts"], i] }.map(&:first)
371
381
  skipped = clean.length - hits.length
372
- return { "pageviews" => 0, "events" => 0, "visits" => 0, "skipped" => skipped } if hits.empty?
382
+ return { "pageviews" => 0, "events" => 0, "visits" => 0, "skipped" => skipped, "secret" => key } if hits.empty?
373
383
 
384
+ hits.each { |h| h["hit"]["key"] = Accounts::Crypto.base64url(Accounts::Crypto.hmac("SHA-256", key, h["hit"]["key"])) }
374
385
  counts = write_step(runlight, site_id, hits[0]["hit"]["ts"], hits[-1]["hit"]["ts"] + 1, hits)
375
- counts.merge("skipped" => skipped)
386
+ counts.merge("skipped" => skipped, "secret" => key)
376
387
  end
377
388
 
378
389
  private_class_method :progress_key, :all, :write_step, :referrer_of, :from_umami, :write_event
@@ -6,7 +6,9 @@ module Runlight
6
6
  module Write
7
7
  # Domains run by the shorteners themselves. Links there stay on Runlight's own path.
8
8
  SHORTENER_DOMAINS = %w[bit.ly bitly.com j.mp dub.sh dub.co dub.link short.gy rebrand.ly rebrandly.com rb.gy].freeze
9
- private_constant :SHORTENER_DOMAINS
9
+ # The most clicks a day one imported link gets from a daily count.
10
+ MAX_DAILY = 10_000
11
+ private_constant :SHORTENER_DOMAINS, :MAX_DAILY
10
12
 
11
13
  # Browser and system names as other tools write them, in Runlight's spelling.
12
14
  BROWSERS = {
@@ -76,8 +78,10 @@ module Runlight
76
78
  # importing again skips what is already there.
77
79
  #
78
80
  # foreign: { "sourceId", "slug", "domain", "name", "url", "createdAt" }; history: { "clicks"?, "daily"? }.
81
+ # link_domain: given a branded domain, the domain as the site keeps it once it passes a link domain's checks,
82
+ # or nil to bring the link over without it.
79
83
  # Returns { "status", "clicks", "reason"?, "code"?, "params"? }.
80
- def write_link(runlight, site, source, foreign, history)
84
+ def write_link(runlight, site, source, foreign, history, link_domain = ->(_domain) {})
81
85
  source_id = Js.string(foreign["sourceId"])
82
86
  id = imported_link_id(source, source_id)
83
87
  return { "status" => "skipped", "clicks" => 0 } unless runlight.store.link_by_id(id).nil?
@@ -96,8 +100,18 @@ module Runlight
96
100
  "params" => { "slug" => slug } }
97
101
  end
98
102
 
99
- domain = Sources.strip_www(Js.truthy?(foreign["domain"]) ? foreign["domain"].to_s : "")
100
- domain = "" if SHORTENER_DOMAINS.include?(domain)
103
+ # The destination passes the checks a link made by hand does.
104
+ begin
105
+ destination = Links.clean_url(foreign["url"])
106
+ rescue LinkError => e
107
+ return { "status" => "failed", "clicks" => 0, "reason" => e.message, "code" => e.code, "params" => e.params }
108
+ end
109
+
110
+ domain = Sources.strip_www(Js.lower(Js.truthy?(foreign["domain"]) ? foreign["domain"].to_s : ""))
111
+ # A branded domain must pass the checks one added in Settings does, so an import can never claim the
112
+ # dashboard's own host or a name inside a private network. One that fails is left off: the link is
113
+ # served from the app's own path instead.
114
+ domain = domain.empty? || SHORTENER_DOMAINS.include?(domain) ? "" : (link_domain.call(domain) || "")
101
115
  now = runlight.now
102
116
  clicks = 0
103
117
  # Nothing in the transaction is one link's own problem (those are checked above),
@@ -173,7 +187,8 @@ module Runlight
173
187
  start = Client.parse_date("#{d["day"]}T00:00:00Z")
174
188
  next if (start.is_a?(Float) && !start.finite?) || !(d["clicks"].is_a?(Numeric) && d["clicks"].positive?)
175
189
 
176
- n = [d["clicks"], 1_000_000].min
190
+ # A day's count is capped, so one made-up figure from a source cannot write millions of rows.
191
+ n = [d["clicks"], MAX_DAILY].min.floor
177
192
  i = 0
178
193
  while i < n
179
194
  store.insert_event({
@@ -194,7 +209,7 @@ module Runlight
194
209
  "domain" => domain,
195
210
  "slug" => slug,
196
211
  "name" => Js.slice(name, 0, 100),
197
- "url" => foreign["url"],
212
+ "url" => destination,
198
213
  "createdAt" => created,
199
214
  "updatedAt" => created,
200
215
  })
@@ -21,6 +21,19 @@ module Runlight
21
21
  SecureRandom.random_bytes(6).bytes.map { |byte| ALPHABET[byte % ALPHABET.length] }.join
22
22
  end
23
23
 
24
+ # A link's destination as it is kept: a full http or https URL, up to 2,000 characters.
25
+ def self.clean_url(value)
26
+ text = Js.trim(Js.string(value.nil? ? "" : value))
27
+ url = Http::Url.parse(text)
28
+ raise LinkError.new("The destination must be a full URL, starting with https://", "link_url") if url.nil?
29
+ if url.protocol != "https:" && url.protocol != "http:"
30
+ raise LinkError.new("The destination must start with http:// or https://", "link_protocol")
31
+ end
32
+ raise LinkError.new("The destination is longer than 2,000 characters", "link_long") if Js.length(text) > 2000
33
+
34
+ url.href
35
+ end
36
+
24
37
  def create(site, input)
25
38
  input = Options.normalize(input)
26
39
  @runlight.init
@@ -109,15 +122,7 @@ module Runlight
109
122
  private
110
123
 
111
124
  def clean_url(value)
112
- text = Js.trim(Js.string(value.nil? ? "" : value))
113
- url = Http::Url.parse(text)
114
- raise LinkError.new("The destination must be a full URL, starting with https://", "link_url") if url.nil?
115
- if url.protocol != "https:" && url.protocol != "http:"
116
- raise LinkError.new("The destination must start with http:// or https://", "link_protocol")
117
- end
118
- raise LinkError.new("The destination is longer than 2,000 characters", "link_long") if Js.length(text) > 2000
119
-
120
- url.href
125
+ self.class.clean_url(value)
121
126
  end
122
127
 
123
128
  def default_name(url)
@@ -83,6 +83,8 @@ module Runlight
83
83
  s.connect(security == "tls", REPLY_TIMEOUT_MS)
84
84
  expect = lambda do |codes, what|
85
85
  reply = s.next_reply(REPLY_TIMEOUT_MS)
86
+ # Something that is not a mail server gets no more than that said, so its own words are never passed on.
87
+ raise MailError, "SMTP #{what}: #{s.host}:#{s.port} did not answer as a mail server does" unless reply["code"].between?(200, 599)
86
88
  raise MailError, Transports.slice16("SMTP #{what}: #{reply["code"]} #{reply["text"]}", 300) unless codes.include?(reply["code"])
87
89
 
88
90
  reply
@@ -9,6 +9,8 @@ module Runlight
9
9
  # (multi-line included, one at a time), and the whole send's deadline, which
10
10
  # every wait is held to.
11
11
  class SmtpSession
12
+ attr_reader :host, :port
13
+
12
14
  # deadline: nanoseconds on Smtp.monotonic's clock.
13
15
  def initialize(host, port, deadline, late_message)
14
16
  @host = host
@@ -20,12 +22,22 @@ module Runlight
20
22
  @lines = []
21
23
  end
22
24
 
25
+ # A mail server may be on this computer or a private network, but never at a link-local or cloud metadata
26
+ # address. The name is resolved here and the connection goes to the addresses checked, so it cannot
27
+ # answer differently a moment later.
23
28
  def connect(tls, timeout_ms)
24
29
  wait = [timeout_ms, left].min
25
30
  raise late if wait <= 0
26
31
 
32
+ bare = @host.sub(/\A\[/, "").sub(/\]\z/, "")
33
+ addresses = Safefetch.metadata_address?(bare) ? [bare] : Safefetch.lookup(bare)
34
+ if addresses.any? { |address| Safefetch.metadata_address?(address) }
35
+ raise MailError.new("SMTP: #{@host} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused", { "host" => @host })
36
+ end
37
+
27
38
  begin
28
- socket = Socket.tcp(@host, @port, connect_timeout: wait / 1000.0)
39
+ # A name that does not resolve is still tried by name, for the resolver's own words.
40
+ socket = open_socket(addresses.empty? ? [@host] : addresses, wait)
29
41
  rescue SystemCallError, SocketError, IO::TimeoutError => e
30
42
  raise late if left <= 0
31
43
 
@@ -192,6 +204,15 @@ module Runlight
192
204
  @socket = ssl
193
205
  end
194
206
 
207
+ # A connection to the first of the addresses that answers, each tried in the time left.
208
+ def open_socket(targets, wait)
209
+ targets.each_with_index do |target, i|
210
+ return Socket.tcp(target, @port, connect_timeout: [wait, left].min / 1000.0)
211
+ rescue SystemCallError, SocketError, IO::TimeoutError
212
+ raise if i == targets.length - 1 || left <= 0
213
+ end
214
+ end
215
+
195
216
  def connect_detail(error, whole_wait)
196
217
  return(whole_wait ? "timed out" : "connection failed") if error.is_a?(IO::TimeoutError) || error.is_a?(Errno::ETIMEDOUT)
197
218
 
data/lib/runlight/mcp.rb CHANGED
@@ -28,6 +28,8 @@ module Runlight
28
28
  ].freeze
29
29
  # query.ts's MAX_FILTERS.
30
30
  MAX_FILTERS = 6
31
+ # The most messages one batch may hold.
32
+ MAX_BATCH = 20
31
33
 
32
34
  RANGE_KEYS = %w[site period from to filters].freeze
33
35
  COMPARE_KEYS = [*RANGE_KEYS, "compare", "compare_from", "compare_to"].freeze
@@ -321,8 +323,13 @@ module Runlight
321
323
  parsed, body = Js.parse_json(request.text)
322
324
  return Http::Response.new(Json.encode(rpc_error(nil, -32_700, "Send a JSON-RPC message")), status: 400, headers: headers) if !parsed || !Js.object?(body)
323
325
 
324
- # Batches were in the 2025-03-26 protocol; answering them costs nothing.
326
+ # Batches were in the 2025-03-26 protocol. Only a short list, so one request cannot start hundreds of
327
+ # reports; they run one after another here, where TypeScript runs four at a time.
325
328
  if body.is_a?(Array)
329
+ if body.length > MAX_BATCH
330
+ return Http::Response.new(Json.encode(rpc_error(nil, -32_600, "Send at most #{MAX_BATCH} messages in a batch")), status: 400, headers: headers)
331
+ end
332
+
326
333
  answers = body.map { |message| answer(message, read_api) }.compact
327
334
  return answers.empty? ? Http::Response.new("", status: 202) : Http::Response.new(Json.encode(answers), status: 200, headers: headers)
328
335
  end
@@ -230,9 +230,8 @@ module Runlight
230
230
  return oauth_error("unsupported_grant_type", "Only authorization_code is supported") if form.get("grant_type") != "authorization_code"
231
231
 
232
232
  key = "oauth-code:#{Hashing.sha256(form.get("code") || "")}"
233
- stored = runlight.store.setting(key)
234
- # A code works once: it is gone before anything else is checked.
235
- runlight.store.set_setting(key, nil) if !stored.nil? && stored != ""
233
+ # A code works once: it is gone before anything else is checked, and of two swaps at once only one gets it.
234
+ stored = runlight.store.take_setting(key)
236
235
  grant = !stored.nil? && stored != "" ? Json.decode(stored) : nil
237
236
  return oauth_error("invalid_grant", "The code has expired or was already used") if grant.nil? || grant["expires"] < runlight.now
238
237
  if grant["client"] != form.get("client_id") || grant["redirect"] != form.get("redirect_uri")
@@ -33,6 +33,8 @@ module Runlight
33
33
  @observe = observe
34
34
  @route_options = route_options
35
35
  @lock = Mutex.new
36
+ @warming = nil
37
+ @warm_failed_at = nil
36
38
  end
37
39
 
38
40
  def core
@@ -51,10 +53,16 @@ module Runlight
51
53
  rescue Http::Request::TooLarge
52
54
  # The rest is never read, so the connection is not fit for another request.
53
55
  return [413, { "content-type" => "application/json; charset=utf-8", "connection" => "close" }, [TOO_LARGE]]
56
+ rescue StandardError => e
57
+ raise if @app.nil?
58
+
59
+ # Whatever Runlight cannot read, the app behind still answers.
60
+ warn "Runlight: #{e.message}"
61
+ return @app.call(env)
54
62
  end
55
63
  response = answer(request)
56
64
  if response.nil?
57
- core.observe(request) if @observe && request.method == "GET"
65
+ observe(request) if @observe && request.method == "GET"
58
66
  return @app.call(env)
59
67
  end
60
68
  status, headers, body = response.to_rack
@@ -63,21 +71,29 @@ module Runlight
63
71
  end
64
72
 
65
73
  # The answer to one request, or nil when it is not Runlight's and an app sits behind.
74
+ #
75
+ # The app's own requests never wait on Runlight: until Runlight has made its tables they go straight to
76
+ # the app while init runs beside them (a link domain is known only once it has), and anything that goes
77
+ # wrong in Runlight is logged and the request still reaches the app.
66
78
  def answer(request)
67
79
  context = { "ip" => request.remote_address }
68
- linked = core.link_domain_response(request, context)
69
- return linked unless linked.nil?
70
-
71
80
  path = Http::Url.parse(request.url)&.pathname || "/"
72
81
  link_path = core.link_path
73
- if request.method == "GET" && path.match?(%r{\A#{Regexp.escape(link_path)}/[^/]+/?\z})
74
- return core.link_handler.call(request, context)
82
+ link = request.method == "GET" && path.match?(%r{\A#{Regexp.escape(link_path)}/[^/]+/?\z})
83
+ # The discovery documents OAuth clients read at the site's root are answered too, as under the base path.
84
+ own = @app.nil? || mine?(path) || link || Routes.oauth_document?(path)
85
+ if own || ready?
86
+ linked = core.link_domain_response(request, context)
87
+ return linked unless linked.nil?
75
88
  end
76
- return routes.handle(request) if @app.nil? || mine?(path)
89
+ return core.link_handler.call(request, context) if link
90
+ return routes.handle(request) if own
77
91
 
78
92
  nil
79
93
  rescue StandardError => e
80
94
  warn "Runlight: #{e.message}"
95
+ return nil if @app && !own
96
+
81
97
  Http::Response.new(INTERNAL, status: 500, headers: {
82
98
  "content-type" => "application/json; charset=utf-8",
83
99
  "cache-control" => "no-store",
@@ -87,6 +103,37 @@ module Runlight
87
103
 
88
104
  private
89
105
 
106
+ # Whether Runlight has made its tables. Until it has, an app's request starts init beside it, once at a
107
+ # time, and again no sooner than a minute after one that failed.
108
+ def ready?
109
+ return true unless core.respond_to?(:ready?)
110
+ return true if core.ready?
111
+
112
+ @lock.synchronize do
113
+ return false if @warming&.alive?
114
+ return false if @warm_failed_at && monotonic - @warm_failed_at < 60
115
+
116
+ @warming = Thread.new do
117
+ core.init
118
+ rescue StandardError => e
119
+ @warm_failed_at = monotonic
120
+ warn "Runlight: could not get ready #{e.message}"
121
+ end
122
+ end
123
+ false
124
+ end
125
+
126
+ # AI agent fetches are recorded once Runlight is ready, and never hold up or break the page.
127
+ def observe(request)
128
+ core.observe(request) if ready?
129
+ rescue StandardError => e
130
+ warn "Runlight: #{e.message}"
131
+ end
132
+
133
+ def monotonic
134
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
135
+ end
136
+
90
137
  def idle
91
138
  core.idle if core.respond_to?(:idle)
92
139
  rescue StandardError => e