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.
- checksums.yaml +4 -4
- data/assets/build.json +3 -3
- data/assets/dashboard.js +17 -9
- data/assets/locales.json +5 -5
- data/lib/runlight/accounts/accounts.rb +82 -13
- data/lib/runlight/accounts/throttle.rb +123 -38
- data/lib/runlight/accounts/web.rb +50 -14
- data/lib/runlight/assistant.rb +11 -0
- data/lib/runlight/connect.rb +2 -3
- data/lib/runlight/core.rb +23 -0
- data/lib/runlight/db/connect.rb +5 -2
- data/lib/runlight/db/db.rb +79 -7
- data/lib/runlight/http/fetcher.rb +4 -1
- data/lib/runlight/http/request.rb +14 -3
- data/lib/runlight/importers/csv_visits.rb +1 -1
- data/lib/runlight/importers/index.rb +10 -2
- data/lib/runlight/importers/visits.rb +17 -6
- data/lib/runlight/importers/write.rb +21 -6
- data/lib/runlight/links.rb +14 -9
- data/lib/runlight/mail/smtp.rb +2 -0
- data/lib/runlight/mail/smtp_session.rb +22 -1
- data/lib/runlight/mcp.rb +8 -1
- data/lib/runlight/oauth.rb +2 -3
- data/lib/runlight/rack_app.rb +54 -7
- data/lib/runlight/routes.rb +101 -55
- data/lib/runlight/safefetch.rb +35 -0
- data/lib/runlight/version.rb +1 -1
- metadata +2 -2
data/lib/runlight/db/connect.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
data/lib/runlight/db/db.rb
CHANGED
|
@@ -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 ||=
|
|
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
|
|
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
|
|
188
|
-
@connection_class.connection_pool.with_connection
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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" =>
|
|
212
|
+
"url" => destination,
|
|
198
213
|
"createdAt" => created,
|
|
199
214
|
"updatedAt" => created,
|
|
200
215
|
})
|
data/lib/runlight/links.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/runlight/mail/smtp.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
data/lib/runlight/oauth.rb
CHANGED
|
@@ -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
|
-
|
|
234
|
-
|
|
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")
|
data/lib/runlight/rack_app.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
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
|