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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +72 -6
- data/app/models/solid_objects/broadcast.rb +8 -0
- data/docs/adr/0009-realtime-updates.md +5 -1
- data/docs/architecture.md +4 -2
- data/docs/authorization.md +11 -4
- data/docs/correctness.md +3 -3
- data/docs/dashboard.md +200 -0
- data/docs/database-schema.md +5 -4
- data/docs/development.md +12 -0
- data/docs/realtime.md +37 -7
- data/docs/roadmap.md +28 -6
- data/docs/security.md +8 -0
- data/examples/application/README.md +5 -5
- data/examples/application/app/actors/chat_room_actor.rb +1 -1
- data/examples/application/app/actors/shopping_cart_actor.rb +5 -5
- data/lib/solid_objects/actor.rb +6 -5
- data/lib/solid_objects/actor_channel.rb +7 -4
- data/lib/solid_objects/actor_definition.rb +15 -3
- data/lib/solid_objects/actor_view.rb +6 -1
- data/lib/solid_objects/effect_executor.rb +10 -2
- data/lib/solid_objects/errors.rb +3 -0
- data/lib/solid_objects/executor.rb +7 -1
- data/lib/solid_objects/reminder_scheduler.rb +28 -14
- data/lib/solid_objects/test_helper.rb +16 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +3 -3
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/web/action.rb +109 -0
- data/lib/solid_objects/web/application.rb +239 -0
- data/lib/solid_objects/web/csrf_protection.rb +130 -0
- data/lib/solid_objects/web/helpers.rb +227 -0
- data/lib/solid_objects/web/paginator.rb +64 -0
- data/lib/solid_objects/web/route.rb +55 -0
- data/lib/solid_objects/web/router.rb +46 -0
- data/lib/solid_objects/web/statistics.rb +78 -0
- data/lib/solid_objects/web.rb +222 -0
- data/sig/generated/lib/solid_objects/actor.rbs +2 -2
- data/sig/generated/lib/solid_objects/actor_definition.rbs +9 -2
- data/sig/generated/lib/solid_objects/errors.rbs +3 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
- data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
- data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
- data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
- data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
- data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
- data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
- data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
- data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
- data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
- data/sig/generated/lib/solid_objects/web.rbs +129 -0
- data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
- data/web/assets/javascripts/application.js +118 -0
- data/web/assets/javascripts/charts.js +190 -0
- data/web/assets/stylesheets/application.css +527 -0
- data/web/views/_messages.erb +29 -0
- data/web/views/_navigation.erb +15 -0
- data/web/views/_paging.erb +17 -0
- data/web/views/_status_filter.erb +8 -0
- data/web/views/_summary.erb +39 -0
- data/web/views/broadcasts.erb +35 -0
- data/web/views/dashboard.erb +128 -0
- data/web/views/dead_letter.erb +40 -0
- data/web/views/dead_letters.erb +42 -0
- data/web/views/effects.erb +35 -0
- data/web/views/instance.erb +139 -0
- data/web/views/instances.erb +49 -0
- data/web/views/layout.erb +22 -0
- data/web/views/mailbox.erb +35 -0
- data/web/views/message.erb +34 -0
- data/web/views/processes.erb +37 -0
- data/web/views/reminders.erb +37 -0
- 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 "—" 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 "—" 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 "—" 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
|