solid_objects 0.12.1 → 0.13.0

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +58 -11
  4. data/docs/adr/0009-realtime-updates.md +5 -1
  5. data/docs/architecture.md +3 -2
  6. data/docs/authorization.md +11 -4
  7. data/docs/correctness.md +3 -3
  8. data/docs/dashboard.md +200 -0
  9. data/docs/database-schema.md +5 -4
  10. data/docs/realtime.md +22 -14
  11. data/docs/roadmap.md +24 -6
  12. data/docs/security.md +7 -7
  13. data/examples/application/README.md +5 -5
  14. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  15. data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
  16. data/lib/solid_objects/actor.rb +1 -1
  17. data/lib/solid_objects/version.rb +1 -1
  18. data/lib/solid_objects/web/action.rb +109 -0
  19. data/lib/solid_objects/web/application.rb +239 -0
  20. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  21. data/lib/solid_objects/web/helpers.rb +227 -0
  22. data/lib/solid_objects/web/paginator.rb +64 -0
  23. data/lib/solid_objects/web/route.rb +55 -0
  24. data/lib/solid_objects/web/router.rb +46 -0
  25. data/lib/solid_objects/web/statistics.rb +78 -0
  26. data/lib/solid_objects/web.rb +222 -0
  27. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  28. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  29. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  30. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  31. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  32. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  33. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  34. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  35. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  36. data/web/assets/javascripts/application.js +118 -0
  37. data/web/assets/javascripts/charts.js +190 -0
  38. data/web/assets/stylesheets/application.css +527 -0
  39. data/web/views/_messages.erb +29 -0
  40. data/web/views/_navigation.erb +15 -0
  41. data/web/views/_paging.erb +17 -0
  42. data/web/views/_status_filter.erb +8 -0
  43. data/web/views/_summary.erb +39 -0
  44. data/web/views/broadcasts.erb +35 -0
  45. data/web/views/dashboard.erb +128 -0
  46. data/web/views/dead_letter.erb +40 -0
  47. data/web/views/dead_letters.erb +42 -0
  48. data/web/views/effects.erb +35 -0
  49. data/web/views/instance.erb +139 -0
  50. data/web/views/instances.erb +49 -0
  51. data/web/views/layout.erb +22 -0
  52. data/web/views/mailbox.erb +35 -0
  53. data/web/views/message.erb +34 -0
  54. data/web/views/processes.erb +37 -0
  55. data/web/views/reminders.erb +37 -0
  56. metadata +55 -2
@@ -0,0 +1,227 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "json"
4
+ require "rack/utils"
5
+
6
+ module SolidObjects
7
+ class Web
8
+ # The methods a view may call. Everything a template prints goes through
9
+ # `h`, because an actor id, an operation name, and an exception message are
10
+ # all application supplied strings that reach this page unchanged.
11
+ module Helpers
12
+ # Only these survive a page link. A filter an operator set stays set when
13
+ # they turn the page; anything else the query string carries does not
14
+ # come back.
15
+ FORWARDED_PARAMS = %w[actor_type actor_id status per_page].freeze
16
+ TRUNCATION_LIMIT = 2_000
17
+
18
+ # @rbs (untyped) -> String
19
+ def h(text)
20
+ ::Rack::Utils.escape_html(text.to_s)
21
+ end
22
+
23
+ # @rbs () -> String
24
+ def root_path
25
+ env["SCRIPT_NAME"].to_s
26
+ end
27
+
28
+ # @rbs (String) -> String
29
+ def path_to(path)
30
+ "#{root_path}#{path}"
31
+ end
32
+
33
+ # @rbs () -> String
34
+ def current_path
35
+ request.path_info
36
+ end
37
+
38
+ # @rbs (String) -> bool
39
+ def current_tab?(path)
40
+ return current_path == "/" if path == "/"
41
+
42
+ current_path.start_with?(path)
43
+ end
44
+
45
+ # @rbs () -> Hash[String, String]
46
+ def tabs
47
+ Web.tabs
48
+ end
49
+
50
+ # @rbs () -> String?
51
+ def csp_nonce
52
+ env[Web::NONCE_KEY]
53
+ end
54
+
55
+ # @rbs () -> String
56
+ def csrf_tag
57
+ %(<input type="hidden" name="authenticity_token" value="#{h(env[Web::CSRF_TOKEN_KEY])}" />)
58
+ end
59
+
60
+ # @rbs (String) -> String
61
+ def form_to(path)
62
+ %(<form method="post" action="#{h(path_to(path))}">#{csrf_tag})
63
+ end
64
+
65
+ # @rbs (untyped) -> String
66
+ def relative_time(time)
67
+ return "&mdash;" unless time
68
+
69
+ stamp = time.getutc.iso8601
70
+ %(<time datetime="#{stamp}" title="#{stamp}">#{h(stamp)}</time>)
71
+ end
72
+
73
+ # @rbs (untyped) -> String
74
+ def number(value)
75
+ h(value.to_i.to_s.reverse.scan(/\d{1,3}/).join(",").reverse)
76
+ end
77
+
78
+ # @rbs (Numeric?) -> String
79
+ def duration(seconds)
80
+ return "&mdash;" unless seconds
81
+
82
+ return "#{h(format("%.3f", seconds))} s" if seconds < 60
83
+
84
+ h("#{(seconds / 60).floor} min #{(seconds % 60).round} s")
85
+ end
86
+
87
+ # @rbs (untyped, ?Integer) -> String
88
+ def json_block(value)
89
+ return "&mdash;" if value.nil?
90
+
91
+ %(<pre class="payload">#{h(truncate(JSON.pretty_generate(value)))}</pre>)
92
+ rescue JSON::GeneratorError, TypeError
93
+ %(<pre class="payload">#{h(truncate(value.inspect))}</pre>)
94
+ end
95
+
96
+ # @rbs (String, ?Integer) -> String
97
+ def truncate(text, limit = TRUNCATION_LIMIT)
98
+ return text if text.length <= limit
99
+
100
+ "#{text[0, limit]}…"
101
+ end
102
+
103
+ # @rbs (String?) -> String
104
+ def status_label(status)
105
+ %(<span class="status status-#{h(status)}">#{h(status)}</span>)
106
+ end
107
+
108
+ # @rbs (untyped) -> String
109
+ def actor_label(record)
110
+ "#{h(record.actor_type)} / #{h(record.actor_id)}"
111
+ end
112
+
113
+ # @rbs (untyped) -> String
114
+ def instance_link(instance)
115
+ %(<a href="#{h(path_to("/instances/#{instance.id}"))}">#{actor_label(instance)}</a>)
116
+ end
117
+
118
+ # @rbs (?Hash[String, untyped]) -> String
119
+ def query_string(overrides = {})
120
+ merged = FORWARDED_PARAMS
121
+ .to_h { |name| [ name, url_params(name) ] }
122
+ .merge(overrides.transform_keys(&:to_s))
123
+ .reject { |_name, value| value.nil? || value.to_s.empty? }
124
+ return "" if merged.empty?
125
+
126
+ "?#{merged.map { |name, value| "#{::Rack::Utils.escape(name)}=#{::Rack::Utils.escape(value.to_s)}" }.join("&")}"
127
+ end
128
+
129
+ # @rbs (?Hash[String, untyped]) -> String
130
+ def page_link(overrides = {})
131
+ h("#{path_to(current_path)}#{query_string(overrides)}")
132
+ end
133
+
134
+ # @rbs (Instance) -> String
135
+ def lease_state(instance)
136
+ return "paused" if instance.paused_at
137
+ return "idle" unless instance.activation_owner_id
138
+ return "idle" unless instance.activation_expires_at
139
+
140
+ (instance.activation_expires_at > statistics.now) ? "activated" : "expired"
141
+ end
142
+
143
+ # @rbs () -> Statistics
144
+ def statistics
145
+ @statistics ||= Statistics.new
146
+ end
147
+
148
+ # @rbs (untyped) -> Paginator
149
+ def paginate(relation)
150
+ Paginator.new(relation:, page: url_params("page"), per_page: url_params("per_page"))
151
+ end
152
+
153
+ # An unrecognized filter falls back to the default rather than returning
154
+ # nothing, so a hand edited query string cannot make a page look empty.
155
+ # @rbs (Array[String], ?default: String?) -> String?
156
+ def filter_value(allowed, default: nil)
157
+ value = url_params("status")
158
+ allowed.include?(value) ? value : default
159
+ end
160
+
161
+ # @rbs () -> Instance
162
+ def find_instance
163
+ instance = Instance.find_by(id: route_params(:id))
164
+ halt(404) unless instance
165
+
166
+ instance
167
+ end
168
+
169
+ # @rbs () -> untyped
170
+ def filtered_instances
171
+ relation = Instance.order(updated_at: :desc, id: :desc)
172
+ actor_type = url_params("actor_type")
173
+ relation = relation.where(actor_type:) unless actor_type.to_s.empty?
174
+ actor_id = url_params("actor_id")
175
+ return relation if actor_id.to_s.empty?
176
+
177
+ relation.where(
178
+ Instance.arel_table[:actor_id].matches("%#{Instance.sanitize_sql_like(actor_id)}%")
179
+ )
180
+ end
181
+
182
+ # @rbs (String) -> untyped
183
+ def mailbox_messages(membership)
184
+ membership_model = (membership == "claimed") ? ClaimedMessage : ReadyMessage
185
+ Message
186
+ .where(id: membership_model.select(:message_id))
187
+ .order(available_at: :asc, id: :asc)
188
+ end
189
+
190
+ # Chart data travels in an attribute rather than an inline script block,
191
+ # so the page needs no script-src exception and an actor type cannot
192
+ # close the attribute and open a tag.
193
+ #
194
+ # The container is not decoration. Chart.js measures a responsive canvas
195
+ # against its parent, so the parent has to have a height of its own; a
196
+ # panel that sizes to its children would grow a little on every redraw.
197
+ # @rbs (String, untyped) -> String
198
+ def chart(name, values)
199
+ return "" unless Web.charts?
200
+
201
+ %(<div class="chart-frame"><canvas data-chart="#{h(name)}" ) +
202
+ %(data-chart-values='#{h(JSON.generate(values))}'></canvas></div>)
203
+ end
204
+
205
+ # A vendored copy is a path below the mount; a CDN copy is an absolute
206
+ # URL and is left alone.
207
+ # @rbs () -> String
208
+ def chart_library_source
209
+ url = Web.chart_library_url.to_s
210
+ url.include?("//") ? url : path_to(url)
211
+ end
212
+
213
+ # @rbs () -> String
214
+ def chart_library_integrity_attributes
215
+ integrity = Web.chart_library_integrity
216
+ return "" unless integrity
217
+
218
+ %( integrity="#{h(integrity)}" crossorigin="anonymous" referrerpolicy="no-referrer")
219
+ end
220
+
221
+ # @rbs () -> String
222
+ def environment_name
223
+ ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,64 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ # Counts and slices one relation. The page size is clamped because the page
6
+ # number and the page size both arrive from the query string, and an
7
+ # operator page that accepts an unbounded limit is a denial of service
8
+ # against the database the actors run on.
9
+ class Paginator
10
+ DEFAULT_PER_PAGE = 25
11
+ MAXIMUM_PER_PAGE = 200
12
+
13
+ # @rbs @page: Integer
14
+ # @rbs @per_page: Integer
15
+ # @rbs @total: Integer
16
+ # @rbs @records: Array[untyped]
17
+
18
+ attr_reader :page, :per_page, :total, :records
19
+
20
+ # @rbs (relation: untyped, ?page: String?, ?per_page: String?) -> void
21
+ def initialize(relation:, page: nil, per_page: nil)
22
+ @per_page = bounded(per_page, default: DEFAULT_PER_PAGE, maximum: MAXIMUM_PER_PAGE)
23
+ @total = relation.count
24
+ @page = bounded(page, default: 1, maximum: last_page)
25
+ @records = relation.offset((@page - 1) * @per_page).limit(@per_page).to_a
26
+ end
27
+
28
+ # @rbs () -> Integer
29
+ def last_page
30
+ [ (total.to_f / per_page).ceil, 1 ].max
31
+ end
32
+
33
+ # @rbs () -> Integer?
34
+ def previous_page
35
+ (page > 1) ? page - 1 : nil
36
+ end
37
+
38
+ # @rbs () -> Integer?
39
+ def next_page
40
+ (page < last_page) ? page + 1 : nil
41
+ end
42
+
43
+ # @rbs () -> Integer
44
+ def first_record
45
+ total.zero? ? 0 : ((page - 1) * per_page) + 1
46
+ end
47
+
48
+ # @rbs () -> Integer
49
+ def last_record
50
+ [ page * per_page, total ].min
51
+ end
52
+
53
+ private
54
+
55
+ # @rbs (String?, default: Integer, maximum: Integer) -> Integer
56
+ def bounded(value, default:, maximum:)
57
+ requested = Integer(value.to_s, 10, exception: false)
58
+ return default unless requested&.positive?
59
+
60
+ [ requested, maximum ].min
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,55 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ class Route
6
+ # A named segment stops at the next slash, so `/instances/:id` never
7
+ # swallows `/instances/1/pause` and route order cannot hide a page.
8
+ NAMED_SEGMENT = %r{/([^/]*):([^.:$/]+)}
9
+ SEGMENT_CAPTURE = '/\1(?<\2>[^$/]+)'
10
+
11
+ # @rbs @matcher: String | Regexp
12
+ # @rbs @request_method: String
13
+ # @rbs @pattern: String
14
+ # @rbs @policy: Hash[Symbol, String]
15
+ # @rbs @handler: Proc
16
+
17
+ attr_reader :request_method, :pattern, :policy, :handler
18
+
19
+ # @rbs (request_method: String, pattern: String, policy: Hash[Symbol, String], handler: Proc) -> void
20
+ def initialize(request_method:, pattern:, policy:, handler:)
21
+ @request_method = request_method
22
+ @pattern = pattern
23
+ @policy = policy
24
+ @handler = handler
25
+ @matcher = compile(pattern)
26
+ end
27
+
28
+ # @rbs (String) -> bool
29
+ def match?(path)
30
+ return @matcher == path if @matcher.is_a?(String)
31
+
32
+ @matcher.match?(path)
33
+ end
34
+
35
+ # @rbs (String) -> Hash[Symbol, String?]
36
+ def capture(path)
37
+ return {} if @matcher.is_a?(String)
38
+
39
+ match = @matcher.match(path)
40
+ return {} unless match
41
+
42
+ match.named_captures.transform_keys(&:to_sym)
43
+ end
44
+
45
+ private
46
+
47
+ # @rbs (String) -> (String | Regexp)
48
+ def compile(pattern)
49
+ return pattern unless pattern.match?(NAMED_SEGMENT)
50
+
51
+ Regexp.new("\\A#{pattern.gsub(NAMED_SEGMENT, SEGMENT_CAPTURE)}\\z")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,46 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ # Declares the pages of the dashboard. Every route carries the
6
+ # administration policy it needs, and a route declared without one raises
7
+ # at load time. A new page therefore cannot reach the database before an
8
+ # application has said who may read it.
9
+ module Router
10
+ # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
11
+ def head(path, policy: nil, &handler)
12
+ route("HEAD", path, policy:, &handler)
13
+ end
14
+
15
+ # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
16
+ def get(path, policy: nil, &handler)
17
+ route("GET", path, policy:, &handler)
18
+ end
19
+
20
+ # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
21
+ def post(path, policy: nil, &handler)
22
+ route("POST", path, policy:, &handler)
23
+ end
24
+
25
+ # @rbs (String, String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
26
+ def route(request_method, path, policy: nil, &handler)
27
+ raise ArgumentError, "route #{path} requires an authorization policy" unless policy
28
+ raise ArgumentError, "route #{path} requires a handler" unless handler
29
+ raise ArgumentError, "route #{path} requires a policy action" unless policy[:action]
30
+ raise ArgumentError, "route #{path} requires a policy resource" unless policy[:resource]
31
+
32
+ routes[request_method] << Route.new(request_method:, pattern: path, policy:, handler:)
33
+ end
34
+
35
+ # @rbs () -> Hash[String, Array[Route]]
36
+ def routes
37
+ @routes ||= Hash.new { |store, request_method| store[request_method] = [] }
38
+ end
39
+
40
+ # @rbs (String, String) -> Route?
41
+ def match(request_method, path)
42
+ routes[request_method].find { |route| route.match?(path) }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,78 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ # The counts behind the dashboard and behind `GET /stats`. Both read the
6
+ # same object, so the polled JSON and the rendered page can never disagree
7
+ # about what a number means.
8
+ class Statistics
9
+ EFFECT_STATUSES = %w[pending processing completed dead].freeze
10
+ BROADCAST_STATUSES = %w[pending processing delivered dead].freeze
11
+ REMINDER_STATUSES = %w[scheduled paused completed].freeze
12
+ PROCESS_STATES = %w[running draining stopped].freeze
13
+
14
+ # @rbs @now: Time
15
+
16
+ attr_reader :now
17
+
18
+ # @rbs (?now: Time) -> void
19
+ def initialize(now: SolidObjects.database_adapter.database_now)
20
+ @now = now
21
+ end
22
+
23
+ # @rbs () -> Hash[Symbol, untyped]
24
+ def to_h
25
+ {
26
+ instances:,
27
+ mailbox:,
28
+ effects: grouped(Effect, :status, EFFECT_STATUSES),
29
+ broadcasts: grouped(Broadcast, :status, BROADCAST_STATUSES),
30
+ reminders:,
31
+ dead_letters: { total: DeadLetter.count },
32
+ processes: grouped(Process, :shutdown_state, PROCESS_STATES),
33
+ server_time: now.utc.iso8601
34
+ }
35
+ end
36
+
37
+ # @rbs () -> Hash[Symbol, Integer]
38
+ def instances
39
+ {
40
+ total: Instance.count,
41
+ paused: Instance.where.not(paused_at: nil).count,
42
+ activated: Instance.where(activation_expires_at: now..).count
43
+ }
44
+ end
45
+
46
+ # The oldest ready message that is already due is the queue latency of
47
+ # this runtime: how far behind the workers are, in seconds.
48
+ # @rbs () -> Hash[Symbol, untyped]
49
+ def mailbox
50
+ due = ReadyMessage.where(available_at: ..now)
51
+ oldest = due.minimum(:available_at)
52
+ {
53
+ ready: ReadyMessage.count,
54
+ due: due.count,
55
+ claimed: ClaimedMessage.count,
56
+ latency: oldest ? (now - oldest).round(3) : 0.0
57
+ }
58
+ end
59
+
60
+ # @rbs () -> Hash[Symbol, Integer]
61
+ def reminders
62
+ grouped(Reminder, :status, REMINDER_STATUSES).merge(
63
+ due: Reminder.where(status: "scheduled", next_run_at: ..now).count
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ # A status the schema allows but the table does not currently hold still
70
+ # reports zero, so a row of counts keeps the same shape between polls.
71
+ # @rbs (untyped, Symbol, Array[String]) -> Hash[Symbol, Integer]
72
+ def grouped(model, column, statuses)
73
+ counts = model.group(column).count
74
+ statuses.to_h { |status| [ status.to_sym, counts.fetch(status, 0) ] }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,222 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "erb"
4
+ require "json"
5
+ require "securerandom"
6
+ require "uri"
7
+ require "rack"
8
+ require "rack/builder"
9
+ require "rack/static"
10
+
11
+ require "solid_objects"
12
+ require "solid_objects/web/route"
13
+ require "solid_objects/web/router"
14
+ require "solid_objects/web/statistics"
15
+ require "solid_objects/web/paginator"
16
+ require "solid_objects/web/helpers"
17
+ require "solid_objects/web/action"
18
+ require "solid_objects/web/csrf_protection"
19
+ require "solid_objects/web/application"
20
+
21
+ module SolidObjects
22
+ # An operator dashboard for the actor runtime, served as a Rack application:
23
+ #
24
+ # Rails.application.routes.draw do
25
+ # mount SolidObjects::Web => "/solid_objects"
26
+ # end
27
+ #
28
+ # It is deliberately not loaded by `require "solid_objects"`. A worker
29
+ # process must not carry a web stack, and an application that never mounts
30
+ # the dashboard must not pay for it.
31
+ #
32
+ # Every page asks `configuration.authorize_administration` first. That block
33
+ # denies by default, so a mount alone exposes nothing until an application
34
+ # states who may read it.
35
+ class Web
36
+ ROOT = File.expand_path("../../web", __dir__)
37
+ VIEWS = File.join(ROOT, "views")
38
+ ASSETS = File.join(ROOT, "assets")
39
+
40
+ NONCE_KEY = "solid_objects.content_security_policy_nonce"
41
+ CSRF_TOKEN_KEY = "solid_objects.csrf_token"
42
+ NONCE_BYTES = 16
43
+ ASSET_CACHE_SECONDS = 86_400
44
+ TEMPLATE_NAME = /\A_?[a-z][a-z0-9_]*\z/
45
+
46
+ # The dashboard charts need a charting library, and this one is fetched
47
+ # from a public CDN with a subresource integrity hash, so a compromised CDN
48
+ # cannot substitute other code. A deployment with no outbound network
49
+ # access should vendor the file and point `chart_library_url` at it, or set
50
+ # that to nil to render the dashboard without charts.
51
+ CHART_LIBRARY_URL = "https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js"
52
+ CHART_LIBRARY_INTEGRITY = "sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4/DCMd7XLcA6Y"
53
+
54
+ DEFAULT_TABS = {
55
+ "Dashboard" => "/",
56
+ "Instances" => "/instances",
57
+ "Mailbox" => "/mailbox",
58
+ "Reminders" => "/reminders",
59
+ "Effects" => "/effects",
60
+ "Broadcasts" => "/broadcasts",
61
+ "Dead letters" => "/dead_letters",
62
+ "Processes" => "/processes"
63
+ }.freeze
64
+
65
+ LOCK = Mutex.new
66
+
67
+ class << self
68
+ # A path below the mount serves a vendored copy; an absolute URL is
69
+ # fetched from that host and is named in the content security policy.
70
+ # nil renders the dashboard without charts.
71
+ # @rbs @chart_library_url: String?
72
+ # @rbs @chart_library_integrity: String?
73
+ attr_writer :chart_library_url, :chart_library_integrity
74
+
75
+ # @rbs () -> String?
76
+ def chart_library_url
77
+ defined?(@chart_library_url) ? @chart_library_url : CHART_LIBRARY_URL
78
+ end
79
+
80
+ # @rbs () -> String?
81
+ def chart_library_integrity
82
+ defined?(@chart_library_integrity) ? @chart_library_integrity : CHART_LIBRARY_INTEGRITY
83
+ end
84
+
85
+ # @rbs () -> bool
86
+ def charts?
87
+ !chart_library_url.nil?
88
+ end
89
+
90
+ # The origin the content security policy has to allow. A vendored copy
91
+ # served from the mount has none, so the policy stays at 'self'.
92
+ # @rbs () -> String?
93
+ def chart_library_origin
94
+ url = chart_library_url
95
+ return nil unless url&.include?("//")
96
+
97
+ uri = URI.parse(url)
98
+ return nil unless uri.scheme && uri.host
99
+
100
+ "#{uri.scheme}://#{uri.host}"
101
+ rescue URI::InvalidURIError
102
+ nil
103
+ end
104
+
105
+ # @rbs () -> Hash[String, String]
106
+ def tabs
107
+ @tabs ||= DEFAULT_TABS.dup
108
+ end
109
+
110
+ # Searched in order, so an extension directory added first wins over the
111
+ # packaged one and an application can replace a single page.
112
+ # @rbs () -> Array[String]
113
+ def views
114
+ @views ||= [ VIEWS ]
115
+ end
116
+
117
+ # @rbs () -> Array[[Array[untyped], Proc?]]
118
+ def middlewares
119
+ @middlewares ||= []
120
+ end
121
+
122
+ # The built stack is memoized, so a middleware added after the first
123
+ # request would otherwise be dropped without a word.
124
+ # @rbs (*untyped) ?{ () -> untyped } -> void
125
+ def use(*arguments, &block)
126
+ middlewares << [ arguments, block ]
127
+ LOCK.synchronize { @application = nil }
128
+ end
129
+
130
+ # Adds pages to the dashboard. The extension receives the application
131
+ # class and declares its own routes on it, which means its routes carry
132
+ # an authorization policy like every other route.
133
+ #
134
+ # @rbs (untyped, tab: String, path: String, ?views: String?) -> void
135
+ def register(extension, tab:, path:, views: nil)
136
+ if views
137
+ self.views.unshift(views)
138
+ # A template compiled before this call resolved against the old
139
+ # search path, so a replacement view would never be reached.
140
+ LOCK.synchronize { @templates = {} }
141
+ end
142
+ tabs[tab] = path
143
+ extension.registered(Application)
144
+ end
145
+
146
+ # @rbs (Symbol) -> untyped
147
+ def template(name)
148
+ LOCK.synchronize do
149
+ templates[name] ||= ERB.new(File.read(template_path(name)), trim_mode: "-")
150
+ end
151
+ end
152
+
153
+ # @rbs () -> void
154
+ def reset!
155
+ LOCK.synchronize { @templates = {} }
156
+ @tabs = nil
157
+ @views = nil
158
+ @middlewares = nil
159
+ @application = nil
160
+ remove_instance_variable(:@chart_library_url) if defined?(@chart_library_url)
161
+ remove_instance_variable(:@chart_library_integrity) if defined?(@chart_library_integrity)
162
+ end
163
+
164
+ # @rbs (Hash[String, untyped]) -> Array[untyped]
165
+ def call(env)
166
+ application.call(env)
167
+ end
168
+
169
+ # @rbs () -> Web
170
+ def application
171
+ LOCK.synchronize { @application ||= new }
172
+ end
173
+
174
+ private
175
+
176
+ # @rbs () -> Hash[Symbol, untyped]
177
+ def templates
178
+ @templates ||= {}
179
+ end
180
+
181
+ # A template name never comes from a request, and this keeps it that way
182
+ # rather than trusting every future caller to know it.
183
+ # @rbs (Symbol) -> String
184
+ def template_path(name)
185
+ raise ArgumentError, "invalid template name" unless name.to_s.match?(TEMPLATE_NAME)
186
+
187
+ found = views.lazy.map { |directory| File.join(directory, "#{name}.erb") }.find { |path| File.exist?(path) }
188
+ found || raise(ArgumentError, "no template named #{name}")
189
+ end
190
+ end
191
+
192
+ # @rbs (Hash[String, untyped]) -> Array[untyped]
193
+ def call(env)
194
+ env[NONCE_KEY] = SecureRandom.base64(NONCE_BYTES)
195
+ app.call(env)
196
+ end
197
+
198
+ # @rbs () -> untyped
199
+ def app
200
+ @app ||= build
201
+ end
202
+
203
+ private
204
+
205
+ # @rbs () -> untyped
206
+ def build
207
+ assets = ASSETS
208
+ extra = self.class.middlewares
209
+
210
+ ::Rack::Builder.new do
211
+ use ::Rack::Static,
212
+ urls: [ "/stylesheets", "/javascripts" ],
213
+ root: assets,
214
+ cascade: true,
215
+ header_rules: [ [ :all, { "cache-control" => "private, max-age=#{ASSET_CACHE_SECONDS}" } ] ]
216
+ extra.each { |arguments, block| use(*arguments, &block) }
217
+ use CsrfProtection
218
+ run Application.new
219
+ end
220
+ end
221
+ end
222
+ end