runlight 0.1.0 → 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 +71 -22
- 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 +90 -19
- data/lib/runlight/accounts/throttle.rb +123 -38
- data/lib/runlight/accounts/web.rb +50 -14
- data/lib/runlight/assistant.rb +40 -8
- data/lib/runlight/connect.rb +2 -3
- data/lib/runlight/core.rb +148 -34
- data/lib/runlight/dates.rb +31 -8
- data/lib/runlight/db/connect.rb +32 -6
- data/lib/runlight/db/db.rb +129 -9
- data/lib/runlight/db/prefixed.rb +92 -0
- data/lib/runlight/geo.rb +4 -3
- data/lib/runlight/http/fetcher.rb +31 -6
- data/lib/runlight/http/request.rb +14 -3
- data/lib/runlight/importers/csv_visits.rb +1 -1
- data/lib/runlight/importers/index.rb +10 -2
- data/lib/runlight/importers/visits.rb +17 -6
- data/lib/runlight/importers/write.rb +21 -6
- data/lib/runlight/links.rb +14 -9
- data/lib/runlight/mail/ses.rb +3 -2
- data/lib/runlight/mail/smtp.rb +2 -0
- data/lib/runlight/mail/smtp_session.rb +22 -1
- data/lib/runlight/mail/transports.rb +19 -2
- data/lib/runlight/mcp.rb +8 -1
- data/lib/runlight/metadata_address_error.rb +13 -0
- data/lib/runlight/oauth.rb +85 -18
- data/lib/runlight/rack_app.rb +64 -10
- data/lib/runlight/redirect_refused_error.rb +14 -0
- data/lib/runlight/routes.rb +306 -144
- data/lib/runlight/safefetch.rb +133 -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
|
@@ -18,8 +18,14 @@ module Runlight
|
|
|
18
18
|
# - cronSecret: also accepted as a bearer token on POST /api/check. Defaults to CRON_SECRET.
|
|
19
19
|
# - observeKey: lets another site report AI agent fetches to POST /api/observe. Defaults to RUNLIGHT_OBSERVE_KEY.
|
|
20
20
|
# - signOut, signIn: links the dashboard shows. The standalone server sets them.
|
|
21
|
-
# - accounts: true for sign-in accounts, or a Runlight::Accounts::Web of your own.
|
|
21
|
+
# - accounts: true for sign-in accounts, or a Runlight::Accounts::Web of your own. Sessions are signed with the
|
|
22
|
+
# Core's secret (RUNLIGHT_SECRET, else RUNLIGHT_TOKEN). With the token left nil and no secret, only
|
|
23
|
+
# development makes one up (a restart signs everyone out); anywhere else that raises ArgumentError, since
|
|
24
|
+
# each process of an app would make its own and sign the others' sessions out.
|
|
22
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.
|
|
23
29
|
# - origin: the address people open the app at, such as https://example.com.
|
|
24
30
|
# - ownHosts: a callable returning more names the dashboard is reached at.
|
|
25
31
|
# - accountOf: a callable taking the Request and returning the account it comes from, or nil (internal, for the
|
|
@@ -44,6 +50,14 @@ module Runlight
|
|
|
44
50
|
PICK_TARGET_PLACEHOLDER = '"__RUNLIGHT_PICK_TARGET__"'
|
|
45
51
|
# Where the hostnames of the site its ticket names go, as JSON inside a string.
|
|
46
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/
|
|
47
61
|
# How long a picker ticket works: long enough to find the element, not to be kept.
|
|
48
62
|
PICK_TICKET_MS = 30 * 60_000
|
|
49
63
|
# Questions one person may put to the assistant in an hour, and at once.
|
|
@@ -59,10 +73,14 @@ module Runlight
|
|
|
59
73
|
PATH_DIMENSIONS = %w[page entry exit ai_page].freeze
|
|
60
74
|
# runlight.ts's LINK_DOMAIN_CHECK: the path on every link domain that answers when the domain reaches this Runlight.
|
|
61
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
|
|
62
79
|
# runlight.ts's RETENTION_MONTHS: the choices for how long a site keeps its visits.
|
|
63
80
|
RETENTION_MONTHS = [6, 12, 24, 36, 60].freeze
|
|
64
|
-
private_constant :RULES_PLACEHOLDER, :PICK_TARGET_PLACEHOLDER, :PICK_HOSTS_PLACEHOLDER, :
|
|
65
|
-
:
|
|
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
|
|
66
84
|
|
|
67
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'"
|
|
68
86
|
|
|
@@ -73,9 +91,7 @@ module Runlight
|
|
|
73
91
|
EMAIL = /\A[^#{Js::SPACE}@<>"]+@[^#{Js::SPACE}@<>"]+\.[^#{Js::SPACE}@<>"]+\z/
|
|
74
92
|
# A dashboard's origin, which a picker ticket names.
|
|
75
93
|
ORIGIN = %r{\Ahttps?://[^/?\##{Js::SPACE}]+\z}
|
|
76
|
-
|
|
77
|
-
HOME = %r{\Ahttps?://[^#{Js::SPACE}]+\z}
|
|
78
|
-
private_constant :EMAIL, :ORIGIN, :HOME
|
|
94
|
+
private_constant :EMAIL, :ORIGIN
|
|
79
95
|
|
|
80
96
|
# The files in assets/, read once per process.
|
|
81
97
|
@assets = {}
|
|
@@ -118,11 +134,17 @@ module Runlight
|
|
|
118
134
|
|
|
119
135
|
# Accounts: the standalone server passes its own, and an app turns them on with true. Sessions need a secret
|
|
120
136
|
# that outlives the process; in development without one, a made-up one does, so a restart signs everyone out.
|
|
121
|
-
# An app left open on purpose (token: nil) is treated like development
|
|
137
|
+
# An app left open on purpose (token: nil) is treated like development for the first account, but outside
|
|
138
|
+
# development it still needs a real secret: TypeScript runs as one process, where a made-up one only lasts
|
|
139
|
+
# until a restart, but a Ruby app usually runs several, and each would sign the others' sessions out.
|
|
122
140
|
token = @token
|
|
123
141
|
open_setup = token.nil? || (token == "" && development?)
|
|
124
|
-
account_secret = runlight.secret || (open_setup ? Hashing.random_id(32) : nil)
|
|
125
142
|
accounts = options["accounts"]
|
|
143
|
+
if accounts == true && token.nil? && runlight.secret.to_s.empty? && !development?
|
|
144
|
+
raise ArgumentError, "Runlight: accounts need a secret every process shares; set RUNLIGHT_SECRET (or pass secret:) to a long random string"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
account_secret = runlight.secret || (open_setup ? Hashing.random_id(32) : nil)
|
|
126
148
|
@web = if accounts.is_a?(Accounts::Web)
|
|
127
149
|
accounts
|
|
128
150
|
elsif accounts == true && !account_secret.nil? && account_secret != ""
|
|
@@ -236,17 +258,22 @@ module Runlight
|
|
|
236
258
|
Js.scrub(value).match?(EMAIL)
|
|
237
259
|
end
|
|
238
260
|
|
|
239
|
-
# A plain page in a visitor's language, for unsubscribing
|
|
240
|
-
|
|
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 = "")
|
|
241
264
|
Http::Response.new(
|
|
242
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" \
|
|
243
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>' \
|
|
244
|
-
"#{body}</main></body></html>",
|
|
267
|
+
"#{body}</main>#{script == "" ? "" : "<script src=\"#{escape_html(script)}\"></script>"}</body></html>",
|
|
245
268
|
status: status,
|
|
246
269
|
headers: {
|
|
247
270
|
"content-type" => "text/html; charset=utf-8",
|
|
248
271
|
"cache-control" => "no-store",
|
|
249
|
-
"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,
|
|
250
277
|
"referrer-policy" => "no-referrer",
|
|
251
278
|
},
|
|
252
279
|
)
|
|
@@ -366,7 +393,7 @@ module Runlight
|
|
|
366
393
|
# The dashboard's page, which holds no data: the API it calls checks access. `embed` is the dashboard inside a
|
|
367
394
|
# CMS's admin pages: { "session", "origin" }, its session (empty once its ticket was used or ran out) and the
|
|
368
395
|
# admin origin that frames it.
|
|
369
|
-
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 = "")
|
|
370
397
|
b = escape_attr(base)
|
|
371
398
|
hash = build_hash("dashboardHash")
|
|
372
399
|
attributes = (share != "" ? " data-share=\"#{escape_attr(share)}\"" : "") +
|
|
@@ -374,6 +401,7 @@ module Runlight
|
|
|
374
401
|
(sign_in != "" ? " data-sign-in=\"#{escape_attr(sign_in)}\"" : "") +
|
|
375
402
|
(geo_credit ? ' data-geo-credit=""' : "") +
|
|
376
403
|
(accounts ? ' data-accounts=""' : "") +
|
|
404
|
+
(cms != "" ? " data-cms=\"#{escape_attr(cms)}\"" : "") +
|
|
377
405
|
(embed.nil? ? "" : " data-embed=\"#{escape_attr(embed["session"])}\" data-embed-origin=\"#{escape_attr(embed["origin"])}\"")
|
|
378
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" \
|
|
379
407
|
"<link rel=\"icon\" href=\"#{Brand.runlight_icon}\">\n" \
|
|
@@ -406,12 +434,10 @@ module Runlight
|
|
|
406
434
|
Js.scrub(value).match?(ORIGIN)
|
|
407
435
|
end
|
|
408
436
|
|
|
409
|
-
#
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
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)
|
|
415
441
|
end
|
|
416
442
|
|
|
417
443
|
# `String(body[key] ?? fallback)`.
|
|
@@ -504,7 +530,7 @@ module Runlight
|
|
|
504
530
|
extend Helpers
|
|
505
531
|
include Helpers
|
|
506
532
|
|
|
507
|
-
PUBLIC_HELPERS = %i[coded json json? host_name cookie_value read_cookie bearer normalise_base dashboard manage_path].freeze
|
|
533
|
+
PUBLIC_HELPERS = %i[coded json json? host_name cookie_value read_cookie bearer normalise_base dashboard manage_path oauth_document?].freeze
|
|
508
534
|
private_constant :PUBLIC_HELPERS
|
|
509
535
|
private(*Helpers.instance_methods(false))
|
|
510
536
|
private_class_method(*(Helpers.instance_methods(false) - PUBLIC_HELPERS))
|
|
@@ -518,6 +544,8 @@ module Runlight
|
|
|
518
544
|
if base != "" && oauth_document?(url.pathname)
|
|
519
545
|
begin
|
|
520
546
|
return OAuth.oauth_response(@oauth, request, url.pathname, url, context) || coded("Not found", "not_found", 404)
|
|
547
|
+
rescue TablesBusyError
|
|
548
|
+
return tables_busy
|
|
521
549
|
rescue StandardError => e
|
|
522
550
|
warn("Runlight: #{e.full_message(highlight: false)}")
|
|
523
551
|
return coded("Internal error", "internal", 500)
|
|
@@ -530,13 +558,16 @@ module Runlight
|
|
|
530
558
|
|
|
531
559
|
begin
|
|
532
560
|
route(request, path, url, context)
|
|
561
|
+
rescue TablesBusyError
|
|
562
|
+
tables_busy
|
|
533
563
|
rescue StandardError => e
|
|
534
564
|
warn("Runlight: #{e.full_message(highlight: false)}")
|
|
535
565
|
coded("Internal error", "internal", 500)
|
|
536
566
|
end
|
|
537
567
|
end
|
|
538
568
|
|
|
539
|
-
# The routes as a Rack app. The answer to a HEAD has no body.
|
|
569
|
+
# The routes as a Rack app. The answer to a HEAD has no body. Once the answer is sent, the work left for
|
|
570
|
+
# later (a retention change's deletions) runs in the Core's idle, as RackApp runs it.
|
|
540
571
|
def call(env)
|
|
541
572
|
begin
|
|
542
573
|
request = Http::Request.from_rack(env)
|
|
@@ -545,11 +576,21 @@ module Runlight
|
|
|
545
576
|
[Json.encode({ "error" => "That request is too large" })]]
|
|
546
577
|
end
|
|
547
578
|
status, headers, body = handle(request).to_rack
|
|
548
|
-
|
|
579
|
+
idle = lambda do
|
|
580
|
+
@rl.idle
|
|
581
|
+
rescue StandardError => e
|
|
582
|
+
warn("Runlight: #{e.message}")
|
|
583
|
+
end
|
|
584
|
+
[status, headers, RackApp::Body.new(request.method == "HEAD" ? [] : body, idle)]
|
|
549
585
|
end
|
|
550
586
|
|
|
551
587
|
private
|
|
552
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
|
+
|
|
553
594
|
def sites
|
|
554
595
|
@rl.sites
|
|
555
596
|
end
|
|
@@ -672,7 +713,7 @@ module Runlight
|
|
|
672
713
|
|
|
673
714
|
# Answers a read for a site counted by another install by asking that install,
|
|
674
715
|
# with its token and its own id for the site, and handing back what it says.
|
|
675
|
-
def pass_through(remote, path, url, request = nil)
|
|
716
|
+
def pass_through(remote, path, url, request = nil, max_bytes = nil)
|
|
676
717
|
target = Http::Url.new("#{remote["url"]}#{path}")
|
|
677
718
|
query = target.search_params
|
|
678
719
|
url.search_params.each { |key, value| query.append(key.to_s, value) }
|
|
@@ -694,6 +735,8 @@ module Runlight
|
|
|
694
735
|
"timeoutMs" => write ? 30_000 : 120_000,
|
|
695
736
|
}
|
|
696
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?
|
|
697
740
|
answer = Safefetch.owner_fetch(target.href, init, @rl.fetcher, @rl.local_installs)
|
|
698
741
|
rescue StandardError => e
|
|
699
742
|
if e.is_a?(Http::FetchError) && e.timed_out?
|
|
@@ -764,6 +807,44 @@ module Runlight
|
|
|
764
807
|
Http::Response.new(answer.text, status: answer.status, headers: back)
|
|
765
808
|
end
|
|
766
809
|
|
|
810
|
+
# The checks a link domain passes before a site gets it, from Settings or from an import: the name as it
|
|
811
|
+
# is kept, or the refusal to send.
|
|
812
|
+
def check_link_domain(request, url, site, value)
|
|
813
|
+
domain = Js.lower(Js.trim(value))
|
|
814
|
+
domain = domain.sub(%r{\Ahttps?://}, "")
|
|
815
|
+
domain = domain.sub(%r{/.*\z}, "")
|
|
816
|
+
domain = domain.sub(/\.+\z/, "")
|
|
817
|
+
domain = domain.sub(/\Awww\./, "")
|
|
818
|
+
return coded("That is not a domain name", "domain_invalid", 400) unless domain.match?(DOMAIN_NAME)
|
|
819
|
+
|
|
820
|
+
if private_name?(domain) || Safefetch.resolves_privately?(domain)
|
|
821
|
+
return coded("#{domain} is not a public domain name. Use one that browsers anywhere can reach.", "domain_not_public", 400, { "domain" => domain })
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# A link domain answers every path on it, so it must never be where the dashboard or a counted site lives.
|
|
825
|
+
# The request's own Host is the caller's to choose, so the configured address and the names people
|
|
826
|
+
# signed in from count too. A hub cannot know every name this app answers on, so it adds none until
|
|
827
|
+
# the app knows its own address.
|
|
828
|
+
return origin_needed if @managed.key?(request) && @origin.nil?
|
|
829
|
+
|
|
830
|
+
here = [request.headers.get("host"), request.headers.get("x-forwarded-host"), url.host].select { |h| Js.truthy?(h) }
|
|
831
|
+
own = [*(@origin.nil? ? [] : [Http::Url.new(@origin).host]), *here]
|
|
832
|
+
@options["ownHosts"]&.call&.each { |host| own << host.to_s }
|
|
833
|
+
taken = own.map { |h| host_name(h) }
|
|
834
|
+
sites.each do |s|
|
|
835
|
+
taken.concat(s["hostnames"])
|
|
836
|
+
taken.concat(@rl.remote(s["id"])&.[]("hostnames") || [])
|
|
837
|
+
end
|
|
838
|
+
if taken.include?(domain)
|
|
839
|
+
return coded("#{domain} is where this dashboard or one of your sites lives. Use a separate domain or subdomain for short links, such as go.#{domain}.", "domain_in_use", 400, { "domain" => domain })
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
owner = store.link_domains.find { |d| d["domain"] == domain }
|
|
843
|
+
return coded("#{domain} already belongs to another site", "domain_taken", 409, { "domain" => domain }) if !owner.nil? && owner["site"] != site
|
|
844
|
+
|
|
845
|
+
domain
|
|
846
|
+
end
|
|
847
|
+
|
|
767
848
|
def links_api(request, path, url)
|
|
768
849
|
@rl.init
|
|
769
850
|
site = query_site(url)
|
|
@@ -778,49 +859,17 @@ module Runlight
|
|
|
778
859
|
body = read_json(request)
|
|
779
860
|
return body if body.is_a?(Http::Response)
|
|
780
861
|
|
|
781
|
-
domain =
|
|
782
|
-
domain
|
|
783
|
-
domain = domain.sub(%r{/.*\z}, "")
|
|
784
|
-
domain = domain.sub(/\.+\z/, "")
|
|
785
|
-
domain = domain.sub(/\Awww\./, "")
|
|
786
|
-
return coded("That is not a domain name", "domain_invalid", 400) unless domain.match?(DOMAIN_NAME)
|
|
787
|
-
|
|
788
|
-
if private_name?(domain) || Safefetch.resolves_privately?(domain)
|
|
789
|
-
return coded("#{domain} is not a public domain name. Use one that browsers anywhere can reach.", "domain_not_public", 400, { "domain" => domain })
|
|
790
|
-
end
|
|
791
|
-
|
|
792
|
-
# A link domain answers every path on it, so it must never be where the dashboard or a counted site lives.
|
|
793
|
-
# The request's own Host is the caller's to choose, so the configured address and the names people
|
|
794
|
-
# signed in from count too. A hub cannot know every name this app answers on, so it adds none until
|
|
795
|
-
# the app knows its own address.
|
|
796
|
-
return origin_needed if @managed.key?(request) && @origin.nil?
|
|
797
|
-
|
|
798
|
-
here = [request.headers.get("host"), request.headers.get("x-forwarded-host"), url.host].select { |h| Js.truthy?(h) }
|
|
799
|
-
own = [*(@origin.nil? ? [] : [Http::Url.new(@origin).host]), *here]
|
|
800
|
-
@options["ownHosts"]&.call&.each { |host| own << host.to_s }
|
|
801
|
-
taken = own.map { |h| host_name(h) }
|
|
802
|
-
sites.each do |s|
|
|
803
|
-
taken.concat(s["hostnames"])
|
|
804
|
-
taken.concat(@rl.remote(s["id"])&.[]("hostnames") || [])
|
|
805
|
-
end
|
|
806
|
-
if taken.include?(domain)
|
|
807
|
-
return coded("#{domain} is where this dashboard or one of your sites lives. Use a separate domain or subdomain for short links, such as go.#{domain}.", "domain_in_use", 400, { "domain" => domain })
|
|
808
|
-
end
|
|
862
|
+
domain = check_link_domain(request, url, site["id"], text(body, "domain"))
|
|
863
|
+
return domain if domain.is_a?(Http::Response)
|
|
809
864
|
|
|
810
|
-
store.link_domains.each do |d|
|
|
811
|
-
next unless d["domain"] == domain
|
|
812
|
-
return coded("#{domain} already belongs to another site", "domain_taken", 409, { "domain" => domain }) if d["site"] != site["id"]
|
|
813
|
-
|
|
814
|
-
break
|
|
815
|
-
end
|
|
816
865
|
store.add_link_domain(domain, site["id"], @rl.now)
|
|
817
866
|
@rl.forget_link_domains
|
|
818
867
|
return json({ "domain" => domain }, 201)
|
|
819
868
|
end
|
|
820
869
|
end
|
|
821
870
|
if (check_match = path.match(%r{\A/api/link-domains/([^/]+)/check\z})) && request.method == "GET"
|
|
822
|
-
domain =
|
|
823
|
-
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)
|
|
824
873
|
|
|
825
874
|
# One added before names inside private networks were refused is never fetched.
|
|
826
875
|
# What the check found, as a code the dashboard says in its own words, beside the English reason.
|
|
@@ -841,9 +890,11 @@ module Runlight
|
|
|
841
890
|
begin
|
|
842
891
|
# Only a public address is fetched, whatever the name resolves to now, so the check cannot be pointed
|
|
843
892
|
# into a private network.
|
|
844
|
-
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)
|
|
845
896
|
parsed, body = begin
|
|
846
|
-
Js.parse_json(answer
|
|
897
|
+
Js.parse_json(Body.read_text_capped(answer, LINK_DOMAIN_MAX_BYTES))
|
|
847
898
|
rescue StandardError
|
|
848
899
|
[false, nil]
|
|
849
900
|
end
|
|
@@ -860,8 +911,8 @@ module Runlight
|
|
|
860
911
|
end
|
|
861
912
|
|
|
862
913
|
if (domain_match = path.match(%r{\A/api/link-domains/([^/]+)\z})) && request.method == "DELETE"
|
|
863
|
-
domain =
|
|
864
|
-
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)
|
|
865
916
|
|
|
866
917
|
store.remove_link_domain(domain)
|
|
867
918
|
@rl.forget_link_domains
|
|
@@ -903,6 +954,11 @@ module Runlight
|
|
|
903
954
|
credentials(Js.get(body, "credentials")),
|
|
904
955
|
cursor.is_a?(String) ? cursor : nil,
|
|
905
956
|
done.is_a?(Float) && done.nan? ? 0 : done,
|
|
957
|
+
# An imported link's domain passes the same checks as one added in Settings, or the link comes over without it.
|
|
958
|
+
lambda do |domain|
|
|
959
|
+
checked = check_link_domain(request, url, site["id"], domain)
|
|
960
|
+
checked.is_a?(Http::Response) ? nil : checked
|
|
961
|
+
end,
|
|
906
962
|
)
|
|
907
963
|
return json(step)
|
|
908
964
|
rescue Importers::ImportError => e
|
|
@@ -976,40 +1032,41 @@ module Runlight
|
|
|
976
1032
|
# The key picker tickets are signed with, made on first use and kept in the database for every process.
|
|
977
1033
|
def pick_key
|
|
978
1034
|
@rl.init
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
made = Hashing.random_id(32)
|
|
983
|
-
store.set_setting("pick-key", made)
|
|
984
|
-
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) }
|
|
985
1037
|
end
|
|
986
1038
|
|
|
987
|
-
# A ticket that lets the picker, on `site`'s pages, send its choice to `origin`, the dashboard that asked, for half
|
|
988
|
-
|
|
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)
|
|
989
1043
|
payload = "#{@rl.now + PICK_TICKET_MS}.#{site.unpack1("H*")}.#{origin.unpack1("H*")}"
|
|
1044
|
+
payload += ".#{back["url"].unpack1("H*")}.#{back["nonce"]}" unless back.nil?
|
|
990
1045
|
"#{payload}.#{Hashing.hmac(pick_key, payload)}"
|
|
991
1046
|
end
|
|
992
1047
|
|
|
993
|
-
#
|
|
1048
|
+
# What a picker ticket names, or nil when it is not one this install signed or has run out.
|
|
994
1049
|
def pick_target(ticket)
|
|
995
|
-
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/)
|
|
996
1051
|
return nil if parts.nil? || Js.number(parts[1]) < @rl.now
|
|
997
|
-
return nil unless constant_time_equal(parts[
|
|
1052
|
+
return nil unless constant_time_equal(parts[6], Hashing.hmac(pick_key, ticket[0, ticket.rindex(".")]))
|
|
998
1053
|
|
|
999
1054
|
unhex = ->(text) { Js.scrub([text[0, text.length - (text.length % 2)]].pack("H*")) }
|
|
1000
1055
|
origin = unhex.call(parts[3])
|
|
1001
|
-
|
|
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] } }
|
|
1002
1063
|
end
|
|
1003
1064
|
|
|
1004
1065
|
# The key embed tickets and sessions are signed with, made on first use and kept in the database for every process.
|
|
1005
1066
|
def embed_key
|
|
1006
1067
|
@rl.init
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
made = Hashing.random_id(32)
|
|
1011
|
-
store.set_setting("embed-key", made)
|
|
1012
|
-
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) }
|
|
1013
1070
|
end
|
|
1014
1071
|
|
|
1015
1072
|
# An embed token that still exists, for a site that still does.
|
|
@@ -1096,10 +1153,10 @@ module Runlight
|
|
|
1096
1153
|
return site if site.is_a?(Http::Response)
|
|
1097
1154
|
|
|
1098
1155
|
existing = store.goals(site["id"])
|
|
1099
|
-
id = path == "/api/goals" ? nil :
|
|
1156
|
+
id = path == "/api/goals" ? nil : decode_part(path["/api/goals/".length..])
|
|
1100
1157
|
before = nil
|
|
1101
1158
|
existing.each { |g| before = g if g["id"] == id }
|
|
1102
|
-
return coded("Unknown goal", "unknown_goal", 404) if
|
|
1159
|
+
return coded("Unknown goal", "unknown_goal", 404) if path != "/api/goals" && (id.nil? || before.nil?)
|
|
1103
1160
|
|
|
1104
1161
|
@trackers = {}
|
|
1105
1162
|
if request.method == "DELETE"
|
|
@@ -1125,7 +1182,7 @@ module Runlight
|
|
|
1125
1182
|
if request.method == "GET"
|
|
1126
1183
|
settings = @rl.mail_settings
|
|
1127
1184
|
known = settings || {}
|
|
1128
|
-
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"] }
|
|
1129
1186
|
# Secret fields come back only as "saved", never as their value.
|
|
1130
1187
|
fields = {}
|
|
1131
1188
|
saved = []
|
|
@@ -1139,16 +1196,23 @@ module Runlight
|
|
|
1139
1196
|
end
|
|
1140
1197
|
# A hub with a manage token learns which service sends the reports and from where, nothing more.
|
|
1141
1198
|
via_manage = @managed.key?(request)
|
|
1142
|
-
|
|
1199
|
+
answer = {
|
|
1143
1200
|
"source" => known["source"],
|
|
1144
|
-
"service" => known["service"] || "",
|
|
1201
|
+
"service" => known["source"] == "app" ? "" : known["service"] || "",
|
|
1145
1202
|
"from" => known["from"] || "",
|
|
1146
1203
|
"fromName" => known["fromName"] || "",
|
|
1147
1204
|
"fields" => via_manage ? {} : fields,
|
|
1148
1205
|
"saved" => via_manage ? [] : saved,
|
|
1149
1206
|
"encrypted" => !@rl.secret.nil?,
|
|
1150
|
-
"services" =>
|
|
1151
|
-
}
|
|
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)
|
|
1152
1216
|
end
|
|
1153
1217
|
if request.method == "PUT"
|
|
1154
1218
|
body = read_json(request)
|
|
@@ -1175,7 +1239,11 @@ module Runlight
|
|
|
1175
1239
|
return coded("Set up a mail service first", "mail_unset", 400) if settings.nil?
|
|
1176
1240
|
|
|
1177
1241
|
t = Messages.translator(text(body, "lang", "en"))["t"]
|
|
1178
|
-
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
|
|
1179
1247
|
@rl.send_mail({
|
|
1180
1248
|
"to" => to,
|
|
1181
1249
|
"subject" => t.call("email.test.subject"),
|
|
@@ -1208,13 +1276,13 @@ module Runlight
|
|
|
1208
1276
|
end
|
|
1209
1277
|
return coded("A site can send to at most 50 addresses", "report_limit", 400) if existing.length >= 50
|
|
1210
1278
|
|
|
1211
|
-
# Links in the email point back to the configured address, or else to this dashboard
|
|
1212
|
-
#
|
|
1213
|
-
#
|
|
1279
|
+
# Links in the email point back to the configured address, or else to this dashboard at the address
|
|
1280
|
+
# the request came to. An address in the body is never used, so whoever adds a report cannot point
|
|
1281
|
+
# its links somewhere else. A report made from a hub needs the configured address, where its
|
|
1282
|
+
# unsubscribe link answers, since the Host its request names is the hub's to choose.
|
|
1214
1283
|
return origin_needed if @managed.key?(request) && @origin.nil?
|
|
1215
1284
|
|
|
1216
|
-
|
|
1217
|
-
home = Js.scrub(given).match?(HOME) ? given.sub(%r{/+\z}, "") : "#{@origin || url.origin}#{@base}"
|
|
1285
|
+
home = "#{@origin || url.origin}#{@base}"
|
|
1218
1286
|
# A period already due counts as sent, so a report added mid-week first goes out on the next Monday, as the form says.
|
|
1219
1287
|
now = @rl.now
|
|
1220
1288
|
due = Reports.last_period(frequency, now, site["timezone"])
|
|
@@ -1308,8 +1376,8 @@ module Runlight
|
|
|
1308
1376
|
return coded("Method not allowed", "method_not_allowed", 405)
|
|
1309
1377
|
end
|
|
1310
1378
|
|
|
1311
|
-
id =
|
|
1312
|
-
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
|
|
1313
1381
|
return coded("Unknown share", "unknown_share", 404) if share.nil? || share["site"] != site["id"]
|
|
1314
1382
|
|
|
1315
1383
|
if request.method == "PATCH"
|
|
@@ -1338,39 +1406,72 @@ module Runlight
|
|
|
1338
1406
|
# As in TypeScript, the hour's questions are counted in this process; the day's, in the settings.
|
|
1339
1407
|
def ask_turn(who, owner)
|
|
1340
1408
|
now = @rl.now
|
|
1409
|
+
# Taken in one step, before anything slow, so questions sent at once each see the others.
|
|
1341
1410
|
mine = @lock.synchronize do
|
|
1342
|
-
held = @asked[who]
|
|
1411
|
+
held = (@asked[who] ||= { "at" => [], "open" => 0 })
|
|
1343
1412
|
held["at"] = held["at"].select { |at| now - at < 3_600_000 }
|
|
1344
|
-
held
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
|
1348
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?
|
|
1349
1422
|
|
|
1423
|
+
release = -> { @lock.synchronize { mine["open"] -= 1 } }
|
|
1350
1424
|
unless owner
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
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?
|
|
1357
1438
|
|
|
1358
|
-
|
|
1359
|
-
store.set_setting(day, Json.encode(counts))
|
|
1360
|
-
store.settings_starting_with("assistant-asked:").each do |entry|
|
|
1361
|
-
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) })
|
|
1362
1440
|
end
|
|
1363
1441
|
end
|
|
1442
|
+
# People who stopped asking are dropped, so the map holds only the last hour's.
|
|
1364
1443
|
@lock.synchronize do
|
|
1365
|
-
mine["at"] << now
|
|
1366
|
-
mine["open"] += 1
|
|
1367
|
-
@asked[who] = mine
|
|
1368
|
-
# People who stopped asking are dropped, so the map holds only the last hour's.
|
|
1369
1444
|
if @asked.size > 1000
|
|
1370
1445
|
@asked.delete_if { |_key, value| value["open"].zero? && value["at"].none? { |at| now - at < 3_600_000 } }
|
|
1371
1446
|
end
|
|
1372
1447
|
end
|
|
1373
|
-
|
|
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
|
|
1374
1475
|
end
|
|
1375
1476
|
|
|
1376
1477
|
def tokens_api(request, path)
|
|
@@ -1394,7 +1495,7 @@ module Runlight
|
|
|
1394
1495
|
asked_scope = Js.get(body, "scope")
|
|
1395
1496
|
scope = %w[manage embed].include?(asked_scope) ? asked_scope : "read"
|
|
1396
1497
|
return coded("A token that changes settings is for one site. Pick the site.", "token_site", 400) if scope == "manage" && site == ""
|
|
1397
|
-
return coded("A
|
|
1498
|
+
return coded("A dashboard key is for one site. Pick the site.", "embed_site", 400) if scope == "embed" && site == ""
|
|
1398
1499
|
|
|
1399
1500
|
secret = "#{TOKEN_PREFIX}#{Hashing.random_id(20)}"
|
|
1400
1501
|
row = { "id" => Hashing.random_id, "name" => name, "site" => site, "scope" => scope, "hash" => Hashing.sha256(secret),
|
|
@@ -1508,7 +1609,7 @@ module Runlight
|
|
|
1508
1609
|
if path == "/api/embed" && method == "POST"
|
|
1509
1610
|
token = api_token(request)
|
|
1510
1611
|
return denied(false) if token.nil?
|
|
1511
|
-
return coded("Use
|
|
1612
|
+
return coded("Use the site's dashboard key, made in Settings, Install", "embed_token", 403) if token["scope"] != "embed"
|
|
1512
1613
|
|
|
1513
1614
|
site = token["site"] != "" ? rl.site(token["site"]) : nil
|
|
1514
1615
|
return coded("Unknown site", "unknown_site", 404) if site.nil?
|
|
@@ -1520,7 +1621,15 @@ module Runlight
|
|
|
1520
1621
|
parsed = origin?(origin) && Js.length(origin) <= 200 ? Http::Url.parse(origin) : nil
|
|
1521
1622
|
return coded("Send the admin page's origin, such as https://example.com", "embed_origin", 400) if parsed.nil? || parsed.origin != origin
|
|
1522
1623
|
|
|
1523
|
-
|
|
1624
|
+
# Only https and the default port, so a plain http page or another service on the same name cannot frame
|
|
1625
|
+
# the dashboard; a page on this computer, for development, may use http and any port. The name must be
|
|
1626
|
+
# one of the site's own, which are kept without www, so www. is the only difference allowed.
|
|
1627
|
+
local = ["localhost", "127.0.0.1", "[::1]"].include?(parsed.hostname)
|
|
1628
|
+
if !local && (parsed.protocol != "https:" || parsed.port != "")
|
|
1629
|
+
return coded("Send the admin page's origin, such as https://example.com", "embed_origin", 400)
|
|
1630
|
+
end
|
|
1631
|
+
|
|
1632
|
+
host = parsed.hostname.sub(/\Awww\./, "")
|
|
1524
1633
|
domains = ((rl.remote(site["id"]) || {})["hostnames"] || site["hostnames"] || []).map { |h| host_name(h) }
|
|
1525
1634
|
unless domains.include?(host)
|
|
1526
1635
|
return coded("#{host} is not one of this site's domains. Add it to the site's domains in Runlight's settings.", "embed_host", 400, { "host" => host })
|
|
@@ -1536,7 +1645,7 @@ module Runlight
|
|
|
1536
1645
|
if !token.nil? && token["scope"] == "manage" && manage_path(method, path)
|
|
1537
1646
|
asked = url.search_params.get("site")
|
|
1538
1647
|
site_match = path.match(%r{\A/api/sites/([^/]+)\z})
|
|
1539
|
-
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"])
|
|
1540
1649
|
return coded("Unknown site", "unknown_site", 404)
|
|
1541
1650
|
end
|
|
1542
1651
|
|
|
@@ -1621,13 +1730,13 @@ module Runlight
|
|
|
1621
1730
|
return site if site.is_a?(Http::Response)
|
|
1622
1731
|
|
|
1623
1732
|
begin
|
|
1624
|
-
return json(Importers::Visits.import_csv_visits(rl, site["id"], plain(Js.get(body, "rows"))))
|
|
1733
|
+
return json(Importers::Visits.import_csv_visits(rl, site["id"], plain(Js.get(body, "rows")), Js.get(body, "secret")))
|
|
1625
1734
|
rescue Importers::ImportError => e
|
|
1626
1735
|
return refused(e, "import_failed")
|
|
1627
1736
|
end
|
|
1628
1737
|
end
|
|
1629
1738
|
|
|
1630
|
-
# 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.
|
|
1631
1740
|
if (path == "/api/observe-key" && method == "GET") || (path == "/api/observe-key/new" && method == "POST")
|
|
1632
1741
|
access = can_read(request)
|
|
1633
1742
|
return denied(access) if access != true
|
|
@@ -1636,13 +1745,7 @@ module Runlight
|
|
|
1636
1745
|
site = query_site(url)
|
|
1637
1746
|
return site if site.is_a?(Http::Response)
|
|
1638
1747
|
|
|
1639
|
-
|
|
1640
|
-
key = path.end_with?("/new") ? nil : store.setting(name)
|
|
1641
|
-
if key.nil? || key == ""
|
|
1642
|
-
key = "rlo_#{Hashing.random_id(20)}"
|
|
1643
|
-
store.set_setting(name, key)
|
|
1644
|
-
end
|
|
1645
|
-
return json({ "key" => key })
|
|
1748
|
+
return json({ "key" => OAuth.agents_key(rl, site["id"], path.end_with?("/new")) })
|
|
1646
1749
|
end
|
|
1647
1750
|
|
|
1648
1751
|
# Making, changing, and deleting funnels; reading them is with the other reports.
|
|
@@ -1826,13 +1929,25 @@ module Runlight
|
|
|
1826
1929
|
origin = text(body, "origin")
|
|
1827
1930
|
return coded("Send the dashboard's origin, such as https://stats.example.com", "pick_origin", 400) unless origin?(origin)
|
|
1828
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
|
|
1829
1944
|
# A hub's ticket only ever sends to the hub it connected from, never to an origin it names now.
|
|
1830
1945
|
hub = @managed[request]
|
|
1831
1946
|
if !hub.nil? && store.setting("token-origin:#{hub["id"]}") != origin
|
|
1832
1947
|
return coded("This hub's address is not the one it connected from. Connect the site again from here.", "pick_hub", 403)
|
|
1833
1948
|
end
|
|
1834
1949
|
|
|
1835
|
-
return json({ "ticket" => pick_ticket(origin, site["id"]) })
|
|
1950
|
+
return json({ "ticket" => pick_ticket(origin, site["id"], back) })
|
|
1836
1951
|
end
|
|
1837
1952
|
|
|
1838
1953
|
if (path == "/api/goals" && method == "POST") || (path.match?(%r{\A/api/goals/[^/]+\z}) && %w[PATCH DELETE].include?(method))
|
|
@@ -1869,8 +1984,11 @@ module Runlight
|
|
|
1869
1984
|
access = can_read(request)
|
|
1870
1985
|
return denied(access) if access != true
|
|
1871
1986
|
|
|
1987
|
+
id = decode_part(site_match[1])
|
|
1988
|
+
return coded("Unknown site", "unknown_site", 404) if id.nil?
|
|
1989
|
+
|
|
1872
1990
|
begin
|
|
1873
|
-
rl.delete_site(
|
|
1991
|
+
rl.delete_site(id)
|
|
1874
1992
|
return json({ "ok" => true })
|
|
1875
1993
|
rescue RangeError => e
|
|
1876
1994
|
return e.message == "Unknown site" ? coded(e.message, "unknown_site", 404) : refused(e, "site_invalid")
|
|
@@ -2036,8 +2154,10 @@ module Runlight
|
|
|
2036
2154
|
months = RETENTION_MONTHS.join(", ")
|
|
2037
2155
|
return coded("Keep visits for #{months} months, or forever", "retention_bad", 400, { "months" => months })
|
|
2038
2156
|
end
|
|
2157
|
+
id = decode_part(raw_id)
|
|
2158
|
+
return coded("Unknown site", "unknown_site", 404) if id.nil?
|
|
2159
|
+
|
|
2039
2160
|
begin
|
|
2040
|
-
id = decode(raw_id)
|
|
2041
2161
|
remote = rl.remote(id)
|
|
2042
2162
|
# How long a connected site keeps visits, and the timezone its days follow, are the install's
|
|
2043
2163
|
# settings: this server passes them on, and changes its own row only once the install took them.
|
|
@@ -2058,14 +2178,14 @@ module Runlight
|
|
|
2058
2178
|
return answer unless answer.ok?
|
|
2059
2179
|
|
|
2060
2180
|
rl.forget_remote_info(id)
|
|
2061
|
-
elsif remote.nil? && !retention.equal?(UNDEFINED)
|
|
2062
|
-
rl.set_retention(id, retention.nil? ? nil : Js.number(retention))
|
|
2063
2181
|
end
|
|
2182
|
+
# update_site checks every field, hostnames too, before it changes anything.
|
|
2064
2183
|
patch = {}
|
|
2184
|
+
patch["retentionMonths"] = retention.nil? ? nil : Js.number(retention) if remote.nil? && !retention.equal?(UNDEFINED)
|
|
2065
2185
|
patch["name"] = Js.string(Js.get(body, "name")) if given?(body, "name")
|
|
2066
2186
|
patch["timezone"] = Js.string(Js.get(body, "timezone")) if given?(body, "timezone")
|
|
2067
2187
|
patch["hostnames"] = plain(Js.get(body, "hostnames")) if given?(body, "hostnames") && rl.managed_sites
|
|
2068
|
-
site = rl.update_site(
|
|
2188
|
+
site = rl.update_site(id, patch).dup
|
|
2069
2189
|
# A connected site answers as the list shows it, so the dashboard keeps its install and domains.
|
|
2070
2190
|
unless remote.nil?
|
|
2071
2191
|
site["remote"] = remote["url"]
|
|
@@ -2153,7 +2273,24 @@ module Runlight
|
|
|
2153
2273
|
return coded("Unknown site", "unknown_site", 404) if site.nil? || (!only.nil? && site["id"] != only)
|
|
2154
2274
|
|
|
2155
2275
|
remote = rl.remote(site["id"])
|
|
2156
|
-
|
|
2276
|
+
# A share link or an embedded dashboard sees how many are here now and what they read, but not the feed of
|
|
2277
|
+
# each visit, with its time and city, since anyone with the link could watch one person's way through the site.
|
|
2278
|
+
unfeed = ->(live) { shared.nil? ? live : live.merge("recent" => []) }
|
|
2279
|
+
unless remote.nil?
|
|
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?
|
|
2283
|
+
|
|
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
|
|
2289
|
+
return json(unfeed.call(live)) if parsed && live.is_a?(Hash)
|
|
2290
|
+
|
|
2291
|
+
host = Http::Url.new(remote["url"].to_s).host
|
|
2292
|
+
return coded("Could not reach #{host}", "unreachable", 502, { "host" => host })
|
|
2293
|
+
end
|
|
2157
2294
|
|
|
2158
2295
|
if path == "/api/icon"
|
|
2159
2296
|
host = site["hostnames"][0]
|
|
@@ -2170,7 +2307,7 @@ module Runlight
|
|
|
2170
2307
|
})
|
|
2171
2308
|
end
|
|
2172
2309
|
|
|
2173
|
-
return json(store.realtime(site["id"], rl.now)) if path == "/api/realtime"
|
|
2310
|
+
return json(unfeed.call(store.realtime(site["id"], rl.now))) if path == "/api/realtime"
|
|
2174
2311
|
|
|
2175
2312
|
read = read_query(url, site)
|
|
2176
2313
|
return read if read.is_a?(Http::Response)
|
|
@@ -2284,10 +2421,12 @@ module Runlight
|
|
|
2284
2421
|
|
|
2285
2422
|
keys = store.event_prop_keys(query, event)
|
|
2286
2423
|
asked = params.get("key")
|
|
2287
|
-
# A property name goes into a JSON path on SQLite, so quotes and backslashes are refused
|
|
2288
|
-
|
|
2424
|
+
# A property name goes into a JSON path on SQLite, so quotes and backslashes are refused, and without
|
|
2425
|
+
# one asked for, the most used name that has none is shown.
|
|
2426
|
+
safe = ->(name) { name.match?(/\A[^"\\]{1,64}\z/) }
|
|
2427
|
+
return coded("Bad property name", "property_bad", 400) if !asked.nil? && !safe.call(asked)
|
|
2289
2428
|
|
|
2290
|
-
key = asked || keys.
|
|
2429
|
+
key = asked || keys.find { |k| safe.call(k["key"].to_s) }&.[]("key")
|
|
2291
2430
|
count = limit(params.get("limit"), 100)
|
|
2292
2431
|
rows = !key.nil? && key != "" ? store.event_prop_values(query, event, key, whole(count)) : []
|
|
2293
2432
|
return json({ "site" => site["id"], "range" => range_out, "event" => event, "keys" => keys, "key" => key, "rows" => rows })
|
|
@@ -2373,11 +2512,32 @@ module Runlight
|
|
|
2373
2512
|
target = pick_target(url.search_params.get("runlight_ticket") || "")
|
|
2374
2513
|
rl.init unless target.nil?
|
|
2375
2514
|
hosts = target.nil? ? [] : rl.site(target["site"])&.[]("hostnames")
|
|
2376
|
-
|
|
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"]))
|
|
2377
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"]))
|
|
2378
2521
|
return Http::Response.new(script, status: 200, headers: { "content-type" => "application/javascript; charset=utf-8", "cache-control" => "no-store" })
|
|
2379
2522
|
end
|
|
2380
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
|
+
|
|
2381
2541
|
if path == "/assets/world.#{build_hash("worldHash")}.json" && method == "GET"
|
|
2382
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" })
|
|
2383
2543
|
end
|
|
@@ -2499,11 +2659,13 @@ module Runlight
|
|
|
2499
2659
|
end
|
|
2500
2660
|
# The page itself holds no data; the API it calls checks access and
|
|
2501
2661
|
# the page explains how to sign in when it is refused.
|
|
2502
|
-
|
|
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: {
|
|
2503
2665
|
"content-type" => "text/html; charset=utf-8",
|
|
2504
2666
|
"cache-control" => "no-store",
|
|
2505
|
-
"content-security-policy" => DASHBOARD_CSP,
|
|
2506
|
-
"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",
|
|
2507
2669
|
"referrer-policy" => "same-origin",
|
|
2508
2670
|
})
|
|
2509
2671
|
end
|