runlight 0.1.1 → 0.1.6
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/assistant-action.json +1 -0
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +65 -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 +30 -17
- data/lib/runlight/accounts/pages.rb +67 -3
- data/lib/runlight/accounts/web.rb +45 -30
- data/lib/runlight/assistant.rb +41 -18
- data/lib/runlight/core.rb +126 -39
- 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/reports.rb +1 -6
- data/lib/runlight/routes.rb +244 -96
- 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 +76 -14
- 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 +8 -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
|
|
@@ -572,6 +600,10 @@ module Runlight
|
|
|
572
600
|
end
|
|
573
601
|
|
|
574
602
|
# Whether this request acts as the owner. "read" is someone signed in who may only read, such as a viewer.
|
|
603
|
+
def session_scope(request)
|
|
604
|
+
@web.signed_in(request) if @web
|
|
605
|
+
end
|
|
606
|
+
|
|
575
607
|
def can_read(request)
|
|
576
608
|
return true if @managed.key?(request)
|
|
577
609
|
|
|
@@ -581,6 +613,8 @@ module Runlight
|
|
|
581
613
|
given = bearer(request)
|
|
582
614
|
return true if authorize.nil? && !@token.nil? && @token != "" && given != "" && constant_time_equal(given, @token)
|
|
583
615
|
|
|
616
|
+
scoped = session_scope(request)
|
|
617
|
+
return false if scoped && !scoped["site"].nil? && Http::Url.new(request.url).pathname.delete_prefix(@base).start_with?("/oauth/")
|
|
584
618
|
answer = authorize.nil? ? @web.access(request) : authorize.call(request)
|
|
585
619
|
# A member changes things like an owner, apart from the few controls admin_only? names.
|
|
586
620
|
@members[request] = true if answer == "member"
|
|
@@ -685,7 +719,7 @@ module Runlight
|
|
|
685
719
|
|
|
686
720
|
# Answers a read for a site counted by another install by asking that install,
|
|
687
721
|
# 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)
|
|
722
|
+
def pass_through(remote, path, url, request = nil, max_bytes = nil)
|
|
689
723
|
target = Http::Url.new("#{remote["url"]}#{path}")
|
|
690
724
|
query = target.search_params
|
|
691
725
|
url.search_params.each { |key, value| query.append(key.to_s, value) }
|
|
@@ -707,6 +741,8 @@ module Runlight
|
|
|
707
741
|
"timeoutMs" => write ? 30_000 : 120_000,
|
|
708
742
|
}
|
|
709
743
|
init["body"] = request.text if write
|
|
744
|
+
# One byte more than the most wanted is asked, so an answer past it is seen as such.
|
|
745
|
+
init.merge!("maxBytes" => max_bytes + 1, "truncate" => true) unless max_bytes.nil?
|
|
710
746
|
answer = Safefetch.owner_fetch(target.href, init, @rl.fetcher, @rl.local_installs)
|
|
711
747
|
rescue StandardError => e
|
|
712
748
|
if e.is_a?(Http::FetchError) && e.timed_out?
|
|
@@ -838,8 +874,8 @@ module Runlight
|
|
|
838
874
|
end
|
|
839
875
|
end
|
|
840
876
|
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)
|
|
877
|
+
domain = decode_part(check_match[1])
|
|
878
|
+
return coded("Unknown domain", "unknown_domain", 404) if domain.nil? || !own_domains.call.include?(domain)
|
|
843
879
|
|
|
844
880
|
# One added before names inside private networks were refused is never fetched.
|
|
845
881
|
# What the check found, as a code the dashboard says in its own words, beside the English reason.
|
|
@@ -860,9 +896,11 @@ module Runlight
|
|
|
860
896
|
begin
|
|
861
897
|
# Only a public address is fetched, whatever the name resolves to now, so the check cannot be pointed
|
|
862
898
|
# into a private network.
|
|
863
|
-
answer
|
|
899
|
+
# Read no further than the check's answer could need; one byte more is asked, so a longer one is seen as such.
|
|
900
|
+
answer = Safefetch.public_fetch("https://#{domain}#{LINK_DOMAIN_CHECK}",
|
|
901
|
+
{ "timeoutMs" => 5000, "maxBytes" => LINK_DOMAIN_MAX_BYTES + 1, "truncate" => true }, @rl.fetcher)
|
|
864
902
|
parsed, body = begin
|
|
865
|
-
Js.parse_json(answer
|
|
903
|
+
Js.parse_json(Body.read_text_capped(answer, LINK_DOMAIN_MAX_BYTES))
|
|
866
904
|
rescue StandardError
|
|
867
905
|
[false, nil]
|
|
868
906
|
end
|
|
@@ -879,8 +917,8 @@ module Runlight
|
|
|
879
917
|
end
|
|
880
918
|
|
|
881
919
|
if (domain_match = path.match(%r{\A/api/link-domains/([^/]+)\z})) && request.method == "DELETE"
|
|
882
|
-
domain =
|
|
883
|
-
return coded("Unknown domain", "unknown_domain", 404)
|
|
920
|
+
domain = decode_part(domain_match[1])
|
|
921
|
+
return coded("Unknown domain", "unknown_domain", 404) if domain.nil? || !own_domains.call.include?(domain)
|
|
884
922
|
|
|
885
923
|
store.remove_link_domain(domain)
|
|
886
924
|
@rl.forget_link_domains
|
|
@@ -1000,40 +1038,41 @@ module Runlight
|
|
|
1000
1038
|
# The key picker tickets are signed with, made on first use and kept in the database for every process.
|
|
1001
1039
|
def pick_key
|
|
1002
1040
|
@rl.init
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
made = Hashing.random_id(32)
|
|
1007
|
-
store.set_setting("pick-key", made)
|
|
1008
|
-
made
|
|
1041
|
+
# Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
|
|
1042
|
+
store.setting_or_add("pick-key") { Hashing.random_id(32) }
|
|
1009
1043
|
end
|
|
1010
1044
|
|
|
1011
|
-
# A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half
|
|
1012
|
-
|
|
1045
|
+
# A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half
|
|
1046
|
+
# an hour. With a return address and a nonce it sends it there, to the relay page, which hands it to the dashboard
|
|
1047
|
+
# tab that made the nonce; without them, as an older dashboard asks, to the tab that opened the site.
|
|
1048
|
+
def pick_ticket(origin, site, back = nil)
|
|
1013
1049
|
payload = "#{@rl.now + PICK_TICKET_MS}.#{site.unpack1("H*")}.#{origin.unpack1("H*")}"
|
|
1050
|
+
payload += ".#{back["url"].unpack1("H*")}.#{back["nonce"]}" unless back.nil?
|
|
1014
1051
|
"#{payload}.#{Hashing.hmac(pick_key, payload)}"
|
|
1015
1052
|
end
|
|
1016
1053
|
|
|
1017
|
-
#
|
|
1054
|
+
# What a picker ticket names, or nil when it is not one this install signed or has run out.
|
|
1018
1055
|
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/)
|
|
1056
|
+
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
1057
|
return nil if parts.nil? || Js.number(parts[1]) < @rl.now
|
|
1021
|
-
return nil unless constant_time_equal(parts[
|
|
1058
|
+
return nil unless constant_time_equal(parts[6], Hashing.hmac(pick_key, ticket[0, ticket.rindex(".")]))
|
|
1022
1059
|
|
|
1023
1060
|
unhex = ->(text) { Js.scrub([text[0, text.length - (text.length % 2)]].pack("H*")) }
|
|
1024
1061
|
origin = unhex.call(parts[3])
|
|
1025
|
-
|
|
1062
|
+
return nil unless origin?(origin)
|
|
1063
|
+
return { "origin" => origin, "site" => unhex.call(parts[2]) } if parts[4].nil?
|
|
1064
|
+
|
|
1065
|
+
address = unhex.call(parts[4])
|
|
1066
|
+
return nil unless address.match?(PICK_RETURN) && address.start_with?("#{origin}/")
|
|
1067
|
+
|
|
1068
|
+
{ "origin" => origin, "site" => unhex.call(parts[2]), "back" => { "url" => address, "nonce" => parts[5] } }
|
|
1026
1069
|
end
|
|
1027
1070
|
|
|
1028
1071
|
# The key embed tickets and sessions are signed with, made on first use and kept in the database for every process.
|
|
1029
1072
|
def embed_key
|
|
1030
1073
|
@rl.init
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
made = Hashing.random_id(32)
|
|
1035
|
-
store.set_setting("embed-key", made)
|
|
1036
|
-
made
|
|
1074
|
+
# Written only if no other request wrote one first, so nothing signed with a key that lost goes stale.
|
|
1075
|
+
store.setting_or_add("embed-key") { Hashing.random_id(32) }
|
|
1037
1076
|
end
|
|
1038
1077
|
|
|
1039
1078
|
# An embed token that still exists, for a site that still does.
|
|
@@ -1120,10 +1159,10 @@ module Runlight
|
|
|
1120
1159
|
return site if site.is_a?(Http::Response)
|
|
1121
1160
|
|
|
1122
1161
|
existing = store.goals(site["id"])
|
|
1123
|
-
id = path == "/api/goals" ? nil :
|
|
1162
|
+
id = path == "/api/goals" ? nil : decode_part(path["/api/goals/".length..])
|
|
1124
1163
|
before = nil
|
|
1125
1164
|
existing.each { |g| before = g if g["id"] == id }
|
|
1126
|
-
return coded("Unknown goal", "unknown_goal", 404) if
|
|
1165
|
+
return coded("Unknown goal", "unknown_goal", 404) if path != "/api/goals" && (id.nil? || before.nil?)
|
|
1127
1166
|
|
|
1128
1167
|
@trackers = {}
|
|
1129
1168
|
if request.method == "DELETE"
|
|
@@ -1149,7 +1188,7 @@ module Runlight
|
|
|
1149
1188
|
if request.method == "GET"
|
|
1150
1189
|
settings = @rl.mail_settings
|
|
1151
1190
|
known = settings || {}
|
|
1152
|
-
service = Mail::Transports::SERVICES.find { |s| s["id"] == known["service"] }
|
|
1191
|
+
service = known["source"] == "app" ? nil : Mail::Transports::SERVICES.find { |s| s["id"] == known["service"] }
|
|
1153
1192
|
# Secret fields come back only as "saved", never as their value.
|
|
1154
1193
|
fields = {}
|
|
1155
1194
|
saved = []
|
|
@@ -1163,16 +1202,23 @@ module Runlight
|
|
|
1163
1202
|
end
|
|
1164
1203
|
# A hub with a manage token learns which service sends the reports and from where, nothing more.
|
|
1165
1204
|
via_manage = @managed.key?(request)
|
|
1166
|
-
|
|
1205
|
+
answer = {
|
|
1167
1206
|
"source" => known["source"],
|
|
1168
|
-
"service" => known["service"] || "",
|
|
1207
|
+
"service" => known["source"] == "app" ? "" : known["service"] || "",
|
|
1169
1208
|
"from" => known["from"] || "",
|
|
1170
1209
|
"fromName" => known["fromName"] || "",
|
|
1171
1210
|
"fields" => via_manage ? {} : fields,
|
|
1172
1211
|
"saved" => via_manage ? [] : saved,
|
|
1173
1212
|
"encrypted" => !@rl.secret.nil?,
|
|
1174
|
-
"services" =>
|
|
1175
|
-
}
|
|
1213
|
+
"services" => @rl.mail_services,
|
|
1214
|
+
}
|
|
1215
|
+
# The app's own mailer, named only when there is one, so the answer without it stays as it was.
|
|
1216
|
+
app = @rl.app_mailer
|
|
1217
|
+
unless app.nil?
|
|
1218
|
+
answer["app"] = { "via" => app["via"], "from" => app["from"], "fromName" => app["fromName"] || "",
|
|
1219
|
+
"replaced" => known["source"] != "app" }
|
|
1220
|
+
end
|
|
1221
|
+
return json(answer)
|
|
1176
1222
|
end
|
|
1177
1223
|
if request.method == "PUT"
|
|
1178
1224
|
body = read_json(request)
|
|
@@ -1199,12 +1245,16 @@ module Runlight
|
|
|
1199
1245
|
return coded("Set up a mail service first", "mail_unset", 400) if settings.nil?
|
|
1200
1246
|
|
|
1201
1247
|
t = Messages.translator(text(body, "lang", "en"))["t"]
|
|
1202
|
-
name =
|
|
1248
|
+
name = if settings["source"] == "app"
|
|
1249
|
+
settings["via"] || ""
|
|
1250
|
+
else
|
|
1251
|
+
Mail::Transports::SERVICES.find { |s| s["id"] == settings["service"] }&.[]("name") || ""
|
|
1252
|
+
end
|
|
1203
1253
|
@rl.send_mail({
|
|
1204
1254
|
"to" => to,
|
|
1205
1255
|
"subject" => t.call("email.test.subject"),
|
|
1206
1256
|
"text" => t.call("email.test.body", { "service" => name }),
|
|
1207
|
-
"html" => "<p style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
|
|
1257
|
+
"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>",
|
|
1208
1258
|
})
|
|
1209
1259
|
return json({ "ok" => true })
|
|
1210
1260
|
end
|
|
@@ -1332,8 +1382,8 @@ module Runlight
|
|
|
1332
1382
|
return coded("Method not allowed", "method_not_allowed", 405)
|
|
1333
1383
|
end
|
|
1334
1384
|
|
|
1335
|
-
id =
|
|
1336
|
-
share = id.match?(SHARE_ID) ? store.share_by_id(id) : nil
|
|
1385
|
+
id = decode_part(path["/api/shares/".length..])
|
|
1386
|
+
share = !id.nil? && id.match?(SHARE_ID) ? store.share_by_id(id) : nil
|
|
1337
1387
|
return coded("Unknown share", "unknown_share", 404) if share.nil? || share["site"] != site["id"]
|
|
1338
1388
|
|
|
1339
1389
|
if request.method == "PATCH"
|
|
@@ -1362,39 +1412,72 @@ module Runlight
|
|
|
1362
1412
|
# As in TypeScript, the hour's questions are counted in this process; the day's, in the settings.
|
|
1363
1413
|
def ask_turn(who, owner)
|
|
1364
1414
|
now = @rl.now
|
|
1415
|
+
# Taken in one step, before anything slow, so questions sent at once each see the others.
|
|
1365
1416
|
mine = @lock.synchronize do
|
|
1366
|
-
held = @asked[who]
|
|
1417
|
+
held = (@asked[who] ||= { "at" => [], "open" => 0 })
|
|
1367
1418
|
held["at"] = held["at"].select { |at| now - at < 3_600_000 }
|
|
1368
|
-
held
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1419
|
+
if held["at"].length >= ASK_PER_HOUR || held["open"] >= ASK_AT_ONCE
|
|
1420
|
+
nil
|
|
1421
|
+
else
|
|
1422
|
+
held["at"] << now
|
|
1423
|
+
held["open"] += 1
|
|
1424
|
+
held
|
|
1425
|
+
end
|
|
1372
1426
|
end
|
|
1427
|
+
return coded("You have asked a lot in a short time. Wait a little and ask again.", "assistant_soon", 429) if mine.nil?
|
|
1373
1428
|
|
|
1429
|
+
release = -> { @lock.synchronize { mine["open"] -= 1 } }
|
|
1374
1430
|
unless owner
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
end
|
|
1431
|
+
counted = begin
|
|
1432
|
+
count_ask(who, now)
|
|
1433
|
+
rescue StandardError
|
|
1434
|
+
release.call
|
|
1435
|
+
raise
|
|
1436
|
+
end
|
|
1437
|
+
unless counted == true
|
|
1438
|
+
release.call
|
|
1439
|
+
@lock.synchronize do
|
|
1440
|
+
at = mine["at"].index(now)
|
|
1441
|
+
mine["at"].delete_at(at) unless at.nil?
|
|
1442
|
+
end
|
|
1443
|
+
return coded("You have asked a lot in a short time. Wait a little and ask again.", "assistant_soon", 429) if counted.nil?
|
|
1381
1444
|
|
|
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
|
|
1445
|
+
return coded("Viewers can ask #{Js.string(counted)} questions a day. Ask again tomorrow.", "assistant_daily", 429, { "limit" => Js.string(counted) })
|
|
1386
1446
|
end
|
|
1387
1447
|
end
|
|
1448
|
+
# People who stopped asking are dropped, so the map holds only the last hour's.
|
|
1388
1449
|
@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
1450
|
if @asked.size > 1000
|
|
1394
1451
|
@asked.delete_if { |_key, value| value["open"].zero? && value["at"].none? { |at| now - at < 3_600_000 } }
|
|
1395
1452
|
end
|
|
1396
1453
|
end
|
|
1397
|
-
|
|
1454
|
+
release
|
|
1455
|
+
end
|
|
1456
|
+
|
|
1457
|
+
# Counts a viewer's question in their own row for the day, or gives the daily limit when it is reached. The
|
|
1458
|
+
# row only ever moves from the count read to the next one, so of questions sent at once, by one viewer or by
|
|
1459
|
+
# every process on the database, each count is taken once. True when counted, nil when the row kept changing.
|
|
1460
|
+
def count_ask(who, now)
|
|
1461
|
+
limit = viewer_daily
|
|
1462
|
+
return limit if limit < 1
|
|
1463
|
+
|
|
1464
|
+
day = "assistant-asked:#{Time.at(now.div(1000)).utc.strftime("%Y-%m-%d")}:"
|
|
1465
|
+
key = "#{day}#{who}"
|
|
1466
|
+
20.times do
|
|
1467
|
+
seen = store.setting(key)
|
|
1468
|
+
if seen.nil?
|
|
1469
|
+
next unless store.add_setting(key, "1")
|
|
1470
|
+
|
|
1471
|
+
# A viewer's first question of the day clears the days before.
|
|
1472
|
+
store.settings_starting_with("assistant-asked:").each do |entry|
|
|
1473
|
+
store.set_setting(entry["key"], nil) unless entry["key"].start_with?(day)
|
|
1474
|
+
end
|
|
1475
|
+
return true
|
|
1476
|
+
end
|
|
1477
|
+
return limit if Js.number(seen) >= limit
|
|
1478
|
+
return true if store.swap_setting(key, seen, Js.string(Js.number(seen) + 1))
|
|
1479
|
+
end
|
|
1480
|
+
nil
|
|
1398
1481
|
end
|
|
1399
1482
|
|
|
1400
1483
|
def tokens_api(request, path)
|
|
@@ -1418,7 +1501,7 @@ module Runlight
|
|
|
1418
1501
|
asked_scope = Js.get(body, "scope")
|
|
1419
1502
|
scope = %w[manage embed].include?(asked_scope) ? asked_scope : "read"
|
|
1420
1503
|
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
|
|
1504
|
+
return coded("A dashboard key is for one site. Pick the site.", "embed_site", 400) if scope == "embed" && site == ""
|
|
1422
1505
|
|
|
1423
1506
|
secret = "#{TOKEN_PREFIX}#{Hashing.random_id(20)}"
|
|
1424
1507
|
row = { "id" => Hashing.random_id, "name" => name, "site" => site, "scope" => scope, "hash" => Hashing.sha256(secret),
|
|
@@ -1458,6 +1541,27 @@ module Runlight
|
|
|
1458
1541
|
def api(request, path, url)
|
|
1459
1542
|
rl = @rl
|
|
1460
1543
|
method = request.method
|
|
1544
|
+
scoped = session_scope(request)
|
|
1545
|
+
if scoped && !scoped["site"].nil?
|
|
1546
|
+
rl.init
|
|
1547
|
+
site = scoped["site"]
|
|
1548
|
+
return coded("Unknown site", "unknown_site", 404) if rl.site(site).nil?
|
|
1549
|
+
asked = url.search_params.get("site")
|
|
1550
|
+
site_path = path.match(%r{\A/api/sites/([^/]+)\z})
|
|
1551
|
+
return coded("Unknown site", "unknown_site", 404) if (!asked.nil? && asked != site) || (site_path && decode_part(site_path[1]) != site)
|
|
1552
|
+
read = method == "GET" && shared_path?(path)
|
|
1553
|
+
assistant = (path == "/api/assistant" && method == "GET") || (path == "/api/assistant/chat" && method == "POST")
|
|
1554
|
+
manage = scoped["role"] == "member" && manage_path(method, path)
|
|
1555
|
+
return coded("Your account only has access to its assigned site", "site_access", 403) unless read || assistant || manage
|
|
1556
|
+
if path == "/api/assistant/chat"
|
|
1557
|
+
parsed, body = Js.parse_json(request.text)
|
|
1558
|
+
return coded("Unknown site", "unknown_site", 404) unless parsed && Js.object?(body) && Js.get(body, "site") == site
|
|
1559
|
+
end
|
|
1560
|
+
url = Http::Url.new(url.href)
|
|
1561
|
+
query = url.search_params
|
|
1562
|
+
query.set("site", site)
|
|
1563
|
+
url.search_params = query
|
|
1564
|
+
end
|
|
1461
1565
|
# An embedded dashboard reads what a share link shows and nothing else, whoever else the request comes from.
|
|
1462
1566
|
if !request.headers.get(EMBED_HEADER).nil? && !(method == "GET" && shared_path?(path))
|
|
1463
1567
|
return coded("Not available on a shared dashboard", "share_not_available", 403)
|
|
@@ -1532,7 +1636,7 @@ module Runlight
|
|
|
1532
1636
|
if path == "/api/embed" && method == "POST"
|
|
1533
1637
|
token = api_token(request)
|
|
1534
1638
|
return denied(false) if token.nil?
|
|
1535
|
-
return coded("Use
|
|
1639
|
+
return coded("Use the site's dashboard key, made in Settings, Install", "embed_token", 403) if token["scope"] != "embed"
|
|
1536
1640
|
|
|
1537
1641
|
site = token["site"] != "" ? rl.site(token["site"]) : nil
|
|
1538
1642
|
return coded("Unknown site", "unknown_site", 404) if site.nil?
|
|
@@ -1568,7 +1672,7 @@ module Runlight
|
|
|
1568
1672
|
if !token.nil? && token["scope"] == "manage" && manage_path(method, path)
|
|
1569
1673
|
asked = url.search_params.get("site")
|
|
1570
1674
|
site_match = path.match(%r{\A/api/sites/([^/]+)\z})
|
|
1571
|
-
if (!asked.nil? && asked != "" && asked != token["site"]) || (!site_match.nil? &&
|
|
1675
|
+
if (!asked.nil? && asked != "" && asked != token["site"]) || (!site_match.nil? && decode_part(site_match[1]) != token["site"])
|
|
1572
1676
|
return coded("Unknown site", "unknown_site", 404)
|
|
1573
1677
|
end
|
|
1574
1678
|
|
|
@@ -1659,7 +1763,7 @@ module Runlight
|
|
|
1659
1763
|
end
|
|
1660
1764
|
end
|
|
1661
1765
|
|
|
1662
|
-
# Each site's key for CMS plugins reporting AI agent fetches: made on first ask, replaced on request.
|
|
1766
|
+
# Each site's agents key, for CMS plugins reporting AI agent fetches: made on first ask, replaced on request.
|
|
1663
1767
|
if (path == "/api/observe-key" && method == "GET") || (path == "/api/observe-key/new" && method == "POST")
|
|
1664
1768
|
access = can_read(request)
|
|
1665
1769
|
return denied(access) if access != true
|
|
@@ -1668,13 +1772,7 @@ module Runlight
|
|
|
1668
1772
|
site = query_site(url)
|
|
1669
1773
|
return site if site.is_a?(Http::Response)
|
|
1670
1774
|
|
|
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 })
|
|
1775
|
+
return json({ "key" => OAuth.agents_key(rl, site["id"], path.end_with?("/new")) })
|
|
1678
1776
|
end
|
|
1679
1777
|
|
|
1680
1778
|
# Making, changing, and deleting funnels; reading them is with the other reports.
|
|
@@ -1858,13 +1956,25 @@ module Runlight
|
|
|
1858
1956
|
origin = text(body, "origin")
|
|
1859
1957
|
return coded("Send the dashboard's origin, such as https://stats.example.com", "pick_origin", 400) unless origin?(origin)
|
|
1860
1958
|
|
|
1959
|
+
# Where the picker sends its choice: the relay page on the same origin, and the nonce the dashboard tab
|
|
1960
|
+
# listens for. An older dashboard sends neither and gets a ticket for the tab that opened the site.
|
|
1961
|
+
back = nil
|
|
1962
|
+
if given?(body, "return") || given?(body, "nonce")
|
|
1963
|
+
address = Js.scrub(text(body, "return"))
|
|
1964
|
+
nonce = text(body, "nonce")
|
|
1965
|
+
if address.unpack1("H*").length > 2048 || !address.match?(PICK_RETURN) || !address.start_with?("#{origin}/") || !nonce.match?(PICK_NONCE)
|
|
1966
|
+
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)
|
|
1967
|
+
end
|
|
1968
|
+
|
|
1969
|
+
back = { "url" => address, "nonce" => nonce }
|
|
1970
|
+
end
|
|
1861
1971
|
# A hub's ticket only ever sends to the hub it connected from, never to an origin it names now.
|
|
1862
1972
|
hub = @managed[request]
|
|
1863
1973
|
if !hub.nil? && store.setting("token-origin:#{hub["id"]}") != origin
|
|
1864
1974
|
return coded("This hub's address is not the one it connected from. Connect the site again from here.", "pick_hub", 403)
|
|
1865
1975
|
end
|
|
1866
1976
|
|
|
1867
|
-
return json({ "ticket" => pick_ticket(origin, site["id"]) })
|
|
1977
|
+
return json({ "ticket" => pick_ticket(origin, site["id"], back) })
|
|
1868
1978
|
end
|
|
1869
1979
|
|
|
1870
1980
|
if (path == "/api/goals" && method == "POST") || (path.match?(%r{\A/api/goals/[^/]+\z}) && %w[PATCH DELETE].include?(method))
|
|
@@ -1901,8 +2011,15 @@ module Runlight
|
|
|
1901
2011
|
access = can_read(request)
|
|
1902
2012
|
return denied(access) if access != true
|
|
1903
2013
|
|
|
2014
|
+
id = decode_part(site_match[1])
|
|
2015
|
+
return coded("Unknown site", "unknown_site", 404) if id.nil?
|
|
2016
|
+
|
|
2017
|
+
if @web && session_scope(request)
|
|
2018
|
+
refused = @web.confirm_password(request)
|
|
2019
|
+
return refused unless refused.nil?
|
|
2020
|
+
end
|
|
1904
2021
|
begin
|
|
1905
|
-
rl.delete_site(
|
|
2022
|
+
rl.delete_site(id)
|
|
1906
2023
|
return json({ "ok" => true })
|
|
1907
2024
|
rescue RangeError => e
|
|
1908
2025
|
return e.message == "Unknown site" ? coded(e.message, "unknown_site", 404) : refused(e, "site_invalid")
|
|
@@ -2068,8 +2185,10 @@ module Runlight
|
|
|
2068
2185
|
months = RETENTION_MONTHS.join(", ")
|
|
2069
2186
|
return coded("Keep visits for #{months} months, or forever", "retention_bad", 400, { "months" => months })
|
|
2070
2187
|
end
|
|
2188
|
+
id = decode_part(raw_id)
|
|
2189
|
+
return coded("Unknown site", "unknown_site", 404) if id.nil?
|
|
2190
|
+
|
|
2071
2191
|
begin
|
|
2072
|
-
id = decode(raw_id)
|
|
2073
2192
|
remote = rl.remote(id)
|
|
2074
2193
|
# How long a connected site keeps visits, and the timezone its days follow, are the install's
|
|
2075
2194
|
# settings: this server passes them on, and changes its own row only once the install took them.
|
|
@@ -2090,14 +2209,14 @@ module Runlight
|
|
|
2090
2209
|
return answer unless answer.ok?
|
|
2091
2210
|
|
|
2092
2211
|
rl.forget_remote_info(id)
|
|
2093
|
-
elsif remote.nil? && !retention.equal?(UNDEFINED)
|
|
2094
|
-
rl.set_retention(id, retention.nil? ? nil : Js.number(retention))
|
|
2095
2212
|
end
|
|
2213
|
+
# update_site checks every field, hostnames too, before it changes anything.
|
|
2096
2214
|
patch = {}
|
|
2215
|
+
patch["retentionMonths"] = retention.nil? ? nil : Js.number(retention) if remote.nil? && !retention.equal?(UNDEFINED)
|
|
2097
2216
|
patch["name"] = Js.string(Js.get(body, "name")) if given?(body, "name")
|
|
2098
2217
|
patch["timezone"] = Js.string(Js.get(body, "timezone")) if given?(body, "timezone")
|
|
2099
2218
|
patch["hostnames"] = plain(Js.get(body, "hostnames")) if given?(body, "hostnames") && rl.managed_sites
|
|
2100
|
-
site = rl.update_site(
|
|
2219
|
+
site = rl.update_site(id, patch).dup
|
|
2101
2220
|
# A connected site answers as the list shows it, so the dashboard keeps its install and domains.
|
|
2102
2221
|
unless remote.nil?
|
|
2103
2222
|
site["remote"] = remote["url"]
|
|
@@ -2121,7 +2240,8 @@ module Runlight
|
|
|
2121
2240
|
share_id = request.headers.get(SHARE_HEADER)
|
|
2122
2241
|
shared = nil
|
|
2123
2242
|
# The one site a share or a site's API token may read; nil for every site.
|
|
2124
|
-
|
|
2243
|
+
scoped = session_scope(request)
|
|
2244
|
+
only = scoped && scoped["site"]
|
|
2125
2245
|
embed = request.headers.get(EMBED_HEADER)
|
|
2126
2246
|
if share_id.nil? && !embed.nil?
|
|
2127
2247
|
token = embed_reader(embed)
|
|
@@ -2137,7 +2257,7 @@ module Runlight
|
|
|
2137
2257
|
if access != true
|
|
2138
2258
|
return coded("API tokens can only read", "token_read_only", 403) unless shared_path?(path)
|
|
2139
2259
|
|
|
2140
|
-
only = access["site"] != "" ? access["site"] : nil
|
|
2260
|
+
only = scoped && scoped["site"] || (access["site"] != "" ? access["site"] : nil)
|
|
2141
2261
|
end
|
|
2142
2262
|
else
|
|
2143
2263
|
shared = share_id.match?(SHARE_ID) ? store.share_by_id(share_id) : nil
|
|
@@ -2171,7 +2291,7 @@ module Runlight
|
|
|
2171
2291
|
row
|
|
2172
2292
|
end
|
|
2173
2293
|
# A share never learns how the install is run.
|
|
2174
|
-
return json(shared.nil? ? { "sites" => rows, "managed" => rl.managed_sites } : { "sites" => rows })
|
|
2294
|
+
return json(shared.nil? ? { "sites" => rows, "managed" => !(scoped && scoped["site"]) && rl.managed_sites } : { "sites" => rows })
|
|
2175
2295
|
end
|
|
2176
2296
|
|
|
2177
2297
|
site = if !shared.nil?
|
|
@@ -2189,10 +2309,15 @@ module Runlight
|
|
|
2189
2309
|
# each visit, with its time and city, since anyone with the link could watch one person's way through the site.
|
|
2190
2310
|
unfeed = ->(live) { shared.nil? ? live : live.merge("recent" => []) }
|
|
2191
2311
|
unless remote.nil?
|
|
2192
|
-
|
|
2193
|
-
|
|
2312
|
+
live_for_share = path == "/api/realtime" && !shared.nil?
|
|
2313
|
+
answer = pass_through(remote, path, url, request, live_for_share ? REALTIME_MAX_BYTES : nil)
|
|
2314
|
+
return answer if !live_for_share || !answer.ok?
|
|
2194
2315
|
|
|
2195
|
-
parsed, live =
|
|
2316
|
+
parsed, live = begin
|
|
2317
|
+
Js.parse_json(Body.read_text_capped(answer, REALTIME_MAX_BYTES))
|
|
2318
|
+
rescue Http::BodyTooLong
|
|
2319
|
+
[false, nil]
|
|
2320
|
+
end
|
|
2196
2321
|
return json(unfeed.call(live)) if parsed && live.is_a?(Hash)
|
|
2197
2322
|
|
|
2198
2323
|
host = Http::Url.new(remote["url"].to_s).host
|
|
@@ -2419,11 +2544,32 @@ module Runlight
|
|
|
2419
2544
|
target = pick_target(url.search_params.get("runlight_ticket") || "")
|
|
2420
2545
|
rl.init unless target.nil?
|
|
2421
2546
|
hosts = target.nil? ? [] : rl.site(target["site"])&.[]("hostnames")
|
|
2422
|
-
|
|
2547
|
+
named = hosts.nil? ? nil : target
|
|
2548
|
+
back = named&.[]("back")
|
|
2549
|
+
script = replace_once(asset("picker.js"), PICK_TARGET_PLACEHOLDER, Json.encode(named.nil? ? "" : named["origin"]))
|
|
2423
2550
|
script = replace_once(script, PICK_HOSTS_PLACEHOLDER, Json.encode(Json.encode(hosts || [])))
|
|
2551
|
+
script = replace_once(script, PICK_RETURN_PLACEHOLDER, Json.encode(back.nil? ? "" : back["url"]))
|
|
2552
|
+
script = replace_once(script, PICK_NONCE_PLACEHOLDER, Json.encode(back.nil? ? "" : back["nonce"]))
|
|
2424
2553
|
return Http::Response.new(script, status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
|
|
2425
2554
|
end
|
|
2426
2555
|
|
|
2556
|
+
# The relay page the picker opens with its choice in the fragment, which never reaches a server. Its
|
|
2557
|
+
# script hands the choice to the dashboard tab on this origin that started the pick, and nothing else.
|
|
2558
|
+
if path == "/pick-return" && method == "GET"
|
|
2559
|
+
translator = Messages.translator(accepted_language(request))
|
|
2560
|
+
t = translator["t"]
|
|
2561
|
+
return small_page(
|
|
2562
|
+
translator["lang"],
|
|
2563
|
+
"<div id=\"done\" hidden><h1>#{escape_html(t.call("pick.doneTitle"))}</h1><p>#{escape_html(t.call("pick.done"))}</p></div>" \
|
|
2564
|
+
"<div id=\"gone\" hidden><h1>#{escape_html(t.call("pick.goneTitle"))}</h1><p>#{escape_html(t.call("pick.gone"))}</p></div>",
|
|
2565
|
+
200,
|
|
2566
|
+
"#{base}/pick-return.js",
|
|
2567
|
+
)
|
|
2568
|
+
end
|
|
2569
|
+
if path == "/pick-return.js" && method == "GET"
|
|
2570
|
+
return Http::Response.new(asset("pick-return.js"), status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
|
|
2571
|
+
end
|
|
2572
|
+
|
|
2427
2573
|
if path == "/assets/world.#{build_hash("worldHash")}.json" && method == "GET"
|
|
2428
2574
|
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
2575
|
end
|
|
@@ -2545,11 +2691,13 @@ module Runlight
|
|
|
2545
2691
|
end
|
|
2546
2692
|
# The page itself holds no data; the API it calls checks access and
|
|
2547
2693
|
# the page explains how to sign in when it is refused.
|
|
2548
|
-
|
|
2694
|
+
# Inside a CMS, its admin, on this same origin, shows the whole dashboard in a frame; nothing else may.
|
|
2695
|
+
cms = @options["cms"].to_s
|
|
2696
|
+
return Http::Response.new(dashboard(base, "", @sign_out || "", Js.truthy?(@options["geoCredit"]), !@web.nil?, @sign_in || "", nil, cms), status: 200, headers: {
|
|
2549
2697
|
"content-type" => "text/html; charset=utf-8",
|
|
2550
2698
|
"cache-control" => "no-store",
|
|
2551
|
-
"content-security-policy" => DASHBOARD_CSP,
|
|
2552
|
-
"x-frame-options" => "DENY",
|
|
2699
|
+
"content-security-policy" => cms != "" ? DASHBOARD_CSP.sub("frame-ancestors 'none'", "frame-ancestors 'self'") : DASHBOARD_CSP,
|
|
2700
|
+
"x-frame-options" => cms != "" ? "SAMEORIGIN" : "DENY",
|
|
2553
2701
|
"referrer-policy" => "same-origin",
|
|
2554
2702
|
})
|
|
2555
2703
|
end
|