solid_objects 0.12.0 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +72 -6
  4. data/app/models/solid_objects/broadcast.rb +8 -0
  5. data/docs/adr/0009-realtime-updates.md +5 -1
  6. data/docs/architecture.md +4 -2
  7. data/docs/authorization.md +11 -4
  8. data/docs/correctness.md +3 -3
  9. data/docs/dashboard.md +200 -0
  10. data/docs/database-schema.md +5 -4
  11. data/docs/development.md +12 -0
  12. data/docs/realtime.md +37 -7
  13. data/docs/roadmap.md +28 -6
  14. data/docs/security.md +8 -0
  15. data/examples/application/README.md +5 -5
  16. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  17. data/examples/application/app/actors/shopping_cart_actor.rb +5 -5
  18. data/lib/solid_objects/actor.rb +6 -5
  19. data/lib/solid_objects/actor_channel.rb +7 -4
  20. data/lib/solid_objects/actor_definition.rb +15 -3
  21. data/lib/solid_objects/actor_view.rb +6 -1
  22. data/lib/solid_objects/effect_executor.rb +10 -2
  23. data/lib/solid_objects/errors.rb +3 -0
  24. data/lib/solid_objects/executor.rb +7 -1
  25. data/lib/solid_objects/reminder_scheduler.rb +28 -14
  26. data/lib/solid_objects/test_helper.rb +16 -0
  27. data/lib/solid_objects/turbo_stream_renderer.rb +3 -3
  28. data/lib/solid_objects/version.rb +1 -1
  29. data/lib/solid_objects/web/action.rb +109 -0
  30. data/lib/solid_objects/web/application.rb +239 -0
  31. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  32. data/lib/solid_objects/web/helpers.rb +227 -0
  33. data/lib/solid_objects/web/paginator.rb +64 -0
  34. data/lib/solid_objects/web/route.rb +55 -0
  35. data/lib/solid_objects/web/router.rb +46 -0
  36. data/lib/solid_objects/web/statistics.rb +78 -0
  37. data/lib/solid_objects/web.rb +222 -0
  38. data/sig/generated/lib/solid_objects/actor.rbs +2 -2
  39. data/sig/generated/lib/solid_objects/actor_definition.rbs +9 -2
  40. data/sig/generated/lib/solid_objects/errors.rbs +3 -0
  41. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
  42. data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
  43. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  44. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  45. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  46. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  47. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  48. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  49. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  50. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  51. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  52. data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
  53. data/web/assets/javascripts/application.js +118 -0
  54. data/web/assets/javascripts/charts.js +190 -0
  55. data/web/assets/stylesheets/application.css +527 -0
  56. data/web/views/_messages.erb +29 -0
  57. data/web/views/_navigation.erb +15 -0
  58. data/web/views/_paging.erb +17 -0
  59. data/web/views/_status_filter.erb +8 -0
  60. data/web/views/_summary.erb +39 -0
  61. data/web/views/broadcasts.erb +35 -0
  62. data/web/views/dashboard.erb +128 -0
  63. data/web/views/dead_letter.erb +40 -0
  64. data/web/views/dead_letters.erb +42 -0
  65. data/web/views/effects.erb +35 -0
  66. data/web/views/instance.erb +139 -0
  67. data/web/views/instances.erb +49 -0
  68. data/web/views/layout.erb +22 -0
  69. data/web/views/mailbox.erb +35 -0
  70. data/web/views/message.erb +34 -0
  71. data/web/views/processes.erb +37 -0
  72. data/web/views/reminders.erb +37 -0
  73. metadata +55 -2
@@ -0,0 +1,239 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ # The pages. Each route states the administration policy it needs, and
6
+ # `call` asks that policy before the handler runs, so a page cannot read
7
+ # the actor tables on behalf of an unauthorized request.
8
+ class Application
9
+ extend Router
10
+
11
+ SCRIPT_PLACEHOLDER = "!script-src!"
12
+ CONTENT_SECURITY_POLICY = [
13
+ "default-src 'self'",
14
+ "base-uri 'self'",
15
+ "form-action 'self'",
16
+ "frame-ancestors 'none'",
17
+ "img-src 'self' data:",
18
+ "style-src 'self'",
19
+ "script-src #{SCRIPT_PLACEHOLDER}",
20
+ "connect-src 'self'",
21
+ "object-src 'none'"
22
+ ].join("; ").freeze
23
+
24
+ MAILBOX_MEMBERSHIPS = %w[ready claimed].freeze
25
+ RECENT_LIMIT = 10
26
+ CHART_TYPE_LIMIT = 12
27
+
28
+ head "/", policy: { action: "index", resource: "dashboard" } do
29
+ # The cheapest liveness check available: it proves the dashboard can
30
+ # reach the database the actors run on, and returns no body.
31
+ ReadyMessage.count
32
+ ""
33
+ end
34
+
35
+ get "/", policy: { action: "index", resource: "dashboard" } do
36
+ @statistics = statistics.to_h
37
+ @processes = Process.order(last_heartbeat_at: :desc).limit(RECENT_LIMIT)
38
+ @dead_letters = DeadLetter.order(last_failed_at: :desc, id: :desc).limit(RECENT_LIMIT)
39
+ # The only chart that costs its own query, and the only one the poller
40
+ # cannot refresh, because the other two read what `/stats` already
41
+ # returns. Bounded so a runtime with many actor types draws a readable
42
+ # chart rather than every type it has ever seen.
43
+ @instances_by_type = Instance
44
+ .group(:actor_type)
45
+ .order(Arel.sql("COUNT(*) DESC"))
46
+ .limit(CHART_TYPE_LIMIT)
47
+ .count
48
+ erb(:dashboard)
49
+ end
50
+
51
+ get "/stats", policy: { action: "index", resource: "dashboard" } do
52
+ json(statistics.to_h)
53
+ end
54
+
55
+ get "/instances", policy: { action: "index", resource: "instances" } do
56
+ @paginator = paginate(filtered_instances)
57
+ # Suggestions come from the registry rather than a DISTINCT over the
58
+ # instances table, which no adapter can answer from an index. The field
59
+ # stays free text, so an actor type that is no longer registered is
60
+ # still reachable.
61
+ @actor_types = SolidObjects.registry.to_h.keys.sort
62
+ erb(:instances)
63
+ end
64
+
65
+ get "/instances/:id", policy: { action: "show", resource: "instances" } do
66
+ @instance = find_instance
67
+ @ready_messages = @instance.messages
68
+ .where(id: ReadyMessage.select(:message_id))
69
+ .order(sequence: :asc)
70
+ .limit(RECENT_LIMIT)
71
+ @claimed_messages = @instance.messages
72
+ .where(id: ClaimedMessage.select(:message_id))
73
+ .order(sequence: :asc)
74
+ .limit(RECENT_LIMIT)
75
+ @recent_messages = @instance.messages.order(sequence: :desc).limit(RECENT_LIMIT)
76
+ @reminders = Reminder.where(instance_id: @instance.id).order(next_run_at: :asc).limit(RECENT_LIMIT)
77
+ @effects = Effect.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT)
78
+ @broadcasts = Broadcast.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT)
79
+ @dead_letters = DeadLetter.where(instance_id: @instance.id).order(last_failed_at: :desc).limit(RECENT_LIMIT)
80
+ erb(:instance)
81
+ end
82
+
83
+ # Pausing stops the activation manager from claiming the instance again.
84
+ # A pass already in flight finishes its turn, and a synchronous caller
85
+ # waiting on this instance times out rather than being answered, so this
86
+ # is an operator brake and not a delivery guarantee.
87
+ post "/instances/:id/pause", policy: { action: "pause", resource: "instances" } do
88
+ instance = find_instance
89
+ instance.update!(paused_at: SolidObjects.database_adapter.database_now)
90
+ redirect("/instances/#{instance.id}")
91
+ end
92
+
93
+ post "/instances/:id/resume", policy: { action: "resume", resource: "instances" } do
94
+ instance = find_instance
95
+ instance.update!(paused_at: nil)
96
+ redirect("/instances/#{instance.id}")
97
+ end
98
+
99
+ get "/mailbox", policy: { action: "index", resource: "messages" } do
100
+ @membership = filter_value(MAILBOX_MEMBERSHIPS, default: "ready")
101
+ @paginator = paginate(mailbox_messages(@membership))
102
+ erb(:mailbox)
103
+ end
104
+
105
+ get "/messages/:id", policy: { action: "show", resource: "messages" } do
106
+ @message = Message.find_by(id: route_params(:id))
107
+ halt(404) unless @message
108
+ erb(:message)
109
+ end
110
+
111
+ get "/reminders", policy: { action: "index", resource: "reminders" } do
112
+ @status = filter_value(Statistics::REMINDER_STATUSES)
113
+ relation = Reminder.order(next_run_at: :asc, id: :asc)
114
+ @paginator = paginate(@status ? relation.where(status: @status) : relation)
115
+ erb(:reminders)
116
+ end
117
+
118
+ get "/effects", policy: { action: "index", resource: "effects" } do
119
+ @status = filter_value(Statistics::EFFECT_STATUSES)
120
+ relation = Effect.order(id: :desc)
121
+ @paginator = paginate(@status ? relation.where(status: @status) : relation)
122
+ erb(:effects)
123
+ end
124
+
125
+ get "/broadcasts", policy: { action: "index", resource: "broadcasts" } do
126
+ @status = filter_value(Statistics::BROADCAST_STATUSES)
127
+ relation = Broadcast.order(id: :desc)
128
+ @paginator = paginate(@status ? relation.where(status: @status) : relation)
129
+ erb(:broadcasts)
130
+ end
131
+
132
+ get "/dead_letters", policy: { action: "index", resource: "dead_letters" } do
133
+ @paginator = paginate(DeadLetter.order(last_failed_at: :desc, id: :desc))
134
+ erb(:dead_letters)
135
+ end
136
+
137
+ get "/dead_letters/:id", policy: { action: "show", resource: "dead_letters" } do
138
+ @dead_letter = DeadLetter.find_by(id: route_params(:id))
139
+ halt(404) unless @dead_letter
140
+ erb(:dead_letter)
141
+ end
142
+
143
+ # A retry re-enters the mailbox, which refuses work the runtime cannot
144
+ # accept: an actor class that no longer exists, a full mailbox, a payload
145
+ # over the cap. The operator who pressed the button is told which,
146
+ # instead of being handed a bare 500 from the Rack handler.
147
+ post "/dead_letters/:id/retry", policy: { action: "retry", resource: "dead_letters" } do
148
+ SolidObjects.dead_letters.retry(route_params(:id).to_i, authorization_context: self)
149
+ redirect("/dead_letters")
150
+ rescue Unauthorized
151
+ raise
152
+ rescue SolidObjects::Error => error
153
+ @dead_letter = DeadLetter.find_by(id: route_params(:id))
154
+ halt(404) unless @dead_letter
155
+ @error = "#{error.class.name.split("::").last}: #{error.message}"
156
+ status(422)
157
+ erb(:dead_letter)
158
+ end
159
+
160
+ get "/processes", policy: { action: "index", resource: "processes" } do
161
+ @status = filter_value(Statistics::PROCESS_STATES)
162
+ relation = Process.order(last_heartbeat_at: :desc)
163
+ @paginator = paginate(@status ? relation.where(shutdown_state: @status) : relation)
164
+ # Counted in one grouped query rather than once per row, because this
165
+ # page is read while the runtime is already under load.
166
+ @activated_counts = Instance
167
+ .where(activation_owner_id: @paginator.records.map(&:id))
168
+ .group(:activation_owner_id)
169
+ .count
170
+ erb(:processes)
171
+ end
172
+
173
+ # @rbs (Hash[String, untyped]) -> Array[untyped]
174
+ def call(env)
175
+ route = self.class.match(env["REQUEST_METHOD"].to_s, env["PATH_INFO"].to_s)
176
+ return not_found unless route
177
+
178
+ action = Action.new(env:, route:)
179
+ return forbidden unless authorized?(action)
180
+
181
+ respond(action, catch(:halt) { action.call })
182
+ rescue Unauthorized
183
+ forbidden
184
+ end
185
+
186
+ private
187
+
188
+ # @rbs (Action, untyped) -> Array[untyped]
189
+ def respond(action, result)
190
+ return result if result.is_a?(Array)
191
+
192
+ [ action.response_status, page_headers(action.env), [ result.to_s ] ]
193
+ end
194
+
195
+ # @rbs (Action) -> bool
196
+ def authorized?(action)
197
+ policy = action.route.policy
198
+ SolidObjects.configuration.authorize_administration.call(
199
+ action: policy.fetch(:action),
200
+ resource: policy.fetch(:resource),
201
+ resource_id: action.route_params(:id),
202
+ authorization_context: action
203
+ )
204
+ end
205
+
206
+ # @rbs (Hash[String, untyped]) -> Hash[String, String]
207
+ def page_headers(env)
208
+ {
209
+ "content-type" => "text/html; charset=utf-8",
210
+ "cache-control" => "private, no-store",
211
+ "content-security-policy" => content_security_policy(env),
212
+ "x-content-type-options" => "nosniff",
213
+ "referrer-policy" => "same-origin"
214
+ }
215
+ end
216
+
217
+ # The chart host is named only when one is configured, so a deployment
218
+ # that vendors the library or turns charts off never advertises a third
219
+ # party origin it does not use.
220
+ # @rbs (Hash[String, untyped]) -> String
221
+ def content_security_policy(env)
222
+ sources = [ "'self'", "'nonce-#{env[Web::NONCE_KEY]}'", Web.chart_library_origin ].compact
223
+ CONTENT_SECURITY_POLICY.sub(SCRIPT_PLACEHOLDER, sources.join(" "))
224
+ end
225
+
226
+ # The cascade header lets a host application serve its own 404 for a path
227
+ # below the mount that the dashboard does not define.
228
+ # @rbs () -> Array[untyped]
229
+ def not_found
230
+ [ 404, { "content-type" => "text/plain", "x-cascade" => "pass" }, [ "Not Found" ] ]
231
+ end
232
+
233
+ # @rbs () -> Array[untyped]
234
+ def forbidden
235
+ [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,130 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "rack/request"
4
+ require "rack/utils"
5
+ require "securerandom"
6
+
7
+ module SolidObjects
8
+ class Web
9
+ # A state changing request must carry the token of the session that asked
10
+ # for the form. The token a form receives is masked with a fresh one-time
11
+ # pad on every request, so the bytes on the wire differ each time and a
12
+ # compression side channel cannot recover the session token.
13
+ class CsrfProtection
14
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
15
+ TOKEN_BYTES = 32
16
+
17
+ MISSING_SESSION = <<~MESSAGE
18
+ SolidObjects::Web needs a Rack session for CSRF protection.
19
+
20
+ Mount it inside the application routes so the Rails session middleware runs first:
21
+
22
+ Rails.application.routes.draw do
23
+ mount SolidObjects::Web => "/solid_objects"
24
+ end
25
+
26
+ In a bare Rack application, run a session middleware before it:
27
+
28
+ use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
29
+ run SolidObjects::Web
30
+ MESSAGE
31
+
32
+ # @rbs (untyped) -> void
33
+ def initialize(app)
34
+ @app = app
35
+ end
36
+
37
+ # @rbs (Hash[String, untyped]) -> Array[untyped]
38
+ def call(env)
39
+ return forbidden unless accept?(env)
40
+
41
+ session = session!(env)
42
+ session[:csrf] ||= SecureRandom.base64(TOKEN_BYTES)
43
+ env[Web::CSRF_TOKEN_KEY] = mask(session[:csrf])
44
+ @app.call(env)
45
+ end
46
+
47
+ private
48
+
49
+ # @rbs (Hash[String, untyped]) -> bool
50
+ def accept?(env)
51
+ return true if SAFE_METHODS.include?(env["REQUEST_METHOD"])
52
+
53
+ valid?(env, ::Rack::Request.new(env).params["authenticity_token"])
54
+ end
55
+
56
+ # @rbs (Hash[String, untyped], String?) -> bool
57
+ def valid?(env, given)
58
+ return false if given.nil? || given.empty?
59
+
60
+ session = session!(env)
61
+ stored = session[:csrf]
62
+ return false if stored.nil?
63
+
64
+ token = decode(given)
65
+ return false unless token
66
+
67
+ # The secret is not rotated here. A page renders one Retry form per
68
+ # dead letter, and a browser keeps pages open in other tabs, so
69
+ # spending the secret on the first submission would answer 403 to
70
+ # every other form already rendered. Single use is not what a CSRF
71
+ # token provides: it proves the request came from a page this session
72
+ # was served, and the per-request mask below is what keeps the value
73
+ # on the wire from repeating.
74
+ matches?(token, stored)
75
+ end
76
+
77
+ # @rbs (String, String) -> bool
78
+ def matches?(token, stored)
79
+ candidate = case token.bytesize
80
+ when TOKEN_BYTES then token
81
+ when TOKEN_BYTES * 2 then unmask(token)
82
+ else return false
83
+ end
84
+
85
+ ::Rack::Utils.secure_compare(candidate, decode(stored).to_s)
86
+ end
87
+
88
+ # @rbs (String) -> String
89
+ def mask(token)
90
+ decoded = decode(token).to_s
91
+ pad = SecureRandom.random_bytes(decoded.bytesize)
92
+ encode(pad + exclusive_or(pad, decoded))
93
+ end
94
+
95
+ # @rbs (String) -> String
96
+ def unmask(masked)
97
+ half = masked.bytesize / 2
98
+ exclusive_or(masked[0, half].to_s, masked[half..].to_s)
99
+ end
100
+
101
+ # @rbs (String, String) -> String
102
+ def exclusive_or(left, right)
103
+ left.bytes.zip(right.bytes).map { |first, second| first ^ second.to_i }.pack("c*")
104
+ end
105
+
106
+ # @rbs (String) -> String
107
+ def encode(token)
108
+ [ token ].pack("m0").tr("+/", "-_")
109
+ end
110
+
111
+ # @rbs (String) -> String?
112
+ def decode(token)
113
+ decoded = token.tr("-_", "+/").unpack1("m0")
114
+ decoded.is_a?(String) ? decoded : nil
115
+ rescue ArgumentError
116
+ nil
117
+ end
118
+
119
+ # @rbs (Hash[String, untyped]) -> Hash[untyped, untyped]
120
+ def session!(env)
121
+ env["rack.session"] || raise(MISSING_SESSION)
122
+ end
123
+
124
+ # @rbs () -> Array[untyped]
125
+ def forbidden
126
+ [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
127
+ end
128
+ end
129
+ end
130
+ end
@@ -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