runlight 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +64 -23
- data/assets/locales.json +5 -5
- data/assets/pick-return.js +1 -0
- data/assets/picker.js +1 -1
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +8 -6
- data/lib/runlight/assistant.rb +37 -16
- data/lib/runlight/core.rb +125 -34
- data/lib/runlight/dates.rb +31 -8
- data/lib/runlight/db/connect.rb +27 -4
- data/lib/runlight/db/db.rb +52 -4
- data/lib/runlight/db/prefixed.rb +92 -0
- data/lib/runlight/geo.rb +4 -3
- data/lib/runlight/http/fetcher.rb +27 -5
- data/lib/runlight/mail/ses.rb +3 -2
- data/lib/runlight/mail/transports.rb +19 -2
- data/lib/runlight/metadata_address_error.rb +13 -0
- data/lib/runlight/oauth.rb +83 -15
- data/lib/runlight/rack_app.rb +11 -4
- data/lib/runlight/redirect_refused_error.rb +14 -0
- data/lib/runlight/routes.rb +208 -92
- data/lib/runlight/safefetch.rb +98 -1
- data/lib/runlight/server/agents.rb +1 -1
- data/lib/runlight/server/cli.rb +1 -1
- data/lib/runlight/server/config.rb +64 -19
- data/lib/runlight/server/standalone.rb +7 -6
- data/lib/runlight/store/sql.rb +10 -0
- data/lib/runlight/store/sql_store.rb +75 -13
- data/lib/runlight/stores.rb +19 -15
- data/lib/runlight/tables_busy_error.rb +11 -0
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight.rb +4 -0
- metadata +7 -2
data/lib/runlight/routes.rb
CHANGED
|
@@ -23,6 +23,9 @@ module Runlight
|
|
|
23
23
|
# development makes one up (a restart signs everyone out); anywhere else that raises ArgumentError, since
|
|
24
24
|
# each process of an app would make its own and sign the others' sessions out.
|
|
25
25
|
# - geoCredit: credits DB-IP in the dashboard's footer.
|
|
26
|
+
# - cms: the CMS that adds the script to every page, such as "WordPress", when Runlight runs inside one. The
|
|
27
|
+
# dashboard then says so in place of the steps to add the script, and pages of the same origin, the CMS's own
|
|
28
|
+
# admin, may show the whole dashboard in a frame. The WordPress, Drupal, and Craft plugins set it.
|
|
26
29
|
# - origin: the address people open the app at, such as https://example.com.
|
|
27
30
|
# - ownHosts: a callable returning more names the dashboard is reached at.
|
|
28
31
|
# - accountOf: a callable taking the Request and returning the account it comes from, or nil (internal, for the
|
|
@@ -47,6 +50,14 @@ module Runlight
|
|
|
47
50
|
PICK_TARGET_PLACEHOLDER = '"__RUNLIGHT_PICK_TARGET__"'
|
|
48
51
|
# Where the hostnames of the site its ticket names go, as JSON inside a string.
|
|
49
52
|
PICK_HOSTS_PLACEHOLDER = '"__RUNLIGHT_PICK_HOSTS__"'
|
|
53
|
+
# Where the relay page the picker sends its choice to goes, the return address its ticket names.
|
|
54
|
+
PICK_RETURN_PLACEHOLDER = '"__RUNLIGHT_PICK_RETURN__"'
|
|
55
|
+
# Where the nonce the dashboard made for this pick goes, which the relay page hands back to it.
|
|
56
|
+
PICK_NONCE_PLACEHOLDER = '"__RUNLIGHT_PICK_NONCE__"'
|
|
57
|
+
# The relay page on a dashboard's origin: the dashboard's address with /pick-return on the end.
|
|
58
|
+
PICK_RETURN = %r{\Ahttps?://[^/?#\s]+(/[^?#\s]*)?/pick-return\z}
|
|
59
|
+
# What the dashboard names one pick by, so its relay page answers only the tab that started it.
|
|
60
|
+
PICK_NONCE = /\A[a-f0-9]{32}\z/
|
|
50
61
|
# How long a picker ticket works: long enough to find the element, not to be kept.
|
|
51
62
|
PICK_TICKET_MS = 30 * 60_000
|
|
52
63
|
# Questions one person may put to the assistant in an hour, and at once.
|
|
@@ -62,10 +73,14 @@ module Runlight
|
|
|
62
73
|
PATH_DIMENSIONS = %w[page entry exit ai_page].freeze
|
|
63
74
|
# runlight.ts's LINK_DOMAIN_CHECK: the path on every link domain that answers when the domain reaches this Runlight.
|
|
64
75
|
LINK_DOMAIN_CHECK = "/.well-known/runlight-link-domain"
|
|
76
|
+
# The most read of a link domain's answer to the check, and of an install's live answer for a share or embed.
|
|
77
|
+
LINK_DOMAIN_MAX_BYTES = 16_384
|
|
78
|
+
REALTIME_MAX_BYTES = 4 * 1024 * 1024
|
|
65
79
|
# runlight.ts's RETENTION_MONTHS: the choices for how long a site keeps its visits.
|
|
66
80
|
RETENTION_MONTHS = [6, 12, 24, 36, 60].freeze
|
|
67
|
-
private_constant :RULES_PLACEHOLDER, :PICK_TARGET_PLACEHOLDER, :PICK_HOSTS_PLACEHOLDER, :
|
|
68
|
-
:
|
|
81
|
+
private_constant :RULES_PLACEHOLDER, :PICK_TARGET_PLACEHOLDER, :PICK_HOSTS_PLACEHOLDER, :PICK_RETURN_PLACEHOLDER,
|
|
82
|
+
:PICK_NONCE_PLACEHOLDER, :PICK_RETURN, :PICK_NONCE, :SHARE_ID,
|
|
83
|
+
:PATH_DIMENSIONS, :LINK_DOMAIN_CHECK, :LINK_DOMAIN_MAX_BYTES, :REALTIME_MAX_BYTES, :RETENTION_MONTHS
|
|
69
84
|
|
|
70
85
|
DASHBOARD_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
71
86
|
|
|
@@ -243,17 +258,22 @@ module Runlight
|
|
|
243
258
|
Js.scrub(value).match?(EMAIL)
|
|
244
259
|
end
|
|
245
260
|
|
|
246
|
-
# A plain page in a visitor's language, for unsubscribing
|
|
247
|
-
|
|
261
|
+
# A plain page in a visitor's language, for unsubscribing, for a share link that is gone, and for the picker's
|
|
262
|
+
# relay page, which alone runs a script: one file from this server, never inline.
|
|
263
|
+
def small_page(lang, body, status = 200, script = "")
|
|
248
264
|
Http::Response.new(
|
|
249
265
|
"<!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>Runlight</title>\n" \
|
|
250
266
|
'<style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f5;font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:#111827}main{max-width:420px;margin:24px;padding:32px;background:#fff;border:1px solid #e5e7eb;border-radius:14px}h1{font-size:20px;margin:0 0 12px}p{margin:0 0 20px;color:#4b5563}button{height:40px;padding:0 18px;border:0;border-radius:8px;background:#111827;color:#fff;font:inherit;font-weight:600;cursor:pointer}</style></head><body><main>' \
|
|
251
|
-
"#{body}</main></body></html>",
|
|
267
|
+
"#{body}</main>#{script == "" ? "" : "<script src=\"#{escape_html(script)}\"></script>"}</body></html>",
|
|
252
268
|
status: status,
|
|
253
269
|
headers: {
|
|
254
270
|
"content-type" => "text/html; charset=utf-8",
|
|
255
271
|
"cache-control" => "no-store",
|
|
256
|
-
"content-security-policy" =>
|
|
272
|
+
"content-security-policy" => if script == ""
|
|
273
|
+
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"
|
|
274
|
+
else
|
|
275
|
+
"default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
276
|
+
end,
|
|
257
277
|
"referrer-policy" => "no-referrer",
|
|
258
278
|
},
|
|
259
279
|
)
|
|
@@ -373,7 +393,7 @@ module Runlight
|
|
|
373
393
|
# The dashboard's page, which holds no data: the API it calls checks access. `embed` is the dashboard inside a
|
|
374
394
|
# CMS's admin pages: { "session", "origin" }, its session (empty once its ticket was used or ran out) and the
|
|
375
395
|
# admin origin that frames it.
|
|
376
|
-
def dashboard(base, share = "", sign_out = "", geo_credit = false, accounts = false, sign_in = "", embed = nil)
|
|
396
|
+
def dashboard(base, share = "", sign_out = "", geo_credit = false, accounts = false, sign_in = "", embed = nil, cms = "")
|
|
377
397
|
b = escape_attr(base)
|
|
378
398
|
hash = build_hash("dashboardHash")
|
|
379
399
|
attributes = (share != "" ? " data-share=\"#{escape_attr(share)}\"" : "") +
|
|
@@ -381,6 +401,7 @@ module Runlight
|
|
|
381
401
|
(sign_in != "" ? " data-sign-in=\"#{escape_attr(sign_in)}\"" : "") +
|
|
382
402
|
(geo_credit ? ' data-geo-credit=""' : "") +
|
|
383
403
|
(accounts ? ' data-accounts=""' : "") +
|
|
404
|
+
(cms != "" ? " data-cms=\"#{escape_attr(cms)}\"" : "") +
|
|
384
405
|
(embed.nil? ? "" : " data-embed=\"#{escape_attr(embed["session"])}\" data-embed-origin=\"#{escape_attr(embed["origin"])}\"")
|
|
385
406
|
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<title>Runlight</title>\n" \
|
|
386
407
|
"<link rel=\"icon\" href=\"#{Brand.runlight_icon}\">\n" \
|
|
@@ -413,12 +434,10 @@ module Runlight
|
|
|
413
434
|
Js.scrub(value).match?(ORIGIN)
|
|
414
435
|
end
|
|
415
436
|
|
|
416
|
-
#
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
decoded
|
|
437
|
+
# One part of a URL path with its percent escapes decoded, or nil when they are broken (such as %E0 or
|
|
438
|
+
# %zz). A route answers nil as it does a name it does not know, never with an error.
|
|
439
|
+
def decode_part(text)
|
|
440
|
+
Js.decode_uri_component(text)
|
|
422
441
|
end
|
|
423
442
|
|
|
424
443
|
# `String(body[key] ?? fallback)`.
|
|
@@ -525,6 +544,8 @@ module Runlight
|
|
|
525
544
|
if base != "" && oauth_document?(url.pathname)
|
|
526
545
|
begin
|
|
527
546
|
return OAuth.oauth_response(@oauth, request, url.pathname, url, context) || coded("Not found", "not_found", 404)
|
|
547
|
+
rescue TablesBusyError
|
|
548
|
+
return tables_busy
|
|
528
549
|
rescue StandardError => e
|
|
529
550
|
warn("Runlight: #{e.full_message(highlight: false)}")
|
|
530
551
|
return coded("Internal error", "internal", 500)
|
|
@@ -537,6 +558,8 @@ module Runlight
|
|
|
537
558
|
|
|
538
559
|
begin
|
|
539
560
|
route(request, path, url, context)
|
|
561
|
+
rescue TablesBusyError
|
|
562
|
+
tables_busy
|
|
540
563
|
rescue StandardError => e
|
|
541
564
|
warn("Runlight: #{e.full_message(highlight: false)}")
|
|
542
565
|
coded("Internal error", "internal", 500)
|
|
@@ -563,6 +586,11 @@ module Runlight
|
|
|
563
586
|
|
|
564
587
|
private
|
|
565
588
|
|
|
589
|
+
# Another process is building the tables, such as an index after an upgrade: worth asking again soon.
|
|
590
|
+
def tables_busy
|
|
591
|
+
coded("Runlight is creating or upgrading its tables. Try again in a minute.", "tables_busy", 503, nil, { "retry-after" => "30" })
|
|
592
|
+
end
|
|
593
|
+
|
|
566
594
|
def sites
|
|
567
595
|
@rl.sites
|
|
568
596
|
end
|
|
@@ -685,7 +713,7 @@ module Runlight
|
|
|
685
713
|
|
|
686
714
|
# Answers a read for a site counted by another install by asking that install,
|
|
687
715
|
# with its token and its own id for the site, and handing back what it says.
|
|
688
|
-
def pass_through(remote, path, url, request = nil)
|
|
716
|
+
def pass_through(remote, path, url, request = nil, max_bytes = nil)
|
|
689
717
|
target = Http::Url.new("#{remote["url"]}#{path}")
|
|
690
718
|
query = target.search_params
|
|
691
719
|
url.search_params.each { |key, value| query.append(key.to_s, value) }
|
|
@@ -707,6 +735,8 @@ module Runlight
|
|
|
707
735
|
"timeoutMs" => write ? 30_000 : 120_000,
|
|
708
736
|
}
|
|
709
737
|
init["body"] = request.text if write
|
|
738
|
+
# One byte more than the most wanted is asked, so an answer past it is seen as such.
|
|
739
|
+
init.merge!("maxBytes" => max_bytes + 1, "truncate" => true) unless max_bytes.nil?
|
|
710
740
|
answer = Safefetch.owner_fetch(target.href, init, @rl.fetcher, @rl.local_installs)
|
|
711
741
|
rescue StandardError => e
|
|
712
742
|
if e.is_a?(Http::FetchError) && e.timed_out?
|
|
@@ -838,8 +868,8 @@ module Runlight
|
|
|
838
868
|
end
|
|
839
869
|
end
|
|
840
870
|
if (check_match = path.match(%r{\A/api/link-domains/([^/]+)/check\z})) && request.method == "GET"
|
|
841
|
-
domain =
|
|
842
|
-
return coded("Unknown domain", "unknown_domain", 404)
|
|
871
|
+
domain = decode_part(check_match[1])
|
|
872
|
+
return coded("Unknown domain", "unknown_domain", 404) if domain.nil? || !own_domains.call.include?(domain)
|
|
843
873
|
|
|
844
874
|
# One added before names inside private networks were refused is never fetched.
|
|
845
875
|
# What the check found, as a code the dashboard says in its own words, beside the English reason.
|
|
@@ -860,9 +890,11 @@ module Runlight
|
|
|
860
890
|
begin
|
|
861
891
|
# Only a public address is fetched, whatever the name resolves to now, so the check cannot be pointed
|
|
862
892
|
# into a private network.
|
|
863
|
-
answer
|
|
893
|
+
# Read no further than the check's answer could need; one byte more is asked, so a longer one is seen as such.
|
|
894
|
+
answer = Safefetch.public_fetch("https://#{domain}#{LINK_DOMAIN_CHECK}",
|
|
895
|
+
{ "timeoutMs" => 5000, "maxBytes" => LINK_DOMAIN_MAX_BYTES + 1, "truncate" => true }, @rl.fetcher)
|
|
864
896
|
parsed, body = begin
|
|
865
|
-
Js.parse_json(answer
|
|
897
|
+
Js.parse_json(Body.read_text_capped(answer, LINK_DOMAIN_MAX_BYTES))
|
|
866
898
|
rescue StandardError
|
|
867
899
|
[false, nil]
|
|
868
900
|
end
|
|
@@ -879,8 +911,8 @@ module Runlight
|
|
|
879
911
|
end
|
|
880
912
|
|
|
881
913
|
if (domain_match = path.match(%r{\A/api/link-domains/([^/]+)\z})) && request.method == "DELETE"
|
|
882
|
-
domain =
|
|
883
|
-
return coded("Unknown domain", "unknown_domain", 404)
|
|
914
|
+
domain = decode_part(domain_match[1])
|
|
915
|
+
return coded("Unknown domain", "unknown_domain", 404) if domain.nil? || !own_domains.call.include?(domain)
|
|
884
916
|
|
|
885
917
|
store.remove_link_domain(domain)
|
|
886
918
|
@rl.forget_link_domains
|
|
@@ -1000,40 +1032,41 @@ module Runlight
|
|
|
1000
1032
|
# The key picker tickets are signed with, made on first use and kept in the database for every process.
|
|
1001
1033
|
def pick_key
|
|
1002
1034
|
@rl.init
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
made = Hashing.random_id(32)
|
|
1007
|
-
store.set_setting("pick-key", made)
|
|
1008
|
-
made
|
|
1035
|
+
# Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
|
|
1036
|
+
store.setting_or_add("pick-key") { Hashing.random_id(32) }
|
|
1009
1037
|
end
|
|
1010
1038
|
|
|
1011
|
-
# A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half
|
|
1012
|
-
|
|
1039
|
+
# A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half
|
|
1040
|
+
# an hour. With a return address and a nonce it sends it there, to the relay page, which hands it to the dashboard
|
|
1041
|
+
# tab that made the nonce; without them, as an older dashboard asks, to the tab that opened the site.
|
|
1042
|
+
def pick_ticket(origin, site, back = nil)
|
|
1013
1043
|
payload = "#{@rl.now + PICK_TICKET_MS}.#{site.unpack1("H*")}.#{origin.unpack1("H*")}"
|
|
1044
|
+
payload += ".#{back["url"].unpack1("H*")}.#{back["nonce"]}" unless back.nil?
|
|
1014
1045
|
"#{payload}.#{Hashing.hmac(pick_key, payload)}"
|
|
1015
1046
|
end
|
|
1016
1047
|
|
|
1017
|
-
#
|
|
1048
|
+
# What a picker ticket names, or nil when it is not one this install signed or has run out.
|
|
1018
1049
|
def pick_target(ticket)
|
|
1019
|
-
parts = ticket.match(/\A(\d+)\.([a-f0-9]{2,512})\.([a-f0-9]{2,512})\.([a-f0-9]{64})\z/)
|
|
1050
|
+
parts = ticket.match(/\A(\d+)\.([a-f0-9]{2,512})\.([a-f0-9]{2,512})(?:\.([a-f0-9]{2,2048})\.([a-f0-9]{32}))?\.([a-f0-9]{64})\z/)
|
|
1020
1051
|
return nil if parts.nil? || Js.number(parts[1]) < @rl.now
|
|
1021
|
-
return nil unless constant_time_equal(parts[
|
|
1052
|
+
return nil unless constant_time_equal(parts[6], Hashing.hmac(pick_key, ticket[0, ticket.rindex(".")]))
|
|
1022
1053
|
|
|
1023
1054
|
unhex = ->(text) { Js.scrub([text[0, text.length - (text.length % 2)]].pack("H*")) }
|
|
1024
1055
|
origin = unhex.call(parts[3])
|
|
1025
|
-
|
|
1056
|
+
return nil unless origin?(origin)
|
|
1057
|
+
return { "origin" => origin, "site" => unhex.call(parts[2]) } if parts[4].nil?
|
|
1058
|
+
|
|
1059
|
+
address = unhex.call(parts[4])
|
|
1060
|
+
return nil unless address.match?(PICK_RETURN) && address.start_with?("#{origin}/")
|
|
1061
|
+
|
|
1062
|
+
{ "origin" => origin, "site" => unhex.call(parts[2]), "back" => { "url" => address, "nonce" => parts[5] } }
|
|
1026
1063
|
end
|
|
1027
1064
|
|
|
1028
1065
|
# The key embed tickets and sessions are signed with, made on first use and kept in the database for every process.
|
|
1029
1066
|
def embed_key
|
|
1030
1067
|
@rl.init
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
made = Hashing.random_id(32)
|
|
1035
|
-
store.set_setting("embed-key", made)
|
|
1036
|
-
made
|
|
1068
|
+
# Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
|
|
1069
|
+
store.setting_or_add("embed-key") { Hashing.random_id(32) }
|
|
1037
1070
|
end
|
|
1038
1071
|
|
|
1039
1072
|
# An embed token that still exists, for a site that still does.
|
|
@@ -1120,10 +1153,10 @@ module Runlight
|
|
|
1120
1153
|
return site if site.is_a?(Http::Response)
|
|
1121
1154
|
|
|
1122
1155
|
existing = store.goals(site["id"])
|
|
1123
|
-
id = path == "/api/goals" ? nil :
|
|
1156
|
+
id = path == "/api/goals" ? nil : decode_part(path["/api/goals/".length..])
|
|
1124
1157
|
before = nil
|
|
1125
1158
|
existing.each { |g| before = g if g["id"] == id }
|
|
1126
|
-
return coded("Unknown goal", "unknown_goal", 404) if
|
|
1159
|
+
return coded("Unknown goal", "unknown_goal", 404) if path != "/api/goals" && (id.nil? || before.nil?)
|
|
1127
1160
|
|
|
1128
1161
|
@trackers = {}
|
|
1129
1162
|
if request.method == "DELETE"
|
|
@@ -1149,7 +1182,7 @@ module Runlight
|
|
|
1149
1182
|
if request.method == "GET"
|
|
1150
1183
|
settings = @rl.mail_settings
|
|
1151
1184
|
known = settings || {}
|
|
1152
|
-
service = Mail::Transports::SERVICES.find { |s| s["id"] == known["service"] }
|
|
1185
|
+
service = known["source"] == "app" ? nil : Mail::Transports::SERVICES.find { |s| s["id"] == known["service"] }
|
|
1153
1186
|
# Secret fields come back only as "saved", never as their value.
|
|
1154
1187
|
fields = {}
|
|
1155
1188
|
saved = []
|
|
@@ -1163,16 +1196,23 @@ module Runlight
|
|
|
1163
1196
|
end
|
|
1164
1197
|
# A hub with a manage token learns which service sends the reports and from where, nothing more.
|
|
1165
1198
|
via_manage = @managed.key?(request)
|
|
1166
|
-
|
|
1199
|
+
answer = {
|
|
1167
1200
|
"source" => known["source"],
|
|
1168
|
-
"service" => known["service"] || "",
|
|
1201
|
+
"service" => known["source"] == "app" ? "" : known["service"] || "",
|
|
1169
1202
|
"from" => known["from"] || "",
|
|
1170
1203
|
"fromName" => known["fromName"] || "",
|
|
1171
1204
|
"fields" => via_manage ? {} : fields,
|
|
1172
1205
|
"saved" => via_manage ? [] : saved,
|
|
1173
1206
|
"encrypted" => !@rl.secret.nil?,
|
|
1174
|
-
"services" =>
|
|
1175
|
-
}
|
|
1207
|
+
"services" => @rl.mail_services,
|
|
1208
|
+
}
|
|
1209
|
+
# The app's own mailer, named only when there is one, so the answer without it stays as it was.
|
|
1210
|
+
app = @rl.app_mailer
|
|
1211
|
+
unless app.nil?
|
|
1212
|
+
answer["app"] = { "via" => app["via"], "from" => app["from"], "fromName" => app["fromName"] || "",
|
|
1213
|
+
"replaced" => known["source"] != "app" }
|
|
1214
|
+
end
|
|
1215
|
+
return json(answer)
|
|
1176
1216
|
end
|
|
1177
1217
|
if request.method == "PUT"
|
|
1178
1218
|
body = read_json(request)
|
|
@@ -1199,7 +1239,11 @@ module Runlight
|
|
|
1199
1239
|
return coded("Set up a mail service first", "mail_unset", 400) if settings.nil?
|
|
1200
1240
|
|
|
1201
1241
|
t = Messages.translator(text(body, "lang", "en"))["t"]
|
|
1202
|
-
name =
|
|
1242
|
+
name = if settings["source"] == "app"
|
|
1243
|
+
settings["via"] || ""
|
|
1244
|
+
else
|
|
1245
|
+
Mail::Transports::SERVICES.find { |s| s["id"] == settings["service"] }&.[]("name") || ""
|
|
1246
|
+
end
|
|
1203
1247
|
@rl.send_mail({
|
|
1204
1248
|
"to" => to,
|
|
1205
1249
|
"subject" => t.call("email.test.subject"),
|
|
@@ -1332,8 +1376,8 @@ module Runlight
|
|
|
1332
1376
|
return coded("Method not allowed", "method_not_allowed", 405)
|
|
1333
1377
|
end
|
|
1334
1378
|
|
|
1335
|
-
id =
|
|
1336
|
-
share = id.match?(SHARE_ID) ? store.share_by_id(id) : nil
|
|
1379
|
+
id = decode_part(path["/api/shares/".length..])
|
|
1380
|
+
share = !id.nil? && id.match?(SHARE_ID) ? store.share_by_id(id) : nil
|
|
1337
1381
|
return coded("Unknown share", "unknown_share", 404) if share.nil? || share["site"] != site["id"]
|
|
1338
1382
|
|
|
1339
1383
|
if request.method == "PATCH"
|
|
@@ -1362,39 +1406,72 @@ module Runlight
|
|
|
1362
1406
|
# As in TypeScript, the hour's questions are counted in this process; the day's, in the settings.
|
|
1363
1407
|
def ask_turn(who, owner)
|
|
1364
1408
|
now = @rl.now
|
|
1409
|
+
# Taken in one step, before anything slow, so questions sent at once each see the others.
|
|
1365
1410
|
mine = @lock.synchronize do
|
|
1366
|
-
held = @asked[who]
|
|
1411
|
+
held = (@asked[who] ||= { "at" => [], "open" => 0 })
|
|
1367
1412
|
held["at"] = held["at"].select { |at| now - at < 3_600_000 }
|
|
1368
|
-
held
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1413
|
+
if held["at"].length >= ASK_PER_HOUR || held["open"] >= ASK_AT_ONCE
|
|
1414
|
+
nil
|
|
1415
|
+
else
|
|
1416
|
+
held["at"] << now
|
|
1417
|
+
held["open"] += 1
|
|
1418
|
+
held
|
|
1419
|
+
end
|
|
1372
1420
|
end
|
|
1421
|
+
return coded("You have asked a lot in a short time. Wait a little and ask again.", "assistant_soon", 429) if mine.nil?
|
|
1373
1422
|
|
|
1423
|
+
release = -> { @lock.synchronize { mine["open"] -= 1 } }
|
|
1374
1424
|
unless owner
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
end
|
|
1425
|
+
counted = begin
|
|
1426
|
+
count_ask(who, now)
|
|
1427
|
+
rescue StandardError
|
|
1428
|
+
release.call
|
|
1429
|
+
raise
|
|
1430
|
+
end
|
|
1431
|
+
unless counted == true
|
|
1432
|
+
release.call
|
|
1433
|
+
@lock.synchronize do
|
|
1434
|
+
at = mine["at"].index(now)
|
|
1435
|
+
mine["at"].delete_at(at) unless at.nil?
|
|
1436
|
+
end
|
|
1437
|
+
return coded("You have asked a lot in a short time. Wait a little and ask again.", "assistant_soon", 429) if counted.nil?
|
|
1381
1438
|
|
|
1382
|
-
|
|
1383
|
-
store.set_setting(day, Json.encode(counts))
|
|
1384
|
-
store.settings_starting_with("assistant-asked:").each do |entry|
|
|
1385
|
-
store.set_setting(entry["key"], nil) if entry["key"] != day
|
|
1439
|
+
return coded("Viewers can ask #{Js.string(counted)} questions a day. Ask again tomorrow.", "assistant_daily", 429, { "limit" => Js.string(counted) })
|
|
1386
1440
|
end
|
|
1387
1441
|
end
|
|
1442
|
+
# People who stopped asking are dropped, so the map holds only the last hour's.
|
|
1388
1443
|
@lock.synchronize do
|
|
1389
|
-
mine["at"] << now
|
|
1390
|
-
mine["open"] += 1
|
|
1391
|
-
@asked[who] = mine
|
|
1392
|
-
# People who stopped asking are dropped, so the map holds only the last hour's.
|
|
1393
1444
|
if @asked.size > 1000
|
|
1394
1445
|
@asked.delete_if { |_key, value| value["open"].zero? && value["at"].none? { |at| now - at < 3_600_000 } }
|
|
1395
1446
|
end
|
|
1396
1447
|
end
|
|
1397
|
-
|
|
1448
|
+
release
|
|
1449
|
+
end
|
|
1450
|
+
|
|
1451
|
+
# Counts a viewer's question in their own row for the day, or gives the daily limit when it is reached. The
|
|
1452
|
+
# row only ever moves from the count read to the next one, so of questions sent at once, by one viewer or by
|
|
1453
|
+
# every process on the database, each count is taken once. True when counted, nil when the row kept changing.
|
|
1454
|
+
def count_ask(who, now)
|
|
1455
|
+
limit = viewer_daily
|
|
1456
|
+
return limit if limit < 1
|
|
1457
|
+
|
|
1458
|
+
day = "assistant-asked:#{Time.at(now.div(1000)).utc.strftime("%Y-%m-%d")}:"
|
|
1459
|
+
key = "#{day}#{who}"
|
|
1460
|
+
20.times do
|
|
1461
|
+
seen = store.setting(key)
|
|
1462
|
+
if seen.nil?
|
|
1463
|
+
next unless store.add_setting(key, "1")
|
|
1464
|
+
|
|
1465
|
+
# A viewer's first question of the day clears the days before.
|
|
1466
|
+
store.settings_starting_with("assistant-asked:").each do |entry|
|
|
1467
|
+
store.set_setting(entry["key"], nil) unless entry["key"].start_with?(day)
|
|
1468
|
+
end
|
|
1469
|
+
return true
|
|
1470
|
+
end
|
|
1471
|
+
return limit if Js.number(seen) >= limit
|
|
1472
|
+
return true if store.swap_setting(key, seen, Js.string(Js.number(seen) + 1))
|
|
1473
|
+
end
|
|
1474
|
+
nil
|
|
1398
1475
|
end
|
|
1399
1476
|
|
|
1400
1477
|
def tokens_api(request, path)
|
|
@@ -1418,7 +1495,7 @@ module Runlight
|
|
|
1418
1495
|
asked_scope = Js.get(body, "scope")
|
|
1419
1496
|
scope = %w[manage embed].include?(asked_scope) ? asked_scope : "read"
|
|
1420
1497
|
return coded("A token that changes settings is for one site. Pick the site.", "token_site", 400) if scope == "manage" && site == ""
|
|
1421
|
-
return coded("A
|
|
1498
|
+
return coded("A dashboard key is for one site. Pick the site.", "embed_site", 400) if scope == "embed" && site == ""
|
|
1422
1499
|
|
|
1423
1500
|
secret = "#{TOKEN_PREFIX}#{Hashing.random_id(20)}"
|
|
1424
1501
|
row = { "id" => Hashing.random_id, "name" => name, "site" => site, "scope" => scope, "hash" => Hashing.sha256(secret),
|
|
@@ -1532,7 +1609,7 @@ module Runlight
|
|
|
1532
1609
|
if path == "/api/embed" && method == "POST"
|
|
1533
1610
|
token = api_token(request)
|
|
1534
1611
|
return denied(false) if token.nil?
|
|
1535
|
-
return coded("Use
|
|
1612
|
+
return coded("Use the site's dashboard key, made in Settings, Install", "embed_token", 403) if token["scope"] != "embed"
|
|
1536
1613
|
|
|
1537
1614
|
site = token["site"] != "" ? rl.site(token["site"]) : nil
|
|
1538
1615
|
return coded("Unknown site", "unknown_site", 404) if site.nil?
|
|
@@ -1568,7 +1645,7 @@ module Runlight
|
|
|
1568
1645
|
if !token.nil? && token["scope"] == "manage" && manage_path(method, path)
|
|
1569
1646
|
asked = url.search_params.get("site")
|
|
1570
1647
|
site_match = path.match(%r{\A/api/sites/([^/]+)\z})
|
|
1571
|
-
if (!asked.nil? && asked != "" && asked != token["site"]) || (!site_match.nil? &&
|
|
1648
|
+
if (!asked.nil? && asked != "" && asked != token["site"]) || (!site_match.nil? && decode_part(site_match[1]) != token["site"])
|
|
1572
1649
|
return coded("Unknown site", "unknown_site", 404)
|
|
1573
1650
|
end
|
|
1574
1651
|
|
|
@@ -1659,7 +1736,7 @@ module Runlight
|
|
|
1659
1736
|
end
|
|
1660
1737
|
end
|
|
1661
1738
|
|
|
1662
|
-
# Each site's key for CMS plugins reporting AI agent fetches: made on first ask, replaced on request.
|
|
1739
|
+
# Each site's agents key, for CMS plugins reporting AI agent fetches: made on first ask, replaced on request.
|
|
1663
1740
|
if (path == "/api/observe-key" && method == "GET") || (path == "/api/observe-key/new" && method == "POST")
|
|
1664
1741
|
access = can_read(request)
|
|
1665
1742
|
return denied(access) if access != true
|
|
@@ -1668,13 +1745,7 @@ module Runlight
|
|
|
1668
1745
|
site = query_site(url)
|
|
1669
1746
|
return site if site.is_a?(Http::Response)
|
|
1670
1747
|
|
|
1671
|
-
|
|
1672
|
-
key = path.end_with?("/new") ? nil : store.setting(name)
|
|
1673
|
-
if key.nil? || key == ""
|
|
1674
|
-
key = "rlo_#{Hashing.random_id(20)}"
|
|
1675
|
-
store.set_setting(name, key)
|
|
1676
|
-
end
|
|
1677
|
-
return json({ "key" => key })
|
|
1748
|
+
return json({ "key" => OAuth.agents_key(rl, site["id"], path.end_with?("/new")) })
|
|
1678
1749
|
end
|
|
1679
1750
|
|
|
1680
1751
|
# Making, changing, and deleting funnels; reading them is with the other reports.
|
|
@@ -1858,13 +1929,25 @@ module Runlight
|
|
|
1858
1929
|
origin = text(body, "origin")
|
|
1859
1930
|
return coded("Send the dashboard's origin, such as https://stats.example.com", "pick_origin", 400) unless origin?(origin)
|
|
1860
1931
|
|
|
1932
|
+
# Where the picker sends its choice: the relay page on the same origin, and the nonce the dashboard tab
|
|
1933
|
+
# listens for. An older dashboard sends neither and gets a ticket for the tab that opened the site.
|
|
1934
|
+
back = nil
|
|
1935
|
+
if given?(body, "return") || given?(body, "nonce")
|
|
1936
|
+
address = Js.scrub(text(body, "return"))
|
|
1937
|
+
nonce = text(body, "nonce")
|
|
1938
|
+
if address.unpack1("H*").length > 2048 || !address.match?(PICK_RETURN) || !address.start_with?("#{origin}/") || !nonce.match?(PICK_NONCE)
|
|
1939
|
+
return coded("Send the dashboard's address with /pick-return on the end, on its origin, and a nonce of 32 hex digits", "pick_return", 400)
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1942
|
+
back = { "url" => address, "nonce" => nonce }
|
|
1943
|
+
end
|
|
1861
1944
|
# A hub's ticket only ever sends to the hub it connected from, never to an origin it names now.
|
|
1862
1945
|
hub = @managed[request]
|
|
1863
1946
|
if !hub.nil? && store.setting("token-origin:#{hub["id"]}") != origin
|
|
1864
1947
|
return coded("This hub's address is not the one it connected from. Connect the site again from here.", "pick_hub", 403)
|
|
1865
1948
|
end
|
|
1866
1949
|
|
|
1867
|
-
return json({ "ticket" => pick_ticket(origin, site["id"]) })
|
|
1950
|
+
return json({ "ticket" => pick_ticket(origin, site["id"], back) })
|
|
1868
1951
|
end
|
|
1869
1952
|
|
|
1870
1953
|
if (path == "/api/goals" && method == "POST") || (path.match?(%r{\A/api/goals/[^/]+\z}) && %w[PATCH DELETE].include?(method))
|
|
@@ -1901,8 +1984,11 @@ module Runlight
|
|
|
1901
1984
|
access = can_read(request)
|
|
1902
1985
|
return denied(access) if access != true
|
|
1903
1986
|
|
|
1987
|
+
id = decode_part(site_match[1])
|
|
1988
|
+
return coded("Unknown site", "unknown_site", 404) if id.nil?
|
|
1989
|
+
|
|
1904
1990
|
begin
|
|
1905
|
-
rl.delete_site(
|
|
1991
|
+
rl.delete_site(id)
|
|
1906
1992
|
return json({ "ok" => true })
|
|
1907
1993
|
rescue RangeError => e
|
|
1908
1994
|
return e.message == "Unknown site" ? coded(e.message, "unknown_site", 404) : refused(e, "site_invalid")
|
|
@@ -2068,8 +2154,10 @@ module Runlight
|
|
|
2068
2154
|
months = RETENTION_MONTHS.join(", ")
|
|
2069
2155
|
return coded("Keep visits for #{months} months, or forever", "retention_bad", 400, { "months" => months })
|
|
2070
2156
|
end
|
|
2157
|
+
id = decode_part(raw_id)
|
|
2158
|
+
return coded("Unknown site", "unknown_site", 404) if id.nil?
|
|
2159
|
+
|
|
2071
2160
|
begin
|
|
2072
|
-
id = decode(raw_id)
|
|
2073
2161
|
remote = rl.remote(id)
|
|
2074
2162
|
# How long a connected site keeps visits, and the timezone its days follow, are the install's
|
|
2075
2163
|
# settings: this server passes them on, and changes its own row only once the install took them.
|
|
@@ -2090,14 +2178,14 @@ module Runlight
|
|
|
2090
2178
|
return answer unless answer.ok?
|
|
2091
2179
|
|
|
2092
2180
|
rl.forget_remote_info(id)
|
|
2093
|
-
elsif remote.nil? && !retention.equal?(UNDEFINED)
|
|
2094
|
-
rl.set_retention(id, retention.nil? ? nil : Js.number(retention))
|
|
2095
2181
|
end
|
|
2182
|
+
# update_site checks every field, hostnames too, before it changes anything.
|
|
2096
2183
|
patch = {}
|
|
2184
|
+
patch["retentionMonths"] = retention.nil? ? nil : Js.number(retention) if remote.nil? && !retention.equal?(UNDEFINED)
|
|
2097
2185
|
patch["name"] = Js.string(Js.get(body, "name")) if given?(body, "name")
|
|
2098
2186
|
patch["timezone"] = Js.string(Js.get(body, "timezone")) if given?(body, "timezone")
|
|
2099
2187
|
patch["hostnames"] = plain(Js.get(body, "hostnames")) if given?(body, "hostnames") && rl.managed_sites
|
|
2100
|
-
site = rl.update_site(
|
|
2188
|
+
site = rl.update_site(id, patch).dup
|
|
2101
2189
|
# A connected site answers as the list shows it, so the dashboard keeps its install and domains.
|
|
2102
2190
|
unless remote.nil?
|
|
2103
2191
|
site["remote"] = remote["url"]
|
|
@@ -2189,10 +2277,15 @@ module Runlight
|
|
|
2189
2277
|
# each visit, with its time and city, since anyone with the link could watch one person's way through the site.
|
|
2190
2278
|
unfeed = ->(live) { shared.nil? ? live : live.merge("recent" => []) }
|
|
2191
2279
|
unless remote.nil?
|
|
2192
|
-
|
|
2193
|
-
|
|
2280
|
+
live_for_share = path == "/api/realtime" && !shared.nil?
|
|
2281
|
+
answer = pass_through(remote, path, url, request, live_for_share ? REALTIME_MAX_BYTES : nil)
|
|
2282
|
+
return answer if !live_for_share || !answer.ok?
|
|
2194
2283
|
|
|
2195
|
-
parsed, live =
|
|
2284
|
+
parsed, live = begin
|
|
2285
|
+
Js.parse_json(Body.read_text_capped(answer, REALTIME_MAX_BYTES))
|
|
2286
|
+
rescue Http::BodyTooLong
|
|
2287
|
+
[false, nil]
|
|
2288
|
+
end
|
|
2196
2289
|
return json(unfeed.call(live)) if parsed && live.is_a?(Hash)
|
|
2197
2290
|
|
|
2198
2291
|
host = Http::Url.new(remote["url"].to_s).host
|
|
@@ -2419,11 +2512,32 @@ module Runlight
|
|
|
2419
2512
|
target = pick_target(url.search_params.get("runlight_ticket") || "")
|
|
2420
2513
|
rl.init unless target.nil?
|
|
2421
2514
|
hosts = target.nil? ? [] : rl.site(target["site"])&.[]("hostnames")
|
|
2422
|
-
|
|
2515
|
+
named = hosts.nil? ? nil : target
|
|
2516
|
+
back = named&.[]("back")
|
|
2517
|
+
script = replace_once(asset("picker.js"), PICK_TARGET_PLACEHOLDER, Json.encode(named.nil? ? "" : named["origin"]))
|
|
2423
2518
|
script = replace_once(script, PICK_HOSTS_PLACEHOLDER, Json.encode(Json.encode(hosts || [])))
|
|
2519
|
+
script = replace_once(script, PICK_RETURN_PLACEHOLDER, Json.encode(back.nil? ? "" : back["url"]))
|
|
2520
|
+
script = replace_once(script, PICK_NONCE_PLACEHOLDER, Json.encode(back.nil? ? "" : back["nonce"]))
|
|
2424
2521
|
return Http::Response.new(script, status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
|
|
2425
2522
|
end
|
|
2426
2523
|
|
|
2524
|
+
# The relay page the picker opens with its choice in the fragment, which never reaches a server. Its
|
|
2525
|
+
# script hands the choice to the dashboard tab on this origin that started the pick, and nothing else.
|
|
2526
|
+
if path == "/pick-return" && method == "GET"
|
|
2527
|
+
translator = Messages.translator(accepted_language(request))
|
|
2528
|
+
t = translator["t"]
|
|
2529
|
+
return small_page(
|
|
2530
|
+
translator["lang"],
|
|
2531
|
+
"<div id=\"done\" hidden><h1>#{escape_html(t.call("pick.doneTitle"))}</h1><p>#{escape_html(t.call("pick.done"))}</p></div>" \
|
|
2532
|
+
"<div id=\"gone\" hidden><h1>#{escape_html(t.call("pick.goneTitle"))}</h1><p>#{escape_html(t.call("pick.gone"))}</p></div>",
|
|
2533
|
+
200,
|
|
2534
|
+
"#{base}/pick-return.js",
|
|
2535
|
+
)
|
|
2536
|
+
end
|
|
2537
|
+
if path == "/pick-return.js" && method == "GET"
|
|
2538
|
+
return Http::Response.new(asset("pick-return.js"), status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
|
|
2539
|
+
end
|
|
2540
|
+
|
|
2427
2541
|
if path == "/assets/world.#{build_hash("worldHash")}.json" && method == "GET"
|
|
2428
2542
|
return Http::Response.new(asset("world.json"), status: 200, headers: { "content-type" => "application/json; charset=utf-8", "cache-control" => "public, max-age=31536000, immutable" })
|
|
2429
2543
|
end
|
|
@@ -2545,11 +2659,13 @@ module Runlight
|
|
|
2545
2659
|
end
|
|
2546
2660
|
# The page itself holds no data; the API it calls checks access and
|
|
2547
2661
|
# the page explains how to sign in when it is refused.
|
|
2548
|
-
|
|
2662
|
+
# Inside a CMS, its admin, on this same origin, shows the whole dashboard in a frame; nothing else may.
|
|
2663
|
+
cms = @options["cms"].to_s
|
|
2664
|
+
return Http::Response.new(dashboard(base, "", @sign_out || "", Js.truthy?(@options["geoCredit"]), !@web.nil?, @sign_in || "", nil, cms), status: 200, headers: {
|
|
2549
2665
|
"content-type" => "text/html; charset=utf-8",
|
|
2550
2666
|
"cache-control" => "no-store",
|
|
2551
|
-
"content-security-policy" => DASHBOARD_CSP,
|
|
2552
|
-
"x-frame-options" => "DENY",
|
|
2667
|
+
"content-security-policy" => cms != "" ? DASHBOARD_CSP.sub("frame-ancestors 'none'", "frame-ancestors 'self'") : DASHBOARD_CSP,
|
|
2668
|
+
"x-frame-options" => cms != "" ? "SAMEORIGIN" : "DENY",
|
|
2553
2669
|
"referrer-policy" => "same-origin",
|
|
2554
2670
|
})
|
|
2555
2671
|
end
|