cronwatch 0.4.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0e24280879ad52eeeaf138342e4266225aa702893875d07c1ae9e3f7728b1b5
4
- data.tar.gz: e828a6fb7fd737df7100480129d986232f59e2adef2f41eed50a42ef7e71913a
3
+ metadata.gz: ce9304826bfc7f8e79615453ee7b5c25503d3dbd7d8b803c229152b3e3c93844
4
+ data.tar.gz: ef0e084ae833b0e57f2dd997fa500ca43c6dfcddd65730265e166d7c227f00d5
5
5
  SHA512:
6
- metadata.gz: af2ec018d78e9277c24a81392e071a26aa31554c7fb3d60967e96761dc615b6aa0e3a501c97415fdf73b8db57320e9d1ecc2e2840e6e843690705424c18e2d04
7
- data.tar.gz: 2ccf44bdaedd6d130d4f77daabd6030ffdaf690ef484dded57e74cf68c8b2f01f8587acda1bfd54732d9ada59f5a2e7e90ad2f92bc0ec33be0c08f3437779dc6
6
+ metadata.gz: 23c832ba7e7ff1e34ca6930f3b9eb7785f6175db5bfec6ef731495f695a16454ea63e0fb474dd305771f33eed145d3ab41fa762ea40baafb8187254c8b1099d5
7
+ data.tar.gz: bdaac367aac5c069fd6c3c3c38117e40c1afa7f073d5cb95c2c56c0a97efa58a0fabe86b1640905d2d01ec44cef44ae78aaff45d03cc4fe9dd9e381295e44d57
@@ -38,7 +38,7 @@ module Cronwatch
38
38
  end
39
39
 
40
40
  def self.origin(url)
41
- uri = URI(url)
41
+ uri = URI(HTTP.clean_url(url))
42
42
  raise URI::InvalidURIError unless uri.scheme && uri.host
43
43
 
44
44
  port = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
@@ -34,7 +34,7 @@ module Cronwatch
34
34
  # past it while the body is still arriving, returns the answer with an
35
35
  # empty body, as the SDK's channels treat a body they could not read.
36
36
  def post(url, body, headers)
37
- uri = URI(url)
37
+ uri = HTTP.postable(url)
38
38
  deadline = HTTP.monotonic + @timeout
39
39
  request = Net::HTTP::Post.new(uri.request_uri)
40
40
  headers.each { |k, v| request[k] = HTTP.trim_header(v) }
@@ -96,6 +96,30 @@ module Cronwatch
96
96
  [deadline - monotonic, 0.001].max
97
97
  end
98
98
 
99
+ # A URL as fetch reads it: the spaces and control characters around it,
100
+ # and any tab or line break inside it, dropped (a pasted webhook URL often
101
+ # ends in a newline).
102
+ def clean_url(url)
103
+ url.to_s.gsub(/\A[\u0000-\u0020]+|[\u0000-\u0020]+\z/, "").delete("\t\n\r")
104
+ end
105
+
106
+ # The URL parsed, once it is one a channel can post to. Refused without
107
+ # quoting it: URI's own errors quote the whole URL, and a webhook URL's
108
+ # path is its credential.
109
+ def postable(url)
110
+ uri = begin
111
+ URI(clean_url(url))
112
+ rescue URI::Error, ArgumentError
113
+ nil
114
+ end
115
+ scheme = uri&.scheme&.downcase
116
+ unless %w[http https].include?(scheme) && uri.host && !uri.host.empty?
117
+ shown = uri&.scheme && uri.host ? "#{scheme}:" : "this URL"
118
+ raise ArgumentError, "only http and https URLs can be posted to, not #{shown}"
119
+ end
120
+ uri
121
+ end
122
+
99
123
  # A header value without the spaces, tabs and line breaks around it, as fetch sends it.
100
124
  def trim_header(value)
101
125
  value.to_s.gsub(/\A[ \t\r\n]+|[ \t\r\n]+\z/, "")
@@ -93,6 +93,34 @@ module Cronwatch
93
93
  nil
94
94
  end
95
95
 
96
+ # Every fire of a cron strictly after `from` and at or before `to`,
97
+ # ascending, or nil when there are more than `limit`. Walks the fires in
98
+ # batches, as firesBetween in schedule.ts asks croner for them, and drops
99
+ # any that do not move forward (see fire_after).
100
+ def fires_between(parsed, from, to, limit)
101
+ raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
102
+
103
+ out = []
104
+ probe = from
105
+ last = from
106
+ 1000.times do
107
+ batch = next_runs(parsed, [limit + 1 - out.length, 24].min, probe)
108
+ return out if batch.empty?
109
+
110
+ batch.each do |t|
111
+ next if t <= last
112
+ return out if t > to
113
+
114
+ out << t
115
+ last = t
116
+ return nil if out.length > limit
117
+ end
118
+ finish = batch.last
119
+ probe = finish > probe ? finish : probe + 3_600_000
120
+ end
121
+ out
122
+ end
123
+
96
124
  # Up to `count` fires, each found from the one before, as croner's nextRuns.
97
125
  def next_runs(parsed, count, from)
98
126
  runs = []
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.1"
6
6
  end
@@ -47,10 +47,17 @@ module Cronwatch
47
47
  COOKIE = "cronwatch_token"
48
48
  DEFAULT_RUNS = 20
49
49
  MAX_RUNS = 500
50
+ # Runs per job the board reads in one go: the table's sparkline, and most jobs' lanes.
51
+ BOARD_PAGE_RUNS = 20
50
52
  COOKIE_MAX_AGE = 60 * 60 * 24 * 30
51
53
 
52
- CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; " \
53
- "frame-ancestors 'none'; base-uri 'none'"
54
+ # 'self' only for what the app shell needs: app.js (which registers the
55
+ # service worker and nothing else), the manifest, the worker and the icons.
56
+ # No inline script, and the pages work without any.
57
+ CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; " \
58
+ "manifest-src 'self'; worker-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
59
+ # For the SVG icons, should one be opened on its own.
60
+ ASSET_CSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
54
61
  # same-origin rather than no-referrer: under no-referrer browsers send
55
62
  # `Origin: null` on form posts, which the CSRF check would refuse, and the
56
63
  # forms redirect back to the page named by the same-origin Referer.
@@ -136,6 +143,18 @@ module Cronwatch
136
143
 
137
144
  announce(request, base) if @generated && !@announced
138
145
 
146
+ # The app shell: the manifest, icons, service worker, app.js and the
147
+ # offline page. Served to anyone, since a browser fetches some of it
148
+ # without cookies and none of it says anything about the jobs.
149
+ if %w[GET HEAD].include?(method)
150
+ if path == "/offline"
151
+ return html(HTML.message_page("You are offline", "CronWatch shows live data from your app, so it needs a connection.", base), 200, "no-cache")
152
+ end
153
+
154
+ asset = PWA.asset(path, base)
155
+ return shell(asset, base) if asset
156
+ end
157
+
139
158
  # No token outside development: fail closed.
140
159
  if !@token && !@opted_out
141
160
  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)
@@ -160,9 +179,9 @@ module Cronwatch
160
179
  end
161
180
  unless cron_secret_ok || token_ok
162
181
  if @generated
163
- 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)
182
+ 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, sign_in: true), 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)
164
183
  end
165
- 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)
184
+ 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, sign_in: true), 401) : api({ ok: false, error: "Unauthorized" }, 401)
166
185
  end
167
186
  unless query.nil?
168
187
  # Move the token from the URL into a cookie so it is not in history or logs.
@@ -179,15 +198,20 @@ module Cronwatch
179
198
 
180
199
  # HTML
181
200
  if method == "GET" && path == "/"
182
- entries = cw.jobs_with_runs(20)
201
+ entries = cw.jobs_with_runs(BOARD_PAGE_RUNS)
202
+ now = cw.now
183
203
  runs_by_job = entries.to_h { |entry| [entry.job.name, entry.runs] }
184
- return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, cw.now, base, nil))
204
+ return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, now, base, nil, board_lanes(cw, entries, now)))
185
205
  end
186
206
  if method == "GET" && parts[0] == "jobs" && parts.length == 2
187
207
  job = cw.job_summary(parts[1])
188
208
  return html(HTML.message_page("No such job", "#{parts[1]} is not in the store.", base), 404) unless job
189
209
 
190
- return html(HTML.job_page(job, cw.runs(job.name, 50), cw.now, base))
210
+ now = cw.now
211
+ # Enough runs to draw the job's week; the page lists the newest fifty.
212
+ limit = Timeline.week_runs_limit(job, now)
213
+ runs = cw.runs(job.name, limit)
214
+ return html(HTML.job_page(job, runs, now, base, runs.length < limit))
191
215
  end
192
216
  if method == "POST" && path == "/check"
193
217
  cw.check
@@ -270,6 +294,22 @@ module Cronwatch
270
294
  api({ ok: false, error: "Not found" }, 404)
271
295
  end
272
296
 
297
+ # The board's timeline lanes, the first Timeline::BOARD_LANES jobs. The
298
+ # runs already read for the table usually cover the last day; only a job
299
+ # whose twenty newest runs all fall inside it (one that runs more often
300
+ # than every hour or so) is read again, deeper.
301
+ def board_lanes(cw, entries, now)
302
+ from = now - Timeline::BOARD_BEHIND_MS
303
+ entries.first(Timeline::BOARD_LANES).map do |entry|
304
+ runs = entry.runs
305
+ short = runs.length >= BOARD_PAGE_RUNS && runs.last.started_at > from
306
+ next Timeline::LaneInput.new(job: entry.job, runs: runs, complete: true) unless short
307
+
308
+ deeper = cw.runs(entry.job.name, Timeline::BOARD_RUNS)
309
+ Timeline::LaneInput.new(job: entry.job, runs: deeper, complete: deeper.length < Timeline::BOARD_RUNS)
310
+ end
311
+ end
312
+
273
313
  # Prints the development sign-in link, once per app.
274
314
  def announce(request, base)
275
315
  first = @announce_lock.synchronize do
@@ -422,9 +462,19 @@ module Cronwatch
422
462
  [303, { "location" => location, "cache-control" => "no-store", **SECURITY_HEADERS, **headers }, []]
423
463
  end
424
464
 
425
- def html(body, status = 200)
465
+ # An app shell file. The worker may be scoped to the base (it is served
466
+ # from there anyway); the SVGs get a CSP of their own.
467
+ def shell(asset, base)
468
+ headers = { "content-type" => asset.type, "cache-control" => asset.cache, **SECURITY_HEADERS }
469
+ headers["content-security-policy"] = ASSET_CSP if asset.type == "image/svg+xml"
470
+ headers["service-worker-allowed"] = "#{base}/" if asset.worker
471
+ headers["content-length"] = asset.body.bytesize.to_s
472
+ [200, headers, [asset.body]]
473
+ end
474
+
475
+ def html(body, status = 200, cache = "no-store")
426
476
  [status, {
427
- "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
477
+ "content-type" => "text/html; charset=utf-8", "cache-control" => cache,
428
478
  "content-security-policy" => CSP, "x-frame-options" => "DENY", "content-length" => body.bytesize.to_s,
429
479
  **SECURITY_HEADERS,
430
480
  }, [body]]