cronwatch 0.3.1

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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +268 -0
  4. data/lib/cronwatch/abort_signal.rb +45 -0
  5. data/lib/cronwatch/active_record.rb +11 -0
  6. data/lib/cronwatch/alerts/console.rb +22 -0
  7. data/lib/cronwatch/alerts/custom.rb +26 -0
  8. data/lib/cronwatch/alerts/discord.rb +52 -0
  9. data/lib/cronwatch/alerts/slack.rb +58 -0
  10. data/lib/cronwatch/alerts/webhook.rb +46 -0
  11. data/lib/cronwatch/client.rb +925 -0
  12. data/lib/cronwatch/cron_pattern.rb +277 -0
  13. data/lib/cronwatch/duration.rb +77 -0
  14. data/lib/cronwatch/environment.rb +29 -0
  15. data/lib/cronwatch/evaluate.rb +345 -0
  16. data/lib/cronwatch/flight.rb +42 -0
  17. data/lib/cronwatch/format.rb +89 -0
  18. data/lib/cronwatch/http.rb +52 -0
  19. data/lib/cronwatch/job.rb +144 -0
  20. data/lib/cronwatch/js.rb +188 -0
  21. data/lib/cronwatch/monitored.rb +259 -0
  22. data/lib/cronwatch/output.rb +199 -0
  23. data/lib/cronwatch/rails/active_job.rb +55 -0
  24. data/lib/cronwatch/rails/check_job.rb +32 -0
  25. data/lib/cronwatch/rails/railtie.rb +37 -0
  26. data/lib/cronwatch/rails/tasks.rb +12 -0
  27. data/lib/cronwatch/rails.rb +35 -0
  28. data/lib/cronwatch/schedule.rb +191 -0
  29. data/lib/cronwatch/scheduler.rb +763 -0
  30. data/lib/cronwatch/serialize.rb +51 -0
  31. data/lib/cronwatch/sidekiq.rb +129 -0
  32. data/lib/cronwatch/stats.rb +23 -0
  33. data/lib/cronwatch/stores/active_record.rb +397 -0
  34. data/lib/cronwatch/stores/memory.rb +163 -0
  35. data/lib/cronwatch/ticker.rb +59 -0
  36. data/lib/cronwatch/triage/anthropic.rb +134 -0
  37. data/lib/cronwatch/types.rb +341 -0
  38. data/lib/cronwatch/version.rb +6 -0
  39. data/lib/cronwatch/walker.rb +137 -0
  40. data/lib/cronwatch/web/app.rb +484 -0
  41. data/lib/cronwatch/web/html.rb +314 -0
  42. data/lib/cronwatch/web.rb +17 -0
  43. data/lib/cronwatch/zone.rb +72 -0
  44. data/lib/cronwatch.rb +96 -0
  45. data/lib/generators/cronwatch/install/install_generator.rb +176 -0
  46. metadata +104 -0
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ # Kept in step with @cronwatch/sdk.
5
+ VERSION = "0.3.1"
6
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ class CronPattern
5
+ # Croner's CronDate: a wall-clock time whose fields are moved forward to
6
+ # the next match, a field at a time, spilling into the next month or year
7
+ # as croner does (including its habit of stepping into a day the month
8
+ # does not have and letting the date roll over). Month is 0 based.
9
+ class Walker
10
+ DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
11
+ # [field, the field above it, offset from a field value to its pattern index]
12
+ ORDER = [%i[month year], %i[day month], %i[hour day], %i[minute hour], %i[second minute]].freeze
13
+ OFFSETS = { month: 0, day: -1, hour: 0, minute: 0, second: 0 }.freeze
14
+
15
+ attr_accessor :year, :month, :day, :hour, :minute, :second
16
+
17
+ def initialize(pattern, wall)
18
+ @pattern = pattern
19
+ @year, month, @day, @hour, @minute, @second = wall
20
+ @month = month - 1
21
+ end
22
+
23
+ def apply
24
+ unless month > 11 || month.negative? || day > DAYS_IN_MONTH[month] || day < 1 || hour > 59 || minute > 59 ||
25
+ second > 59 || hour.negative? || minute.negative? || second.negative?
26
+ return false
27
+ end
28
+
29
+ first = Time.utc(year + month.div(12), (month % 12) + 1, 1).to_i
30
+ t = Time.at(first + ((day - 1) * 86_400) + (hour * 3600) + (minute * 60) + second).utc
31
+ @year = t.year
32
+ @month = t.month - 1
33
+ @day = t.day
34
+ @hour = t.hour
35
+ @minute = t.min
36
+ @second = t.sec
37
+ true
38
+ end
39
+
40
+ # Croner's recurse, as a loop. Returns the wall-clock match or nil.
41
+ def search
42
+ level = 0
43
+ loop do
44
+ field, above = ORDER[level]
45
+ found = find_next(field, OFFSETS[field])
46
+ if found > 1
47
+ ((level + 1)...ORDER.length).each { |i| set(ORDER[i][0], -OFFSETS[ORDER[i][0]]) }
48
+ if found == 3
49
+ set(above, get(above) + 1)
50
+ set(field, -OFFSETS[field])
51
+ apply
52
+ level = 0
53
+ next
54
+ elsif apply
55
+ level -= 1
56
+ next
57
+ end
58
+ end
59
+ level += 1
60
+ return [year, month + 1, day, hour, minute, second] if level >= ORDER.length
61
+ return nil if year >= 3000
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def get(field) = instance_variable_get(:"@#{field}")
68
+ def set(field, value) = instance_variable_set(:"@#{field}", value)
69
+
70
+ def table(field)
71
+ case field
72
+ when :month then @pattern.months
73
+ when :day then @pattern.days
74
+ when :hour then @pattern.hours
75
+ when :minute then @pattern.minutes
76
+ else @pattern.seconds
77
+ end
78
+ end
79
+
80
+ # 1: the field already matches, 2: moved forward to a match, 3: none left.
81
+ def find_next(field, offset)
82
+ before = get(field)
83
+ values = table(field)
84
+ last_day = last_day_of_month(year, month) if @pattern.last_day_of_month
85
+ first_weekday = weekday(year, month, 1) if field == :day && !@pattern.star_dow
86
+ index = get(field) + offset
87
+ while index < values.length
88
+ matched = values[index]
89
+ if field == :day
90
+ day_of_month = index - offset
91
+ matched = true if @pattern.last_day_of_month && day_of_month == last_day
92
+ unless @pattern.star_dow
93
+ bits = @pattern.weekdays[(first_weekday + (day_of_month - 1)) % 7]
94
+ weekday_ok = bits.positive? && nth_weekday?(year, month, day_of_month, bits)
95
+ matched =
96
+ if @pattern.and_logic || @pattern.star_dom then matched && weekday_ok
97
+ else matched || weekday_ok
98
+ end
99
+ end
100
+ end
101
+ if matched
102
+ set(field, index - offset)
103
+ return before == get(field) ? 1 : 2
104
+ end
105
+ index += 1
106
+ end
107
+ 3
108
+ end
109
+
110
+ def last_day_of_month(year, month)
111
+ return DAYS_IN_MONTH[month] unless month == 1
112
+
113
+ (year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?) ? 29 : 28
114
+ end
115
+
116
+ # 0 is Sunday. A day past the month's end rolls into the next, as Date.UTC does.
117
+ def weekday(year, month, day)
118
+ days = Time.utc(year + month.div(12), (month % 12) + 1, 1).to_i.div(86_400) + day - 1
119
+ (days + 4) % 7
120
+ end
121
+
122
+ def nth_weekday?(year, month, day, bits)
123
+ return true if bits == ANY_NTH
124
+
125
+ target = weekday(year, month, day)
126
+ count = (1..day).count { |d| weekday(year, month, d) == target }
127
+ return true if (bits & ANY_NTH).positive? && count <= 5 && (NTH[count - 1] & bits).positive?
128
+
129
+ if (bits & LAST_NTH).positive?
130
+ ((day + 1)..last_day_of_month(year, month)).none? { |d| weekday(year, month, d) == target }
131
+ else
132
+ false
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "securerandom"
6
+
7
+ module Cronwatch
8
+ # The dashboard and the small JSON API, as a Rack app: the SDK's routes
9
+ # (routes/index.ts) with the same URLs, JSON, auth, CSRF rules and headers,
10
+ # so @cronwatch/mcp works against a Ruby app as it does against a Node one.
11
+ #
12
+ # # config/routes.rb
13
+ # mount Cronwatch::Web.new => "/cronwatch"
14
+ #
15
+ # # config.ru, standalone
16
+ # run Cronwatch::Web.new(CW)
17
+ #
18
+ # token: required to reach anything. Send it as `Authorization: Bearer <token>`,
19
+ # or open the dashboard once with `?token=<token>` and a cookie is set.
20
+ # Defaults to ENV["CRONWATCH_TOKEN"]; an empty string counts as unset.
21
+ # With no token while Cronwatch::Environment is development or
22
+ # test, the app makes a random one and prints a sign-in link to
23
+ # stdout on its first request; with no token otherwise it answers
24
+ # 503. Pass `token: nil` to opt out and serve it open everywhere,
25
+ # for example behind your own auth. /api/check also accepts the
26
+ # client's cron_secret as a bearer, for a platform cron.
27
+ # base_path: where the app is mounted, so links resolve. Defaults to the
28
+ # mount point (SCRIPT_NAME), which is right under Rails' `mount`.
29
+ class Web
30
+ # Tells "token not given" (read CRONWATCH_TOKEN) from "token: nil" (open on purpose).
31
+ UNSET = Object.new.freeze
32
+ COOKIE = "cronwatch_token"
33
+ DEFAULT_RUNS = 20
34
+ MAX_RUNS = 500
35
+ COOKIE_MAX_AGE = 60 * 60 * 24 * 30
36
+
37
+ CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; " \
38
+ "frame-ancestors 'none'; base-uri 'none'"
39
+ # same-origin rather than no-referrer: under no-referrer browsers send
40
+ # `Origin: null` on form posts, which the CSRF check would refuse, and the
41
+ # forms redirect back to the page named by the same-origin Referer.
42
+ SECURITY_HEADERS = {
43
+ "x-content-type-options" => "nosniff", "referrer-policy" => "same-origin", "x-robots-tag" => "noindex",
44
+ }.freeze
45
+ BEARER = Regexp.new("\\ABearer[#{JS::WHITESPACE}]+", Regexp::IGNORECASE)
46
+ DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
47
+ RADIX = { "x" => 16, "o" => 8, "b" => 2 }.freeze
48
+
49
+ def initialize(client = nil, token: UNSET, base_path: nil)
50
+ @client = client
51
+ @opted_out = token.nil?
52
+ given = token.equal?(UNSET) ? nil : token
53
+ @token = @opted_out ? nil : [given, ENV.fetch("CRONWATCH_TOKEN", nil)].map(&:to_s).find { |t| !t.empty? }
54
+ @base_path = base_path&.to_s&.sub(%r{/+\z}, "")
55
+ # A Rack app cannot reliably tell a local caller from a remote one
56
+ # (proxies, tunnels and a server bound to every interface all look
57
+ # alike), so development gets a token too: made here, and shown only in
58
+ # the server log.
59
+ @generated = @token.nil? && !@opted_out && Client.development?
60
+ @token = Web.development_token if @generated
61
+ @announced = false
62
+ @announce_lock = Mutex.new
63
+ end
64
+
65
+ # A token for one app in development, when none is configured: 32 random
66
+ # bytes, base64url (43 characters).
67
+ def self.development_token
68
+ Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)
69
+ end
70
+
71
+ # The line a development token is announced with, printed once to stdout
72
+ # on the app's first request. `origin` is that request's origin (scheme,
73
+ # host and any port), `base` the base path without a trailing slash (""
74
+ # when mounted at the root).
75
+ def self.development_sign_in_line(origin, base, token)
76
+ "[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. " \
77
+ "Sign in: #{origin}#{base}/?token=#{token}"
78
+ end
79
+
80
+ # The client given, or Cronwatch.client when none was.
81
+ def client
82
+ @client || Cronwatch.client
83
+ end
84
+
85
+ def call(env)
86
+ wants_html = true
87
+ base = @base_path || env["SCRIPT_NAME"].to_s.sub(%r{/+\z}, "")
88
+ begin
89
+ request = Request.new(env)
90
+ path = strip_base(request.pathname, base)
91
+ wants_html = !path.start_with?("/api")
92
+ serve(request, path, wants_html, base)
93
+ rescue StandardError => e
94
+ begin
95
+ client.report(e, "routes")
96
+ rescue StandardError
97
+ # Reporting must not turn a 500 into an exception.
98
+ end
99
+ if wants_html
100
+ html(HTML.message_page("Something went wrong", "The request failed and the error was reported.", base), 500)
101
+ else
102
+ api({ ok: false, error: "Internal error" }, 500)
103
+ end
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def serve(request, path, wants_html, base)
110
+ cw = client
111
+ method = request.verb
112
+
113
+ announce(request, base) if @generated && !@announced
114
+
115
+ # No token outside development: fail closed.
116
+ if !@token && !@opted_out
117
+ return wants_html ? html(HTML.message_page("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token: to Cronwatch::Web.new), or pass token: nil to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503)
118
+ end
119
+
120
+ if method != "GET" && method != "HEAD" && cross_site?(request)
121
+ return wants_html ? html(HTML.message_page("Cross-site request refused", "Changes can only be made from the dashboard itself.", base), 403) : api({ ok: false, error: "Cross-site request refused" }, 403)
122
+ end
123
+
124
+ authorization = request.header("authorization")
125
+ bearer = authorization&.sub(BEARER, "")
126
+ if @token
127
+ # ?token= is only the sign-in that moves the token into a cookie.
128
+ query = wants_html && method == "GET" ? request.query("token") : nil
129
+ cookie = read_cookie(request, COOKIE)
130
+ secret = cw.cron_secret
131
+ cron_secret_ok = path == "/api/check" && !bearer.nil? && !secret.nil? && HTTP.constant_time_equal?(bearer, secret)
132
+ token_ok =
133
+ if !bearer.nil? then HTTP.constant_time_equal?(bearer, @token)
134
+ elsif !query.nil? then HTTP.constant_time_equal?(query, @token)
135
+ else !cookie.nil? && HTTP.constant_time_equal?(cookie, cookie_value(@token))
136
+ end
137
+ unless cron_secret_ok || token_ok
138
+ if @generated
139
+ return wants_html ? html(HTML.message_page("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401)
140
+ end
141
+ return wants_html ? html(HTML.message_page("Sign in", "Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.", base), 401) : api({ ok: false, error: "Unauthorized" }, 401)
142
+ end
143
+ unless query.nil?
144
+ # Move the token from the URL into a cookie so it is not in history or logs.
145
+ secure = request.https? ? "; Secure" : ""
146
+ return redirect(request.pathname + request.search_without("token"),
147
+ "set-cookie" => "#{COOKIE}=#{cookie_value(@token)}; Path=#{base.empty? ? "/" : base}; HttpOnly; SameSite=Lax; Max-Age=#{COOKIE_MAX_AGE}#{secure}")
148
+ end
149
+ end
150
+
151
+ parts = path.split("/").reject(&:empty?).map { |part| safe_decode(part) }
152
+ if parts.include?(nil)
153
+ return wants_html ? html(HTML.message_page("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400)
154
+ end
155
+
156
+ # HTML
157
+ if method == "GET" && path == "/"
158
+ entries = cw.jobs_with_runs(20)
159
+ runs_by_job = entries.to_h { |entry| [entry.job.name, entry.runs] }
160
+ return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, cw.now, base, nil))
161
+ end
162
+ if method == "GET" && parts[0] == "jobs" && parts.length == 2
163
+ job = cw.job_summary(parts[1])
164
+ return html(HTML.message_page("No such job", "#{parts[1]} is not in the store.", base), 404) unless job
165
+
166
+ return html(HTML.job_page(job, cw.runs(job.name, 50), cw.now, base))
167
+ end
168
+ if method == "POST" && path == "/check"
169
+ cw.check
170
+ return redirect_back(request, base)
171
+ end
172
+ if method == "POST" && parts[0] == "jobs" && parts.length == 3
173
+ name = parts[1]
174
+ action = parts[2]
175
+ if action == "forget"
176
+ cw.forget(name)
177
+ return redirect("#{base}/")
178
+ end
179
+ return html(HTML.message_page("Not found", path, base), 404) unless %w[silence unsilence].include?(action)
180
+ return html(HTML.message_page("No such job", "#{name} is not in the store.", base), 404) unless cw.job_summary(name)
181
+
182
+ if action == "silence"
183
+ begin
184
+ duration = silence_duration(read_body(request)["for"])
185
+ rescue ArgumentError => e
186
+ return html(HTML.message_page("Not silenced", e.message, base), 400)
187
+ end
188
+ cw.silence(name, duration)
189
+ else
190
+ cw.unsilence(name)
191
+ end
192
+ return redirect_back(request, base)
193
+ end
194
+
195
+ return serve_api(request, method, parts.drop(1), bearer) if parts[0] == "api"
196
+
197
+ html(HTML.message_page("Not found", path, base), 404)
198
+ end
199
+
200
+ def serve_api(request, method, rest, bearer)
201
+ cw = client
202
+ return api({ ok: true, jobs: cw.jobs }) if method == "GET" && rest[0] == "jobs" && rest.length == 1
203
+
204
+ if rest[0] == "jobs" && rest.length == 2
205
+ name = rest[1]
206
+ if method == "GET"
207
+ job = cw.job_summary(name)
208
+ return api({ ok: false, error: "No such job" }, 404) unless job
209
+
210
+ return api({ ok: true, job: job, runs: cw.runs(name, runs_limit(request.query("runs"))) })
211
+ end
212
+ if method == "DELETE"
213
+ return api({ ok: false, error: "No such job" }, 404) unless cw.job_summary(name)
214
+
215
+ cw.forget(name)
216
+ return api({ ok: true })
217
+ end
218
+ end
219
+ if method == "POST" && rest[0] == "jobs" && rest.length == 3
220
+ name = rest[1]
221
+ return api({ ok: false, error: "No such job" }, 404) unless cw.job_summary(name)
222
+
223
+ if rest[2] == "silence"
224
+ body = read_body(request)
225
+ begin
226
+ duration = silence_duration(body.key?("for") ? body["for"] : request.query("for"))
227
+ rescue ArgumentError => e
228
+ return api({ ok: false, error: e.message }, 400)
229
+ end
230
+ return api({ ok: true, state: cw.silence(name, duration) })
231
+ end
232
+ return api({ ok: true, state: cw.unsilence(name) }) if rest[2] == "unsilence"
233
+ end
234
+ if rest[0] == "check" && rest.length == 1
235
+ # A page cannot send an Authorization header cross-site, so a GET
236
+ # may only run the check when it carries a bearer (token or cron secret).
237
+ if method == "GET" && bearer.nil?
238
+ return api({ ok: false, error: "Use POST, or GET with an Authorization bearer" }, 405, "allow" => "POST")
239
+ end
240
+ return api({ "ok" => true }.merge(cw.check.to_h)) if %w[GET POST].include?(method)
241
+ end
242
+ if method == "GET" && rest[0] == "runs" && rest.length == 2
243
+ run = cw.get_run(rest[1])
244
+ return run ? api({ ok: true, run: run }) : api({ ok: false, error: "No such run" }, 404)
245
+ end
246
+ api({ ok: false, error: "Not found" }, 404)
247
+ end
248
+
249
+ # Prints the development sign-in link, once per app.
250
+ def announce(request, base)
251
+ first = @announce_lock.synchronize do
252
+ next false if @announced
253
+
254
+ @announced = true
255
+ end
256
+ return unless first
257
+
258
+ $stdout.puts(Web.development_sign_in_line(request.origin, base, @token))
259
+ $stdout.flush
260
+ end
261
+
262
+ # The cookie holds a digest of the token, so a leaked cookie does not reveal the bearer token itself.
263
+ def cookie_value(token)
264
+ Digest::SHA256.hexdigest("cronwatch-cookie:#{token}")
265
+ end
266
+
267
+ def strip_base(pathname, base)
268
+ path = pathname.start_with?(base) ? pathname[base.length..] : pathname
269
+ path = "/" if path.empty?
270
+ path = path[0...-1] if path.length > 1 && path.end_with?("/")
271
+ path
272
+ end
273
+
274
+ def read_cookie(request, name)
275
+ header = request.header("cookie")
276
+ return nil if header.nil? || header.empty?
277
+
278
+ header.split(";").each do |part|
279
+ key, *rest = JS.trim(part).split("=", -1)
280
+ # A malformed escape counts as no cookie.
281
+ return safe_decode(rest.join("=")) if key == name
282
+ end
283
+ nil
284
+ end
285
+
286
+ # decodeURIComponent, or nil where it would throw: a bad escape, or bytes that are not UTF-8.
287
+ def safe_decode(value)
288
+ return nil if value.match?(/%(?![0-9A-Fa-f]{2})/)
289
+
290
+ decoded = value.b.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
291
+ decoded.valid_encoding? ? decoded : nil
292
+ end
293
+
294
+ # A browser attaches Origin or Sec-Fetch-Site to a cross-site form post, and
295
+ # a page cannot forge either. Non-browser clients send neither.
296
+ def cross_site?(request)
297
+ origin = request.header("origin")
298
+ return true if !origin.nil? && origin != request.origin
299
+
300
+ site = request.header("sec-fetch-site")
301
+ !site.nil? && site != "same-origin" && site != "none"
302
+ end
303
+
304
+ # The form fields or JSON object of a request, each value as String(value)
305
+ # gives it in JavaScript. The body is read as the SDK's request.json()
306
+ # reads it: bytes that are not UTF-8 become U+FFFD, and a leading byte
307
+ # order mark is dropped.
308
+ def read_body(request)
309
+ type = request.header("content-type") || ""
310
+ if type.include?("application/json")
311
+ data = JSON.parse(Output.utf8(request.body).delete_prefix(""))
312
+ return data.to_h { |k, v| [k.to_s, js_string(v)] } if data.is_a?(Hash)
313
+ return data.each_with_index.to_h { |v, i| [i.to_s, js_string(v)] } if data.is_a?(Array)
314
+ elsif type.include?("application/x-www-form-urlencoded") || type.include?("multipart/form-data")
315
+ return request.form.transform_values { |v| form_value(v) }
316
+ end
317
+ {}
318
+ rescue StandardError
319
+ {}
320
+ end
321
+
322
+ # String(value) for a parsed JSON value.
323
+ def js_string(value)
324
+ case value
325
+ when nil then "null"
326
+ when Array then value.map { |v| v.nil? ? "" : js_string(v) }.join(",")
327
+ when Hash then "[object Object]"
328
+ when Numeric then JS.number(value)
329
+ else value.to_s
330
+ end
331
+ end
332
+
333
+ # String(value) for a form field Rack parsed: a file is "[object File]".
334
+ def form_value(value)
335
+ case value
336
+ when String then Output.utf8(value)
337
+ when Hash then value.key?(:tempfile) ? "[object File]" : "[object Object]"
338
+ when Array then value.map { |v| form_value(v) }.join(",")
339
+ else js_string(value)
340
+ end
341
+ end
342
+
343
+ # Absent means one hour; a number or numeric string is milliseconds. Raises on anything else.
344
+ def silence_duration(value)
345
+ return "1h" if value.nil?
346
+
347
+ text = JS.trim(value.to_s)
348
+ duration = text.match?(/\A\d+(\.\d+)?\z/) ? whole(Float(text)) : text
349
+ Duration.parse(duration, "silence duration")
350
+ duration
351
+ end
352
+
353
+ def runs_limit(value)
354
+ n = value.nil? || JS.trim(value).empty? ? Float::NAN : js_number(value)
355
+ JS.finite?(n) ? n.truncate.clamp(1, MAX_RUNS) : DEFAULT_RUNS
356
+ end
357
+
358
+ # Number(string): decimal, 0x/0o/0b, Infinity, or NaN.
359
+ def js_number(value)
360
+ text = JS.trim(value)
361
+ return 0 if text.empty?
362
+ # Ruby's Float() wants a digit on both sides of the point; JavaScript does not.
363
+ return Float(text.sub(/\A([+-]?)\./, "\\10.").sub(/\.(?=[eE]|\z)/, ".0")) if DECIMAL.match?(text)
364
+ return text.start_with?("-") ? -Float::INFINITY : Float::INFINITY if /\A[+-]?Infinity\z/.match?(text)
365
+
366
+ if (m = /\A0([xXoObB])([0-9a-fA-F]+)\z/.match(text))
367
+ return Integer(m[2], RADIX.fetch(m[1].downcase))
368
+ end
369
+
370
+ Float::NAN
371
+ rescue ArgumentError
372
+ Float::NAN
373
+ end
374
+
375
+ def whole(number)
376
+ number.finite? && number == number.floor ? number.to_i : number
377
+ end
378
+
379
+ def redirect_back(request, base)
380
+ referer = request.header("referer") || ""
381
+ redirect(referer.start_with?("#{request.origin}/") ? referer : "#{base}/")
382
+ end
383
+
384
+ def api(body, status = 200, headers = {})
385
+ text = JS.json(body)
386
+ [status, {
387
+ "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store",
388
+ "content-length" => text.bytesize.to_s, **SECURITY_HEADERS, **headers,
389
+ }, [text]]
390
+ end
391
+
392
+ def redirect(location, headers = {})
393
+ [303, { "location" => location, "cache-control" => "no-store", **SECURITY_HEADERS, **headers }, []]
394
+ end
395
+
396
+ def html(body, status = 200)
397
+ [status, {
398
+ "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
399
+ "content-security-policy" => CSP, "x-frame-options" => "DENY", "content-length" => body.bytesize.to_s,
400
+ **SECURITY_HEADERS,
401
+ }, [body]]
402
+ end
403
+
404
+ # What the routes need from a Rack env, read the way the SDK reads a fetch Request.
405
+ class Request
406
+ attr_reader :env
407
+
408
+ def initialize(env)
409
+ @env = env
410
+ @rack = Rack::Request.new(env)
411
+ end
412
+
413
+ def verb
414
+ @env["REQUEST_METHOD"].to_s.upcase
415
+ end
416
+
417
+ # The path as the browser asked for it, still percent-encoded, mount point included.
418
+ def pathname
419
+ path = "#{@env["SCRIPT_NAME"]}#{@env["PATH_INFO"]}"
420
+ path.empty? ? "/" : path
421
+ end
422
+
423
+ def header(name)
424
+ key = name.tr("-", "_").upcase
425
+ key = "HTTP_#{key}" unless %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
426
+ @env[key]
427
+ end
428
+
429
+ # scheme://host[:port], the page's origin.
430
+ def origin
431
+ @rack.base_url
432
+ end
433
+
434
+ def https?
435
+ @rack.scheme == "https"
436
+ end
437
+
438
+ # URLSearchParams#get: the first value, or nil.
439
+ def query(name)
440
+ pair = Request.parse_query(@env["QUERY_STRING"].to_s).find { |k, _| k == name }
441
+ pair && pair[1]
442
+ end
443
+
444
+ # The query string with a parameter removed, "" when nothing is left, as
445
+ # URL#search reads after searchParams.delete.
446
+ def search_without(name)
447
+ pairs = Request.parse_query(@env["QUERY_STRING"].to_s).reject { |k, _| k == name }
448
+ pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
449
+ end
450
+
451
+ def body
452
+ input = @env["rack.input"]
453
+ return "" unless input
454
+
455
+ input.rewind if input.respond_to?(:rewind)
456
+ text = input.read.to_s
457
+ input.rewind if input.respond_to?(:rewind)
458
+ text.force_encoding(Encoding::UTF_8)
459
+ end
460
+
461
+ # The form's fields, as Rack parses them. Rack keeps what it parsed, so
462
+ # a body Rack::MethodOverride (or anything else) already read, which
463
+ # cannot be read twice, still gives its fields.
464
+ def form
465
+ fields = @rack.POST
466
+ fields.each_with_object({}) { |(k, v), out| out[Output.utf8(k)] = v }
467
+ end
468
+
469
+ # application/x-www-form-urlencoded parsing as URLSearchParams does it:
470
+ # "+" is a space, and a bad escape is kept as written rather than refused.
471
+ def self.parse_query(text)
472
+ text = text.delete_prefix("?")
473
+ text.split("&").reject(&:empty?).map do |pair|
474
+ key, value = pair.include?("=") ? pair.split("=", 2) : [pair, ""]
475
+ [decode(key), decode(value)]
476
+ end
477
+ end
478
+
479
+ def self.decode(text)
480
+ text.tr("+", " ").b.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8).scrub
481
+ end
482
+ end
483
+ end
484
+ end