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.
@@ -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 replaces it.
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
- return runlight.store.setting_or_add(name) { "rlo_#{Hashing.random_id(20)}" } unless replace
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("This app is not registered", "<p>Start connecting again from the app.</p>", 400)
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("This app asked in a way Runlight does not support",
148
- "<p>#{esc(client["name"].to_s)} sent #{esc(params["error_description"] || params["error"])}. Start connecting again from the app.</p>", 400)
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("Ask an owner to connect this", "<p>You are signed in as a viewer, and only an owner of this Runlight can connect #{esc(name)}.</p>", 403)
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("Sign in first", "<p>Open your Runlight dashboard at <a href=\"#{esc(home)}\">#{esc("#{url.host}#{home}")}</a> and sign in, then connect #{esc(name)} again.</p>", 401)
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\">Allowing sends you back to <strong>#{esc(Http::Url.new(redirect).host)}</strong>. Only allow it if you started connecting there.</p>"
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("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)
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\">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" \
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>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>',
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
- "Connect #{esc(name)}",
226
- "<p><strong>#{esc(name)}</strong> wants to show this site’s stats and change its settings, so you can manage it from there.</p>\n" \
227
- "<p>It will be able to change goals, funnels, short links, link domains, email reports, and share links for the site you pick, along with its name, " \
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>Site<select name=\"site\">#{choices}</select></label>\n" \
232
- "<p class=\"note\">Its token appears in Settings, API and AI, where deleting it disconnects #{esc(name)}.</p>\n" \
233
- '<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>',
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"])} only</option>" }
238
- return page(
239
- "Connect #{esc(name)}",
240
- "<p><strong>#{esc(name)}</strong> wants to read your Runlight stats so it can answer questions about them. It will be able to read and never to change anything.</p>\n" \
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>Which sites it can read<select name=\"site\"><option value=\"\">Every site</option>#{sites.length > 1 ? options : ""}</select></label>\n" \
244
- "<p class=\"note\">Its token appears in Settings, API and AI, where deleting it disconnects the app.</p>\n" \
245
- '<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>',
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("This request came from another site", "<p>Start connecting again from the app.</p>", 403) if !origin.nil? && origin != "" && origin != url.origin
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
- def page(title, body, status = 200)
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=\"en\"><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" \
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, :page
496
+ :allowed_redirect, :json_form, :allow_registration, :register, :page_in
478
497
  end
479
498
  end
@@ -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
- # `Math.min(1000, Math.max(1, Number(value) || fallback))`.
471
- def limit(value, fallback)
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 = fallback if (n.is_a?(Float) && n.nan?) || n.zero?
474
- [1000, [1, n].max].min
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 from where, nothing more.
1204
- via_manage = @managed.key?(request)
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
- key_site = id if id != "" && !row["value"].nil? && constant_time_equal(given, row["value"]) && !rl.site(id).nil?
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
- options["start"] = params.get("start") if Js.truthy?(params.get("start"))
2434
- options["end"] = params.get("end") if Js.truthy?(params.get("end"))
2435
- options["through"] = { "step" => Js.number(through[1]), "value" => through[2] } unless through.nil?
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 = limit(params.get("limit"), 100)
2463
- rows = !key.nil? && key != "" ? store.event_prop_values(query, event, key, whole(count)) : []
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 = limit(params.get("limit"), 10)
2472
- page = Js.number(params.get("page"))
2473
- page = [1, (page.is_a?(Float) && page.nan?) || page.zero? ? 1 : page].max
2474
- rows = store.breakdown(query, dimension, whole(count), whole((page - 1) * count))
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"] = "in the file setup.txt in Runlight's data folder (<code>runlight setup</code> prints it too)"
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 where it is
58
- # written down, for the page that asks for it.
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, and
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 })]
@@ -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)