runlight 0.1.0 → 0.1.3

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/assets/build.json +4 -4
  3. data/assets/dashboard.css +1 -1
  4. data/assets/dashboard.js +71 -22
  5. data/assets/locales.json +5 -5
  6. data/assets/pick-return.js +1 -0
  7. data/assets/picker.js +1 -1
  8. data/assets/tracker.js +1 -1
  9. data/lib/runlight/accounts/accounts.rb +90 -19
  10. data/lib/runlight/accounts/throttle.rb +123 -38
  11. data/lib/runlight/accounts/web.rb +50 -14
  12. data/lib/runlight/assistant.rb +40 -8
  13. data/lib/runlight/connect.rb +2 -3
  14. data/lib/runlight/core.rb +148 -34
  15. data/lib/runlight/dates.rb +31 -8
  16. data/lib/runlight/db/connect.rb +32 -6
  17. data/lib/runlight/db/db.rb +129 -9
  18. data/lib/runlight/db/prefixed.rb +92 -0
  19. data/lib/runlight/geo.rb +4 -3
  20. data/lib/runlight/http/fetcher.rb +31 -6
  21. data/lib/runlight/http/request.rb +14 -3
  22. data/lib/runlight/importers/csv_visits.rb +1 -1
  23. data/lib/runlight/importers/index.rb +10 -2
  24. data/lib/runlight/importers/visits.rb +17 -6
  25. data/lib/runlight/importers/write.rb +21 -6
  26. data/lib/runlight/links.rb +14 -9
  27. data/lib/runlight/mail/ses.rb +3 -2
  28. data/lib/runlight/mail/smtp.rb +2 -0
  29. data/lib/runlight/mail/smtp_session.rb +22 -1
  30. data/lib/runlight/mail/transports.rb +19 -2
  31. data/lib/runlight/mcp.rb +8 -1
  32. data/lib/runlight/metadata_address_error.rb +13 -0
  33. data/lib/runlight/oauth.rb +85 -18
  34. data/lib/runlight/rack_app.rb +64 -10
  35. data/lib/runlight/redirect_refused_error.rb +14 -0
  36. data/lib/runlight/routes.rb +306 -144
  37. data/lib/runlight/safefetch.rb +133 -1
  38. data/lib/runlight/server/agents.rb +1 -1
  39. data/lib/runlight/server/cli.rb +1 -1
  40. data/lib/runlight/server/config.rb +64 -19
  41. data/lib/runlight/server/standalone.rb +7 -6
  42. data/lib/runlight/store/sql.rb +10 -0
  43. data/lib/runlight/store/sql_store.rb +75 -13
  44. data/lib/runlight/stores.rb +19 -15
  45. data/lib/runlight/tables_busy_error.rb +11 -0
  46. data/lib/runlight/version.rb +1 -1
  47. data/lib/runlight.rb +4 -0
  48. metadata +7 -2
@@ -19,8 +19,10 @@ module Runlight
19
19
  COMPARE_MODES = %w[previous year custom off].freeze
20
20
 
21
21
  MAX_BUCKETS = 1000
22
- # A month of hours. Longer hourly ranges are cut off rather than refused.
22
+ # A month of hours. A longer range is counted by the day instead.
23
23
  MAX_HOURS = 744
24
+ # The next coarser interval, for a range too long for its own.
25
+ COARSER = { "hour" => "day", "day" => "week", "week" => "month" }.freeze
24
26
 
25
27
  # Names Intl reads as another zone where TZInfo has no name, or reads it differently.
26
28
  ALIASES = {
@@ -47,7 +49,7 @@ module Runlight
47
49
  }.freeze
48
50
 
49
51
  OFFSET = /\A([+\-]|−)([01][0-9]|2[0-3])(?::?([0-5][0-9]))?\z/
50
- private_constant :MAX_BUCKETS, :MAX_HOURS, :ALIASES, :OFFSET
52
+ private_constant :MAX_BUCKETS, :MAX_HOURS, :COARSER, :ALIASES, :OFFSET
51
53
 
52
54
  # A zone: a TZInfo::Timezone, or the seconds of a fixed offset; false where Intl throws a RangeError.
53
55
  @zones = {}
@@ -284,8 +286,26 @@ module Runlight
284
286
  end
285
287
 
286
288
  interval = INTERVALS.include?(input["interval"]) ? input["interval"] : default_interval(from_date, to_date)
287
- { "from" => start_of(from_date, timezone), "to" => start_of(add_days(to_date, 1), timezone), "fromDate" => from_date,
288
- "toDate" => to_date, "interval" => interval }
289
+ from_ms = start_of(from_date, timezone)
290
+ to_ms = start_of(add_days(to_date, 1), timezone)
291
+ # An interval too fine for the range moves up to the next one that fits, and the range says which it is.
292
+ # Past 1,000 months there is none.
293
+ while bucket_count(from_ms, to_ms, from_date, to_date, interval) > (interval == "hour" ? MAX_HOURS : MAX_BUCKETS)
294
+ interval = COARSER[interval]
295
+ return nil if interval.nil?
296
+ end
297
+ { "from" => from_ms, "to" => to_ms, "fromDate" => from_date, "toDate" => to_date, "interval" => interval }
298
+ end
299
+
300
+ # How many chart buckets a range has at an interval, the first and last counted even when partial.
301
+ def bucket_count(from_ms, to_ms, from_date, to_date, interval)
302
+ return (to_ms - from_ms).fdiv(3_600_000).ceil if interval == "hour"
303
+ return days_between(from_date, to_date) + 1 if interval == "day"
304
+ return tdiv(days_between(add_days(from_date, -weekday(from_date)), to_date), 7) + 1 if interval == "week"
305
+
306
+ fy, fm = split(from_date)
307
+ ty, tm = split(to_date)
308
+ ((ty - fy) * 12) + (tm - fm) + 1
289
309
  end
290
310
 
291
311
  def add_years(date, years)
@@ -323,12 +343,15 @@ module Runlight
323
343
  "toDate" => to_date, "interval" => range["interval"] }
324
344
  end
325
345
 
326
- # Chart buckets covering a range, each starting on a local boundary.
346
+ # Chart buckets covering a range, each starting on a local boundary and ending at the next, so only the
347
+ # first and last can be partial. resolve_range picks an interval that fits; a range given here with more
348
+ # buckets than that, such as a longer comparison, is cut off at a boundary, never merged into its last bucket.
327
349
  def buckets(range, timezone)
328
350
  starts = []
351
+ # One start past the limit, so the last bucket kept still ends where the next would begin.
329
352
  if range["interval"] == "hour"
330
353
  t = range["from"]
331
- while t < range["to"] && starts.length < MAX_HOURS
354
+ while t < range["to"] && starts.length <= MAX_HOURS
332
355
  starts << t
333
356
  t += 3_600_000
334
357
  end
@@ -339,7 +362,7 @@ module Runlight
339
362
  elsif range["interval"] == "month"
340
363
  date = "#{date[0, 8]}01"
341
364
  end
342
- while date <= range["toDate"] && starts.length < MAX_BUCKETS
365
+ while date <= range["toDate"] && starts.length <= MAX_BUCKETS
343
366
  starts << start_of(date, timezone)
344
367
  date = case range["interval"]
345
368
  when "day" then add_days(date, 1)
@@ -348,7 +371,7 @@ module Runlight
348
371
  end
349
372
  end
350
373
  end
351
- starts.each_with_index.map do |start, i|
374
+ starts.first(range["interval"] == "hour" ? MAX_HOURS : MAX_BUCKETS).each_with_index.map do |start, i|
352
375
  { "start" => [start, range["from"]].max, "end" => [starts[i + 1] || range["to"], range["to"]].min }
353
376
  end
354
377
  end
@@ -31,9 +31,15 @@ module Runlight
31
31
  def sqlite(path)
32
32
  path = path.to_s
33
33
  memory = path == ":memory:" || path.empty?
34
- # A file in a folder that is not there yet gets the folder, as a fresh app's data/ often is.
35
- FileUtils.mkdir_p(File.dirname(path)) if !memory && !path.start_with?("file:") && !File.directory?(File.dirname(path))
36
- config = { adapter: "sqlite3", database: memory ? ":memory:" : path, timeout: 5000,
34
+ file = !memory && !path.start_with?("file:")
35
+ # A file in a folder that is not there yet gets the folder, as a fresh app's data/ often is, for its
36
+ # owner and group alone, as the file is.
37
+ if file && !File.directory?(File.dirname(path))
38
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o770)
39
+ owner_and_group(File.dirname(path), 0o770)
40
+ end
41
+ made(path) if file && !File.exist?(path)
42
+ config = { adapter: "sqlite3", database: memory ? ":memory:" : path, timeout: Database::BUSY_TIMEOUT_MS,
37
43
  # One connection for a database in memory, which every connection would otherwise make anew.
38
44
  pool: memory ? 1 : 5 }
39
45
  Database.new(Pools.make(config), owned: true, dialect: "sqlite")
@@ -57,10 +63,11 @@ module Runlight
57
63
  Database.new(Pools.make(config.compact), owned: true, dialect: "postgres")
58
64
  end
59
65
 
60
- # MySQL 8.4 or MariaDB 11.4 and later from a URL like mysql://user:pass@host:3306/db (or mariadb://),
66
+ # MySQL 8.0.20 or MariaDB 11.4 and later from a URL like mysql://user:pass@host:3306/db (or mariadb://),
61
67
  # through the trilogy gem when it is installed, else mysql2. The session is utf8mb4 with the server's own
62
68
  # 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
69
+ # that, which neither Ruby driver can, so it is set as the session's mode), less NO_BACKSLASH_ESCAPES, as
70
+ # stores/mysql.ts drops it. Runlight's tables carry their
64
71
  # own binary collation, so text compares and sorts by code point. statement_timeout is set per
65
72
  # connection as stores/mysql.ts sets it; 0 turns it off.
66
73
  def mysql(url, statement_timeout: 120_000)
@@ -71,7 +78,9 @@ module Runlight
71
78
  db = Database.new(Pools.make(config.compact), owned: true, statement_timeout: statement_timeout.to_i, dialect: "mysql")
72
79
  version, mode = db.connection_class.connection_pool.with_connection { |c| c.select_rows("SELECT VERSION(), @@GLOBAL.sql_mode")[0] }
73
80
  db.connection_class.connection_pool.disconnect!
74
- variables = { sql_mode: (mode.to_s.split(",") | ["IGNORE_SPACE"]).join(",") }
81
+ # Less NO_BACKSLASH_ESCAPES, since values are written with backslash escapes (Database drops it from
82
+ # every session too).
83
+ variables = { sql_mode: ((mode.to_s.split(",") - ["NO_BACKSLASH_ESCAPES"]) | ["IGNORE_SPACE"]).join(",") }
75
84
  if statement_timeout.to_i.positive?
76
85
  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
86
  end
@@ -90,6 +99,23 @@ module Runlight
90
99
  end
91
100
  end
92
101
 
102
+ # A database file made here is for its owner and group alone (0660), whatever the umask: the visits in it
103
+ # are nobody else's to read, and a scheduled job running as another user of the same group can still
104
+ # write to it. It is made empty, which SQLite takes as a new database, before anything opens it, so the
105
+ # -wal and -shm files SQLite makes beside it later take the same mode.
106
+ def made(path)
107
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o660) { nil }
108
+ owner_and_group(path, 0o660)
109
+ rescue SystemCallError
110
+ # Made by another process meanwhile, or a folder this one may not write to, which opening reports.
111
+ end
112
+
113
+ def owner_and_group(path, mode)
114
+ File.chmod(mode, path)
115
+ rescue SystemCallError, NotImplementedError
116
+ # Not a file this process may change, or a system with no such modes.
117
+ end
118
+
93
119
  def quote_name(name, quote)
94
120
  "#{quote}#{name.to_s.gsub(quote, quote * 2)}#{quote}"
95
121
  end
@@ -22,10 +22,38 @@ module Runlight
22
22
  # Arbitrary but fixed, so every Runlight process takes the same lock to create tables.
23
23
  MIGRATION_LOCK = 7_331_906
24
24
 
25
+ # The longest a process waits for another one creating or upgrading the tables before it gives up.
26
+ LOCK_WAIT_MS = 30_000
27
+
28
+ @lock_wait = LOCK_WAIT_MS
29
+
30
+ class << self
31
+ # How long the lock for creating tables is waited for: LOCK_WAIT_MS, unless a test has set another.
32
+ def lock_wait_ms
33
+ @lock_wait
34
+ end
35
+
36
+ # For tests: the lock is waited for this long instead, or LOCK_WAIT_MS again with nil.
37
+ def lock_wait_in_tests(ms)
38
+ @lock_wait = ms || LOCK_WAIT_MS
39
+ end
40
+ end
41
+
25
42
  # One lock per MySQL database, so installs sharing a server do not wait on each other. Lock names are 64
26
43
  # characters at most.
27
44
  MYSQL_LOCK = "CONCAT('runlight:', LEFT(SHA2(COALESCE(DATABASE(), ''), 256), 48))"
28
45
 
46
+ # The session's sql_mode as it was, less NO_BACKSLASH_ESCAPES, wherever it sits in the list, as
47
+ # stores/mysql.ts sets it.
48
+ NO_BACKSLASH_ESCAPES_OFF = "SET SESSION sql_mode = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', @@SESSION.sql_mode, ','), ',NO_BACKSLASH_ESCAPES,', ','))"
49
+
50
+ # Deleted rows, such as a day's salt once it is gone, are overwritten where that costs no extra writes, so
51
+ # they do not linger in the file's free space. Rails' pragmas setting takes this one only as on or off.
52
+ SECURE_DELETE = "PRAGMA secure_delete = FAST"
53
+
54
+ # How long a SQLite connection waits for a lock another one holds, as Connect.sqlite sets it.
55
+ BUSY_TIMEOUT_MS = 5000
56
+
29
57
  attr_reader :connection_class
30
58
 
31
59
  # connection_class: an ActiveRecord::Base subclass whose pool to use (ActiveRecord::Base for the app's
@@ -38,11 +66,16 @@ module Runlight
38
66
  @statement_timeout = statement_timeout
39
67
  @dialect = dialect
40
68
  @held = 0
69
+ # Set on the thread inside a SQLite turn, whose BEGIN IMMEDIATE Rails does not know about.
70
+ @turn_key = :"runlight_turn_#{object_id}"
71
+ # The MySQL sessions, and the SQLite connections this database opened, already set up, by their driver
72
+ # connection, which a reconnect replaces.
73
+ @set_up = ObjectSpace::WeakMap.new
41
74
  end
42
75
 
43
76
  # "sqlite", "postgres", or "mysql".
44
77
  def dialect
45
- @dialect ||= self.class.dialect_of(connection { |c| c.adapter_name })
78
+ @dialect ||= @connection_class.connection_pool.with_connection { |c| self.class.dialect_of(c.adapter_name) }
46
79
  end
47
80
 
48
81
  def self.dialect_of(adapter)
@@ -80,7 +113,7 @@ module Runlight
80
113
  # transaction already open (the store's, or the app's) is joined, not nested. Yields this database.
81
114
  def transaction
82
115
  connection do |c|
83
- if c.transaction_open?
116
+ if c.transaction_open? || Thread.current[@turn_key]
84
117
  yield self
85
118
  elsif dialect == "mysql"
86
119
  # As Postgres does by default: each statement sees what was committed before it began, and InnoDB
@@ -93,7 +126,8 @@ module Runlight
93
126
  end
94
127
 
95
128
  # Runs the block while holding a database-wide lock, so two processes starting at once do not race to
96
- # create the same tables. Yields this database.
129
+ # create the same tables. Waits for the lock at most LOCK_WAIT_MS, then raises a TablesBusyError. Yields
130
+ # this database.
97
131
  def exclusive(&block)
98
132
  connection do |c|
99
133
  @held += 1
@@ -105,6 +139,41 @@ module Runlight
105
139
  end
106
140
  end
107
141
 
142
+ # Runs the block as the only one reading and changing what it reads, across threads and processes, and
143
+ # yields this database. On Postgres and MySQL that is the database-wide lock. On SQLite it is a
144
+ # transaction begun IMMEDIATE, which takes the file's write lock before the block reads anything, so a
145
+ # second turn waits (up to the busy timeout) rather than reading what the first is about to change. A turn
146
+ # inside a turn, or inside a transaction already open, joins it.
147
+ def turn(&block)
148
+ return exclusive(&block) unless dialect == "sqlite"
149
+
150
+ connection do |c|
151
+ next yield(self) if Thread.current[@turn_key] || c.transaction_open?
152
+
153
+ c.execute("BEGIN IMMEDIATE", "Runlight")
154
+ Thread.current[@turn_key] = true
155
+ failed = nil
156
+ begin
157
+ yield self
158
+ rescue Exception => e
159
+ failed = e
160
+ raise
161
+ ensure
162
+ Thread.current[@turn_key] = nil
163
+ if failed
164
+ begin
165
+ c.execute("ROLLBACK", "Runlight")
166
+ rescue StandardError
167
+ # The error that got here matters more than one rolling back.
168
+ end
169
+ else
170
+ # Also when the block leaves early with return, break, or next.
171
+ c.execute("COMMIT", "Runlight")
172
+ end
173
+ end
174
+ end
175
+ end
176
+
108
177
  def close
109
178
  return unless @owned
110
179
 
@@ -115,7 +184,7 @@ module Runlight
115
184
  # seconds and applies it to every statement; MySQL counts milliseconds and applies it to reads.
116
185
  def limit_statements(ms)
117
186
  connection do |c|
118
- @mariadb = c.select_value("SELECT VERSION() AS v").to_s.match?(/mariadb/i) if @mariadb.nil?
187
+ @mariadb = value(c, "SELECT VERSION() AS v").to_s.match?(/mariadb/i) if @mariadb.nil?
119
188
  c.execute(@mariadb ? "SET SESSION max_statement_time = #{Json.number(ms / 1000.0)}" : "SET SESSION max_execution_time = #{ms.to_i}")
120
189
  end
121
190
  end
@@ -184,8 +253,35 @@ module Runlight
184
253
 
185
254
  private
186
255
 
187
- def connection(&block)
188
- @connection_class.connection_pool.with_connection(&block)
256
+ def connection
257
+ @connection_class.connection_pool.with_connection do |c|
258
+ set_up(c) if dialect == "mysql" || (@owned && dialect == "sqlite")
259
+ yield c
260
+ end
261
+ end
262
+
263
+ # Values are escaped with backslashes, which a server set to NO_BACKSLASH_ESCAPES reads as text, so a
264
+ # quote could end the value early. Every MySQL session Runlight uses drops that one mode first, an app's
265
+ # own ActiveRecord connection's too, and again after a reconnect, which starts the session over. A SQLite
266
+ # file Runlight opened itself turns on secure_delete, as stores/sqlite.ts does.
267
+ def set_up(c)
268
+ raw = c.instance_variable_get(:@raw_connection)
269
+ return if raw && @set_up[raw]
270
+
271
+ c.execute(dialect == "sqlite" ? SECURE_DELETE : NO_BACKSLASH_ESCAPES_OFF, "Runlight")
272
+ raw = c.instance_variable_get(:@raw_connection)
273
+ wait_without_the_lock(raw) if dialect == "sqlite"
274
+ @set_up[raw] = true if raw
275
+ end
276
+
277
+ # Rails 7.2 sets SQLite's busy timeout with busy_timeout, which sleeps in C holding Ruby's global lock. A
278
+ # thread waiting for the file's write lock then stops the thread of the same process that holds it from
279
+ # ever finishing, so the wait runs out and raises "database is locked". busy_handler_timeout= (sqlite3
280
+ # 2.x, and what Rails 8 uses) waits the same time but lets other threads run.
281
+ def wait_without_the_lock(raw)
282
+ return unless raw.respond_to?(:busy_handler_timeout=)
283
+
284
+ raw.busy_handler_timeout = BUSY_TIMEOUT_MS
189
285
  end
190
286
 
191
287
  # A statement on a connection. A connection the server dropped (a restart, a failover, an idle timeout)
@@ -199,6 +295,7 @@ module Runlight
199
295
  warn "Runlight: a #{dialect == "mysql" ? "MySQL" : "Postgres"} connection was lost; it reconnects on the next query. #{e.message}"
200
296
  connection do |c|
201
297
  c.reconnect!
298
+ set_up(c) if dialect == "mysql"
202
299
  block.call(c)
203
300
  end
204
301
  end
@@ -233,7 +330,14 @@ module Runlight
233
330
  if dialect == "postgres"
234
331
  # Asked for again and again rather than waited on: a waiting statement would hold up an index being
235
332
  # 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"))
333
+ # Up to LOCK_WAIT_MS, so a request does not wait on a long index build for good.
334
+ wait = self.class.lock_wait_ms
335
+ until_at = monotonic_ms + wait
336
+ until truthy(value(c, "SELECT pg_try_advisory_lock(#{MIGRATION_LOCK}) AS ok"))
337
+ raise TablesBusyError, wait if monotonic_ms >= until_at
338
+
339
+ sleep 0.1
340
+ end
237
341
  begin
238
342
  return yield(self)
239
343
  ensure
@@ -244,11 +348,16 @@ module Runlight
244
348
  end
245
349
  end
246
350
  end
351
+ # Up to LOCK_WAIT_MS, so a request does not wait on a long index build for good.
352
+ wait = self.class.lock_wait_ms
353
+ until_at = monotonic_ms + wait
247
354
  loop do
248
- ok = c.select_value("SELECT GET_LOCK(#{MYSQL_LOCK}, 5) AS ok")
355
+ left = ((until_at - monotonic_ms) / 1000.0).ceil.clamp(1, 5)
356
+ ok = value(c, "SELECT GET_LOCK(#{MYSQL_LOCK}, #{left}) AS ok")
249
357
  raise "Runlight: MySQL refused the lock for creating tables" if ok.nil?
250
358
  break if ok.to_i == 1
251
- # Not got within 5 seconds: another process is creating the tables. Ask again.
359
+ # Not got in time: another process is creating the tables. Ask again, until the wait is up.
360
+ raise TablesBusyError, wait if monotonic_ms >= until_at
252
361
  end
253
362
  begin
254
363
  # An index on a big table takes a while to build, so the build may run past the statement timeout.
@@ -265,6 +374,17 @@ module Runlight
265
374
  end
266
375
  end
267
376
 
377
+ def monotonic_ms
378
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
379
+ end
380
+
381
+ # The first column of a statement's first row, always asked of the database. select_value goes through
382
+ # Rails' query cache, which a Rails request turns on for every pool, so a poll for a lock would see its
383
+ # first answer again and again; exec_query is never cached.
384
+ def value(c, sql)
385
+ c.exec_query(sql, "Runlight").rows.first&.first
386
+ end
387
+
268
388
  def truthy(value)
269
389
  value == true || value == "t" || value == 1 || value == "1" || value == "true"
270
390
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runlight
4
+ module Db
5
+ # A database whose tables and indexes start with a prefix of the app's choosing in place of `rl_`, such as
6
+ # `wp_rl_` beside a WordPress database's tables, so two apps sharing one database each keep a set of their
7
+ # own. Runlight's SQL names every table and index `rl_` and something, and those names alone are rewritten
8
+ # on the way through, in each statement and in the names a schema check compares as text. A store given a
9
+ # prefix puts one in front of its database, so the accounts and importers that reach the database through
10
+ # the store follow it too. Values bound to `?` are never rewritten.
11
+ class Prefixed
12
+ # Whatever else the database underneath has (close, limit_statements, connection_class) is passed on.
13
+ OPTIONAL = %i[affected turn metered?].freeze
14
+ private_constant :OPTIONAL
15
+
16
+ attr_reader :inner, :prefix
17
+
18
+ def initialize(inner, prefix)
19
+ # A name in a statement, and short enough that the longest index name stays inside MySQL's 64.
20
+ unless prefix.is_a?(String) && prefix.match?(/\A[A-Za-z_][A-Za-z0-9_]{0,31}\z/)
21
+ raise ArgumentError, "Runlight: a table prefix is up to 32 letters, digits, and underscores, and does not start with a digit"
22
+ end
23
+
24
+ @inner = inner
25
+ @prefix = prefix
26
+ end
27
+
28
+ # A statement with each of Runlight's names under the prefix.
29
+ def sql(text)
30
+ text.gsub(/\brl_(?=[a-z])/, @prefix)
31
+ end
32
+
33
+ def dialect
34
+ @inner.dialect
35
+ end
36
+
37
+ def all(text, params = [])
38
+ @inner.all(sql(text), params)
39
+ end
40
+
41
+ def run(text, params = [])
42
+ @inner.run(sql(text), params)
43
+ end
44
+
45
+ def affected(text, params = [])
46
+ @inner.affected(sql(text), params)
47
+ end
48
+
49
+ def metered?
50
+ @inner.metered?
51
+ end
52
+
53
+ def transaction
54
+ @inner.transaction { |db| yield wrap(db) }
55
+ end
56
+
57
+ def exclusive
58
+ @inner.exclusive { |db| yield wrap(db) }
59
+ end
60
+
61
+ def turn
62
+ @inner.turn { |db| yield wrap(db) }
63
+ end
64
+
65
+ def close
66
+ @inner.close
67
+ end
68
+
69
+ def respond_to?(name, include_all = false)
70
+ return @inner.respond_to?(name, include_all) if OPTIONAL.include?(name.to_sym)
71
+
72
+ super
73
+ end
74
+
75
+ private
76
+
77
+ def wrap(db)
78
+ db.equal?(@inner) ? self : Prefixed.new(db, @prefix)
79
+ end
80
+
81
+ def method_missing(name, ...)
82
+ return super unless @inner.respond_to?(name)
83
+
84
+ @inner.public_send(name, ...)
85
+ end
86
+
87
+ def respond_to_missing?(name, include_all = false)
88
+ @inner.respond_to?(name, include_all) || super
89
+ end
90
+ end
91
+ end
92
+ end
data/lib/runlight/geo.rb CHANGED
@@ -102,9 +102,10 @@ module Runlight
102
102
  end
103
103
 
104
104
  # The location of a request: the platform's headers when they name a country, else the lookup's answer
105
- # for the address, else nowhere.
106
- def locate(headers, ip, lookup = nil)
107
- from_headers = location_from_headers(headers)
105
+ # for the address, else nowhere. The headers are believed only with trust_headers, as the address is: without
106
+ # a proxy in front, a client writes them itself.
107
+ def locate(headers, ip, lookup = nil, trust_headers = true)
108
+ from_headers = trust_headers ? location_from_headers(headers) : nil
108
109
  return from_headers if !from_headers.nil? && from_headers["country"] != ""
109
110
 
110
111
  if !lookup.nil? && ip != ""
@@ -58,6 +58,20 @@ module Runlight
58
58
 
59
59
  private
60
60
 
61
+ # Opens the connection. A name pinned to several addresses is tried address by address, as a connection
62
+ # by name is, so a host that listens on one of them is reached; only the failure of the last is raised.
63
+ def connect(http, addresses)
64
+ tries = addresses.empty? ? [nil] : addresses
65
+ tries.each_with_index do |address, at|
66
+ http.ipaddr = address unless address.nil?
67
+ begin
68
+ return http.start
69
+ rescue SystemCallError, SocketError, Net::OpenTimeout
70
+ raise if at == tries.length - 1
71
+ end
72
+ end
73
+ end
74
+
61
75
  def monotonic
62
76
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
77
  end
@@ -72,16 +86,24 @@ module Runlight
72
86
  left = deadline - monotonic
73
87
  raise FetchError.new(TIMED_OUT, timed_out: true) if left <= 0
74
88
 
75
- http = Net::HTTP.new(host, port)
89
+ # A pinned request never goes through a proxy from the environment (http_proxy and the rest), which would
90
+ # connect wherever the name leads now rather than to the address that was checked.
91
+ pinned = !Array(init["resolve"]).empty?
92
+ http = pinned ? Net::HTTP.new(host, port, nil) : Net::HTTP.new(host, port)
76
93
  http.use_ssl = https
77
94
  # A pin connects to the checked address, with the name kept for the Host header and the certificate.
95
+ addresses = []
78
96
  Array(init["resolve"]).each do |pin|
79
- m = pin.to_s.match(/\A(.+):(\d+):\[?([^\],]+)\]?/)
80
- next unless m && m[1].casecmp?(host) && m[2].to_i == port
97
+ lead = "#{host}:#{port}:"
98
+ next unless pin.to_s.length > lead.length && pin.to_s[0, lead.length].casecmp?(lead)
81
99
 
82
- http.ipaddr = m[3]
100
+ addresses = pin.to_s[lead.length..].split(",").map { |address| address.delete_prefix("[").delete_suffix("]") }.reject(&:empty?)
101
+ http.ipaddr = addresses.first
83
102
  break
84
103
  end
104
+ # A pin that names another host or port would leave the name to be looked up again: refused, never
105
+ # fetched unpinned.
106
+ raise FetchError, "fetch failed" if pinned && http.ipaddr.nil?
85
107
  http.open_timeout = [left, 15].min
86
108
  http.read_timeout = left
87
109
  http.write_timeout = left
@@ -105,8 +127,9 @@ module Runlight
105
127
  # Past maxBytes the whole request is left by a throw, which closes the connection: a break would leave
106
128
  # only read_body, and Net::HTTP would then read the rest of the body after all.
107
129
  catch(:too_long) do
108
- http.start do |connection|
109
- connection.request(request) do |answer|
130
+ connect(http, addresses)
131
+ begin
132
+ http.request(request) do |answer|
110
133
  status = answer.code.to_i
111
134
  answer.each_capitalized_name { |name| answer_headers[name.downcase] = answer.get_fields(name) }
112
135
  answer.read_body do |chunk|
@@ -120,6 +143,8 @@ module Runlight
120
143
  received << chunk
121
144
  end
122
145
  end
146
+ ensure
147
+ http.finish if http.started?
123
148
  end
124
149
  end
125
150
  rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error
@@ -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"