maquina_stream 0.1.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 +7 -0
- data/.rdoc_options +30 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +163 -0
- data/app/assets/stylesheets/maquina_stream/components/attachment.css +35 -0
- data/app/assets/stylesheets/maquina_stream/components/code_block.css +30 -0
- data/app/assets/stylesheets/maquina_stream/components/shimmer.css +31 -0
- data/app/assets/stylesheets/maquina_stream/components/snippet.css +22 -0
- data/app/assets/stylesheets/maquina_stream/components/source_citation.css +14 -0
- data/app/assets/stylesheets/maquina_stream/components/suggestion.css +32 -0
- data/app/assets/stylesheets/maquina_stream/reveal.css +48 -0
- data/app/assets/stylesheets/maquina_stream/themes/dark.css +235 -0
- data/app/assets/stylesheets/maquina_stream/themes/light.css +117 -0
- data/app/controllers/maquina_stream/application_controller.rb +21 -0
- data/app/controllers/maquina_stream/blocks_controller.rb +41 -0
- data/app/controllers/maquina_stream/manifests_controller.rb +15 -0
- data/app/helpers/maquina_stream/components_helper.rb +80 -0
- data/app/javascript/maquina_stream/controllers/application_controller.js +169 -0
- data/app/javascript/maquina_stream/controllers/ms_autoscroll_controller.js +110 -0
- data/app/javascript/maquina_stream/controllers/ms_code_controller.js +96 -0
- data/app/javascript/maquina_stream/controllers/ms_deferred_controller.js +223 -0
- data/app/javascript/maquina_stream/controllers/ms_diagram_controller.js +40 -0
- data/app/javascript/maquina_stream/controllers/ms_link_safety_controller.js +196 -0
- data/app/javascript/maquina_stream/controllers/ms_math_controller.js +32 -0
- data/app/javascript/maquina_stream/controllers/ms_repair_controller.js +167 -0
- data/app/javascript/maquina_stream/controllers/ms_reveal_controller.js +320 -0
- data/app/javascript/maquina_stream/controllers/ms_table_controller.js +183 -0
- data/app/javascript/maquina_stream/index.js +59 -0
- data/app/views/maquina_stream/components/_attachment.html.erb +139 -0
- data/app/views/maquina_stream/components/_code_block.html.erb +74 -0
- data/app/views/maquina_stream/components/_shimmer.html.erb +36 -0
- data/app/views/maquina_stream/components/_snippet.html.erb +50 -0
- data/app/views/maquina_stream/components/_source_citation.html.erb +42 -0
- data/app/views/maquina_stream/components/_suggestion.html.erb +73 -0
- data/config/importmap.rb +10 -0
- data/config/locales/en.yml +79 -0
- data/config/locales/es.yml +82 -0
- data/config/routes.rb +11 -0
- data/docs/configuration.md +219 -0
- data/docs/deferred-renderers.md +184 -0
- data/docs/getting-started.md +356 -0
- data/docs/javascript.md +298 -0
- data/docs/registries.md +283 -0
- data/docs/repair.md +162 -0
- data/docs/security.md +247 -0
- data/docs/streaming.md +308 -0
- data/lib/generators/maquina_stream/install/USAGE +26 -0
- data/lib/generators/maquina_stream/install/install_generator.rb +199 -0
- data/lib/generators/maquina_stream/install/templates/initializer.rb.tt +121 -0
- data/lib/generators/maquina_stream/streamable/USAGE +28 -0
- data/lib/generators/maquina_stream/streamable/streamable_generator.rb +187 -0
- data/lib/generators/maquina_stream/streamable/templates/migration.rb.tt +21 -0
- data/lib/generators/maquina_stream/streamable/templates/model.rb.tt +4 -0
- data/lib/maquina_stream/block.rb +99 -0
- data/lib/maquina_stream/broadcaster.rb +233 -0
- data/lib/maquina_stream/component_cache.rb +0 -0
- data/lib/maquina_stream/components/contract.rb +184 -0
- data/lib/maquina_stream/components.rb +135 -0
- data/lib/maquina_stream/configuration.rb +240 -0
- data/lib/maquina_stream/document.rb +296 -0
- data/lib/maquina_stream/engine.rb +46 -0
- data/lib/maquina_stream/errors.rb +17 -0
- data/lib/maquina_stream/export.rb +66 -0
- data/lib/maquina_stream/frame.rb +73 -0
- data/lib/maquina_stream/manifest.rb +137 -0
- data/lib/maquina_stream/registries.rb +116 -0
- data/lib/maquina_stream/renderer/fence.rb +115 -0
- data/lib/maquina_stream/renderer/post_pass.rb +363 -0
- data/lib/maquina_stream/renderer/tag_blocks.rb +276 -0
- data/lib/maquina_stream/renderer/view_context.rb +72 -0
- data/lib/maquina_stream/renderer.rb +128 -0
- data/lib/maquina_stream/sanitizer.rb +392 -0
- data/lib/maquina_stream/streamable.rb +281 -0
- data/lib/maquina_stream/text_direction.rb +56 -0
- data/lib/maquina_stream/themes.rb +84 -0
- data/lib/maquina_stream/version.rb +5 -0
- data/lib/maquina_stream.rb +175 -0
- metadata +204 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module MaquinaStream
|
|
7
|
+
# Allowlist plus URL hardening. The last pass before output, and it runs
|
|
8
|
+
# unconditionally.
|
|
9
|
+
#
|
|
10
|
+
# ```ruby
|
|
11
|
+
# MaquinaStream::Sanitizer.call(html, config: MaquinaStream.config) # => String
|
|
12
|
+
# ```
|
|
13
|
+
#
|
|
14
|
+
# The input is model output: hostile, prompt-injectable, and never trusted
|
|
15
|
+
# because an earlier stage already looked at it. Nothing here is a cleanup
|
|
16
|
+
# pass — an element or an attribute survives only by being named, and a URL
|
|
17
|
+
# survives only by being re-parsed and re-checked. Everything else is
|
|
18
|
+
# dropped, not escaped and kept.
|
|
19
|
+
#
|
|
20
|
+
# Renderer calls this itself, last, on every render. A host only calls it
|
|
21
|
+
# directly when it produces HTML of its own that a model had a hand in.
|
|
22
|
+
#
|
|
23
|
+
# Five configuration keys steer it — `default_origin`, `allowed_protocols`,
|
|
24
|
+
# `allowed_link_prefixes`, `allowed_image_prefixes` and `allow_data_images`.
|
|
25
|
+
# Nothing else about it is configurable: the element and attribute allowlists
|
|
26
|
+
# are constants, and widening one means editing this file with the regression
|
|
27
|
+
# suite in front of you.
|
|
28
|
+
#
|
|
29
|
+
# It is not the last line of defence either. Renderer output is sanitized
|
|
30
|
+
# *again* client-side before it reaches the DOM, because the payload came from
|
|
31
|
+
# a model and model output is prompt-injectable.
|
|
32
|
+
#
|
|
33
|
+
# See docs/security.md for what survives, what is dropped, and the known
|
|
34
|
+
# holes.
|
|
35
|
+
class Sanitizer
|
|
36
|
+
# Elements that survive: rendered markdown, plus the wrappers the post-pass
|
|
37
|
+
# adds around it.
|
|
38
|
+
ALLOWED_ELEMENTS = %w[
|
|
39
|
+
p div span br hr
|
|
40
|
+
h1 h2 h3 h4 h5 h6
|
|
41
|
+
ul ol li dl dt dd
|
|
42
|
+
table thead tbody tfoot tr td th caption colgroup col
|
|
43
|
+
pre code kbd samp var
|
|
44
|
+
blockquote figure figcaption details summary section article aside
|
|
45
|
+
a img button
|
|
46
|
+
em strong b i u s del ins mark small sub sup q abbr dfn cite time wbr
|
|
47
|
+
input
|
|
48
|
+
].to_set.freeze
|
|
49
|
+
|
|
50
|
+
# Removed with everything inside them. Unwrapping these would smuggle their
|
|
51
|
+
# contents back into the document: script text, CSS, or a foreign-content
|
|
52
|
+
# (SVG/MathML) subtree whose parsing rules are not HTML's.
|
|
53
|
+
DROP_WITH_CONTENT = %w[
|
|
54
|
+
script style svg math template noscript iframe frame frameset object
|
|
55
|
+
embed applet param form select option optgroup textarea label
|
|
56
|
+
fieldset legend base link meta head title html body audio video source
|
|
57
|
+
track canvas map area portal dialog marquee plaintext xmp listing
|
|
58
|
+
].to_set.freeze
|
|
59
|
+
|
|
60
|
+
# `hidden` earns its place: the raw-source carrier is a hidden <pre>, and a
|
|
61
|
+
# carrier that loses its hidden attribute renders every code block twice.
|
|
62
|
+
GLOBAL_ATTRIBUTES = %w[id class title lang dir role translate hidden].to_set.freeze
|
|
63
|
+
|
|
64
|
+
ELEMENT_ATTRIBUTES = {
|
|
65
|
+
"a" => %w[href target rel hreflang type],
|
|
66
|
+
# The component controls are buttons, so button cannot be dropped
|
|
67
|
+
# wholesale. It is allowed with a deliberately short attribute list: no
|
|
68
|
+
# `name`, `value`, `form`, `formaction` or `formmethod`, so an injected
|
|
69
|
+
# button cannot submit anything, and `type` is forced to "button" below.
|
|
70
|
+
# Behaviour still comes only from `data-action`, which is already
|
|
71
|
+
# restricted to the ms- namespace, and every ms- controller treats its own
|
|
72
|
+
# values as untrusted.
|
|
73
|
+
"button" => %w[type disabled aria-pressed aria-expanded aria-controls],
|
|
74
|
+
"img" => %w[src alt width height loading decoding],
|
|
75
|
+
"ol" => %w[start reversed type],
|
|
76
|
+
"li" => %w[value],
|
|
77
|
+
"td" => %w[colspan rowspan align valign headers scope],
|
|
78
|
+
"th" => %w[colspan rowspan align valign headers scope abbr],
|
|
79
|
+
"col" => %w[span align],
|
|
80
|
+
"colgroup" => %w[span align],
|
|
81
|
+
"table" => %w[align],
|
|
82
|
+
"input" => %w[type checked disabled],
|
|
83
|
+
"time" => %w[datetime],
|
|
84
|
+
"details" => %w[open]
|
|
85
|
+
}.transform_values { |names| names.to_set.freeze }.freeze
|
|
86
|
+
|
|
87
|
+
# The allowlist already excludes every one of these. Naming them keeps the
|
|
88
|
+
# intent across refactors and gives the regression suite something explicit
|
|
89
|
+
# to assert on.
|
|
90
|
+
FORBIDDEN_ATTRIBUTES = %w[
|
|
91
|
+
srcdoc formaction xlink:href xlink:show xlink:actuate xml:base
|
|
92
|
+
style action background dynsrc lowsrc ping http-equiv srcset usemap
|
|
93
|
+
accesskey contenteditable name
|
|
94
|
+
].to_set.freeze
|
|
95
|
+
|
|
96
|
+
# Non-Stimulus data hooks our own partials emit.
|
|
97
|
+
STATIC_DATA_ATTRIBUTES = %w[
|
|
98
|
+
data-component data-variant data-size data-slot data-state data-side
|
|
99
|
+
data-orientation data-controller data-action data-turbo-permanent
|
|
100
|
+
data-turbo-temporary
|
|
101
|
+
].to_set.freeze
|
|
102
|
+
|
|
103
|
+
DATA_ATTRIBUTE_SHAPE = /\Adata-[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
104
|
+
|
|
105
|
+
# Component internals: data-code-block-part, data-shimmer-part and friends.
|
|
106
|
+
# They carry no behaviour, only styling hooks for a component's own parts.
|
|
107
|
+
COMPONENT_PART_ATTRIBUTE = /\Adata-[a-z0-9]+(?:-[a-z0-9]+)*-part\z/
|
|
108
|
+
ARIA_ATTRIBUTE_SHAPE = /\Aaria-[a-z]+\z/
|
|
109
|
+
|
|
110
|
+
# Only our own controller namespace. A host or third-party identifier
|
|
111
|
+
# arriving inside model output has no business being instantiated.
|
|
112
|
+
CONTROLLER_IDENTIFIER = /\Ams-[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
113
|
+
ACTION_DESCRIPTOR = %r{
|
|
114
|
+
\A
|
|
115
|
+
(?:[a-zA-Z0-9:.-]+(?:@[a-z]+)?->)?
|
|
116
|
+
ms-[a-z0-9-]+\#[a-zA-Z_][a-zA-Z0-9_]*
|
|
117
|
+
(?::[a-z]+)*
|
|
118
|
+
\z
|
|
119
|
+
}x
|
|
120
|
+
|
|
121
|
+
# Rejected however they are spelled, including after the decoding a browser
|
|
122
|
+
# would do on our behalf.
|
|
123
|
+
DANGEROUS_SCHEMES = %w[
|
|
124
|
+
javascript livescript vbscript jscript mocha data file blob about jar
|
|
125
|
+
view-source chrome chrome-extension resource feed ms-its
|
|
126
|
+
].to_set.freeze
|
|
127
|
+
|
|
128
|
+
# Control characters, and the whitespace a browser strips before it reads
|
|
129
|
+
# the scheme. Removing them first means the scheme we test is the scheme
|
|
130
|
+
# the browser will act on.
|
|
131
|
+
URL_NOISE = /[\u0000-\u0020\u007F-\u00A0\u1680\u180E\u2000-\u200F\u2028\u2029\u202F\u205F\u3000\uFEFF]/
|
|
132
|
+
|
|
133
|
+
DATA_IMAGE = %r{
|
|
134
|
+
\Adata:image/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)
|
|
135
|
+
;base64,[A-Za-z0-9+/=]+\z
|
|
136
|
+
}xi
|
|
137
|
+
|
|
138
|
+
class << self
|
|
139
|
+
# Sanitizes one HTML string. The usual entry point.
|
|
140
|
+
def call(html, config: MaquinaStream.config)
|
|
141
|
+
new(config: config).call(html)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The Configuration whose URL keys this sanitizer reads.
|
|
146
|
+
attr_reader :config
|
|
147
|
+
|
|
148
|
+
# Builds a reusable sanitizer. A nil `config:` falls back to the global
|
|
149
|
+
# one rather than failing later, deep in a URL check.
|
|
150
|
+
def initialize(config: MaquinaStream.config)
|
|
151
|
+
@config = config || MaquinaStream.config
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Sanitizes `html` and returns the result as a String — a plain String, not
|
|
155
|
+
# `html_safe`: marking it is the caller's decision, and Renderer is the
|
|
156
|
+
# caller that makes it. Empty in, empty out.
|
|
157
|
+
def call(html)
|
|
158
|
+
return "" if html.nil?
|
|
159
|
+
|
|
160
|
+
source = html.to_s
|
|
161
|
+
return "" if source.empty?
|
|
162
|
+
|
|
163
|
+
fragment = parse(source)
|
|
164
|
+
scrub_children(fragment)
|
|
165
|
+
fragment.to_html
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
# HTML5 parsing wherever Nokogiri offers it. The HTML4 parser's error
|
|
170
|
+
# recovery is not any browser's, and a sanitizer that parses a document
|
|
171
|
+
# differently from the engine that will display it is a mutation XSS
|
|
172
|
+
# waiting for its input.
|
|
173
|
+
def parse(html)
|
|
174
|
+
if defined?(Nokogiri::HTML5)
|
|
175
|
+
Nokogiri::HTML5.fragment(html)
|
|
176
|
+
else
|
|
177
|
+
Nokogiri::HTML::DocumentFragment.parse(html)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def scrub_children(node)
|
|
182
|
+
node.children.to_a.each { |child| scrub_node(child) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def scrub_node(node)
|
|
186
|
+
if node.element?
|
|
187
|
+
scrub_element(node)
|
|
188
|
+
elsif node.cdata?
|
|
189
|
+
# A parser artifact. Its characters are inert once they are text and
|
|
190
|
+
# the serializer escapes them.
|
|
191
|
+
node.replace(Nokogiri::XML::Text.new(node.content, node.document))
|
|
192
|
+
elsif !node.text?
|
|
193
|
+
# Comments, processing instructions, doctypes. A conditional comment
|
|
194
|
+
# is script delivery in a costume.
|
|
195
|
+
node.unlink
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def scrub_element(node)
|
|
200
|
+
name = node.name.downcase
|
|
201
|
+
|
|
202
|
+
return node.unlink if DROP_WITH_CONTENT.include?(name) || foreign?(node)
|
|
203
|
+
return unwrap(node) unless ALLOWED_ELEMENTS.include?(name)
|
|
204
|
+
|
|
205
|
+
scrub_attributes(node, name)
|
|
206
|
+
return unless node.parent # an element rule may have removed the node
|
|
207
|
+
|
|
208
|
+
scrub_children(node)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# SVG and MathML subtrees arrive through foreign-content parsing rules,
|
|
212
|
+
# where `<style>` and `<annotation-xml>` re-enter HTML parsing. Nothing in
|
|
213
|
+
# rendered markdown needs either namespace.
|
|
214
|
+
def foreign?(node)
|
|
215
|
+
href = node.namespace&.href.to_s
|
|
216
|
+
href.include?("svg") || href.include?("MathML")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def unwrap(node)
|
|
220
|
+
scrub_children(node)
|
|
221
|
+
children = node.children
|
|
222
|
+
children.empty? ? node.unlink : node.replace(children)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def scrub_attributes(node, name)
|
|
226
|
+
# A button never submits. Even allowing `type` through, the only value
|
|
227
|
+
# it may hold is "button": an injected `type="submit"` inside a host
|
|
228
|
+
# form would otherwise submit it.
|
|
229
|
+
node["type"] = "button" if name == "button"
|
|
230
|
+
|
|
231
|
+
node.attribute_nodes.each do |attr|
|
|
232
|
+
attr_name = attr.name.downcase
|
|
233
|
+
qualified = attr.namespace ? "#{attr.namespace.prefix}:#{attr_name}" : attr_name
|
|
234
|
+
|
|
235
|
+
# Namespaced attributes (xlink:href, xml:base) never survive: an
|
|
236
|
+
# allowlist for them would have to be a second allowlist.
|
|
237
|
+
next attr.unlink if attr.namespace
|
|
238
|
+
next attr.unlink if FORBIDDEN_ATTRIBUTES.include?(qualified)
|
|
239
|
+
next attr.unlink unless allowed_attribute?(name, attr_name)
|
|
240
|
+
|
|
241
|
+
scrub_attribute_value(node, name, attr, attr_name)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
enforce_element_rules(node, name)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def allowed_attribute?(element, attr_name)
|
|
248
|
+
return false if attr_name.start_with?("on")
|
|
249
|
+
return data_attribute?(attr_name) if attr_name.start_with?("data-")
|
|
250
|
+
return true if attr_name.match?(ARIA_ATTRIBUTE_SHAPE)
|
|
251
|
+
return true if GLOBAL_ATTRIBUTES.include?(attr_name)
|
|
252
|
+
|
|
253
|
+
ELEMENT_ATTRIBUTES.fetch(element, Set[]).include?(attr_name)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# `data-ms-*` is ours, and so is every Stimulus value, target, class,
|
|
257
|
+
# param and outlet derived from an `ms-` identifier. Anything else,
|
|
258
|
+
# a third-party controller's values included, is dropped.
|
|
259
|
+
def data_attribute?(attr_name)
|
|
260
|
+
return false unless attr_name.match?(DATA_ATTRIBUTE_SHAPE)
|
|
261
|
+
return true if attr_name.start_with?("data-ms-")
|
|
262
|
+
return true if attr_name.match?(COMPONENT_PART_ATTRIBUTE)
|
|
263
|
+
|
|
264
|
+
STATIC_DATA_ATTRIBUTES.include?(attr_name)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def scrub_attribute_value(node, name, attr, attr_name)
|
|
268
|
+
case attr_name
|
|
269
|
+
when "href", "src"
|
|
270
|
+
harden_url(node, name, attr, attr_name)
|
|
271
|
+
when "data-controller"
|
|
272
|
+
filter_tokens(attr) { |token| token.match?(CONTROLLER_IDENTIFIER) }
|
|
273
|
+
when "data-action"
|
|
274
|
+
filter_tokens(attr) { |token| token.match?(ACTION_DESCRIPTOR) }
|
|
275
|
+
when "target"
|
|
276
|
+
attr.value = "_blank" unless %w[_blank _self].include?(attr.value)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def filter_tokens(attr)
|
|
281
|
+
kept = attr.value.to_s.split(/\s+/).reject(&:empty?).select { |token| yield token }
|
|
282
|
+
kept.empty? ? attr.unlink : attr.value = kept.join(" ")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def harden_url(node, name, attr, attr_name)
|
|
286
|
+
kind = (name == "img" || attr_name == "src") ? :image : :link
|
|
287
|
+
safe = safe_url(attr.value, kind: kind)
|
|
288
|
+
|
|
289
|
+
return attr.value = safe if safe
|
|
290
|
+
|
|
291
|
+
attr.unlink
|
|
292
|
+
# An image with no source is a broken rectangle carrying an
|
|
293
|
+
# attacker-chosen alt string. A link with no href still shows its text.
|
|
294
|
+
node.unlink if name == "img"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def safe_url(raw, kind:)
|
|
298
|
+
url = raw.to_s.gsub(URL_NOISE, "")
|
|
299
|
+
return nil if url.empty?
|
|
300
|
+
return url if url.start_with?("#") # same-document fragment
|
|
301
|
+
|
|
302
|
+
# `//evil.example`, and the backslash spellings browsers normalize into
|
|
303
|
+
# it, inherit the page's scheme and none of its origin.
|
|
304
|
+
return nil if url.match?(%r{\A[\\/]{2}}) || url.start_with?("\\")
|
|
305
|
+
|
|
306
|
+
scheme = scheme_of(url)
|
|
307
|
+
decoded_scheme = scheme_of(decode(url))
|
|
308
|
+
|
|
309
|
+
# A scheme that only appears once the browser decodes (`%6Aavascript:`,
|
|
310
|
+
# `javascript:` double-encoded) is still a scheme.
|
|
311
|
+
return nil if decoded_scheme && decoded_scheme != scheme && DANGEROUS_SCHEMES.include?(decoded_scheme)
|
|
312
|
+
|
|
313
|
+
if scheme
|
|
314
|
+
return (kind == :image) ? data_image_url(url) : nil if scheme == "data"
|
|
315
|
+
return nil unless allowed_protocols.include?(scheme)
|
|
316
|
+
else
|
|
317
|
+
url = resolve(url)
|
|
318
|
+
return nil if url.nil?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
allowed_prefix?(url, kind: kind) ? url : nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def scheme_of(url)
|
|
325
|
+
match = /\A([a-zA-Z][a-zA-Z0-9+.-]*):/.match(url)
|
|
326
|
+
match && match[1].downcase
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# What a browser is left with after entity decoding (the HTML parser has
|
|
330
|
+
# usually done one round already; double-encoded payloads survive it) and
|
|
331
|
+
# after percent decoding.
|
|
332
|
+
def decode(url)
|
|
333
|
+
decoded = url.gsub(/&#x([0-9a-fA-F]+);?/) { [Regexp.last_match(1).hex].pack("U") }
|
|
334
|
+
decoded = decoded.gsub(/&#(\d+);?/) { [Regexp.last_match(1).to_i].pack("U") }
|
|
335
|
+
decoded = decoded.gsub(/%([0-9a-fA-F]{2})/) { [Regexp.last_match(1)].pack("H2") }
|
|
336
|
+
decoded.force_encoding(Encoding::UTF_8).gsub(URL_NOISE, "")
|
|
337
|
+
rescue ArgumentError, RangeError
|
|
338
|
+
url
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Base64 raster images only. `data:image/svg+xml` is a scriptable
|
|
342
|
+
# document wearing an image's MIME type.
|
|
343
|
+
def data_image_url(url)
|
|
344
|
+
return nil unless allow_data_images?
|
|
345
|
+
return nil unless url.match?(DATA_IMAGE)
|
|
346
|
+
|
|
347
|
+
url
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def resolve(url)
|
|
351
|
+
origin = config.default_origin
|
|
352
|
+
return url if origin.nil? || origin.to_s.empty?
|
|
353
|
+
|
|
354
|
+
joined = URI.join(origin.to_s, url).to_s
|
|
355
|
+
allowed_protocols.include?(scheme_of(joined).to_s) ? joined : nil
|
|
356
|
+
rescue URI::Error
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def allowed_prefix?(url, kind:)
|
|
361
|
+
prefixes = Array((kind == :image) ? config.allowed_image_prefixes : config.allowed_link_prefixes)
|
|
362
|
+
return true if prefixes.empty? || prefixes.include?("*")
|
|
363
|
+
|
|
364
|
+
prefixes.any? { |prefix| url.start_with?(prefix.to_s) }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def allowed_protocols
|
|
368
|
+
@allowed_protocols ||= Array(config.allowed_protocols).map { |p| p.to_s.downcase.delete_suffix(":") }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def allow_data_images?
|
|
372
|
+
!!config.allow_data_images
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def enforce_element_rules(node, name)
|
|
376
|
+
case name
|
|
377
|
+
when "input"
|
|
378
|
+
# The tasklist extension's disabled checkbox, and nothing else.
|
|
379
|
+
(node["type"].to_s.downcase == "checkbox") ? node["disabled"] = "disabled" : node.unlink
|
|
380
|
+
when "a"
|
|
381
|
+
harden_link(node)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def harden_link(node)
|
|
386
|
+
return unless node["href"]
|
|
387
|
+
|
|
388
|
+
rel = node["rel"].to_s.split(/\s+/) | %w[noopener noreferrer]
|
|
389
|
+
node["rel"] = rel.reject(&:empty?).join(" ")
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module MaquinaStream
|
|
6
|
+
# Host contract for a streamable record. The host owns persistence; the
|
|
7
|
+
# engine never guesses a broadcast target and never invents authorization.
|
|
8
|
+
#
|
|
9
|
+
# ```ruby
|
|
10
|
+
# class Message < ApplicationRecord
|
|
11
|
+
# include MaquinaStream::Streamable
|
|
12
|
+
#
|
|
13
|
+
# maquina_stream buffer: :content,
|
|
14
|
+
# stream_for: ->(m) { [m.conversation, :messages] }
|
|
15
|
+
# end
|
|
16
|
+
# ```
|
|
17
|
+
#
|
|
18
|
+
# The macro generates the contract methods when the column names match (see
|
|
19
|
+
# docs/streaming.md). Anything the host defines itself wins, and a
|
|
20
|
+
# method whose backing column is missing raises ContractError naming the
|
|
21
|
+
# method, the column and the class.
|
|
22
|
+
#
|
|
23
|
+
# ## What the columns must be
|
|
24
|
+
#
|
|
25
|
+
# | Method | Column it reads |
|
|
26
|
+
# |---|---|
|
|
27
|
+
# | `#maquina_stream_buffer`, `#maquina_stream_append` | whatever `buffer:` named |
|
|
28
|
+
# | `#maquina_stream_sequence`, `#maquina_stream_advance` | `stream_sequence`, an integer |
|
|
29
|
+
# | `#maquina_stream_open?`, `#maquina_stream_seal!`, `#maquina_stream_status` | `stream_status`, a string holding `open` / `complete` / `cancelled` / `errored` / `timed_out` |
|
|
30
|
+
#
|
|
31
|
+
# `#maquina_stream_id` needs no column — it is `to_param`. `#maquina_stream_target`
|
|
32
|
+
# needs none either; it calls the `stream_for:` lambda.
|
|
33
|
+
#
|
|
34
|
+
# ## The generated methods
|
|
35
|
+
#
|
|
36
|
+
# | Method | Returns |
|
|
37
|
+
# |---|---|
|
|
38
|
+
# | `#maquina_stream_id` | `String`, stable and unique per message |
|
|
39
|
+
# | `#maquina_stream_buffer` | `String`, the raw markdown written so far |
|
|
40
|
+
# | `#maquina_stream_append(text)` | the whole buffer after appending and persisting |
|
|
41
|
+
# | `#maquina_stream_sequence` | `Integer`, monotonic, one per frame that went out |
|
|
42
|
+
# | `#maquina_stream_advance` | `Integer`, the next sequence number, incremented atomically |
|
|
43
|
+
# | `#maquina_stream_open?` | `Boolean` |
|
|
44
|
+
# | `#maquina_stream_status` | the recorded end state as a Symbol, or `nil` while open |
|
|
45
|
+
# | `#maquina_stream_seal!(status: :complete)` | the status Symbol it sealed with |
|
|
46
|
+
# | `#maquina_stream_target` | the Turbo broadcast target, from `stream_for:` |
|
|
47
|
+
#
|
|
48
|
+
# Each is documented on Streamable::Generated. Defining any of them in the
|
|
49
|
+
# model body overrides the generated one — the macro `include`s a module, so
|
|
50
|
+
# the class body always wins.
|
|
51
|
+
module Streamable
|
|
52
|
+
extend ActiveSupport::Concern
|
|
53
|
+
|
|
54
|
+
# The column `#maquina_stream_sequence` and `#maquina_stream_advance` read.
|
|
55
|
+
SEQUENCE_COLUMN = :stream_sequence
|
|
56
|
+
|
|
57
|
+
# The column `#maquina_stream_open?`, `#maquina_stream_status` and
|
|
58
|
+
# `#maquina_stream_seal!` read.
|
|
59
|
+
STATUS_COLUMN = :stream_status
|
|
60
|
+
|
|
61
|
+
# The one value of STATUS_COLUMN that means the stream is still being
|
|
62
|
+
# written. Everything else is a seal.
|
|
63
|
+
OPEN_STATUS = "open"
|
|
64
|
+
# A stream that timed out is not the same as one that errored: nothing went
|
|
65
|
+
# wrong, the model simply stopped answering, and the partial text it did
|
|
66
|
+
# produce is still worth keeping and replaying.
|
|
67
|
+
SEAL_STATUSES = %i[complete cancelled errored timed_out].freeze
|
|
68
|
+
|
|
69
|
+
# Every method a host must answer to. The macro generates all of them; a
|
|
70
|
+
# host that cannot use the macro implements this list itself.
|
|
71
|
+
CONTRACT_METHODS = %i[
|
|
72
|
+
maquina_stream_id
|
|
73
|
+
maquina_stream_buffer
|
|
74
|
+
maquina_stream_append
|
|
75
|
+
maquina_stream_sequence
|
|
76
|
+
maquina_stream_advance
|
|
77
|
+
maquina_stream_open?
|
|
78
|
+
maquina_stream_seal!
|
|
79
|
+
maquina_stream_target
|
|
80
|
+
].freeze
|
|
81
|
+
|
|
82
|
+
included do
|
|
83
|
+
class_attribute :maquina_stream_buffer_column, instance_writer: false
|
|
84
|
+
class_attribute :maquina_stream_target_resolver, instance_writer: false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class_methods do
|
|
88
|
+
# Declares this model streamable and generates the contract methods.
|
|
89
|
+
#
|
|
90
|
+
# ```ruby
|
|
91
|
+
# maquina_stream buffer: :content,
|
|
92
|
+
# stream_for: ->(m) { [m.conversation, :messages] }
|
|
93
|
+
# ```
|
|
94
|
+
#
|
|
95
|
+
# `buffer:` names the column holding the raw markdown. `stream_for:` is a
|
|
96
|
+
# callable receiving the record and returning the Turbo broadcast target
|
|
97
|
+
# — the engine never guesses one, because who may listen to a stream is
|
|
98
|
+
# the host's question and not the engine's.
|
|
99
|
+
#
|
|
100
|
+
# The generated methods arrive through an included module, so anything
|
|
101
|
+
# the class body defines wins over them.
|
|
102
|
+
def maquina_stream(buffer:, stream_for:)
|
|
103
|
+
self.maquina_stream_buffer_column = buffer.to_sym
|
|
104
|
+
self.maquina_stream_target_resolver = stream_for
|
|
105
|
+
|
|
106
|
+
include Generated
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Names the contract methods this class cannot satisfy — the generated
|
|
110
|
+
# ones whose column is missing and that the host has not defined itself.
|
|
111
|
+
#
|
|
112
|
+
# Returns an Array of method names, empty when the contract is complete.
|
|
113
|
+
# Worth asserting in a host's own test suite: the alternative is finding
|
|
114
|
+
# out mid-stream, when the ContractError raises inside a broadcast.
|
|
115
|
+
def maquina_stream_contract_gaps
|
|
116
|
+
Generated.instance_methods.filter_map do |method|
|
|
117
|
+
column = Generated::REQUIRED_COLUMNS[method]
|
|
118
|
+
next if column.nil?
|
|
119
|
+
|
|
120
|
+
column = maquina_stream_buffer_column if column == :buffer
|
|
121
|
+
method unless maquina_stream_column?(column)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Whether this model has the named column. Answers `false` rather than
|
|
126
|
+
# raising when the database is unreachable — the contract check must not
|
|
127
|
+
# be the thing that breaks a boot.
|
|
128
|
+
def maquina_stream_column?(column)
|
|
129
|
+
column_names.include?(column.to_s)
|
|
130
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The host contract, generated by the `maquina_stream` macro. Included, so
|
|
136
|
+
# a host method defined in the class body overrides it.
|
|
137
|
+
#
|
|
138
|
+
# These are the methods the engine calls on a record — Broadcaster,
|
|
139
|
+
# Manifest, Export and MaquinaStream.render between them use nothing else.
|
|
140
|
+
# A host that cannot use the macro implements this module's public methods
|
|
141
|
+
# itself and never includes it.
|
|
142
|
+
module Generated
|
|
143
|
+
REQUIRED_COLUMNS = {
|
|
144
|
+
maquina_stream_buffer: :buffer,
|
|
145
|
+
maquina_stream_append: :buffer,
|
|
146
|
+
maquina_stream_sequence: SEQUENCE_COLUMN,
|
|
147
|
+
maquina_stream_open?: STATUS_COLUMN,
|
|
148
|
+
maquina_stream_seal!: STATUS_COLUMN
|
|
149
|
+
}.freeze
|
|
150
|
+
|
|
151
|
+
# A stable String, unique per message, used to build every DOM id in the
|
|
152
|
+
# message and to look the record back up in a repair request. Defaults to
|
|
153
|
+
# `to_param`.
|
|
154
|
+
def maquina_stream_id
|
|
155
|
+
to_param.to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The raw markdown written so far, as a String. Never HTML: the buffer is
|
|
159
|
+
# what the model wrote, and rendering it is the engine's job.
|
|
160
|
+
#
|
|
161
|
+
# Raises ContractError when the column named by `buffer:` does not exist.
|
|
162
|
+
def maquina_stream_buffer
|
|
163
|
+
maquina_stream_read(buffer_column, :maquina_stream_buffer).to_s
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Appends `text` to the buffer, persists it, and returns the whole
|
|
167
|
+
# buffer.
|
|
168
|
+
#
|
|
169
|
+
# The host owns persistence, which is why this and not the broadcaster
|
|
170
|
+
# writes. Broadcaster#append calls it first and only then has something
|
|
171
|
+
# to broadcast.
|
|
172
|
+
#
|
|
173
|
+
# Raises ContractError when the buffer column does not exist.
|
|
174
|
+
def maquina_stream_append(text)
|
|
175
|
+
column = buffer_column
|
|
176
|
+
maquina_stream_require_column!(column, :maquina_stream_append)
|
|
177
|
+
update!(column => "#{public_send(column)}#{text}")
|
|
178
|
+
maquina_stream_buffer
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The current frame sequence number, as an Integer. Monotonic. It moves
|
|
182
|
+
# once per frame that actually goes out, not once per append — the client
|
|
183
|
+
# uses it to notice that it missed one.
|
|
184
|
+
def maquina_stream_sequence
|
|
185
|
+
maquina_stream_read(SEQUENCE_COLUMN, :maquina_stream_sequence).to_i
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Increments the sequence and returns the new value.
|
|
189
|
+
#
|
|
190
|
+
# Added in Phase 3. The sequence is documented as "incremented per frame",
|
|
191
|
+
# but the Broadcaster cannot write host state directly without
|
|
192
|
+
# contradicting "host owns persistence" - so it asks, through the
|
|
193
|
+
# contract, and the host's database does the incrementing atomically.
|
|
194
|
+
# That is also what keeps it monotonic under concurrent appends.
|
|
195
|
+
def maquina_stream_advance
|
|
196
|
+
maquina_stream_require_column!(SEQUENCE_COLUMN, :maquina_stream_advance)
|
|
197
|
+
self.class.where(id: id).update_all("#{SEQUENCE_COLUMN} = #{SEQUENCE_COLUMN} + 1")
|
|
198
|
+
reload.maquina_stream_sequence
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The recorded end state as a Symbol — one of SEAL_STATUSES — for replay
|
|
202
|
+
# and export. Nil while still open.
|
|
203
|
+
#
|
|
204
|
+
# Export reads this to decide whether to append a status footer: a
|
|
205
|
+
# cancelled message that exports as though it were complete is a lie in a
|
|
206
|
+
# file somebody keeps.
|
|
207
|
+
def maquina_stream_status
|
|
208
|
+
return nil if maquina_stream_open?
|
|
209
|
+
|
|
210
|
+
maquina_stream_read(STATUS_COLUMN, :maquina_stream_status)&.to_sym
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Whether the stream is still being written.
|
|
214
|
+
#
|
|
215
|
+
# This is what decides render mode and what decides cacheability: an open
|
|
216
|
+
# message is never cached, because it is about to change.
|
|
217
|
+
def maquina_stream_open?
|
|
218
|
+
maquina_stream_read(STATUS_COLUMN, :maquina_stream_open?).to_s == OPEN_STATUS
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Closes the stream and returns the status Symbol it sealed with.
|
|
222
|
+
#
|
|
223
|
+
# `status:` must be one of SEAL_STATUSES: `:complete`, `:cancelled`,
|
|
224
|
+
# `:errored` or `:timed_out`. Anything else raises ArgumentError.
|
|
225
|
+
#
|
|
226
|
+
# Sealing only records the status. Broadcaster#seal! is what also emits
|
|
227
|
+
# the final frame, and that frame is never coalesced and never skipped —
|
|
228
|
+
# so a host seals through the broadcaster, not through this directly,
|
|
229
|
+
# unless it means to close a stream silently.
|
|
230
|
+
def maquina_stream_seal!(status: :complete)
|
|
231
|
+
unless SEAL_STATUSES.include?(status.to_sym)
|
|
232
|
+
raise ArgumentError, "status must be one of #{SEAL_STATUSES.join(", ")}, got #{status.inspect}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
maquina_stream_require_column!(STATUS_COLUMN, :maquina_stream_seal!)
|
|
236
|
+
update!(STATUS_COLUMN => status.to_s)
|
|
237
|
+
status.to_sym
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The Turbo broadcast target, from the `stream_for:` callable the macro
|
|
241
|
+
# was given. Whatever that callable returns is passed straight to
|
|
242
|
+
# `Turbo::StreamsChannel`.
|
|
243
|
+
#
|
|
244
|
+
# Raises ContractError when `stream_for:` is not callable: the broadcast
|
|
245
|
+
# target belongs to the host, and the engine never guesses one.
|
|
246
|
+
def maquina_stream_target
|
|
247
|
+
resolver = self.class.maquina_stream_target_resolver
|
|
248
|
+
unless resolver.respond_to?(:call)
|
|
249
|
+
raise ContractError, <<~MESSAGE
|
|
250
|
+
#{self.class.name} does not supply #maquina_stream_target: `stream_for:`
|
|
251
|
+
is not callable. The broadcast target belongs to the host — the engine
|
|
252
|
+
never guesses one. See docs/streaming.md.
|
|
253
|
+
MESSAGE
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
resolver.call(self)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private
|
|
260
|
+
def buffer_column
|
|
261
|
+
self.class.maquina_stream_buffer_column
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def maquina_stream_read(column, method)
|
|
265
|
+
maquina_stream_require_column!(column, method)
|
|
266
|
+
public_send(column)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def maquina_stream_require_column!(column, method)
|
|
270
|
+
return if self.class.maquina_stream_column?(column)
|
|
271
|
+
|
|
272
|
+
raise ContractError, <<~MESSAGE
|
|
273
|
+
#{self.class.name} does not satisfy MaquinaStream::Streamable: ##{method}
|
|
274
|
+
needs a `#{column}` column, and #{self.class.name} has none. Add the column,
|
|
275
|
+
or define ##{method} on #{self.class.name} yourself.
|
|
276
|
+
See docs/streaming.md.
|
|
277
|
+
MESSAGE
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|