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.
- checksums.yaml +4 -4
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +71 -22
- 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 +90 -19
- data/lib/runlight/accounts/throttle.rb +123 -38
- data/lib/runlight/accounts/web.rb +50 -14
- data/lib/runlight/assistant.rb +40 -8
- data/lib/runlight/connect.rb +2 -3
- data/lib/runlight/core.rb +148 -34
- data/lib/runlight/dates.rb +31 -8
- data/lib/runlight/db/connect.rb +32 -6
- data/lib/runlight/db/db.rb +129 -9
- data/lib/runlight/db/prefixed.rb +92 -0
- data/lib/runlight/geo.rb +4 -3
- data/lib/runlight/http/fetcher.rb +31 -6
- 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/ses.rb +3 -2
- data/lib/runlight/mail/smtp.rb +2 -0
- data/lib/runlight/mail/smtp_session.rb +22 -1
- data/lib/runlight/mail/transports.rb +19 -2
- data/lib/runlight/mcp.rb +8 -1
- data/lib/runlight/metadata_address_error.rb +13 -0
- data/lib/runlight/oauth.rb +85 -18
- data/lib/runlight/rack_app.rb +64 -10
- data/lib/runlight/redirect_refused_error.rb +14 -0
- data/lib/runlight/routes.rb +306 -144
- data/lib/runlight/safefetch.rb +133 -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
|
@@ -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/ses.rb
CHANGED
|
@@ -79,13 +79,14 @@ module Runlight
|
|
|
79
79
|
})
|
|
80
80
|
headers.delete("host")
|
|
81
81
|
begin
|
|
82
|
-
response = (fetcher || Http::NetFetcher.new).fetch(url.href, { "method" => "POST", "headers" => headers, "body" => body, "timeoutMs" => 20_000
|
|
82
|
+
response = (fetcher || Http::NetFetcher.new).fetch(url.href, { "method" => "POST", "headers" => headers, "body" => body, "timeoutMs" => 20_000,
|
|
83
|
+
"maxBytes" => Transports::MAX_ERROR_BYTES + 1, "truncate" => true })
|
|
83
84
|
rescue Http::FetchError => e
|
|
84
85
|
raise MailError.new("Could not reach Amazon SES: #{e.message}", "mail_unreachable", { "host" => "Amazon SES", "detail" => e.message })
|
|
85
86
|
end
|
|
86
87
|
return if response.ok?
|
|
87
88
|
|
|
88
|
-
message = Transports.service_message(response
|
|
89
|
+
message = Transports.service_message(Transports.error_text(response))
|
|
89
90
|
raise MailError.new("Amazon SES answered #{response.status}#{message.empty? ? "" : ": #{message}"}", "mail_refused",
|
|
90
91
|
{ "host" => "Amazon SES", "detail" => "#{response.status}#{message.empty? ? "" : " #{message}"}" })
|
|
91
92
|
end
|
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
|
|
|
@@ -81,17 +81,34 @@ module Runlight
|
|
|
81
81
|
""
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# The most of a service's error read, so an answer without end never fills memory. One byte more is asked of
|
|
85
|
+
# the fetcher, which hands back what it read, so that an answer past the limit is seen as such and read as empty.
|
|
86
|
+
MAX_ERROR_BYTES = 65_536
|
|
87
|
+
|
|
88
|
+
# A service's error text, or none when it runs past MAX_ERROR_BYTES.
|
|
89
|
+
def error_text(response)
|
|
90
|
+
Body.read_text_capped(response, MAX_ERROR_BYTES)
|
|
91
|
+
rescue Http::BodyTooLong
|
|
92
|
+
""
|
|
93
|
+
end
|
|
94
|
+
|
|
84
95
|
# POSTs to a service, with the errors the dashboard shows. init: "headers" and "body".
|
|
85
96
|
def post(fetcher, url, init, explains = true)
|
|
86
97
|
begin
|
|
87
|
-
|
|
98
|
+
# A webhook can be at any address, so it and each redirect are checked, as for every service.
|
|
99
|
+
response = Safefetch.service_fetch(url, { "method" => "POST", "headers" => init["headers"], "body" => init["body"], "timeoutMs" => 20_000,
|
|
100
|
+
"maxBytes" => MAX_ERROR_BYTES + 1, "truncate" => true }, fetcher)
|
|
101
|
+
rescue MetadataAddressError => e
|
|
102
|
+
raise MailError.new(e.message, "address_refused", { "host" => e.host })
|
|
103
|
+
rescue RedirectRefusedError => e
|
|
104
|
+
raise MailError.new(e.message, "redirect_refused", { "host" => e.host, "to" => e.to })
|
|
88
105
|
rescue Http::FetchError => e
|
|
89
106
|
host = Http::Url.new(url).host
|
|
90
107
|
raise MailError.new("Could not reach #{host}: #{e.message}", "mail_unreachable", { "host" => host, "detail" => e.message })
|
|
91
108
|
end
|
|
92
109
|
return if response.ok?
|
|
93
110
|
|
|
94
|
-
message = explains ? service_message(response
|
|
111
|
+
message = explains ? service_message(error_text(response)) : ""
|
|
95
112
|
host = Http::Url.new(url).host
|
|
96
113
|
raise MailError.new("#{host} answered #{response.status}#{message.empty? ? "" : ": #{message}"}", "mail_refused",
|
|
97
114
|
{ "host" => host, "detail" => "#{response.status}#{message.empty? ? "" : " #{message}"}" })
|
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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runlight
|
|
4
|
+
# Refused before anything was fetched, because the address is link-local or a cloud's metadata service.
|
|
5
|
+
class MetadataAddressError < RuntimeError
|
|
6
|
+
attr_reader :host
|
|
7
|
+
|
|
8
|
+
def initialize(host)
|
|
9
|
+
@host = host
|
|
10
|
+
super("#{host} is a link-local or cloud metadata address, which Runlight never connects to")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/runlight/oauth.rb
CHANGED
|
@@ -16,7 +16,10 @@ module Runlight
|
|
|
16
16
|
# The token is an ordinary API token, so it appears in Settings, API and AI,
|
|
17
17
|
# beside the others, and deleting it there disconnects the app. It reads
|
|
18
18
|
# stats, or with the "manage" scope (asked for by a Runlight hub) it also
|
|
19
|
-
# changes one site's settings.
|
|
19
|
+
# changes one site's settings. With the "cms" scope (asked for by the
|
|
20
|
+
# WordPress, Drupal, and Craft plugins) it is the site's dashboard key, which
|
|
21
|
+
# only opens its read-only dashboard in the CMS, and the answer also carries
|
|
22
|
+
# the site's agents key, so connecting fills in the plugin's settings.
|
|
20
23
|
#
|
|
21
24
|
# The context the routes pass is a Hash: "runlight", "base", "isOwner" (callable(request) giving a bool), and
|
|
22
25
|
# optionally "signIn" (String), "isReader" (callable(request) giving a bool), "accountOf" (callable(request)
|
|
@@ -29,6 +32,7 @@ module Runlight
|
|
|
29
32
|
REGISTRATIONS_PER_MINUTE = 10
|
|
30
33
|
# The longest client id, which carries the app's name and redirect addresses.
|
|
31
34
|
MAX_CLIENT_ID = 2048
|
|
35
|
+
SCOPES = %w[read manage cms].freeze
|
|
32
36
|
|
|
33
37
|
# Where the per-address count of registrations is kept. TypeScript keeps it in memory per install; here it
|
|
34
38
|
# lives in the install's settings, keyed by a hash of the address, so every process shares it.
|
|
@@ -41,7 +45,7 @@ module Runlight
|
|
|
41
45
|
}.freeze
|
|
42
46
|
|
|
43
47
|
ESCAPES = { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.freeze
|
|
44
|
-
private_constant :REGISTRATIONS, :CORS, :ESCAPES
|
|
48
|
+
private_constant :REGISTRATIONS, :CORS, :ESCAPES, :SCOPES
|
|
45
49
|
|
|
46
50
|
module_function
|
|
47
51
|
|
|
@@ -50,6 +54,16 @@ module Runlight
|
|
|
50
54
|
base64url(OpenSSL::Digest::SHA256.digest(verifier))
|
|
51
55
|
end
|
|
52
56
|
|
|
57
|
+
# The key a CMS plugin reports AI agents reading a site's pages with: made on first ask, or a new one that replaces it.
|
|
58
|
+
def agents_key(runlight, site, replace = false)
|
|
59
|
+
name = "observe-key:#{site}"
|
|
60
|
+
return runlight.store.setting_or_add(name) { "rlo_#{Hashing.random_id(20)}" } unless replace
|
|
61
|
+
|
|
62
|
+
key = "rlo_#{Hashing.random_id(20)}"
|
|
63
|
+
runlight.store.set_setting(name, key)
|
|
64
|
+
key
|
|
65
|
+
end
|
|
66
|
+
|
|
53
67
|
# The URL that a 401 from the MCP endpoint points clients at, to start OAuth.
|
|
54
68
|
def resource_metadata_url(origin, base)
|
|
55
69
|
"#{origin}#{base}/.well-known/oauth-protected-resource"
|
|
@@ -71,7 +85,7 @@ module Runlight
|
|
|
71
85
|
end
|
|
72
86
|
|
|
73
87
|
if known.start_with?("/.well-known/oauth-protected-resource")
|
|
74
|
-
return json({ "resource" => "#{issuer}/mcp", "authorization_servers" => [issuer], "scopes_supported" =>
|
|
88
|
+
return json({ "resource" => "#{issuer}/mcp", "authorization_servers" => [issuer], "scopes_supported" => SCOPES, "bearer_methods_supported" => ["header"] })
|
|
75
89
|
end
|
|
76
90
|
if known.start_with?("/.well-known/oauth-authorization-server") || known.start_with?("/.well-known/openid-configuration")
|
|
77
91
|
return json({
|
|
@@ -83,7 +97,7 @@ module Runlight
|
|
|
83
97
|
"grant_types_supported" => ["authorization_code"],
|
|
84
98
|
"code_challenge_methods_supported" => ["S256"],
|
|
85
99
|
"token_endpoint_auth_methods_supported" => ["none"],
|
|
86
|
-
"scopes_supported" =>
|
|
100
|
+
"scopes_supported" => SCOPES,
|
|
87
101
|
})
|
|
88
102
|
end
|
|
89
103
|
|
|
@@ -141,8 +155,15 @@ module Runlight
|
|
|
141
155
|
return refuse.call({ "error" => "invalid_request", "error_description" => "PKCE with S256 is required" })
|
|
142
156
|
end
|
|
143
157
|
|
|
144
|
-
|
|
158
|
+
scopes = (form.get("scope") || "").split(/[#{Js::SPACE}]+/o)
|
|
159
|
+
cms = scopes.include?("cms")
|
|
160
|
+
manage = !cms && scopes.include?("manage")
|
|
145
161
|
name = client["name"].to_s
|
|
162
|
+
# A CMS plugin is for the site it runs on: one of the sites counting its admin's domain, or a new one for it.
|
|
163
|
+
host = site_host(redirect)
|
|
164
|
+
# As the tracker counts: a site that lists the domain, or the only site here when it lists none, which counts any.
|
|
165
|
+
local = runlight.sites.select { |s| runlight.remote(s["id"]).nil? }
|
|
166
|
+
counting = local.length == 1 && local[0]["hostnames"].empty? ? local : local.select { |s| s["hostnames"].include?(host) }
|
|
146
167
|
|
|
147
168
|
unless ctx["isOwner"].call(request)
|
|
148
169
|
# Someone signed in who may only read would be sent to sign in again and again.
|
|
@@ -169,6 +190,27 @@ module Runlight
|
|
|
169
190
|
end
|
|
170
191
|
# The app names itself, so the page also shows where the answer goes, which it cannot fake.
|
|
171
192
|
sends_to = "<p class=\"note\">Allowing sends you back to <strong>#{esc(Http::Url.new(redirect).host)}</strong>. Only allow it if you started connecting there.</p>"
|
|
193
|
+
if cms
|
|
194
|
+
if counting.empty? && !runlight.managed_sites
|
|
195
|
+
return page("Add this site first",
|
|
196
|
+
"<p>No site in this Runlight counts <strong>#{esc(host)}</strong>. Add it to a site's domains in your code, then connect again from #{esc(name)}.</p>", 400)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
choices = +""
|
|
200
|
+
counting.each { |s| choices << "<option value=\"#{esc(s["id"])}\">#{esc(s["name"])}</option>" }
|
|
201
|
+
choices << "<option value=\"new\">A new site for #{esc(host)}</option>" if runlight.managed_sites && counting.empty?
|
|
202
|
+
return page(
|
|
203
|
+
"Connect #{esc(name)}",
|
|
204
|
+
"<p><strong>#{esc(name)}</strong> wants to count <strong>#{esc(host)}</strong> in this Runlight.</p>\n" \
|
|
205
|
+
"<p>It will get the site's agents key, to report AI agents such as ChatGPT and Claude reading its pages, and a dashboard key, to show the site's " \
|
|
206
|
+
"read-only dashboard in its admin pages. It cannot change anything here or read other sites.</p>\n" \
|
|
207
|
+
"#{sends_to}\n" \
|
|
208
|
+
"<form method=\"post\" action=\"#{esc(base)}/oauth/authorize\">#{hidden}\n" \
|
|
209
|
+
"<label>Site<select name=\"site\">#{choices}</select></label>\n" \
|
|
210
|
+
"<p class=\"note\">Its dashboard key appears in Settings, API and AI, where deleting it stops the dashboard in #{esc(name)}.</p>\n" \
|
|
211
|
+
'<div class="buttons"><button type="submit" name="decision" value="deny" class="ghost">Deny</button><button type="submit" name="decision" value="allow">Allow</button></div></form>',
|
|
212
|
+
)
|
|
213
|
+
end
|
|
172
214
|
sites = runlight.sites
|
|
173
215
|
if manage
|
|
174
216
|
# Changing settings is for one site at a time, so there is no "every site" here.
|
|
@@ -209,6 +251,22 @@ module Runlight
|
|
|
209
251
|
return back.call({ "error" => "access_denied" }) if form.get("decision") != "allow"
|
|
210
252
|
|
|
211
253
|
site = form.get("site") || ""
|
|
254
|
+
if cms
|
|
255
|
+
if site == "new"
|
|
256
|
+
begin
|
|
257
|
+
site = if !counting.empty? then counting[0]["id"]
|
|
258
|
+
elsif runlight.managed_sites then runlight.add_site({ "hostnames" => [host] })["id"]
|
|
259
|
+
else ""
|
|
260
|
+
end
|
|
261
|
+
rescue RangeError => e
|
|
262
|
+
# Such as an address, which a site cannot count by: there is no new site to make.
|
|
263
|
+
return back.call({ "error" => "invalid_request", "error_description" => e.message })
|
|
264
|
+
end
|
|
265
|
+
elsif counting.none? { |s| s["id"] == site }
|
|
266
|
+
site = ""
|
|
267
|
+
end
|
|
268
|
+
return back.call({ "error" => "invalid_request", "error_description" => "Pick a site that counts #{host}" }) if site == ""
|
|
269
|
+
end
|
|
212
270
|
return back.call({ "error" => "invalid_request", "error_description" => "Unknown site" }) if site != "" && runlight.site(site).nil?
|
|
213
271
|
if manage && (site == "" || !runlight.remote(site).nil?)
|
|
214
272
|
return back.call({ "error" => "invalid_request", "error_description" => "Pick the site to manage" })
|
|
@@ -216,7 +274,7 @@ module Runlight
|
|
|
216
274
|
|
|
217
275
|
code = Hashing.random_id(32)
|
|
218
276
|
by = ctx["accountOf"]&.call(request)
|
|
219
|
-
grant = { "client" => client_id, "redirect" => redirect, "challenge" => challenge, "site" => site, "scope" => manage ? "manage" : "read",
|
|
277
|
+
grant = { "client" => client_id, "redirect" => redirect, "challenge" => challenge, "site" => site, "scope" => cms ? "cms" : manage ? "manage" : "read",
|
|
220
278
|
"expires" => runlight.now + CODE_MS }
|
|
221
279
|
grant["by"] = by if !by.nil? && by != ""
|
|
222
280
|
runlight.store.set_setting("oauth-code:#{Hashing.sha256(code)}", Json.encode(grant))
|
|
@@ -230,9 +288,8 @@ module Runlight
|
|
|
230
288
|
return oauth_error("unsupported_grant_type", "Only authorization_code is supported") if form.get("grant_type") != "authorization_code"
|
|
231
289
|
|
|
232
290
|
key = "oauth-code:#{Hashing.sha256(form.get("code") || "")}"
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
runlight.store.set_setting(key, nil) if !stored.nil? && stored != ""
|
|
291
|
+
# A code works once: it is gone before anything else is checked, and of two swaps at once only one gets it.
|
|
292
|
+
stored = runlight.store.take_setting(key)
|
|
236
293
|
grant = !stored.nil? && stored != "" ? Json.decode(stored) : nil
|
|
237
294
|
return oauth_error("invalid_grant", "The code has expired or was already used") if grant.nil? || grant["expires"] < runlight.now
|
|
238
295
|
if grant["client"] != form.get("client_id") || grant["redirect"] != form.get("redirect_uri")
|
|
@@ -249,12 +306,16 @@ module Runlight
|
|
|
249
306
|
runlight.store.set_setting(found["usedKey"], stored_client ? Json.encode(used) : runlight.now.to_s)
|
|
250
307
|
end
|
|
251
308
|
secret = "rl_#{Hashing.random_id(20)}"
|
|
252
|
-
scope = grant["scope"] == "
|
|
309
|
+
scope = if grant["scope"] == "cms" then "cms"
|
|
310
|
+
elsif grant["scope"] == "manage" then "manage"
|
|
311
|
+
else "read"
|
|
312
|
+
end
|
|
313
|
+
# A CMS plugin's token is the site's dashboard key, which only opens its read-only dashboard there.
|
|
253
314
|
row = {
|
|
254
315
|
"id" => Hashing.random_id,
|
|
255
316
|
"name" => Js.slice("#{Js.string(client["name"].nil? ? "An app" : client["name"])} (OAuth)", 0, 100),
|
|
256
317
|
"site" => grant["site"].to_s,
|
|
257
|
-
"scope" => scope,
|
|
318
|
+
"scope" => scope == "cms" ? "embed" : scope,
|
|
258
319
|
"hash" => Hashing.sha256(secret),
|
|
259
320
|
"hint" => secret[-4..],
|
|
260
321
|
"createdAt" => runlight.now,
|
|
@@ -268,6 +329,11 @@ module Runlight
|
|
|
268
329
|
end
|
|
269
330
|
# A hub's own address, from where it asked to be sent back, so the picker only ever sends choices there.
|
|
270
331
|
runlight.store.set_setting("token-origin:#{row["id"]}", Http::Url.new(grant["redirect"].to_s).origin) if scope == "manage"
|
|
332
|
+
# A CMS plugin gets the site's agents key too, so connecting fills in every key its settings ask for.
|
|
333
|
+
if scope == "cms"
|
|
334
|
+
return json({ "access_token" => secret, "token_type" => "Bearer", "scope" => scope, "site" => grant["site"],
|
|
335
|
+
"agents_key" => agents_key(runlight, grant["site"].to_s) })
|
|
336
|
+
end
|
|
271
337
|
# site is not part of OAuth, but a hub needs to know which site it was given.
|
|
272
338
|
answer = { "access_token" => secret, "token_type" => "Bearer", "scope" => scope }
|
|
273
339
|
answer["site"] = grant["site"] if grant["site"] != ""
|
|
@@ -287,12 +353,8 @@ module Runlight
|
|
|
287
353
|
|
|
288
354
|
# The key client ids are signed with, made on first use and kept in the database for every process.
|
|
289
355
|
def client_key(runlight)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
made = Hashing.random_id(32)
|
|
294
|
-
runlight.store.set_setting("oauth-key", made)
|
|
295
|
-
made
|
|
356
|
+
# Written only if no other request wrote one first, so every process signs with the same key.
|
|
357
|
+
runlight.store.setting_or_add("oauth-key") { Hashing.random_id(32) }
|
|
296
358
|
end
|
|
297
359
|
|
|
298
360
|
# The app a client id names, and where to note that it connected: {"client" => ..., "usedKey" => ...}, or nil.
|
|
@@ -315,6 +377,11 @@ module Runlight
|
|
|
315
377
|
{ "client" => client, "usedKey" => used_key }
|
|
316
378
|
end
|
|
317
379
|
|
|
380
|
+
# A redirect address's host as a site's domains are kept: lowercase, with no port and no www.
|
|
381
|
+
def site_host(redirect)
|
|
382
|
+
Http::Url.new(redirect).hostname.downcase.sub(/\.+\z/, "").sub(/\Awww\./, "")
|
|
383
|
+
end
|
|
384
|
+
|
|
318
385
|
def constant_time_equal(a, b)
|
|
319
386
|
a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
|
|
320
387
|
end
|
|
@@ -406,7 +473,7 @@ module Runlight
|
|
|
406
473
|
)
|
|
407
474
|
end
|
|
408
475
|
|
|
409
|
-
private_class_method :base64url, :from_base64url, :client_key, :client_for, :constant_time_equal, :esc, :json, :oauth_error,
|
|
476
|
+
private_class_method :base64url, :from_base64url, :client_key, :client_for, :site_host, :constant_time_equal, :esc, :json, :oauth_error,
|
|
410
477
|
:allowed_redirect, :json_form, :allow_registration, :register, :page
|
|
411
478
|
end
|
|
412
479
|
end
|
data/lib/runlight/rack_app.rb
CHANGED
|
@@ -5,9 +5,10 @@ module Runlight
|
|
|
5
5
|
# in the order the standalone server answers: a link domain added in Settings
|
|
6
6
|
# first (it leaves the dashboard's own paths alone), then `{linkPath}/{slug}`
|
|
7
7
|
# on the app's own domain, then the dashboard and API under the routes' base
|
|
8
|
-
# path. As middleware, every other request goes on to the app,
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# path. As middleware, every other request goes on to the app, a short link
|
|
9
|
+
# with no link by that name included, so the app's own 404 page answers it,
|
|
10
|
+
# after AI agent fetches among them are recorded (observe), which returns at
|
|
11
|
+
# once for everyone else. Alone, Runlight answers those with its own 404.
|
|
11
12
|
#
|
|
12
13
|
# # config.ru, Runlight alone
|
|
13
14
|
# run Runlight::RackApp.new(runlight: RL, base_path: "/runlight")
|
|
@@ -33,6 +34,8 @@ module Runlight
|
|
|
33
34
|
@observe = observe
|
|
34
35
|
@route_options = route_options
|
|
35
36
|
@lock = Mutex.new
|
|
37
|
+
@warming = nil
|
|
38
|
+
@warm_failed_at = nil
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
def core
|
|
@@ -51,10 +54,16 @@ module Runlight
|
|
|
51
54
|
rescue Http::Request::TooLarge
|
|
52
55
|
# The rest is never read, so the connection is not fit for another request.
|
|
53
56
|
return [413, { "content-type" => "application/json; charset=utf-8", "connection" => "close" }, [TOO_LARGE]]
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
raise if @app.nil?
|
|
59
|
+
|
|
60
|
+
# Whatever Runlight cannot read, the app behind still answers.
|
|
61
|
+
warn "Runlight: #{e.message}"
|
|
62
|
+
return @app.call(env)
|
|
54
63
|
end
|
|
55
64
|
response = answer(request)
|
|
56
65
|
if response.nil?
|
|
57
|
-
|
|
66
|
+
observe(request) if @observe && request.method == "GET"
|
|
58
67
|
return @app.call(env)
|
|
59
68
|
end
|
|
60
69
|
status, headers, body = response.to_rack
|
|
@@ -63,21 +72,35 @@ module Runlight
|
|
|
63
72
|
end
|
|
64
73
|
|
|
65
74
|
# The answer to one request, or nil when it is not Runlight's and an app sits behind.
|
|
75
|
+
#
|
|
76
|
+
# The app's own requests never wait on Runlight: until Runlight has made its tables they go straight to
|
|
77
|
+
# the app while init runs beside them (a link domain is known only once it has), and anything that goes
|
|
78
|
+
# wrong in Runlight is logged and the request still reaches the app.
|
|
66
79
|
def answer(request)
|
|
67
80
|
context = { "ip" => request.remote_address }
|
|
68
|
-
linked = core.link_domain_response(request, context)
|
|
69
|
-
return linked unless linked.nil?
|
|
70
|
-
|
|
71
81
|
path = Http::Url.parse(request.url)&.pathname || "/"
|
|
72
82
|
link_path = core.link_path
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
link = request.method == "GET" && path.match?(%r{\A#{Regexp.escape(link_path)}/[^/]+/?\z})
|
|
84
|
+
# The discovery documents OAuth clients read at the site's root are answered too, as under the base path.
|
|
85
|
+
own = @app.nil? || mine?(path) || link || Routes.oauth_document?(path)
|
|
86
|
+
if own || ready?
|
|
87
|
+
linked = core.link_domain_response(request, context)
|
|
88
|
+
return linked unless linked.nil?
|
|
89
|
+
end
|
|
90
|
+
if link
|
|
91
|
+
linked = core.link_handler.call(request, context)
|
|
92
|
+
# A slug with no link is the app's to answer, so its own 404 page shows.
|
|
93
|
+
return linked unless @app && linked.status == 404
|
|
94
|
+
|
|
95
|
+
own = mine?(path) || Routes.oauth_document?(path)
|
|
75
96
|
end
|
|
76
|
-
return routes.handle(request) if
|
|
97
|
+
return routes.handle(request) if own
|
|
77
98
|
|
|
78
99
|
nil
|
|
79
100
|
rescue StandardError => e
|
|
80
101
|
warn "Runlight: #{e.message}"
|
|
102
|
+
return nil if @app && !own
|
|
103
|
+
|
|
81
104
|
Http::Response.new(INTERNAL, status: 500, headers: {
|
|
82
105
|
"content-type" => "application/json; charset=utf-8",
|
|
83
106
|
"cache-control" => "no-store",
|
|
@@ -87,6 +110,37 @@ module Runlight
|
|
|
87
110
|
|
|
88
111
|
private
|
|
89
112
|
|
|
113
|
+
# Whether Runlight has made its tables. Until it has, an app's request starts init beside it, once at a
|
|
114
|
+
# time, and again no sooner than a minute after one that failed.
|
|
115
|
+
def ready?
|
|
116
|
+
return true unless core.respond_to?(:ready?)
|
|
117
|
+
return true if core.ready?
|
|
118
|
+
|
|
119
|
+
@lock.synchronize do
|
|
120
|
+
return false if @warming&.alive?
|
|
121
|
+
return false if @warm_failed_at && monotonic - @warm_failed_at < 60
|
|
122
|
+
|
|
123
|
+
@warming = Thread.new do
|
|
124
|
+
core.init
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
@warm_failed_at = monotonic
|
|
127
|
+
warn "Runlight: could not get ready #{e.message}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# AI agent fetches are recorded once Runlight is ready, and never hold up or break the page.
|
|
134
|
+
def observe(request)
|
|
135
|
+
core.observe(request) if ready?
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
warn "Runlight: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def monotonic
|
|
141
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
142
|
+
end
|
|
143
|
+
|
|
90
144
|
def idle
|
|
91
145
|
core.idle if core.respond_to?(:idle)
|
|
92
146
|
rescue StandardError => e
|