runlight 0.2.0 → 0.3.0
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/README.md +1 -1
- data/assets/build.json +3 -3
- data/assets/dashboard.js +7 -7
- data/assets/locales.json +5 -5
- data/lib/runlight/accounts/accounts.rb +15 -5
- data/lib/runlight/accounts/crypto.rb +2 -0
- data/lib/runlight/accounts/throttle.rb +10 -19
- data/lib/runlight/accounts/web.rb +23 -1
- data/lib/runlight/core.rb +69 -31
- data/lib/runlight/http/fetcher.rb +1 -1
- data/lib/runlight/importers/client.rb +6 -6
- data/lib/runlight/importers/index.rb +3 -2
- data/lib/runlight/importers/visits.rb +2 -2
- data/lib/runlight/payload.rb +18 -5
- data/lib/runlight/routes.rb +24 -5
- data/lib/runlight/safefetch.rb +20 -14
- data/lib/runlight/server/config.rb +3 -2
- data/lib/runlight/store/sql.rb +2 -0
- data/lib/runlight/store/sql_store.rb +16 -1
- data/lib/runlight/version.rb +1 -1
- metadata +2 -2
|
@@ -13,8 +13,8 @@ module Runlight
|
|
|
13
13
|
# stats and changes nothing.
|
|
14
14
|
#
|
|
15
15
|
# A user is a Hash with the TypeScript User's keys: id, email, hash, role ("owner", "admin", "member", or
|
|
16
|
-
# "viewer"), createdAt, twoFactor (whether sign-in also asks for a code),
|
|
17
|
-
# yet used). An invite has id, email, role, invitedBy, createdAt, and expiresAt.
|
|
16
|
+
# "viewer"), createdAt, twoFactor (whether sign-in also asks for a code), recoveryLeft (recovery codes not
|
|
17
|
+
# yet used), and signedOut (a mark signing out replaces, which every session is signed with, when set). An invite has id, email, role, invitedBy, createdAt, and expiresAt.
|
|
18
18
|
class Accounts
|
|
19
19
|
SESSION_COOKIE = "runlight_session"
|
|
20
20
|
# Thirty days, renewed on every sign-in.
|
|
@@ -401,6 +401,13 @@ module Runlight
|
|
|
401
401
|
Crypto.same_text(sign("#{id}.#{expires}", session_key(user)), signature) ? user : nil
|
|
402
402
|
end
|
|
403
403
|
|
|
404
|
+
# Ends every session an account has, in every browser, as signing out does.
|
|
405
|
+
def end_sessions(id)
|
|
406
|
+
init
|
|
407
|
+
@store.db.run("UPDATE rl_users SET signed_out = ? WHERE id = ?", [Hashing.random_id(8), id])
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
|
|
404
411
|
# A long-lived mark for a browser that signed in to an account. With it, failed tries by others
|
|
405
412
|
# against that account cannot lock this browser out; the per-address limit still applies.
|
|
406
413
|
# A new password withdraws it.
|
|
@@ -455,7 +462,8 @@ module Runlight
|
|
|
455
462
|
names = columns.map { |c| (c["name"] || c["NAME"] || "").to_s }
|
|
456
463
|
db.run("ALTER TABLE rl_users ADD COLUMN role #{str.call(20)} NOT NULL DEFAULT 'owner'") unless names.include?("role")
|
|
457
464
|
# Two-factor came later still: the secret (sealed), one being set up, recovery code hashes, and the last code's step.
|
|
458
|
-
|
|
465
|
+
# And the mark signing out replaces, later again.
|
|
466
|
+
[%w[totp_secret TEXT], %w[totp_pending TEXT], %w[totp_recovery TEXT], %w[totp_step BIGINT], %w[signed_out TEXT]].each do |name, type|
|
|
459
467
|
db.run("ALTER TABLE rl_users ADD COLUMN #{name} #{type}") unless names.include?(name)
|
|
460
468
|
end
|
|
461
469
|
db.run(
|
|
@@ -486,6 +494,7 @@ module Runlight
|
|
|
486
494
|
"createdAt" => Js.number(r["created_at"]),
|
|
487
495
|
"twoFactor" => Js.truthy?(r["totp_secret"]),
|
|
488
496
|
"recoveryLeft" => recovery.length,
|
|
497
|
+
**(Js.truthy?(r["signed_out"]) ? { "signedOut" => r["signed_out"].to_s } : {}),
|
|
489
498
|
}
|
|
490
499
|
end
|
|
491
500
|
|
|
@@ -580,9 +589,10 @@ module Runlight
|
|
|
580
589
|
{ "invite" => invite, "code" => code }
|
|
581
590
|
end
|
|
582
591
|
|
|
583
|
-
# What a session is signed with: the password hash,
|
|
592
|
+
# What a session is signed with: the password hash, whether two-factor is on, and a mark that signing out
|
|
593
|
+
# replaces, so changing any of them ends other sessions.
|
|
584
594
|
def session_key(user)
|
|
585
|
-
"#{user["hash"]}#{user["twoFactor"] ? ".2fa" : ""}"
|
|
595
|
+
"#{user["hash"]}#{user["twoFactor"] ? ".2fa" : ""}#{user["signedOut"] ? ".#{user["signedOut"]}" : ""}"
|
|
586
596
|
end
|
|
587
597
|
|
|
588
598
|
def sign(body, hash)
|
|
@@ -132,6 +132,8 @@ module Runlight
|
|
|
132
132
|
n, r, p = parts[1, 3].map { |part| Js.number(part) }
|
|
133
133
|
whole = ->(v, max) { v.is_a?(Integer) && v >= 1 && v <= max }
|
|
134
134
|
return nil unless whole.call(n, 1 << 20) && n >= 2 && (n & (n - 1)).zero? && whole.call(r, 32) && whole.call(p, 16)
|
|
135
|
+
# N must also be below 2^(16r), as scrypt itself requires, so a stored hash past that fails to match rather than raises.
|
|
136
|
+
return nil if Math.log2(n) >= 16 * r
|
|
135
137
|
|
|
136
138
|
{ n: n, r: r, p: p }
|
|
137
139
|
end
|
|
@@ -192,9 +192,10 @@ module Runlight
|
|
|
192
192
|
save(id, entry)
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
-
# Expired entries go first, then the
|
|
196
|
-
#
|
|
197
|
-
# made, since only then can
|
|
195
|
+
# Expired entries go first, then those with the fewest tries, oldest first, down to nine tenths of the
|
|
196
|
+
# ceiling so this runs seldom. A flood of made-up names has one try each, so it cannot wipe out a block,
|
|
197
|
+
# or the count of a name someone is close to blocking. Run when a new entry is made, since only then can
|
|
198
|
+
# there be more.
|
|
198
199
|
def prune(now)
|
|
199
200
|
entries = []
|
|
200
201
|
@store.settings_starting_with(prefix).each do |row|
|
|
@@ -204,23 +205,13 @@ module Runlight
|
|
|
204
205
|
@store.set_setting(row["key"], nil)
|
|
205
206
|
next
|
|
206
207
|
end
|
|
207
|
-
entries << { "key" => row["key"], "until" => until_ms, "
|
|
208
|
-
end
|
|
209
|
-
size = entries.length
|
|
210
|
-
return if size <= Accounts::MAX_THROTTLED
|
|
211
|
-
|
|
212
|
-
# Oldest first: each entry's window started windowMs before its end.
|
|
213
|
-
entries = entries.each_with_index.sort_by { |e, i| [e["until"], i] }.map(&:first)
|
|
214
|
-
[false, true].each do |blocked|
|
|
215
|
-
entries.each do |e|
|
|
216
|
-
return if size <= Accounts::MAX_THROTTLED
|
|
217
|
-
|
|
218
|
-
if e["blocked"] == blocked
|
|
219
|
-
@store.set_setting(e["key"], nil)
|
|
220
|
-
size -= 1
|
|
221
|
-
end
|
|
222
|
-
end
|
|
208
|
+
entries << { "key" => row["key"], "until" => until_ms, "count" => int(entry["count"]) }
|
|
223
209
|
end
|
|
210
|
+
return if entries.length <= Accounts::MAX_THROTTLED
|
|
211
|
+
|
|
212
|
+
# Fewest tries first, and among equals the oldest: each entry's window started windowMs before its end.
|
|
213
|
+
fewest = entries.each_with_index.sort_by { |e, i| [e["count"], e["until"], i] }.map(&:first)
|
|
214
|
+
fewest.first(entries.length - (Accounts::MAX_THROTTLED * 0.9).floor).each { |e| @store.set_setting(e["key"], nil) }
|
|
224
215
|
end
|
|
225
216
|
end
|
|
226
217
|
end
|
|
@@ -127,6 +127,11 @@ module Runlight
|
|
|
127
127
|
true
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
# The ids of the tokens an account made.
|
|
131
|
+
def tokens_of(by)
|
|
132
|
+
@store.settings_starting_with(MADE_BY).select { |row| row["value"] == by }.map { |row| row["key"][MADE_BY.length..] }.to_set
|
|
133
|
+
end
|
|
134
|
+
|
|
130
135
|
# Answers an account page or API request at a path under the base, or nil for anything else.
|
|
131
136
|
# context: {"ip" => the connection's address}, optional.
|
|
132
137
|
def handle(request, path, context = {})
|
|
@@ -353,6 +358,12 @@ module Runlight
|
|
|
353
358
|
if path == "/auth.js"
|
|
354
359
|
return Http::Response.new(Pages.auth_js(query.get("lang") || "en"), status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "public, max-age=3600" })
|
|
355
360
|
end
|
|
361
|
+
# A form posted from another site, or a sibling one, could sign someone in to an account that is not theirs or
|
|
362
|
+
# plant a session, so these pages take forms only from their own. The browser says where a request came from.
|
|
363
|
+
cross_site = %w[cross-site same-site].include?(request.headers.get("sec-fetch-site") || "")
|
|
364
|
+
if cross_site && (method == "POST" || path == "/logout") && %w[/setup /login /login/code /invite /logout].include?(path)
|
|
365
|
+
return html(Pages.login_page(base, { "error" => t.call("oauth.anotherSiteTitle"), "next" => @home_path, "forgot" => @forgot }, lang), 403)
|
|
366
|
+
end
|
|
356
367
|
|
|
357
368
|
if path == "/setup"
|
|
358
369
|
return redirect("#{base}/login") if has_account?
|
|
@@ -431,7 +442,10 @@ module Runlight
|
|
|
431
442
|
return signed_in_to(request, user, next_path)
|
|
432
443
|
end
|
|
433
444
|
|
|
434
|
-
|
|
445
|
+
# Signing out ends this browser's session; the account page can end them everywhere.
|
|
446
|
+
if path == "/logout"
|
|
447
|
+
return redirect("#{base}/login", { "set-cookie" => session_cookie(request, "", 0) })
|
|
448
|
+
end
|
|
435
449
|
|
|
436
450
|
if path == "/invite"
|
|
437
451
|
if method == "GET"
|
|
@@ -552,6 +566,12 @@ module Runlight
|
|
|
552
566
|
return coded("Send JSON", "send_json", 415) if method == "POST" && media_type(request) != "application/json"
|
|
553
567
|
return reply({ "account" => person(user) }) if path == "/api/account" && method == "GET"
|
|
554
568
|
|
|
569
|
+
# Ends the account's sessions in every browser, this one too, so a cookie copied before stops working.
|
|
570
|
+
if path == "/api/account/sign-out-everywhere" && method == "POST"
|
|
571
|
+
@accounts.end_sessions(user["id"])
|
|
572
|
+
return reply({ "ok" => true }, 200, { "set-cookie" => session_cookie(request, "", 0) })
|
|
573
|
+
end
|
|
574
|
+
|
|
555
575
|
fresh = ->(updated) { { "set-cookie" => session_cookie(request, @accounts.session_for(updated, now), Accounts::SESSION_MS / 1000) } }
|
|
556
576
|
if path == "/api/account/password" && method == "POST"
|
|
557
577
|
input = body(request)
|
|
@@ -562,6 +582,8 @@ module Runlight
|
|
|
562
582
|
|
|
563
583
|
begin
|
|
564
584
|
updated = @accounts.set_password(user["email"], field(input, "next"), now)
|
|
585
|
+
# Asked for after a password got out: the tokens and apps the account made could still read on.
|
|
586
|
+
drop_tokens_of(user["id"]) if input["revokeTokens"] == true
|
|
565
587
|
# The new password ends every other sign-in; this browser gets a fresh one.
|
|
566
588
|
return reply({ "ok" => true }, 200, fresh.call(updated))
|
|
567
589
|
rescue AccountError => e
|
data/lib/runlight/core.rb
CHANGED
|
@@ -17,12 +17,13 @@ module Runlight
|
|
|
17
17
|
# standalone server does. `site` and `sites` are ignored.
|
|
18
18
|
# - geo: a callable taking an IP and giving { "country", "region", "city" } or nil, a location for an IP when
|
|
19
19
|
# the platform sends no location headers.
|
|
20
|
-
# - trustProxy: true
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
20
|
+
# - trustProxy: true, false, or one of "x-forwarded-for", "x-real-ip", "cf-connecting-ip". Read the client IP
|
|
21
|
+
# from forwarding headers: the last X-Forwarded-For entry, which the nearest proxy wrote, then X-Real-IP,
|
|
22
|
+
# then CF-Connecting-IP. Name one of them to read only that header, such as "cf-connecting-ip" behind
|
|
23
|
+
# Cloudflare and another proxy. Left unset, the headers count only on a connection from this computer or a
|
|
24
|
+
# private network, where proxies and load balancers connect from, so a client that reaches the app straight
|
|
25
|
+
# from the internet cannot choose its own address. True reads them from anywhere, for a CDN that connects
|
|
26
|
+
# from public addresses. False reads only the connection's address.
|
|
26
27
|
# - linkPath: where short links on the app's own domain live, as `{linkPath}/{slug}`. Default "/go".
|
|
27
28
|
# - mail: how email reports, invites, and sign-in links are sent. Either a mail service in code (a Transports
|
|
28
29
|
# config plus `from` and `fromName`), which the dashboard shows and cannot change, or a mailer of the app's
|
|
@@ -124,8 +125,10 @@ module Runlight
|
|
|
124
125
|
|
|
125
126
|
@geo = options["geo"]
|
|
126
127
|
@trust_proxy = options["trustProxy"].nil? ? true : options["trustProxy"]
|
|
127
|
-
# True
|
|
128
|
-
@
|
|
128
|
+
# True when trustProxy was left at its default, which believes forwarding headers only from a private address.
|
|
129
|
+
@trust_private = options["trustProxy"].nil?
|
|
130
|
+
# True until a public address has sent forwarding headers that the default did not believe, and been warned about once.
|
|
131
|
+
@warn_direct = @trust_private
|
|
129
132
|
per_minute = options["rateLimit"].nil? ? 120 : options["rateLimit"]
|
|
130
133
|
# false, 0, or anything that is not a positive number means no limit, never a limit of nothing.
|
|
131
134
|
number = per_minute == false ? Float::NAN : Js.number(per_minute)
|
|
@@ -781,7 +784,7 @@ module Runlight
|
|
|
781
784
|
# Cloudflare before nginx) reads only that one. Otherwise it is the connection's address: the
|
|
782
785
|
# context's "ip", or else the request's own remote_address.
|
|
783
786
|
def client_ip(request, context = {})
|
|
784
|
-
if
|
|
787
|
+
if trusts?(request, context)
|
|
785
788
|
h = request.headers
|
|
786
789
|
last = lambda do |name|
|
|
787
790
|
value = h.get(name)
|
|
@@ -799,16 +802,27 @@ module Runlight
|
|
|
799
802
|
end
|
|
800
803
|
return Js.trim(forwarded) if !forwarded.nil? && Js.trim(forwarded) != ""
|
|
801
804
|
end
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
805
|
+
connection_ip(request, context).to_s
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# Whether a request's forwarding headers count. Left at its default, trustProxy believes them only on a
|
|
809
|
+
# connection from this computer or a private network, or one whose address the app did not pass on, since a
|
|
810
|
+
# client reaching the server straight from the internet could write them itself.
|
|
811
|
+
def trusts?(request, context = {})
|
|
812
|
+
return false if @trust_proxy == false
|
|
813
|
+
|
|
814
|
+
ip = connection_ip(request, context)
|
|
815
|
+
return true if !@trust_private || ip.nil? || ip.to_s == "" || !Safefetch.public_address?(ip.to_s)
|
|
816
|
+
|
|
817
|
+
# Said once: a CDN that connects from public addresses needs trustProxy set, or its visitors all look alike.
|
|
818
|
+
h = request.headers
|
|
819
|
+
if @warn_direct && (h.has?("x-forwarded-for") || h.has?("x-real-ip") || h.has?("cf-connecting-ip"))
|
|
807
820
|
@warn_direct = false
|
|
808
|
-
warn("Runlight: a request came
|
|
821
|
+
warn("Runlight: a request came from a public address with forwarding headers, which are not believed by default, since a client could write them itself and choose its own address. Behind a CDN that connects from public addresses, such as Cloudflare in front of this server, set trust_proxy to the header it sets (cf-connecting-ip) or to true.")
|
|
809
822
|
end
|
|
810
|
-
|
|
823
|
+
false
|
|
811
824
|
end
|
|
825
|
+
private :trusts?
|
|
812
826
|
|
|
813
827
|
# Handles one tracker request. Bad input is dropped quietly; only a database that keeps failing raises.
|
|
814
828
|
def collect(request, context = {})
|
|
@@ -834,8 +848,11 @@ module Runlight
|
|
|
834
848
|
# another process writing the SQLite file) gets it a little later, at the time it arrived.
|
|
835
849
|
at = now
|
|
836
850
|
attempt = 1
|
|
851
|
+
# How far recording the hit got: its visit, and which of its writes went in. A hit tried again after a write
|
|
852
|
+
# failed skips the ones before it, so a visit's counts never take the same hit twice.
|
|
853
|
+
done = {}
|
|
837
854
|
begin
|
|
838
|
-
record(payload, request, context, at)
|
|
855
|
+
record(payload, request, context, at, done)
|
|
839
856
|
rescue StandardError => e
|
|
840
857
|
raise if attempt >= 3 || !busy?(e)
|
|
841
858
|
|
|
@@ -872,7 +889,7 @@ module Runlight
|
|
|
872
889
|
def link_domain_response(request, context = {})
|
|
873
890
|
url = Http::Url.new(request.url)
|
|
874
891
|
# A forwarded host only counts behind a proxy that sets it; otherwise any client could pick one.
|
|
875
|
-
given = forwarded_host(request) || request.headers.get("host") || url.host
|
|
892
|
+
given = forwarded_host(request, context) || request.headers.get("host") || url.host
|
|
876
893
|
host = Sources.strip_www(Js.trim(given.split(",", -1).first.to_s).split(":", -1).first.to_s)
|
|
877
894
|
return nil unless link_domain_set.key?(host)
|
|
878
895
|
|
|
@@ -899,7 +916,7 @@ module Runlight
|
|
|
899
916
|
def redirect(request, slug, domain, context = {})
|
|
900
917
|
init
|
|
901
918
|
url = Http::Url.new(request.url)
|
|
902
|
-
host = Sources.strip_www((forwarded_host(request) || request.headers.get("host") || url.host).split(":", -1).first.to_s)
|
|
919
|
+
host = Sources.strip_www((forwarded_host(request, context) || request.headers.get("host") || url.host).split(":", -1).first.to_s)
|
|
903
920
|
link = @store.link_by_slug(slug)
|
|
904
921
|
# The app's own link path answers for every link, so a link whose domain
|
|
905
922
|
# was removed keeps working; a link domain answers only for its own links.
|
|
@@ -1290,8 +1307,16 @@ module Runlight
|
|
|
1290
1307
|
|
|
1291
1308
|
today = @store.salt(day, Hashing.random_salt)
|
|
1292
1309
|
yesterday = @store.salt_if_exists(Dates.add_days(day, -1))
|
|
1293
|
-
|
|
1294
|
-
|
|
1310
|
+
salts = { "day" => day, "today" => today, "yesterday" => yesterday }
|
|
1311
|
+
# Kept before old salts go, so dropping yesterday's from the store drops it from here too.
|
|
1312
|
+
@salts[timezone] = salts
|
|
1313
|
+
begin
|
|
1314
|
+
drop_old_salts(at)
|
|
1315
|
+
rescue StandardError
|
|
1316
|
+
@salts.delete(timezone)
|
|
1317
|
+
raise
|
|
1318
|
+
end
|
|
1319
|
+
salts
|
|
1295
1320
|
end
|
|
1296
1321
|
|
|
1297
1322
|
# Deletes every salt no visit can still use. A day's salt counts visitors while it is that day in a
|
|
@@ -1319,11 +1344,17 @@ module Runlight
|
|
|
1319
1344
|
end
|
|
1320
1345
|
|
|
1321
1346
|
# The host a proxy says the request was for, read only when proxy headers are trusted, as the client's address is.
|
|
1322
|
-
def forwarded_host(request)
|
|
1323
|
-
|
|
1347
|
+
def forwarded_host(request, context = {})
|
|
1348
|
+
trusts?(request, context) ? request.headers.get("x-forwarded-host") : nil
|
|
1324
1349
|
end
|
|
1325
1350
|
|
|
1326
|
-
|
|
1351
|
+
# The connection's own address: the context's "ip", or else the request's remote_address, or nil.
|
|
1352
|
+
def connection_ip(request, context)
|
|
1353
|
+
ip = context_ip(context)
|
|
1354
|
+
ip.nil? ? request.remote_address : ip
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
def record(payload, request, context, at, done = {})
|
|
1327
1358
|
# Managed sites load from the database in init(), so it must come first.
|
|
1328
1359
|
init
|
|
1329
1360
|
url = payload["url"]
|
|
@@ -1331,19 +1362,19 @@ module Runlight
|
|
|
1331
1362
|
return if site.nil?
|
|
1332
1363
|
|
|
1333
1364
|
if payload["kind"] == "engagement"
|
|
1334
|
-
engagement(site, payload, at)
|
|
1365
|
+
engagement(site, payload, at, done)
|
|
1335
1366
|
return
|
|
1336
1367
|
end
|
|
1337
1368
|
|
|
1338
1369
|
page = Sources.parse_page(url)
|
|
1339
|
-
session =
|
|
1370
|
+
session = done["session"]
|
|
1340
1371
|
reopen = true
|
|
1341
1372
|
if payload["kind"] == "event" && payload["pageviewId"] != ""
|
|
1342
1373
|
pageview = @store.pageview(site["id"], payload["pageviewId"])
|
|
1343
1374
|
# An event joins its page's visit unless that visit began longer ago than reports look for its rows
|
|
1344
1375
|
# (a tab left open for days); it then starts a visit of its own, as any later activity would.
|
|
1345
1376
|
if !pageview.nil? && at - pageview["startedAt"] < Store::SqlStore::EVENT_TAIL_MS
|
|
1346
|
-
session
|
|
1377
|
+
session ||= { "id" => pageview["session"], "visitor" => pageview["visitor"] }
|
|
1347
1378
|
# A visit idle past the 30 minutes stays ended: the event counts in it without reopening it.
|
|
1348
1379
|
reopen = at - pageview["lastAt"] <= SESSION_IDLE_MS
|
|
1349
1380
|
if at - pageview["startedAt"] > 3_600_000
|
|
@@ -1359,8 +1390,12 @@ module Runlight
|
|
|
1359
1390
|
session ||= session_for(site, request, context, page, payload["referrer"], at, {
|
|
1360
1391
|
"screenWidth" => payload["screenWidth"], "screen" => screen, "language" => payload["language"],
|
|
1361
1392
|
})
|
|
1393
|
+
done["session"] = session
|
|
1362
1394
|
|
|
1363
|
-
|
|
1395
|
+
unless done["touched"]
|
|
1396
|
+
@store.touch_session(session["id"], at, payload["kind"], page["path"], reopen)
|
|
1397
|
+
done["touched"] = true
|
|
1398
|
+
end
|
|
1364
1399
|
@store.insert_event({
|
|
1365
1400
|
"site" => site["id"],
|
|
1366
1401
|
"ts" => at,
|
|
@@ -1401,7 +1436,7 @@ module Runlight
|
|
|
1401
1436
|
"mobile" => request.headers.get("sec-ch-ua-mobile"),
|
|
1402
1437
|
"platform" => request.headers.get("sec-ch-ua-platform"),
|
|
1403
1438
|
}, client["screenWidth"])
|
|
1404
|
-
location = Geo.locate(request.headers, ip, @geo,
|
|
1439
|
+
location = Geo.locate(request.headers, ip, @geo, trusts?(request, context))
|
|
1405
1440
|
row = {
|
|
1406
1441
|
"id" => session["id"],
|
|
1407
1442
|
"site" => site["id"],
|
|
@@ -1434,14 +1469,17 @@ module Runlight
|
|
|
1434
1469
|
domains
|
|
1435
1470
|
end
|
|
1436
1471
|
|
|
1437
|
-
def engagement(site, payload, at)
|
|
1472
|
+
def engagement(site, payload, at, done = {})
|
|
1438
1473
|
return if payload["engagedMs"] <= 0
|
|
1439
1474
|
|
|
1440
1475
|
pageview = @store.pageview(site["id"], payload["pageviewId"])
|
|
1441
1476
|
# Reports look for a visit's rows only so long after it began, so later time on it is let go.
|
|
1442
1477
|
return if pageview.nil? || at - pageview["startedAt"] >= Store::SqlStore::EVENT_TAIL_MS
|
|
1443
1478
|
|
|
1444
|
-
|
|
1479
|
+
unless done["engaged"]
|
|
1480
|
+
@store.add_engagement(pageview["session"], payload["engagedMs"])
|
|
1481
|
+
done["engaged"] = true
|
|
1482
|
+
end
|
|
1445
1483
|
# Only a visit that began more than an hour ago can belong to a day that is already added up.
|
|
1446
1484
|
if at - pageview["startedAt"] > 3_600_000
|
|
1447
1485
|
@store.touched_old_visit(site["id"], pageview["startedAt"].to_i, at - ROLLUP_DELAY_MS + 3_600_000)
|
|
@@ -27,7 +27,7 @@ module Runlight
|
|
|
27
27
|
REDIRECTS = [301, 302, 303, 307, 308].freeze
|
|
28
28
|
TIMED_OUT = "The operation was aborted due to timeout"
|
|
29
29
|
# Sent only to the origin they were given to.
|
|
30
|
-
CREDENTIALS = %w[authorization cookie proxy-authorization x-api-key].freeze
|
|
30
|
+
CREDENTIALS = %w[authorization cookie proxy-authorization x-api-key apikey].freeze
|
|
31
31
|
|
|
32
32
|
def fetch(url, init = {})
|
|
33
33
|
deadline = monotonic + ((init["timeoutMs"] || 30_000).to_i / 1000.0)
|
|
@@ -14,11 +14,9 @@ module Runlight
|
|
|
14
14
|
ISO_DATE = /\A([+-]\d{6}|\d{4})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2})?)?\z/i
|
|
15
15
|
private_constant :ISO_DATE
|
|
16
16
|
|
|
17
|
-
# sleep: a callable that waits this many milliseconds.
|
|
18
|
-
|
|
19
|
-
def initialize(fetcher = nil, sleep = nil, owner: false)
|
|
17
|
+
# sleep: a callable that waits this many milliseconds.
|
|
18
|
+
def initialize(fetcher = nil, sleep = nil)
|
|
20
19
|
@fetcher = fetcher || Http::NetFetcher.new
|
|
21
|
-
@owner = owner
|
|
22
20
|
@sleep = sleep || ->(ms) { Kernel.sleep(ms / 1000.0) if ms.positive? }
|
|
23
21
|
end
|
|
24
22
|
|
|
@@ -26,7 +24,9 @@ module Runlight
|
|
|
26
24
|
@sleep.call(ms)
|
|
27
25
|
end
|
|
28
26
|
|
|
29
|
-
# Fetches JSON, decoded. `init` may hold "headers", "method", and "body".
|
|
27
|
+
# Fetches JSON, decoded. `init` may hold "headers", "method", and "body". The address can come from whoever
|
|
28
|
+
# runs an import (a self-hosted Umami), so every service, a hosted one too, is asked only at public https
|
|
29
|
+
# addresses, with no redirect followed, which would carry the key somewhere else, as public_fetch allows.
|
|
30
30
|
def get_json(url, init = {})
|
|
31
31
|
attempt = 1
|
|
32
32
|
loop do
|
|
@@ -35,7 +35,7 @@ module Runlight
|
|
|
35
35
|
options["method"] = init["method"] unless init["method"].nil?
|
|
36
36
|
options["body"] = init["body"] unless init["body"].nil?
|
|
37
37
|
begin
|
|
38
|
-
response =
|
|
38
|
+
response = Safefetch.public_fetch(url, options.merge("redirects" => 0), @fetcher)
|
|
39
39
|
rescue PrivateAddressError, Http::BodyTooLong
|
|
40
40
|
# An address off the public internet, or an answer past the cap, is the same on every try.
|
|
41
41
|
host = Http::Url.new(url).host
|
|
@@ -25,8 +25,9 @@ module Runlight
|
|
|
25
25
|
raise ImportError.new("Runlight cannot import from #{source}", "import_source", { "source" => source }) if name.nil?
|
|
26
26
|
|
|
27
27
|
runlight.init
|
|
28
|
-
# Umami is at an address the owner typed
|
|
29
|
-
|
|
28
|
+
# Umami is at an address the owner typed, and the others are their services' own; each is asked only on the
|
|
29
|
+
# public internet.
|
|
30
|
+
client = Client.new(runlight.fetcher)
|
|
30
31
|
importer = Importers.const_get(name).new(client, -> { runlight.now })
|
|
31
32
|
known = lambda do |source_id, slug = nil, url = nil|
|
|
32
33
|
return true unless runlight.store.link_by_id(Write.imported_link_id(source, Js.string(source_id))).nil?
|
|
@@ -36,7 +36,7 @@ module Runlight
|
|
|
36
36
|
# The websites an Umami account can see, to pick which one becomes this site's history: a list of
|
|
37
37
|
# { "id", "name", "domain" }.
|
|
38
38
|
def umami_websites(credentials, fetcher = nil)
|
|
39
|
-
http = Client.new(fetcher
|
|
39
|
+
http = Client.new(fetcher)
|
|
40
40
|
login = Umami.umami_sign_in(http, credentials)
|
|
41
41
|
headers = { "authorization" => "Bearer #{Client.str(login["token"])}" }
|
|
42
42
|
out = []
|
|
@@ -74,7 +74,7 @@ module Runlight
|
|
|
74
74
|
raise ImportError.new("Unknown site", "unknown_site") if site.nil?
|
|
75
75
|
raise ImportError.new("Pick the Umami website to import", "import_website") unless website.match?(/\A[A-Za-z0-9-]{1,64}\z/)
|
|
76
76
|
|
|
77
|
-
http = Client.new(runlight.fetcher
|
|
77
|
+
http = Client.new(runlight.fetcher)
|
|
78
78
|
|
|
79
79
|
saved = !cursor.nil? && cursor != "" ? Json.decode(cursor) : nil
|
|
80
80
|
login = Umami.umami_sign_in(http, credentials, saved.nil? ? nil : saved["token"])
|
data/lib/runlight/payload.rb
CHANGED
|
@@ -55,9 +55,8 @@ module Runlight
|
|
|
55
55
|
else
|
|
56
56
|
next
|
|
57
57
|
end
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
out[k] = text if k != "__proto__"
|
|
58
|
+
# A prop named __proto__ is kept like any other, as the SDK keeps it in an object with no prototype.
|
|
59
|
+
out[k] = text
|
|
61
60
|
count += 1
|
|
62
61
|
end
|
|
63
62
|
return nil if count.zero?
|
|
@@ -67,6 +66,20 @@ module Runlight
|
|
|
67
66
|
|
|
68
67
|
# An object's entries in JavaScript's order: keys that are array indexes first, ascending, then the
|
|
69
68
|
# rest in the order they were added.
|
|
69
|
+
# The events the tracker sends on its own for a link that was followed, each with the link as `url`.
|
|
70
|
+
LINK_EVENTS = ["Outbound link", "File download"].freeze
|
|
71
|
+
private_constant :LINK_EVENTS
|
|
72
|
+
|
|
73
|
+
# An automatic link event's `url` without its query string or fragment, as page paths are kept, since tokens and
|
|
74
|
+
# email addresses ride in them. Done here rather than in the tracker, which has no bytes to spare, and so a
|
|
75
|
+
# tracker cached from before is held to it too.
|
|
76
|
+
def link_props(name, given)
|
|
77
|
+
return given if given.nil? || !LINK_EVENTS.include?(name) || !given.key?("url")
|
|
78
|
+
|
|
79
|
+
given["url"] = given["url"].sub(/[?#].*\z/m, "")
|
|
80
|
+
given
|
|
81
|
+
end
|
|
82
|
+
|
|
70
83
|
def entries(object)
|
|
71
84
|
indexes = []
|
|
72
85
|
names = []
|
|
@@ -124,13 +137,13 @@ module Runlight
|
|
|
124
137
|
"screenHeight" => int(get.call("h"), 0, 20_000),
|
|
125
138
|
"language" => str(get.call("l"), 35),
|
|
126
139
|
"name" => name,
|
|
127
|
-
"props" => kind == "event" ? props(get.call("p")) : nil,
|
|
140
|
+
"props" => kind == "event" ? link_props(name, props(get.call("p"))) : nil,
|
|
128
141
|
"pageviewId" => pageview_id,
|
|
129
142
|
"engagedMs" => kind == "engagement" ? int(get.call("e"), 0, MAX_ENGAGED_MS) || 0 : 0,
|
|
130
143
|
"scroll" => int(get.call("d"), 0, 100),
|
|
131
144
|
}
|
|
132
145
|
end
|
|
133
146
|
|
|
134
|
-
private_class_method :str, :int, :props, :entries, :parse
|
|
147
|
+
private_class_method :str, :int, :props, :link_props, :entries, :parse
|
|
135
148
|
end
|
|
136
149
|
end
|
data/lib/runlight/routes.rb
CHANGED
|
@@ -31,6 +31,8 @@ module Runlight
|
|
|
31
31
|
# - accountOf: a callable taking the Request and returning the account it comes from, or nil (internal, for the
|
|
32
32
|
# standalone server).
|
|
33
33
|
# - tokenMade: a callable taking a token row and who made it, returning whether that is allowed (internal).
|
|
34
|
+
# - tokensOf: a callable taking an account id, returning the ids of the tokens it made, so a member sees and
|
|
35
|
+
# revokes only their own (internal).
|
|
34
36
|
class Routes
|
|
35
37
|
COOKIE = "runlight_token"
|
|
36
38
|
IMPLEMENTATION = { "library" => "runlight", "language" => "ruby" }.freeze
|
|
@@ -169,6 +171,7 @@ module Runlight
|
|
|
169
171
|
@sign_out = options["signOut"].nil? ? (web ? "#{@base}/logout" : nil) : options["signOut"].to_s
|
|
170
172
|
@account_of = options["accountOf"] || (web ? ->(r) { web.account_of(r) } : nil)
|
|
171
173
|
@token_made = options["tokenMade"] || (web ? ->(row, by) { web.token_made(row, by) } : nil)
|
|
174
|
+
@tokens_of = options["tokensOf"] || (web ? ->(by) { web.tokens_of(by) } : nil)
|
|
172
175
|
authorize = options["authorize"]
|
|
173
176
|
oauth = {
|
|
174
177
|
"runlight" => runlight,
|
|
@@ -471,12 +474,14 @@ module Runlight
|
|
|
471
474
|
n.is_a?(Float) ? (n.finite? ? n.to_i : 0) : n.to_i
|
|
472
475
|
end
|
|
473
476
|
|
|
474
|
-
# The controls a member cannot change: the mail service and its keys, the assistant's settings,
|
|
477
|
+
# The controls a member cannot change: the mail service and its keys, the assistant's settings, a site's agents key,
|
|
478
|
+
# and deleting a site.
|
|
475
479
|
def admin_only?(path, method)
|
|
476
480
|
(path == "/api/mail" && %w[PUT DELETE].include?(method)) ||
|
|
477
481
|
(path == "/api/assistant" && %w[PUT DELETE].include?(method)) ||
|
|
478
482
|
(path == "/api/assistant/limits" && method == "PUT") ||
|
|
479
483
|
(path == "/api/assistant/models" && method == "POST") ||
|
|
484
|
+
(path == "/api/observe-key/new" && method == "POST") ||
|
|
480
485
|
(path.match?(%r{\A/api/sites/[^/]+\z}) && method == "DELETE")
|
|
481
486
|
end
|
|
482
487
|
|
|
@@ -1481,13 +1486,23 @@ module Runlight
|
|
|
1481
1486
|
nil
|
|
1482
1487
|
end
|
|
1483
1488
|
|
|
1489
|
+
# The tokens the account behind a request made: none when nothing notes who made what.
|
|
1490
|
+
def own_tokens(request)
|
|
1491
|
+
by = @account_of&.call(request)
|
|
1492
|
+
!by.nil? && by != "" && @tokens_of ? @tokens_of.call(by) : Set.new
|
|
1493
|
+
end
|
|
1494
|
+
|
|
1484
1495
|
def tokens_api(request, path)
|
|
1485
1496
|
@rl.init
|
|
1486
1497
|
view = lambda do |t|
|
|
1487
1498
|
{ "id" => t["id"], "name" => t["name"], "site" => t["site"], "scope" => t["scope"], "hint" => t["hint"],
|
|
1488
1499
|
"createdAt" => t["createdAt"], "lastUsedAt" => t["lastUsedAt"] }
|
|
1489
1500
|
end
|
|
1490
|
-
|
|
1501
|
+
# A member makes tokens like anyone who manages sites, but sees and revokes only the ones they made.
|
|
1502
|
+
mine = @members.key?(request) ? own_tokens(request) : nil
|
|
1503
|
+
if path == "/api/tokens" && request.method == "GET"
|
|
1504
|
+
return json({ "tokens" => store.tokens.select { |t| mine.nil? || mine.include?(t["id"]) }.map(&view) })
|
|
1505
|
+
end
|
|
1491
1506
|
|
|
1492
1507
|
if path == "/api/tokens" && request.method == "POST"
|
|
1493
1508
|
body = read_json(request)
|
|
@@ -1517,6 +1532,8 @@ module Runlight
|
|
|
1517
1532
|
return json({ "token" => view.call(row), "secret" => secret }, 201)
|
|
1518
1533
|
end
|
|
1519
1534
|
if (match = path.match(%r{\A/api/tokens/([a-f0-9]{24})\z})) && request.method == "DELETE"
|
|
1535
|
+
return coded("Unknown token", "unknown_token", 404) if mine && !mine.include?(match[1])
|
|
1536
|
+
|
|
1520
1537
|
return store.delete_token(match[1]) ? json({ "ok" => true }) : coded("Unknown token", "unknown_token", 404)
|
|
1521
1538
|
end
|
|
1522
1539
|
|
|
@@ -1898,7 +1915,7 @@ module Runlight
|
|
|
1898
1915
|
end
|
|
1899
1916
|
return chat(request, url) if path == "/api/assistant/chat" && method == "POST"
|
|
1900
1917
|
|
|
1901
|
-
# Only the
|
|
1918
|
+
# Only people at the dashboard manage tokens, a member only their own: an API token cannot make or revoke one.
|
|
1902
1919
|
if path == "/api/tokens" || path.start_with?("/api/tokens/")
|
|
1903
1920
|
access = can_read(request)
|
|
1904
1921
|
return denied(access) if access != true
|
|
@@ -2456,8 +2473,10 @@ module Runlight
|
|
|
2456
2473
|
return json({ "site" => site["id"], "funnels" => store.funnels(site["id"]) }) if params.get("definitions") == "1"
|
|
2457
2474
|
# One funnel at a time, so a page of funnels never takes every database connection at once.
|
|
2458
2475
|
rows = store.funnels(site["id"]).map do |funnel|
|
|
2459
|
-
|
|
2460
|
-
|
|
2476
|
+
counted = store.funnel_counts(query, funnel)
|
|
2477
|
+
counts = counted["counts"]
|
|
2478
|
+
row = funnel.merge("steps" => funnel["steps"].each_with_index.map { |step, i| step.merge("visits" => counts[i]) })
|
|
2479
|
+
counted["sampled"] ? row.merge("sampled" => store.funnel_visits) : row
|
|
2461
2480
|
end
|
|
2462
2481
|
return json({ "site" => site["id"], "range" => range_out, "funnels" => rows })
|
|
2463
2482
|
end
|