runlight 0.1.6 → 0.1.8
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 +12 -12
- data/assets/intl.json +1 -1
- data/assets/locales.json +5 -5
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +23 -15
- data/lib/runlight/accounts/crypto.rb +41 -13
- data/lib/runlight/accounts/pages.rb +102 -63
- data/lib/runlight/accounts/web.rb +55 -50
- data/lib/runlight/assistant.rb +9 -1
- data/lib/runlight/core.rb +44 -11
- data/lib/runlight/dates.rb +8 -0
- data/lib/runlight/http/fetcher.rb +12 -0
- data/lib/runlight/http/request.rb +3 -0
- data/lib/runlight/http/url.rb +6 -1
- data/lib/runlight/messages.rb +15 -1
- data/lib/runlight/oauth.rb +60 -41
- data/lib/runlight/routes.rb +47 -29
- data/lib/runlight/server/config.rb +1 -1
- data/lib/runlight/server/standalone.rb +3 -8
- data/lib/runlight/store/sql.rb +13 -0
- data/lib/runlight/store/sql_store.rb +187 -35
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight/zip.rb +3 -1
- metadata +2 -2
data/lib/runlight/oauth.rb
CHANGED
|
@@ -54,16 +54,29 @@ module Runlight
|
|
|
54
54
|
base64url(OpenSSL::Digest::SHA256.digest(verifier))
|
|
55
55
|
end
|
|
56
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
|
|
57
|
+
# The key a CMS plugin reports AI agents reading a site's pages with: made on first ask, or a new one that
|
|
58
|
+
# replaces it. It is kept sealed with the server's secret, as the mail service's keys are, so a copied
|
|
59
|
+
# database alone cannot report for the site; one kept before as plain text is sealed the next time it is read.
|
|
58
60
|
def agents_key(runlight, site, replace = false)
|
|
59
61
|
name = "observe-key:#{site}"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
unless replace
|
|
63
|
+
made = Mail::Secret.seal("rlo_#{Hashing.random_id(20)}", runlight.secret)
|
|
64
|
+
stored = runlight.store.setting_or_add(name) { made }
|
|
65
|
+
key = open_agents_key(runlight, stored)
|
|
66
|
+
runlight.store.set_setting(name, Mail::Secret.seal(key, runlight.secret)) if key && stored.start_with?("rlo_")
|
|
67
|
+
# One sealed under a secret the server no longer has cannot be shown or checked, so it is replaced.
|
|
68
|
+
return key if key
|
|
69
|
+
end
|
|
62
70
|
key = "rlo_#{Hashing.random_id(20)}"
|
|
63
|
-
runlight.store.set_setting(name, key)
|
|
71
|
+
runlight.store.set_setting(name, Mail::Secret.seal(key, runlight.secret))
|
|
64
72
|
key
|
|
65
73
|
end
|
|
66
74
|
|
|
75
|
+
# A site's agents key as kept in its setting: sealed, or plain text from before keys were sealed. Nil when it cannot be opened.
|
|
76
|
+
def open_agents_key(runlight, stored)
|
|
77
|
+
stored.start_with?("rlo_") ? stored : Mail::Secret.unseal(stored, runlight.secret)
|
|
78
|
+
end
|
|
79
|
+
|
|
67
80
|
# The URL that a 401 from the MCP endpoint points clients at, to start OAuth.
|
|
68
81
|
def resource_metadata_url(origin, base)
|
|
69
82
|
"#{origin}#{base}/.well-known/oauth-protected-resource"
|
|
@@ -120,13 +133,17 @@ module Runlight
|
|
|
120
133
|
|
|
121
134
|
if path == "/oauth/authorize" && (request.method == "GET" || request.method == "POST")
|
|
122
135
|
runlight.init
|
|
136
|
+
translator = Messages.translator(Messages.accepted_language(request))
|
|
137
|
+
h = translator["html"]
|
|
138
|
+
lang = translator["lang"]
|
|
139
|
+
page = ->(title, body, status = 200) { page_in(lang, title, body, status) }
|
|
123
140
|
form = request.method == "POST" ? Http::SearchParams.new(request.text) : url.search_params
|
|
124
141
|
client_id = form.get("client_id") || ""
|
|
125
142
|
client = client_for(runlight, client_id)&.fetch("client", nil)
|
|
126
143
|
redirect = form.get("redirect_uri") || ""
|
|
127
144
|
# Without a known client and one of its own addresses there is nowhere safe to send an answer.
|
|
128
145
|
if client.nil? || !Array(client["redirects"]).include?(redirect)
|
|
129
|
-
return page("
|
|
146
|
+
return page.call(h.call("oauth.unregisteredTitle"), "<p>#{h.call("oauth.startAgain")}</p>", 400)
|
|
130
147
|
end
|
|
131
148
|
|
|
132
149
|
back = lambda do |params|
|
|
@@ -144,8 +161,8 @@ module Runlight
|
|
|
144
161
|
if client.key?("usedAt") && Js.truthy?(client["usedAt"])
|
|
145
162
|
back.call(params)
|
|
146
163
|
else
|
|
147
|
-
page("
|
|
148
|
-
|
|
164
|
+
page.call(h.call("oauth.unsupportedTitle"),
|
|
165
|
+
"<p>#{h.call("oauth.unsupported", { "app" => esc(client["name"].to_s), "error" => esc(params["error_description"] || params["error"]) })}</p>", 400)
|
|
149
166
|
end
|
|
150
167
|
end
|
|
151
168
|
return refuse.call({ "error" => "unsupported_response_type" }) if form.get("response_type") != "code"
|
|
@@ -168,7 +185,7 @@ module Runlight
|
|
|
168
185
|
unless ctx["isOwner"].call(request)
|
|
169
186
|
# Someone signed in who may only read would be sent to sign in again and again.
|
|
170
187
|
if ctx["isReader"]&.call(request)
|
|
171
|
-
return page("
|
|
188
|
+
return page.call(h.call("oauth.viewerTitle"), "<p>#{h.call("oauth.viewer", { "app" => esc(name) })}</p>", 403)
|
|
172
189
|
end
|
|
173
190
|
|
|
174
191
|
# The site stays, since on the way in it only says which one to offer first.
|
|
@@ -180,7 +197,8 @@ module Runlight
|
|
|
180
197
|
end
|
|
181
198
|
|
|
182
199
|
home = base == "" ? "/" : base
|
|
183
|
-
return page(
|
|
200
|
+
return page.call(h.call("oauth.signInTitle"),
|
|
201
|
+
"<p>#{h.call("oauth.signIn", { "link" => "<a href=\"#{esc(home)}\">#{esc("#{url.host}#{home}")}</a>", "app" => esc(name) })}</p>", 401)
|
|
184
202
|
end
|
|
185
203
|
|
|
186
204
|
if request.method == "GET"
|
|
@@ -189,26 +207,27 @@ module Runlight
|
|
|
189
207
|
hidden << "<input type=\"hidden\" name=\"#{k}\" value=\"#{esc(form.get(k))}\">" unless form.get(k).nil?
|
|
190
208
|
end
|
|
191
209
|
# The app names itself, so the page also shows where the answer goes, which it cannot fake.
|
|
192
|
-
sends_to = "<p class=\"note\"
|
|
210
|
+
sends_to = "<p class=\"note\">#{h.call("oauth.sendsTo", { "host" => "<strong>#{esc(Http::Url.new(redirect).host)}</strong>" })}</p>"
|
|
211
|
+
buttons = "<div class=\"buttons\"><button type=\"submit\" name=\"decision\" value=\"deny\" class=\"ghost\">#{h.call("oauth.deny")}</button>" \
|
|
212
|
+
"<button type=\"submit\" name=\"decision\" value=\"allow\">#{h.call("oauth.allow")}</button></div></form>"
|
|
193
213
|
if cms
|
|
194
214
|
if counting.empty? && !runlight.managed_sites
|
|
195
|
-
return page("
|
|
196
|
-
|
|
215
|
+
return page.call(h.call("oauth.addSiteTitle"),
|
|
216
|
+
"<p>#{h.call("oauth.addSite", { "host" => "<strong>#{esc(host)}</strong>", "app" => esc(name) })}</p>", 400)
|
|
197
217
|
end
|
|
198
218
|
|
|
199
219
|
choices = +""
|
|
200
220
|
counting.each { |s| choices << "<option value=\"#{esc(s["id"])}\">#{esc(s["name"])}</option>" }
|
|
201
|
-
choices << "<option value=\"new\"
|
|
202
|
-
return page(
|
|
203
|
-
"
|
|
204
|
-
"<p
|
|
205
|
-
"<p
|
|
206
|
-
"read-only dashboard in its admin pages. It cannot change anything here or read other sites.</p>\n" \
|
|
221
|
+
choices << "<option value=\"new\">#{h.call("oauth.newSite", { "host" => esc(host) })}</option>" if runlight.managed_sites && counting.empty?
|
|
222
|
+
return page.call(
|
|
223
|
+
h.call("oauth.connectTitle", { "app" => esc(name) }),
|
|
224
|
+
"<p>#{h.call("oauth.cmsIntro", { "app" => "<strong>#{esc(name)}</strong>", "host" => "<strong>#{esc(host)}</strong>" })}</p>\n" \
|
|
225
|
+
"<p>#{h.call("oauth.cmsDetail")}</p>\n" \
|
|
207
226
|
"#{sends_to}\n" \
|
|
208
227
|
"<form method=\"post\" action=\"#{esc(base)}/oauth/authorize\">#{hidden}\n" \
|
|
209
|
-
"<label
|
|
210
|
-
"<p class=\"note\"
|
|
211
|
-
|
|
228
|
+
"<label>#{h.call("app.site")}<select name=\"site\">#{choices}</select></label>\n" \
|
|
229
|
+
"<p class=\"note\">#{h.call("oauth.cmsNote", { "app" => esc(name) })}</p>\n" \
|
|
230
|
+
"#{buttons}",
|
|
212
231
|
)
|
|
213
232
|
end
|
|
214
233
|
sites = runlight.sites
|
|
@@ -221,33 +240,32 @@ module Runlight
|
|
|
221
240
|
|
|
222
241
|
choices << "<option value=\"#{esc(s["id"])}\"#{s["id"] == wanted ? " selected" : ""}>#{esc(s["name"])}</option>"
|
|
223
242
|
end
|
|
224
|
-
return page(
|
|
225
|
-
"
|
|
226
|
-
"<p
|
|
227
|
-
"<p
|
|
228
|
-
"timezone, and retention. It cannot read other sites, add people, make tokens, or change how email is sent.</p>\n" \
|
|
243
|
+
return page.call(
|
|
244
|
+
h.call("oauth.connectTitle", { "app" => esc(name) }),
|
|
245
|
+
"<p>#{h.call("oauth.manageIntro", { "app" => "<strong>#{esc(name)}</strong>" })}</p>\n" \
|
|
246
|
+
"<p>#{h.call("oauth.manageDetail")}</p>\n" \
|
|
229
247
|
"#{sends_to}\n" \
|
|
230
248
|
"<form method=\"post\" action=\"#{esc(base)}/oauth/authorize\">#{hidden}\n" \
|
|
231
|
-
"<label
|
|
232
|
-
"<p class=\"note\"
|
|
233
|
-
|
|
249
|
+
"<label>#{h.call("app.site")}<select name=\"site\">#{choices}</select></label>\n" \
|
|
250
|
+
"<p class=\"note\">#{h.call("oauth.manageNote", { "app" => esc(name) })}</p>\n" \
|
|
251
|
+
"#{buttons}",
|
|
234
252
|
)
|
|
235
253
|
end
|
|
236
254
|
options = +""
|
|
237
|
-
sites.each { |s| options << "<option value=\"#{esc(s["id"])}\">#{esc(s["name"])}
|
|
238
|
-
return page(
|
|
239
|
-
"
|
|
240
|
-
"<p
|
|
255
|
+
sites.each { |s| options << "<option value=\"#{esc(s["id"])}\">#{h.call("oauth.siteOnly", { "site" => esc(s["name"]) })}</option>" }
|
|
256
|
+
return page.call(
|
|
257
|
+
h.call("oauth.connectTitle", { "app" => esc(name) }),
|
|
258
|
+
"<p>#{h.call("oauth.readIntro", { "app" => "<strong>#{esc(name)}</strong>" })}</p>\n" \
|
|
241
259
|
"#{sends_to}\n" \
|
|
242
260
|
"<form method=\"post\" action=\"#{esc(base)}/oauth/authorize\">#{hidden}\n" \
|
|
243
|
-
"<label
|
|
244
|
-
"<p class=\"note\"
|
|
245
|
-
|
|
261
|
+
"<label>#{h.call("oauth.readSites")}<select name=\"site\"><option value=\"\">#{h.call("tokens.allSites")}</option>#{sites.length > 1 ? options : ""}</select></label>\n" \
|
|
262
|
+
"<p class=\"note\">#{h.call("oauth.readNote")}</p>\n" \
|
|
263
|
+
"#{buttons}",
|
|
246
264
|
)
|
|
247
265
|
end
|
|
248
266
|
# The consent form posts here from this page only; a form from another site is refused.
|
|
249
267
|
origin = request.headers.get("origin")
|
|
250
|
-
return page("
|
|
268
|
+
return page.call(h.call("oauth.anotherSiteTitle"), "<p>#{h.call("oauth.startAgain")}</p>", 403) if !origin.nil? && origin != "" && origin != url.origin
|
|
251
269
|
return back.call({ "error" => "access_denied" }) if form.get("decision") != "allow"
|
|
252
270
|
|
|
253
271
|
site = form.get("site") || ""
|
|
@@ -391,7 +409,7 @@ module Runlight
|
|
|
391
409
|
end
|
|
392
410
|
|
|
393
411
|
def json(body, status = 200)
|
|
394
|
-
Http::Response.new(Json.encode(body), status: status, headers: { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }.merge(CORS))
|
|
412
|
+
Http::Response.new(Json.encode(body), status: status, headers: { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store", "x-content-type-options" => "nosniff" }.merge(CORS))
|
|
395
413
|
end
|
|
396
414
|
|
|
397
415
|
def oauth_error(error, description, status = 400)
|
|
@@ -455,9 +473,10 @@ module Runlight
|
|
|
455
473
|
"grant_types" => ["authorization_code"], "response_types" => ["code"] }, 201)
|
|
456
474
|
end
|
|
457
475
|
|
|
458
|
-
|
|
476
|
+
# A page in the visitor's language, with a title already written as HTML.
|
|
477
|
+
def page_in(lang, title, body, status = 200)
|
|
459
478
|
Http::Response.new(
|
|
460
|
-
"<!doctype html><html lang=\"
|
|
479
|
+
"<!doctype html><html lang=\"#{lang}\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"robots\" content=\"noindex\"><title>#{title} | Runlight</title>\n" \
|
|
461
480
|
'<style>:root{color-scheme:light dark;--page:#f4f4f5;--card:#fff;--ink:#111827;--muted:#4b5563;--line:#e5e7eb}@media (prefers-color-scheme:dark){:root{--page:#09090b;--card:#141417;--ink:#fafafa;--muted:#a1a1aa;--line:#27272a}}body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--page);color:var(--ink);font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif}main{width:min(440px,calc(100% - 32px));padding:28px;background:var(--card);border:1px solid var(--line);border-radius:14px}h1{font-size:20px;margin:0 0 12px}p{margin:0 0 16px;color:var(--muted)}p strong{color:var(--ink)}a{color:inherit}label{display:block;margin:0 0 14px;font-size:13px;font-weight:600}select{display:block;width:100%;height:40px;margin-top:6px;padding:0 36px 0 12px;border:1px solid var(--line);border-radius:8px;background:var(--card) url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3E%3Cpath d=\'M4 6l4 4 4-4\' fill=\'none\' stroke=\'%238a8a93\' stroke-width=\'1.6\' stroke-linecap=\'round\' stroke-linejoin=\'round\'/%3E%3C/svg%3E") right 12px center/14px no-repeat;color:var(--ink);font:inherit;font-weight:400;appearance:none;cursor:pointer}.note{font-size:13px}.buttons{display:flex;justify-content:flex-end;gap:8px}button{height:40px;padding:0 18px;border:0;border-radius:8px;background:var(--ink);color:var(--card);font:inherit;font-weight:600;cursor:pointer}button.ghost{background:none;color:var(--ink);border:1px solid var(--line)}</style></head><body><main><h1>' \
|
|
462
481
|
"#{title}</h1>#{body}</main></body></html>",
|
|
463
482
|
status: status,
|
|
@@ -474,6 +493,6 @@ module Runlight
|
|
|
474
493
|
end
|
|
475
494
|
|
|
476
495
|
private_class_method :base64url, :from_base64url, :client_key, :client_for, :site_host, :constant_time_equal, :esc, :json, :oauth_error,
|
|
477
|
-
:allowed_redirect, :json_form, :allow_registration, :register, :
|
|
496
|
+
:allowed_redirect, :json_form, :allow_registration, :register, :page_in
|
|
478
497
|
end
|
|
479
498
|
end
|
data/lib/runlight/routes.rb
CHANGED
|
@@ -279,15 +279,6 @@ module Runlight
|
|
|
279
279
|
)
|
|
280
280
|
end
|
|
281
281
|
|
|
282
|
-
# The first language a browser asks for that the dashboard speaks, else English.
|
|
283
|
-
def accepted_language(request)
|
|
284
|
-
(request.headers.get("accept-language") || "").split(",", -1).each do |part|
|
|
285
|
-
code = Js.lower(Js.slice(Js.trim(part.split(";", -1).first.to_s), 0, 2))
|
|
286
|
-
return code if Messages.languages.include?(code)
|
|
287
|
-
end
|
|
288
|
-
"en"
|
|
289
|
-
end
|
|
290
|
-
|
|
291
282
|
# Rows of objects as CSV, with a column for every key the first row has, in the units a spreadsheet reads.
|
|
292
283
|
def rows_csv(rows, sheet)
|
|
293
284
|
readable = rows.map { |r| sheet_row(r, sheet) }
|
|
@@ -467,11 +458,12 @@ module Runlight
|
|
|
467
458
|
out
|
|
468
459
|
end
|
|
469
460
|
|
|
470
|
-
#
|
|
471
|
-
|
|
461
|
+
# A whole number from a query, within 1 and `max`, or `fallback` for anything else: a fraction, a huge
|
|
462
|
+
# number, or text.
|
|
463
|
+
def whole_param(value, fallback, max)
|
|
472
464
|
n = Js.number(value)
|
|
473
|
-
n =
|
|
474
|
-
|
|
465
|
+
n = n.to_i if n.is_a?(Float) && n.finite? && n == n.floor
|
|
466
|
+
n.is_a?(Integer) && n.abs <= 9_007_199_254_740_991 && n >= 1 ? [n, max].min : fallback
|
|
475
467
|
end
|
|
476
468
|
|
|
477
469
|
# A number as a whole one for SQL, as PHP's (int) cast makes it.
|
|
@@ -1200,8 +1192,9 @@ module Runlight
|
|
|
1200
1192
|
fields[f["name"]] = value.nil? ? "" : Js.string(value)
|
|
1201
1193
|
end
|
|
1202
1194
|
end
|
|
1203
|
-
# A hub with a manage token learns which service sends the reports and
|
|
1204
|
-
|
|
1195
|
+
# A hub with a manage token, or an account kept to one site, learns which service sends the reports and
|
|
1196
|
+
# from where, nothing more: a webhook's address is often its own credential.
|
|
1197
|
+
via_manage = @managed.key?(request) || !session_scope(request)&.dig("site").nil?
|
|
1205
1198
|
answer = {
|
|
1206
1199
|
"source" => known["source"],
|
|
1207
1200
|
"service" => known["source"] == "app" ? "" : known["service"] || "",
|
|
@@ -1244,6 +1237,12 @@ module Runlight
|
|
|
1244
1237
|
settings = @rl.mail_settings
|
|
1245
1238
|
return coded("Set up a mail service first", "mail_unset", 400) if settings.nil?
|
|
1246
1239
|
|
|
1240
|
+
# One test a minute through each mail service, as samples are held, so the button cannot flood an inbox
|
|
1241
|
+
# or use up a service's quota; one just set up can be tried at once.
|
|
1242
|
+
tested = "test:#{settings["source"]}:#{settings["service"]}"
|
|
1243
|
+
last = @lock.synchronize { @sample_sent[tested] || 0 }
|
|
1244
|
+
return coded("A test went out a moment ago. Wait a minute and try again.", "test_soon", 429) if @rl.now - last < 60_000
|
|
1245
|
+
|
|
1247
1246
|
t = Messages.translator(text(body, "lang", "en"))["t"]
|
|
1248
1247
|
name = if settings["source"] == "app"
|
|
1249
1248
|
settings["via"] || ""
|
|
@@ -1256,6 +1255,8 @@ module Runlight
|
|
|
1256
1255
|
"text" => t.call("email.test.body", { "service" => name }),
|
|
1257
1256
|
"html" => "<img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\"><p style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
|
|
1258
1257
|
})
|
|
1258
|
+
# Counted once it went out, so a test that failed can be tried again as soon as the setting is fixed.
|
|
1259
|
+
@lock.synchronize { @sample_sent[tested] = @rl.now }
|
|
1259
1260
|
return json({ "ok" => true })
|
|
1260
1261
|
end
|
|
1261
1262
|
|
|
@@ -1557,6 +1558,12 @@ module Runlight
|
|
|
1557
1558
|
parsed, body = Js.parse_json(request.text)
|
|
1558
1559
|
return coded("Unknown site", "unknown_site", 404) unless parsed && Js.object?(body) && Js.get(body, "site") == site
|
|
1559
1560
|
end
|
|
1561
|
+
if site_path && json?(request)
|
|
1562
|
+
# Where a site lives stays with whoever runs the install: an account kept to the site may rename it, as
|
|
1563
|
+
# a hub may, never move it.
|
|
1564
|
+
parsed, body = Js.parse_json(request.text)
|
|
1565
|
+
return coded("Your account cannot change a site's domains", "site_domains", 403) if parsed && body.is_a?(Hash) && body.key?("hostnames")
|
|
1566
|
+
end
|
|
1560
1567
|
url = Http::Url.new(url.href)
|
|
1561
1568
|
query = url.search_params
|
|
1562
1569
|
query.set("site", site)
|
|
@@ -2020,6 +2027,8 @@ module Runlight
|
|
|
2020
2027
|
end
|
|
2021
2028
|
begin
|
|
2022
2029
|
rl.delete_site(id)
|
|
2030
|
+
# Those kept to the site go with it, so a site made later with the same id is not theirs.
|
|
2031
|
+
@web.accounts.forget_site(id) if @web
|
|
2023
2032
|
return json({ "ok" => true })
|
|
2024
2033
|
rescue RangeError => e
|
|
2025
2034
|
return e.message == "Unknown site" ? coded(e.message, "unknown_site", 404) : refused(e, "site_invalid")
|
|
@@ -2049,7 +2058,8 @@ module Runlight
|
|
|
2049
2058
|
rl.init
|
|
2050
2059
|
store.settings_starting_with("observe-key:").each do |row|
|
|
2051
2060
|
id = row["key"].delete_prefix("observe-key:")
|
|
2052
|
-
|
|
2061
|
+
opened = row["value"].nil? ? nil : OAuth.open_agents_key(rl, row["value"])
|
|
2062
|
+
key_site = id if id != "" && !opened.nil? && constant_time_equal(given, opened) && !rl.site(id).nil?
|
|
2053
2063
|
end
|
|
2054
2064
|
return coded("Unauthorized", "unauthorized", 401) if key_site.nil?
|
|
2055
2065
|
end
|
|
@@ -2339,8 +2349,10 @@ module Runlight
|
|
|
2339
2349
|
})
|
|
2340
2350
|
end
|
|
2341
2351
|
|
|
2342
|
-
return json(unfeed.call(store.realtime(site["id"], rl.now))) if path == "/api/realtime"
|
|
2352
|
+
return json(unfeed.call(store.realtime(site["id"], @rl.now))) if path == "/api/realtime"
|
|
2343
2353
|
|
|
2354
|
+
# Statistics that fit the data, for an app that never runs the scheduled check (at most hourly).
|
|
2355
|
+
@rl.freshen_statistics
|
|
2344
2356
|
read = read_query(url, site)
|
|
2345
2357
|
return read if read.is_a?(Http::Response)
|
|
2346
2358
|
|
|
@@ -2358,6 +2370,7 @@ module Runlight
|
|
|
2358
2370
|
end
|
|
2359
2371
|
|
|
2360
2372
|
if path == "/api/goals"
|
|
2373
|
+
return json({ "site" => site["id"], "goals" => store.goals(site["id"]) }) if params.get("definitions") == "1"
|
|
2361
2374
|
goals = store.goals(site["id"])
|
|
2362
2375
|
visitors = store.visitors(query)
|
|
2363
2376
|
previous_visitors = before.nil? ? 0 : store.visitors(before)
|
|
@@ -2430,15 +2443,17 @@ module Runlight
|
|
|
2430
2443
|
# Journeys reads the newest visits up to a cap; say when it was reached.
|
|
2431
2444
|
read = store.journey_pages(query, Journeys::PAGES_PER_VISIT)
|
|
2432
2445
|
options = { "steps" => Js.number(params.get("steps") || 5) }
|
|
2433
|
-
|
|
2434
|
-
options["
|
|
2435
|
-
options["
|
|
2446
|
+
# Pages as they are recorded, percent-encoded, as filters take them: /café is /caf%C3%A9.
|
|
2447
|
+
options["start"] = Sources.recorded_path(params.get("start")) || params.get("start") if Js.truthy?(params.get("start"))
|
|
2448
|
+
options["end"] = Sources.recorded_path(params.get("end")) || params.get("end") if Js.truthy?(params.get("end"))
|
|
2449
|
+
options["through"] = { "step" => Js.number(through[1]), "value" => Sources.recorded_path(through[2]) || through[2] } unless through.nil?
|
|
2436
2450
|
answer = { "site" => site["id"], "range" => range_out }.merge(Journeys.journeys(read["rows"], options)) { |_key, mine, _theirs| mine }
|
|
2437
2451
|
answer["sampled"] = Store::SqlStore::JOURNEY_VISITS if read["sampled"]
|
|
2438
2452
|
return json(answer)
|
|
2439
2453
|
end
|
|
2440
2454
|
|
|
2441
2455
|
if path == "/api/funnels"
|
|
2456
|
+
return json({ "site" => site["id"], "funnels" => store.funnels(site["id"]) }) if params.get("definitions") == "1"
|
|
2442
2457
|
# One funnel at a time, so a page of funnels never takes every database connection at once.
|
|
2443
2458
|
rows = store.funnels(site["id"]).map do |funnel|
|
|
2444
2459
|
counts = store.funnel_counts(query, funnel)
|
|
@@ -2459,8 +2474,8 @@ module Runlight
|
|
|
2459
2474
|
return coded("Bad property name", "property_bad", 400) if !asked.nil? && !safe.call(asked)
|
|
2460
2475
|
|
|
2461
2476
|
key = asked || keys.find { |k| safe.call(k["key"].to_s) }&.[]("key")
|
|
2462
|
-
count =
|
|
2463
|
-
rows = !key.nil? && key != "" ? store.event_prop_values(query, event, key,
|
|
2477
|
+
count = whole_param(params.get("limit"), 100, 1000)
|
|
2478
|
+
rows = !key.nil? && key != "" ? store.event_prop_values(query, event, key, count) : []
|
|
2464
2479
|
return json({ "site" => site["id"], "range" => range_out, "event" => event, "keys" => keys, "key" => key, "rows" => rows })
|
|
2465
2480
|
end
|
|
2466
2481
|
|
|
@@ -2468,10 +2483,13 @@ module Runlight
|
|
|
2468
2483
|
dimension = params.get("dimension") || ""
|
|
2469
2484
|
return coded("Unknown dimension \"#{dimension}\"", "unknown_dimension", 400, { "dimension" => dimension }) unless Query.dimension?(dimension)
|
|
2470
2485
|
|
|
2471
|
-
count =
|
|
2472
|
-
page =
|
|
2473
|
-
|
|
2474
|
-
|
|
2486
|
+
count = whole_param(params.get("limit"), 10, 1000)
|
|
2487
|
+
page = whole_param(params.get("page"), 1, 100_000)
|
|
2488
|
+
if dimension == "hostname" && params.get("suggestions") == "1" && params.get("format") != "csv"
|
|
2489
|
+
return json({ "site" => site["id"], "range" => range_out, "dimension" => dimension, "rows" => store.hostname_suggestions(query, [count, 50].min) })
|
|
2490
|
+
end
|
|
2491
|
+
|
|
2492
|
+
rows = store.breakdown(query, dimension, count, (page - 1) * count)
|
|
2475
2493
|
if params.get("format") == "csv"
|
|
2476
2494
|
return download("#{site["id"]}-#{dimension}-#{range["fromDate"]}-#{range["toDate"]}.csv",
|
|
2477
2495
|
rows_csv(rows, { "timezone" => site["timezone"], "dimension" => dimension }), "text/csv; charset=utf-8")
|
|
@@ -2556,7 +2574,7 @@ module Runlight
|
|
|
2556
2574
|
# The relay page the picker opens with its choice in the fragment, which never reaches a server. Its
|
|
2557
2575
|
# script hands the choice to the dashboard tab on this origin that started the pick, and nothing else.
|
|
2558
2576
|
if path == "/pick-return" && method == "GET"
|
|
2559
|
-
translator = Messages.translator(accepted_language(request))
|
|
2577
|
+
translator = Messages.translator(Messages.accepted_language(request))
|
|
2560
2578
|
t = translator["t"]
|
|
2561
2579
|
return small_page(
|
|
2562
2580
|
translator["lang"],
|
|
@@ -2642,7 +2660,7 @@ module Runlight
|
|
|
2642
2660
|
rl.init
|
|
2643
2661
|
found = redeem_embed(url.search_params.get("ticket") || "")
|
|
2644
2662
|
if found.nil?
|
|
2645
|
-
translator = Messages.translator(accepted_language(request))
|
|
2663
|
+
translator = Messages.translator(Messages.accepted_language(request))
|
|
2646
2664
|
t = translator["t"]
|
|
2647
2665
|
return small_page(translator["lang"], "<h1>#{escape_html(t.call("embed.goneTitle"))}</h1><p>#{escape_html(t.call("embed.gone"))}</p>", 404)
|
|
2648
2666
|
end
|
|
@@ -2662,7 +2680,7 @@ module Runlight
|
|
|
2662
2680
|
id = share_page[1]
|
|
2663
2681
|
share = id.match?(SHARE_ID) ? store.share_by_id(id) : nil
|
|
2664
2682
|
if share.nil?
|
|
2665
|
-
translator = Messages.translator(accepted_language(request))
|
|
2683
|
+
translator = Messages.translator(Messages.accepted_language(request))
|
|
2666
2684
|
t = translator["t"]
|
|
2667
2685
|
return small_page(translator["lang"], "<h1>#{escape_html(t.call("share.goneTitle"))}</h1><p>#{escape_html(t.call("share.gone"))}</p>", 404)
|
|
2668
2686
|
end
|
|
@@ -199,7 +199,7 @@ module Runlight
|
|
|
199
199
|
settings["geo"] = geo unless geo.nil?
|
|
200
200
|
unless code.nil?
|
|
201
201
|
settings["setupCode"] = code
|
|
202
|
-
settings["setupWhere"] =
|
|
202
|
+
settings["setupWhere"] = true
|
|
203
203
|
end
|
|
204
204
|
Standalone.new(settings.merge(Options.normalize(options)))
|
|
205
205
|
end
|
|
@@ -54,8 +54,8 @@ module Runlight
|
|
|
54
54
|
# - url: the dashboard's public address, such as https://stats.example.com. It can never become a link
|
|
55
55
|
# domain, short links never answer on it, and emails link to it whatever Host header a request carries.
|
|
56
56
|
# - trustProxy, geo, geoCredit, now, fetcher: as the Core's and the routes' options of those names.
|
|
57
|
-
# - setupCode: the one-time code that unlocks /setup while no account exists; setupWhere says
|
|
58
|
-
# written
|
|
57
|
+
# - setupCode: the one-time code that unlocks /setup while no account exists; setupWhere (true) says it is
|
|
58
|
+
# written in setup.txt, for the page that asks for it.
|
|
59
59
|
# - cronSecret: a bearer secret for POST /api/check, for a scheduler that calls it over HTTP.
|
|
60
60
|
# - observeKey: one key for every site's AI agent reports.
|
|
61
61
|
def initialize(options)
|
|
@@ -160,8 +160,7 @@ module Runlight
|
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
# The Rack entry point: answers the request, then does the work left after answering (a retention change's
|
|
163
|
-
# deletions) once the server has sent the answer. Bodies past the limits the Node server keeps get a 413
|
|
164
|
-
# X-Forwarded-Proto names the scheme, as the Node server reads them.
|
|
163
|
+
# deletions) once the server has sent the answer. Bodies past the limits the Node server keeps get a 413.
|
|
165
164
|
def call(env)
|
|
166
165
|
limit = Http::Request.limit_for("#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}")
|
|
167
166
|
method = env["REQUEST_METHOD"].to_s.upcase
|
|
@@ -172,10 +171,6 @@ module Runlight
|
|
|
172
171
|
env["rack.input"] = StringIO.new(text)
|
|
173
172
|
end
|
|
174
173
|
request = Http::Request.from_rack(env)
|
|
175
|
-
proto = request.headers.get("x-forwarded-proto").to_s.split(",")[0].to_s.strip.downcase
|
|
176
|
-
if %w[http https].include?(proto) && !request.url.start_with?("#{proto}://")
|
|
177
|
-
request = request.with(url: request.url.sub(%r{\Ahttps?://}, "#{proto}://"))
|
|
178
|
-
end
|
|
179
174
|
status, headers, body = handle(request, { "ip" => request.remote_address }).to_rack
|
|
180
175
|
body = [] if method == "HEAD"
|
|
181
176
|
[status, headers, Finished.new(body, -> { idle })]
|
data/lib/runlight/store/sql.rb
CHANGED
|
@@ -70,6 +70,11 @@ module Runlight
|
|
|
70
70
|
a.b <=> b.b
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# The events table, through the kind and time index on SQLite, which otherwise may read a site's whole history.
|
|
74
|
+
def event_kind_table(dialect)
|
|
75
|
+
dialect == "sqlite" ? "rl_events e INDEXED BY rl_events_site_kind_ts" : "rl_events e"
|
|
76
|
+
end
|
|
77
|
+
|
|
73
78
|
# Runs a query over pieces of a list and joins the answers, in order.
|
|
74
79
|
def in_pieces(items, size)
|
|
75
80
|
out = []
|
|
@@ -117,6 +122,8 @@ module Runlight
|
|
|
117
122
|
# MySQL takes an index that leads with the site as a way to read all of a site's rows, even where a
|
|
118
123
|
# range of time would read far fewer, so there an index for looking a value up leads with that value.
|
|
119
124
|
"CREATE INDEX IF NOT EXISTS rl_sessions_visitor ON rl_sessions (#{my ? "visitor, site" : "site, visitor"}, last_at)",
|
|
125
|
+
# Keep exact visitor counts in index order without loading every session row.
|
|
126
|
+
*(dialect == "sqlite" ? ["CREATE INDEX IF NOT EXISTS rl_sessions_visit_visitors ON rl_sessions (site, visitor, started_at) WHERE pageviews > 0 OR events > 0"] : []),
|
|
120
127
|
"CREATE TABLE IF NOT EXISTS rl_events (
|
|
121
128
|
id #{id}, site #{site} NOT NULL, ts BIGINT NOT NULL, kind #{str.(20)} NOT NULL,
|
|
122
129
|
visitor #{text.(100)}, session #{text.(100)}, pageview #{text.(100)},
|
|
@@ -129,6 +136,7 @@ module Runlight
|
|
|
129
136
|
# Page and event filters find the visits they pick through these, rather than reading every row in the range.
|
|
130
137
|
# MySQL indexes the first 255 characters of a path, which is enough to find it.
|
|
131
138
|
"CREATE INDEX IF NOT EXISTS rl_events_site_path ON rl_events (#{my ? "path(255), site" : "site, path"}, ts)",
|
|
139
|
+
*(dialect == "sqlite" ? ["CREATE INDEX IF NOT EXISTS rl_events_hostname_suggest ON rl_events (site, hostname, ts, session) WHERE kind = 'pageview'"] : []),
|
|
132
140
|
# MySQL has no partial index, so its index of event names holds the kind too.
|
|
133
141
|
if my
|
|
134
142
|
"CREATE INDEX IF NOT EXISTS rl_events_site_name ON rl_events (name, site, kind, ts)"
|
|
@@ -273,6 +281,11 @@ module Runlight
|
|
|
273
281
|
Js.number(value)
|
|
274
282
|
end
|
|
275
283
|
|
|
284
|
+
# Text cut to at most `max` characters, as a VARCHAR(max) holds it, never through the middle of one.
|
|
285
|
+
def clip(text, max)
|
|
286
|
+
text.is_a?(String) && text.length > max ? text[0, max] : text
|
|
287
|
+
end
|
|
288
|
+
|
|
276
289
|
# String(value ?? "") for text a driver read, as valid UTF-8.
|
|
277
290
|
def string(value)
|
|
278
291
|
value.nil? ? "" : Js.scrub(value.to_s)
|