runlight 0.1.1 → 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.
- checksums.yaml +4 -4
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +64 -23
- data/assets/locales.json +5 -5
- data/assets/pick-return.js +1 -0
- data/assets/picker.js +1 -1
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +8 -6
- data/lib/runlight/assistant.rb +37 -16
- data/lib/runlight/core.rb +125 -34
- data/lib/runlight/dates.rb +31 -8
- data/lib/runlight/db/connect.rb +27 -4
- data/lib/runlight/db/db.rb +52 -4
- data/lib/runlight/db/prefixed.rb +92 -0
- data/lib/runlight/geo.rb +4 -3
- data/lib/runlight/http/fetcher.rb +27 -5
- data/lib/runlight/mail/ses.rb +3 -2
- data/lib/runlight/mail/transports.rb +19 -2
- data/lib/runlight/metadata_address_error.rb +13 -0
- data/lib/runlight/oauth.rb +83 -15
- data/lib/runlight/rack_app.rb +11 -4
- data/lib/runlight/redirect_refused_error.rb +14 -0
- data/lib/runlight/routes.rb +208 -92
- data/lib/runlight/safefetch.rb +98 -1
- data/lib/runlight/server/agents.rb +1 -1
- data/lib/runlight/server/cli.rb +1 -1
- data/lib/runlight/server/config.rb +64 -19
- data/lib/runlight/server/standalone.rb +7 -6
- data/lib/runlight/store/sql.rb +10 -0
- data/lib/runlight/store/sql_store.rb +75 -13
- data/lib/runlight/stores.rb +19 -15
- data/lib/runlight/tables_busy_error.rb +11 -0
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight.rb +4 -0
- metadata +7 -2
data/lib/runlight/safefetch.rb
CHANGED
|
@@ -222,6 +222,103 @@ module Runlight
|
|
|
222
222
|
end
|
|
223
223
|
end
|
|
224
224
|
|
|
225
|
+
# Headers that describe a body, dropped with it when a redirect turns a request into a GET.
|
|
226
|
+
BODY_HEADERS = %w[content-type content-length content-encoding content-language content-location].freeze
|
|
227
|
+
|
|
228
|
+
# Fetches from a service someone set up, such as an AI model's API or a
|
|
229
|
+
# mail webhook, which may well be on this computer or a private network,
|
|
230
|
+
# but is never at a link-local or cloud metadata address. The address is
|
|
231
|
+
# checked, and so is every redirect's, up to "redirects" of them (default
|
|
232
|
+
# 5), each followed as fetch follows it: a 303, or a 301 or 302 answering a
|
|
233
|
+
# POST, goes on as a GET with no body. Only a redirect on the same origin is
|
|
234
|
+
# followed: the request carries a key, in whatever header the service names
|
|
235
|
+
# it, and a 307 or 308 would carry its body too, an email or a conversation, so
|
|
236
|
+
# a redirect to another origin, https to http among them, is refused before
|
|
237
|
+
# anything is sent there. A redirect past the last one comes back as it is.
|
|
238
|
+
# Raises a MetadataAddressError or a RedirectRefusedError for an address it refuses.
|
|
239
|
+
#
|
|
240
|
+
# init: a Hash with optionally "method", "headers" (a Hash), "body", "timeoutMs" (for every hop together),
|
|
241
|
+
# "redirects", and "maxBytes" and "truncate" for the fetcher.
|
|
242
|
+
def service_fetch(target, init, fetcher = nil)
|
|
243
|
+
fetcher ||= Http::NetFetcher.new
|
|
244
|
+
# A fetcher that reaches the network is held to the address checked. One a test or an app supplies is
|
|
245
|
+
# not, since a made-up host may not resolve anywhere.
|
|
246
|
+
strict = fetcher.is_a?(Http::NetFetcher)
|
|
247
|
+
redirects = init["redirects"] || 5
|
|
248
|
+
url = Http::Url.parse(target)
|
|
249
|
+
raise Http::FetchError, "Invalid URL" if url.nil?
|
|
250
|
+
|
|
251
|
+
method = (init["method"] || "GET").to_s.upcase
|
|
252
|
+
headers = (init["headers"] || {}).to_h
|
|
253
|
+
body = init["body"]
|
|
254
|
+
until_ns = init["timeoutMs"] && monotonic + (init["timeoutMs"] * 1_000_000)
|
|
255
|
+
hop = 0
|
|
256
|
+
loop do
|
|
257
|
+
host = url.hostname.sub(/\A\[/, "").sub(/\]\z/, "")
|
|
258
|
+
pin = service_pin(url, host, strict)
|
|
259
|
+
|
|
260
|
+
options = { "method" => method, "headers" => headers, "redirect" => "manual" }
|
|
261
|
+
options["resolve"] = pin unless pin.nil?
|
|
262
|
+
options["body"] = body unless body.nil?
|
|
263
|
+
%w[maxBytes truncate].each { |key| options[key] = init[key] unless init[key].nil? }
|
|
264
|
+
unless until_ns.nil?
|
|
265
|
+
# The whole limit for the first hop, and what is left of it for each redirect.
|
|
266
|
+
left = hop.zero? ? init["timeoutMs"] : (until_ns - monotonic).div(1_000_000)
|
|
267
|
+
raise timed_out if left <= 0
|
|
268
|
+
|
|
269
|
+
options["timeoutMs"] = left
|
|
270
|
+
end
|
|
271
|
+
answer = fetcher.fetch(url.href, options)
|
|
272
|
+
location = answer.headers.get("location")
|
|
273
|
+
return answer if answer.status < 300 || answer.status >= 400 || location.nil? || location == "" || hop >= redirects
|
|
274
|
+
|
|
275
|
+
following = Http::Url.parse(location, url.href)
|
|
276
|
+
raise Http::FetchError, "Invalid URL" if following.nil?
|
|
277
|
+
|
|
278
|
+
if following.origin != url.origin
|
|
279
|
+
there = following.hostname.sub(/\A\[/, "").sub(/\]\z/, "")
|
|
280
|
+
raise MetadataAddressError, there if metadata_host?(there)
|
|
281
|
+
|
|
282
|
+
raise RedirectRefusedError.new(url.host, %w[http: https:].include?(following.protocol) ? following.origin : following.protocol)
|
|
283
|
+
end
|
|
284
|
+
if answer.status == 303 || ([301, 302].include?(answer.status) && method == "POST")
|
|
285
|
+
method = "GET" unless method == "HEAD"
|
|
286
|
+
body = nil
|
|
287
|
+
headers = headers.reject { |name, _| BODY_HEADERS.include?(name.to_s.downcase) }
|
|
288
|
+
end
|
|
289
|
+
url = following
|
|
290
|
+
hop += 1
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The pin for one hop of a service fetch: the name is resolved here, every address it gives is checked, and
|
|
295
|
+
# the connection is pinned to them (the fetcher's "resolve", which also keeps it off any proxy from the
|
|
296
|
+
# environment), so a second lookup cannot hand back another. A private or local address is fine, since a
|
|
297
|
+
# model or a mail relay may be there; a link-local or metadata one is refused. A name that does not resolve
|
|
298
|
+
# is refused when strict, and let through to the fetcher otherwise.
|
|
299
|
+
def service_pin(url, host, strict)
|
|
300
|
+
name = Js.lower(Js.trim(host)).sub(/\.+\z/, "")
|
|
301
|
+
raise MetadataAddressError, host if METADATA_NAMES.include?(name) || metadata_address?(name)
|
|
302
|
+
|
|
303
|
+
literal = !v4(name).nil? || !v6(name).nil?
|
|
304
|
+
addresses = begin
|
|
305
|
+
literal ? [name] : Safefetch.resolver.call(name).map(&:to_s)
|
|
306
|
+
rescue StandardError
|
|
307
|
+
[]
|
|
308
|
+
end
|
|
309
|
+
if addresses.empty?
|
|
310
|
+
raise Http::FetchError, "Could not look up #{host}" if strict
|
|
311
|
+
|
|
312
|
+
return nil
|
|
313
|
+
end
|
|
314
|
+
raise MetadataAddressError, host if addresses.any? { |address| metadata_address?(address) }
|
|
315
|
+
|
|
316
|
+
port = url.port == "" ? { "https:" => "443", "http:" => "80" }[url.protocol] : url.port
|
|
317
|
+
return nil if port.nil?
|
|
318
|
+
|
|
319
|
+
["#{name}:#{port}:#{addresses.map { |a| a.include?(":") ? "[#{a}]" : a }.join(",")}"]
|
|
320
|
+
end
|
|
321
|
+
|
|
225
322
|
# An install on this machine: http://localhost or http://127.0.0.1, with any port.
|
|
226
323
|
LOCAL_INSTALL = %r{\Ahttp://(localhost|127\.0\.0\.1)(:\d+)?(/|\z)}
|
|
227
324
|
|
|
@@ -250,6 +347,6 @@ module Runlight
|
|
|
250
347
|
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
251
348
|
end
|
|
252
349
|
|
|
253
|
-
private_class_method :v4, :public_v4?, :v6, :timed_out, :monotonic
|
|
350
|
+
private_class_method :v4, :public_v4?, :v6, :timed_out, :monotonic, :service_pin
|
|
254
351
|
end
|
|
255
352
|
end
|
|
@@ -191,7 +191,7 @@ module Runlight
|
|
|
191
191
|
raise SendError, e.message
|
|
192
192
|
end
|
|
193
193
|
if answer.status == 401
|
|
194
|
-
raise SendError, "Runlight refused the key. Use the site's key from Settings, Install
|
|
194
|
+
raise SendError, "Runlight refused the key. Use the site's agents key from Settings, Install."
|
|
195
195
|
end
|
|
196
196
|
raise SendError, "Runlight answered #{answer.status}: #{Js.slice(Js.scrub(answer.text), 0, 200)}" unless answer.ok?
|
|
197
197
|
|
data/lib/runlight/server/cli.rb
CHANGED
|
@@ -42,7 +42,7 @@ module Runlight
|
|
|
42
42
|
|
|
43
43
|
--log <file> The access log, in nginx or Apache's combined format, or Caddy's JSON
|
|
44
44
|
--to <url> Your Runlight, as its dashboard address (or RUNLIGHT_URL)
|
|
45
|
-
--key <key> The site's key from Settings, Install
|
|
45
|
+
--key <key> The site's agents key from Settings, Install (or RUNLIGHT_OBSERVE_KEY)
|
|
46
46
|
--site <url> The site's address, such as https://example.com, when the log has no host in it
|
|
47
47
|
--follow Keep running and send fetches as they happen
|
|
48
48
|
--state <file> Remember where it stopped, so the next run, or a restarted --follow, starts there.
|
|
@@ -101,25 +101,7 @@ module Runlight
|
|
|
101
101
|
given = get("RUNLIGHT_SECRET")
|
|
102
102
|
return @secret = given unless given.nil?
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
saved = read(file)
|
|
106
|
-
return @secret = saved if saved != ""
|
|
107
|
-
|
|
108
|
-
made = SecureRandom.hex(32)
|
|
109
|
-
# Made once: whoever writes the file first wins, and everyone else reads theirs.
|
|
110
|
-
begin
|
|
111
|
-
File.open(file, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |out| out.write("#{made}\n") }
|
|
112
|
-
rescue SystemCallError
|
|
113
|
-
sleep(0.05)
|
|
114
|
-
saved = read(file)
|
|
115
|
-
if saved == ""
|
|
116
|
-
raise RuntimeError, "Runlight: could not write #{file}. Make the data folder writable, or set RUNLIGHT_SECRET."
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
return @secret = saved
|
|
120
|
-
end
|
|
121
|
-
File.chmod(0o600, file)
|
|
122
|
-
@secret = made
|
|
104
|
+
@secret = secret_in(data_dir)
|
|
123
105
|
end
|
|
124
106
|
|
|
125
107
|
# The dashboard's public address, such as https://stats.example.com, or nil.
|
|
@@ -234,6 +216,69 @@ module Runlight
|
|
|
234
216
|
end
|
|
235
217
|
|
|
236
218
|
# A file's text, trimmed unless asked not to, or "" when there is none.
|
|
219
|
+
# Shorter than any secret this file has ever been given, so a file caught half written is never used.
|
|
220
|
+
SHORTEST_SECRET = 32
|
|
221
|
+
|
|
222
|
+
# The secret kept in `dir`, made on first run, readable only by this user. Servers starting at once on one
|
|
223
|
+
# folder agree on it: the new secret is written whole to a file of its own, then linked into place, which
|
|
224
|
+
# refuses to replace a file already there, and the one that loses reads the winner's. A file read empty or
|
|
225
|
+
# short is read again for a moment, and then refused, never used.
|
|
226
|
+
def secret_in(dir)
|
|
227
|
+
file = "#{dir}/secret"
|
|
228
|
+
found = whole_secret(file)
|
|
229
|
+
return found unless found.nil?
|
|
230
|
+
|
|
231
|
+
made = SecureRandom.hex(32)
|
|
232
|
+
temp = "#{dir}/.secret-#{Process.pid}-#{SecureRandom.hex(6)}"
|
|
233
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |out| out.write("#{made}\n") }
|
|
234
|
+
begin
|
|
235
|
+
File.chmod(0o600, temp)
|
|
236
|
+
begin
|
|
237
|
+
File.link(temp, file)
|
|
238
|
+
made
|
|
239
|
+
rescue Errno::EEXIST
|
|
240
|
+
whole_secret(file) || refuse_secret(file)
|
|
241
|
+
rescue SystemCallError
|
|
242
|
+
# A file system with no hard links still creates the file only if it is not there; a reader that
|
|
243
|
+
# catches it before its contents land reads it again.
|
|
244
|
+
begin
|
|
245
|
+
File.open(file, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |out| out.write("#{made}\n") }
|
|
246
|
+
File.chmod(0o600, file)
|
|
247
|
+
made
|
|
248
|
+
rescue Errno::EEXIST
|
|
249
|
+
whole_secret(file) || refuse_secret(file)
|
|
250
|
+
rescue SystemCallError
|
|
251
|
+
raise RuntimeError, "Runlight: could not write #{file}. Make the data folder writable, or set RUNLIGHT_SECRET."
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
ensure
|
|
255
|
+
begin
|
|
256
|
+
File.unlink(temp)
|
|
257
|
+
rescue SystemCallError
|
|
258
|
+
# Gone already.
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The secret in a file, waiting up to two seconds for one too short to be whole; nil when there is no file.
|
|
264
|
+
def whole_secret(file)
|
|
265
|
+
tries = 0
|
|
266
|
+
loop do
|
|
267
|
+
return nil unless File.exist?(file)
|
|
268
|
+
|
|
269
|
+
text = read(file)
|
|
270
|
+
return text if text.length >= SHORTEST_SECRET
|
|
271
|
+
refuse_secret(file) if tries >= 20
|
|
272
|
+
|
|
273
|
+
tries += 1
|
|
274
|
+
sleep(0.1)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def refuse_secret(file)
|
|
279
|
+
raise RuntimeError, "Runlight: #{file} holds no whole secret. Delete it to have a new one made, which signs everyone out, or set RUNLIGHT_SECRET."
|
|
280
|
+
end
|
|
281
|
+
|
|
237
282
|
def read(file, strip: true)
|
|
238
283
|
text = File.file?(file) ? File.read(file) : ""
|
|
239
284
|
strip ? text.strip : text
|
|
@@ -17,7 +17,7 @@ module Runlight
|
|
|
17
17
|
class Standalone
|
|
18
18
|
# The server's own pages, which answer as the server on every name it is reached at, a link domain too.
|
|
19
19
|
SERVER_PATHS = ["/login", "/logout", "/setup", "/invite", "/healthz", "/auth.css", "/auth.js", "/api", "/mcp",
|
|
20
|
-
"/s.js", "/pick.js", "/e", "/embed"].freeze
|
|
20
|
+
"/s.js", "/pick.js", "/pick-return", "/pick-return.js", "/e", "/embed"].freeze
|
|
21
21
|
|
|
22
22
|
# The most names remembered as the server's own. The first ones stay and later ones are not learned, so
|
|
23
23
|
# a server reached at more names than this needs RUNLIGHT_URL to keep the rest from becoming link domains.
|
|
@@ -135,8 +135,11 @@ module Runlight
|
|
|
135
135
|
begin
|
|
136
136
|
# A domain pointed at this server for short links answers at its root, with links one segment deep. The
|
|
137
137
|
# server's own pages and its public address never answer as links, and "/" stays the dashboard for someone
|
|
138
|
-
# signed in, so a link domain added on the dashboard's own name can always be removed again.
|
|
139
|
-
|
|
138
|
+
# signed in, so a link domain added on the dashboard's own name can always be removed again. /go/:slug is
|
|
139
|
+
# the server's own link path, which a link domain answers as a path with no link (a 404), as every adapter
|
|
140
|
+
# does.
|
|
141
|
+
own = request.method == "GET" && path.match?(%r{\A/go/[^/]+/?\z})
|
|
142
|
+
linkable = path == Core::LINK_DOMAIN_CHECK || own ||
|
|
140
143
|
(path.match?(%r{\A/[^/]*\z}) && !SERVER_PATHS.include?(path) &&
|
|
141
144
|
!(path == "/" && !@web.signed_in(request).nil?))
|
|
142
145
|
if linkable && !(!@public_host.nil? && host_of(request) == @public_host)
|
|
@@ -146,9 +149,7 @@ module Runlight
|
|
|
146
149
|
if path == "/healthz"
|
|
147
150
|
return Http::Response.new("ok", status: 200, headers: { "content-type" => "text/plain", "cache-control" => "no-store" })
|
|
148
151
|
end
|
|
149
|
-
|
|
150
|
-
return @runlight.link_handler.call(request, context)
|
|
151
|
-
end
|
|
152
|
+
return @runlight.link_handler.call(request, context) if own
|
|
152
153
|
|
|
153
154
|
# Everything else, the sign-in pages and People included, is the routes'.
|
|
154
155
|
@routes.handle(request, context)
|
data/lib/runlight/store/sql.rb
CHANGED
|
@@ -31,6 +31,16 @@ module Runlight
|
|
|
31
31
|
# How long after a visit starts its events are looked for: far past any real visit.
|
|
32
32
|
EVENT_TAIL_MS = 2 * 86_400_000
|
|
33
33
|
|
|
34
|
+
# What every table and index name starts with, unless a store is given another prefix in its place.
|
|
35
|
+
PREFIX = "rl_"
|
|
36
|
+
|
|
37
|
+
# Runlight's tables, after the prefix: the store's, and the sign-in accounts' two. An app dropping
|
|
38
|
+
# Runlight's tables drops these by name, never every table that starts with the prefix.
|
|
39
|
+
TABLES = %w[
|
|
40
|
+
meta sites salts sessions events links link_domains shares goals settings
|
|
41
|
+
reports tokens funnels rollup_days rollups users invites
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
34
44
|
PIECE_MS = 86_400_000
|
|
35
45
|
|
|
36
46
|
# Pageviews that can report engaged time: the tracker's, which carry a pageview id. Imported history has
|
|
@@ -28,14 +28,33 @@ module Runlight
|
|
|
28
28
|
EVENT_TAIL_MS = Sql::EVENT_TAIL_MS
|
|
29
29
|
MYSQL_COLLATION = Sql::MYSQL_COLLATION
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
PREFIX = Sql::PREFIX
|
|
32
|
+
TABLES = Sql::TABLES
|
|
33
|
+
|
|
34
|
+
attr_reader :db,
|
|
35
|
+
# What the store's table and index names start with: `rl_` unless it was given another.
|
|
36
|
+
:prefix
|
|
37
|
+
|
|
38
|
+
# `prefix` takes the place of `rl_` at the start of every table and index name, so two apps can each keep
|
|
39
|
+
# Runlight's tables in one database. A database that is already prefixed keeps its own.
|
|
40
|
+
def initialize(db, prefix = PREFIX)
|
|
41
|
+
if db.is_a?(Db::Prefixed)
|
|
42
|
+
prefix = db.prefix
|
|
43
|
+
elsif prefix != PREFIX
|
|
44
|
+
db = Db::Prefixed.new(db, prefix)
|
|
45
|
+
end
|
|
34
46
|
@db = db
|
|
47
|
+
@prefix = prefix
|
|
35
48
|
@ready = false
|
|
36
49
|
@checked_all = false
|
|
37
50
|
end
|
|
38
51
|
|
|
52
|
+
# The store's tables as named in the database, for an app removing Runlight: each one by name, since
|
|
53
|
+
# another app's tables may well start with the same letters.
|
|
54
|
+
def tables
|
|
55
|
+
TABLES.map { |table| "#{@prefix}#{table}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
39
58
|
# Creates the tables on first use. Safe to call any number of times.
|
|
40
59
|
#
|
|
41
60
|
# When the database already records the current schema version, that is taken as done, so a store opened
|
|
@@ -619,13 +638,37 @@ module Runlight
|
|
|
619
638
|
value = setting(key)
|
|
620
639
|
return nil if value.nil?
|
|
621
640
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
641
|
+
setting_changed("DELETE FROM rl_settings WHERE \"key\" = ?", [key]) == 1 ? value : nil
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Writes a setting only if it is not there yet. Of two callers at once, only one gets true.
|
|
645
|
+
def add_setting(key, value)
|
|
646
|
+
# MySQL counts a row a duplicate key found as written, so it ignores the insert instead.
|
|
647
|
+
sql = if @db.dialect == "mysql"
|
|
648
|
+
"INSERT IGNORE INTO rl_settings (\"key\", value) VALUES (?, ?)"
|
|
649
|
+
else
|
|
650
|
+
Sql.upsert(@db.dialect, "rl_settings", ['"key"', "value"], ['"key"'], [])
|
|
651
|
+
end
|
|
652
|
+
setting_changed(sql, [key, value]) == 1
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Changes a setting only if it still holds `from`. Of two callers at once, only one gets true.
|
|
656
|
+
def swap_setting(key, from, to)
|
|
657
|
+
setting_changed("UPDATE rl_settings SET value = ? WHERE \"key\" = ? AND value = ?", [to, key, from]) == 1
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# A setting, or the block's value written as it when there is none yet, such as a signing key made on first
|
|
661
|
+
# use. Of two callers at once, both get the value written first, so nothing signed with a key that lost is
|
|
662
|
+
# left invalid. An empty one counts as none.
|
|
663
|
+
def setting_or_add(key)
|
|
664
|
+
5.times do
|
|
665
|
+
saved = setting(key)
|
|
666
|
+
return saved if !saved.nil? && saved != ""
|
|
667
|
+
|
|
668
|
+
value = yield
|
|
669
|
+
return value if saved.nil? ? add_setting(key, value) : swap_setting(key, saved, value)
|
|
670
|
+
end
|
|
671
|
+
raise "Runlight: could not read or write the setting #{key}"
|
|
629
672
|
end
|
|
630
673
|
|
|
631
674
|
def set_setting(key, value)
|
|
@@ -1302,6 +1345,13 @@ module Runlight
|
|
|
1302
1345
|
|
|
1303
1346
|
private
|
|
1304
1347
|
|
|
1348
|
+
# How many settings a statement changed. MySQL has no RETURNING, so it asks the connection how many rows matched.
|
|
1349
|
+
def setting_changed(sql, params)
|
|
1350
|
+
return @db.affected(sql, params) if @db.dialect == "mysql" && @db.respond_to?(:affected)
|
|
1351
|
+
|
|
1352
|
+
@db.all("#{sql} RETURNING \"key\"", params).length
|
|
1353
|
+
end
|
|
1354
|
+
|
|
1305
1355
|
# SQL for "a visit that started in one of these stretches".
|
|
1306
1356
|
def within(rest)
|
|
1307
1357
|
return { "sql" => "1 = 0", "params" => [] } if rest.empty?
|
|
@@ -1358,16 +1408,18 @@ module Runlight
|
|
|
1358
1408
|
# A concurrent build that was stopped leaves its index unusable; it goes, and is built again below.
|
|
1359
1409
|
broken = db.all(
|
|
1360
1410
|
"SELECT c.relname AS name FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
|
|
1361
|
-
WHERE NOT i.indisvalid AND c.relname LIKE '
|
|
1411
|
+
WHERE NOT i.indisvalid AND c.relname LIKE '#{@prefix.gsub("_", "\\\\_")}%' AND c.relnamespace = current_schema()::regnamespace",
|
|
1362
1412
|
)
|
|
1363
|
-
|
|
1413
|
+
# The name comes back as the database has it, so it goes back under rl_ before the prefix is put on
|
|
1414
|
+
# again, which would otherwise be put on twice for a prefix that itself starts rl_, such as rl_wp_.
|
|
1415
|
+
broken.each { |row| db.run("DROP INDEX IF EXISTS \"#{unnamed(Sql.string(row["name"])).delete('"')}\"") }
|
|
1364
1416
|
end
|
|
1365
1417
|
statements.each do |statement|
|
|
1366
1418
|
index = statement.match(/\ACREATE (UNIQUE )?INDEX IF NOT EXISTS (\w+) ON (\w+)/)
|
|
1367
1419
|
if db.dialect == "mysql" && index
|
|
1368
1420
|
# MySQL has no CREATE INDEX IF NOT EXISTS, so it is looked for first.
|
|
1369
1421
|
unique, name, table = index.captures
|
|
1370
|
-
there = db.all("SELECT 1 AS there FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? LIMIT 1", [table, name])
|
|
1422
|
+
there = db.all("SELECT 1 AS there FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? LIMIT 1", [named(table), named(name)])
|
|
1371
1423
|
db.run(statement.sub(/\ACREATE (UNIQUE )?INDEX IF NOT EXISTS/, "CREATE #{unique}INDEX")) if there.empty?
|
|
1372
1424
|
else
|
|
1373
1425
|
db.run(postgres ? statement.sub(/\ACREATE (UNIQUE )?INDEX IF NOT EXISTS/) { "CREATE #{Regexp.last_match(1)}INDEX CONCURRENTLY IF NOT EXISTS" } : statement)
|
|
@@ -1390,6 +1442,16 @@ module Runlight
|
|
|
1390
1442
|
db.run(Sql.upsert(db.dialect, "rl_meta", ['"key"', "value"], ['"key"'], ["value"]), ["schema", Sql::SCHEMA_VERSION.to_s])
|
|
1391
1443
|
end
|
|
1392
1444
|
|
|
1445
|
+
# A name as the database has it, under the store's prefix, as Runlight's SQL writes it, under rl_.
|
|
1446
|
+
def unnamed(name)
|
|
1447
|
+
name.start_with?(@prefix) ? "#{PREFIX}#{name[@prefix.length..]}" : name
|
|
1448
|
+
end
|
|
1449
|
+
|
|
1450
|
+
# A table or index name as the database has it, under the store's prefix.
|
|
1451
|
+
def named(name)
|
|
1452
|
+
name.start_with?(PREFIX) ? "#{@prefix}#{name[PREFIX.length..]}" : name
|
|
1453
|
+
end
|
|
1454
|
+
|
|
1393
1455
|
# When a site's oldest row at or after `from` is (nil for no lower bound), or nil when there is none.
|
|
1394
1456
|
def oldest(table, col, site, from)
|
|
1395
1457
|
row = @db.all("SELECT MIN(#{col}) AS t FROM #{table} WHERE site = ?#{from.nil? ? "" : " AND #{col} >= ?"}", from.nil? ? [site] : [site, from])[0]
|
data/lib/runlight/stores.rb
CHANGED
|
@@ -3,47 +3,51 @@
|
|
|
3
3
|
module Runlight
|
|
4
4
|
# The stores stores/sqlite.ts, stores/postgres.ts, and stores/mysql.ts make, over ActiveRecord. Tables are
|
|
5
5
|
# prefixed `rl_`, so the database can be the app's own, and a database made by the TypeScript SDK opens here.
|
|
6
|
+
# Each takes `prefix:` in place of `rl_`: what every table and index name starts with, so two apps sharing
|
|
7
|
+
# one database each keep a set of their own, up to 32 letters, digits, and underscores, not starting with a
|
|
8
|
+
# digit.
|
|
6
9
|
module Stores
|
|
7
10
|
module_function
|
|
8
11
|
|
|
9
|
-
# Runlight's tables in a SQLite file (or ":memory:"), in WAL mode, as better-sqlite3 opens it there.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
# Runlight's tables in a SQLite file (or ":memory:"), in WAL mode, as better-sqlite3 opens it there. A
|
|
13
|
+
# file it makes is for its owner and group alone (0660).
|
|
14
|
+
def sqlite(path, prefix: Store::Sql::PREFIX)
|
|
15
|
+
Store::SqlStore.new(Db::Connect.sqlite(path), prefix)
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
# Runlight's tables in Postgres, from a connection string. statement_timeout is the longest one statement
|
|
15
19
|
# may run, in milliseconds (default 120000, 0 for no limit); schema is the search path.
|
|
16
|
-
def postgres(url, statement_timeout: 120_000, schema: nil)
|
|
20
|
+
def postgres(url, statement_timeout: 120_000, schema: nil, prefix: Store::Sql::PREFIX)
|
|
17
21
|
raise ArgumentError, "Runlight: postgres() needs a url" if url.to_s.empty?
|
|
18
22
|
|
|
19
|
-
Store::SqlStore.new(Db::Connect.postgres(url, statement_timeout: statement_timeout, schema: schema))
|
|
23
|
+
Store::SqlStore.new(Db::Connect.postgres(url, statement_timeout: statement_timeout, schema: schema), prefix)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
|
-
# Runlight's tables in MySQL 8.
|
|
26
|
+
# Runlight's tables in MySQL 8.0.20 or MariaDB 11.4 and later, from a mysql:// or mariadb:// URL. Text is
|
|
23
27
|
# utf8mb4 with a binary collation, so it compares and sorts by code point, case and trailing spaces
|
|
24
28
|
# included, as SQLite and Postgres do. statement_timeout is in milliseconds (default 120000, 0 for no
|
|
25
29
|
# limit), which MySQL applies to reads only and MariaDB to every statement.
|
|
26
|
-
def mysql(url, statement_timeout: 120_000)
|
|
30
|
+
def mysql(url, statement_timeout: 120_000, prefix: Store::Sql::PREFIX)
|
|
27
31
|
raise ArgumentError, "Runlight: mysql() needs a url" if url.to_s.empty?
|
|
28
32
|
|
|
29
|
-
Store::SqlStore.new(Db::Connect.mysql(url, statement_timeout: statement_timeout))
|
|
33
|
+
Store::SqlStore.new(Db::Connect.mysql(url, statement_timeout: statement_timeout), prefix)
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
# The store a DATABASE_URL names, as the standalone server picks one: postgres:// or postgresql:// for
|
|
33
37
|
# Postgres, mysql:// or mariadb:// for MySQL, and sqlite: or file: followed by a path for SQLite.
|
|
34
|
-
def url(database_url)
|
|
35
|
-
Store::SqlStore.new(Db::Connect.url(database_url))
|
|
38
|
+
def url(database_url, prefix: Store::Sql::PREFIX)
|
|
39
|
+
Store::SqlStore.new(Db::Connect.url(database_url), prefix)
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
# Runlight's tables in the database an ActiveRecord class connects to: the app's own by default. Writes
|
|
39
43
|
# join a transaction the app has open.
|
|
40
|
-
def active_record(connection_class = ::ActiveRecord::Base)
|
|
41
|
-
Store::SqlStore.new(Db::Database.new(connection_class))
|
|
44
|
+
def active_record(connection_class = ::ActiveRecord::Base, prefix: Store::Sql::PREFIX)
|
|
45
|
+
Store::SqlStore.new(Db::Database.new(connection_class), prefix)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
|
-
# A store over any Db::Database.
|
|
45
|
-
def from_db(db)
|
|
46
|
-
Store::SqlStore.new(db)
|
|
48
|
+
# A store over any Db::Database, with its tables under `prefix` in place of `rl_` when one is given.
|
|
49
|
+
def from_db(db, prefix: Store::Sql::PREFIX)
|
|
50
|
+
Store::SqlStore.new(db, prefix)
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
53
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runlight
|
|
4
|
+
# Another process held the lock for creating tables past Db::Database::LOCK_WAIT_MS, such as one building an
|
|
5
|
+
# index on a big table after an upgrade. The request that waited fails with this, rather than waiting for good.
|
|
6
|
+
class TablesBusyError < RuntimeError
|
|
7
|
+
def initialize(waited = Db::Database::LOCK_WAIT_MS)
|
|
8
|
+
super("Runlight: another process is creating or upgrading the tables, and had not finished after #{(waited / 1000.0).round} seconds. Try again in a minute.")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/runlight/version.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Runlight
|
|
6
6
|
# The gem's own version, which the release keeps equal to the npm package's.
|
|
7
|
-
GEM_VERSION = "0.1.
|
|
7
|
+
GEM_VERSION = "0.1.3"
|
|
8
8
|
|
|
9
9
|
# The SDK's version and the HTTP API's, read from assets/build.json, which
|
|
10
10
|
# scripts/ruby-assets.mts writes from the TypeScript SDK, so the two always
|
data/lib/runlight.rb
CHANGED
|
@@ -38,11 +38,13 @@ module Runlight
|
|
|
38
38
|
autoload :Mcp, "runlight/mcp"
|
|
39
39
|
autoload :McpError, "runlight/mcp_error"
|
|
40
40
|
autoload :Messages, "runlight/messages"
|
|
41
|
+
autoload :MetadataAddressError, "runlight/metadata_address_error"
|
|
41
42
|
autoload :Mmdb, "runlight/mmdb"
|
|
42
43
|
autoload :OAuth, "runlight/oauth"
|
|
43
44
|
autoload :Options, "runlight/options"
|
|
44
45
|
autoload :Payload, "runlight/payload"
|
|
45
46
|
autoload :PrivateAddressError, "runlight/private_address_error"
|
|
47
|
+
autoload :RedirectRefusedError, "runlight/redirect_refused_error"
|
|
46
48
|
autoload :Query, "runlight/query"
|
|
47
49
|
autoload :RackApp, "runlight/rack_app"
|
|
48
50
|
autoload :RateLimit, "runlight/rate_limit"
|
|
@@ -51,6 +53,7 @@ module Runlight
|
|
|
51
53
|
autoload :Safefetch, "runlight/safefetch"
|
|
52
54
|
autoload :SettingsError, "runlight/settings_error"
|
|
53
55
|
autoload :Sources, "runlight/sources"
|
|
56
|
+
autoload :TablesBusyError, "runlight/tables_busy_error"
|
|
54
57
|
autoload :Ua, "runlight/ua"
|
|
55
58
|
autoload :Zip, "runlight/zip"
|
|
56
59
|
|
|
@@ -73,6 +76,7 @@ module Runlight
|
|
|
73
76
|
autoload :Connect, "runlight/db/connect"
|
|
74
77
|
autoload :Database, "runlight/db/db"
|
|
75
78
|
autoload :Pools, "runlight/db/connect"
|
|
79
|
+
autoload :Prefixed, "runlight/db/prefixed"
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
module Http
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: runlight
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon C. Phillips
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- assets/dashboard.js
|
|
47
47
|
- assets/intl.json
|
|
48
48
|
- assets/locales.json
|
|
49
|
+
- assets/pick-return.js
|
|
49
50
|
- assets/picker.js
|
|
50
51
|
- assets/tracker.js
|
|
51
52
|
- assets/world.json
|
|
@@ -71,6 +72,7 @@ files:
|
|
|
71
72
|
- lib/runlight/dates.rb
|
|
72
73
|
- lib/runlight/db/connect.rb
|
|
73
74
|
- lib/runlight/db/db.rb
|
|
75
|
+
- lib/runlight/db/prefixed.rb
|
|
74
76
|
- lib/runlight/env.rb
|
|
75
77
|
- lib/runlight/funnel_error.rb
|
|
76
78
|
- lib/runlight/funnels.rb
|
|
@@ -114,6 +116,7 @@ files:
|
|
|
114
116
|
- lib/runlight/mcp.rb
|
|
115
117
|
- lib/runlight/mcp_error.rb
|
|
116
118
|
- lib/runlight/messages.rb
|
|
119
|
+
- lib/runlight/metadata_address_error.rb
|
|
117
120
|
- lib/runlight/mmdb.rb
|
|
118
121
|
- lib/runlight/oauth.rb
|
|
119
122
|
- lib/runlight/options.rb
|
|
@@ -123,6 +126,7 @@ files:
|
|
|
123
126
|
- lib/runlight/rack_app.rb
|
|
124
127
|
- lib/runlight/rails.rb
|
|
125
128
|
- lib/runlight/rate_limit.rb
|
|
129
|
+
- lib/runlight/redirect_refused_error.rb
|
|
126
130
|
- lib/runlight/reports.rb
|
|
127
131
|
- lib/runlight/routes.rb
|
|
128
132
|
- lib/runlight/safefetch.rb
|
|
@@ -137,6 +141,7 @@ files:
|
|
|
137
141
|
- lib/runlight/store/sql.rb
|
|
138
142
|
- lib/runlight/store/sql_store.rb
|
|
139
143
|
- lib/runlight/stores.rb
|
|
144
|
+
- lib/runlight/tables_busy_error.rb
|
|
140
145
|
- lib/runlight/ua.rb
|
|
141
146
|
- lib/runlight/undefined.rb
|
|
142
147
|
- lib/runlight/version.rb
|