sqrl-rack 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.
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SQRL
6
+ module Rack
7
+ # Framework-neutral request handlers around a {Server::Handler}: issue login URLs,
8
+ # answer the SQRL client's POST, the browser's status poll and the login callback.
9
+ # {Middleware} drives these for plain Rack; the Rails engine calls them from
10
+ # controllers.
11
+ #
12
+ # The pending login id lives in the browser session under +session_key+; the
13
+ # identity of a completed login is written under +identity_key+ by {#callback}.
14
+ class Endpoint
15
+ # Default session key holding the pending-login id.
16
+ SESSION_KEY = "sqrl.pending_login"
17
+ # Default session key holding the logged-in identity key (base64url).
18
+ IDENTITY_KEY = "sqrl.identity"
19
+
20
+ # What {#callback} returns when a login completes.
21
+ Completion = Struct.new(:identity, :pending_login, :via, keyword_init: true) do
22
+ # @return [String] the identity key as base64url
23
+ def idk_b64 = identity.idk_b64
24
+ # @return [String, nil] the same-site return path captured when the login page was issued
25
+ def return_to = pending_login.return_to
26
+ def cps? = via == :cps
27
+ end
28
+
29
+ attr_reader :handler, :config, :session_key, :identity_key, :status_path, :callback_path, :script_path,
30
+ :refresh_path
31
+
32
+ # Headers of a 429 answer.
33
+ RATE_LIMITED_HEADERS = { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }.freeze
34
+
35
+ # @param handler [Server::Handler]
36
+ # @param status_path [String] app-relative path of the JSON poll endpoint
37
+ # @param callback_path [String] app-relative path of the login callback
38
+ # (+config.callback_path+ must be the public form of the same path)
39
+ # @param script_path [String] app-relative path serving +sqrl.js+
40
+ # @param ip [#call, nil] +(request) -> String+ resolving the client IP; defaults to
41
+ # +Rack::Request#ip+
42
+ # +poll_requires_ip_match+ (default false): when true, a login completed through the
43
+ # browser's own cookie (the poll path, no CPS token) counts only when the SQRL
44
+ # client's IP matched the browser's. This is an optional hardening against a client
45
+ # that sent +noiptest+ being tricked into authenticating a login page opened by
46
+ # someone else; it also refuses legitimate logins from a phone on another network,
47
+ # which is why it is off by default. CPS completions are unaffected.
48
+ # +refresh_path+ serves {#refresh}: a JSON endpoint the page calls (through
49
+ # +SQRL.refresh+) to swap an expired login URL for a fresh one without a reload.
50
+ def initialize(handler:, session_key: SESSION_KEY, identity_key: IDENTITY_KEY, status_path: "/sqrl/status",
51
+ callback_path: nil, script_path: "/sqrl/sqrl.js", refresh_path: "/sqrl/refresh", ip: nil,
52
+ poll_requires_ip_match: false)
53
+ @handler = handler
54
+ @refresh_path = refresh_path
55
+ @poll_requires_ip_match = poll_requires_ip_match
56
+ @config = handler.config
57
+ @session_key = session_key
58
+ @identity_key = identity_key
59
+ @status_path = status_path
60
+ @callback_path = callback_path || handler.config.callback_path
61
+ @script_path = script_path
62
+ @ip = ip
63
+ end
64
+
65
+ # Issue a fresh login URL and remember its pending login in the browser session.
66
+ #
67
+ # @param request [Rack::Request]
68
+ # @param return_to [String, nil] where the browser goes after login (same-site path)
69
+ # @param cancel_url [String, nil] encoded into +can=+
70
+ # @param link_user_ref [Object, nil] signed-in user to link a new identity to
71
+ # @return [Server::LoginURL]
72
+ def issue(request, return_to: nil, cancel_url: nil, link_user_ref: nil)
73
+ session = session_of(request)
74
+ login = @handler.new_login_url(ip: client_ip(request), cancel_url: cancel_url, return_to: return_to,
75
+ link_user_ref: link_user_ref)
76
+ session[@session_key] = login.pending_login_id
77
+ login
78
+ end
79
+
80
+ # The pending login id stored in the browser session, if any.
81
+ def pending_login_id(request)
82
+ session = request.env["rack.session"]
83
+ session && session[@session_key]
84
+ end
85
+
86
+ # Handle the SQRL client's POST. Always 200 with the protocol body; TIF carries
87
+ # failures.
88
+ #
89
+ # @return [Array] a Rack response triple
90
+ def cli(request)
91
+ result = @handler.handle(nut: request.GET["nut"].to_s, body: form_body(request), ip: client_ip(request))
92
+ [200, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store",
93
+ "x-sqrl-tif" => result.tif.to_hex }, [result.body]]
94
+ rescue Server::RateLimited => e
95
+ rate_limited(e)
96
+ end
97
+
98
+ # The 429 response for a {Server::RateLimited} raised by a hook.
99
+ #
100
+ # @return [Array] a Rack response triple
101
+ def rate_limited(error, json: false)
102
+ headers = RATE_LIMITED_HEADERS.dup
103
+ headers["retry-after"] = error.retry_after.to_i.to_s if error.retry_after
104
+ body = json ? JSON.generate(error: "rate_limited", message: error.message) : "#{error.message}\n"
105
+ headers["content-type"] = "application/json; charset=utf-8" if json
106
+ [429, headers, [body]]
107
+ end
108
+
109
+ # Hand the page a fresh login URL for its browser session (a new pending login and
110
+ # nut, carrying over +return_to+ and the link target of the previous one), as JSON:
111
+ # +{"sqrl_url", "status_url", "qr_svg"}+. +qr=0+ leaves the QR code out.
112
+ #
113
+ # @return [Array] a Rack response triple
114
+ def refresh(request)
115
+ previous_id = pending_login_id(request)
116
+ previous = previous_id && @handler.stores.pending_logins.find(previous_id)
117
+ login = issue(request, return_to: previous&.return_to, link_user_ref: previous&.link_user_ref)
118
+ payload = { sqrl_url: login.url, status_url: status_url(request), refresh_url: refresh_url(request) }
119
+ payload[:qr_svg] = QR.svg(login.url) unless request.GET["qr"] == "0"
120
+ [200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" },
121
+ [JSON.generate(payload)]]
122
+ rescue Server::RateLimited => e
123
+ rate_limited(e, json: true)
124
+ end
125
+
126
+ # Answer the browser's poll with JSON.
127
+ #
128
+ # @return [Array] a Rack response triple
129
+ def status(request)
130
+ id = pending_login_id(request)
131
+ payload =
132
+ if id.nil?
133
+ { authenticated: false }
134
+ else
135
+ login = @handler.stores.pending_logins.find(id)
136
+ if login.nil? || login.expired?(@config.now)
137
+ { authenticated: false, expired: true }
138
+ elsif login.authenticated? && poll_completable?(login)
139
+ { authenticated: true, next: callback_url(request) }
140
+ else
141
+ { authenticated: false }
142
+ end
143
+ end
144
+ [200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" },
145
+ [JSON.generate(payload)]]
146
+ end
147
+
148
+ # Complete a login. With a +t=+ token (CPS) the token is claimed once and must
149
+ # belong to this browser's pending login; without one, the browser's own pending
150
+ # login is completed if the SQRL client authenticated it. Either way the pending
151
+ # login is detached from the session so it completes at most once. On success the
152
+ # identity's +idk+ is stored under +identity_key+.
153
+ #
154
+ # @return [Completion, nil]
155
+ def callback(request)
156
+ session = session_of(request)
157
+ token = request.GET["t"]
158
+ expected_id = session[@session_key]
159
+ login, via =
160
+ if token && !token.empty?
161
+ [@handler.claim_cps(token), :cps]
162
+ else
163
+ [expected_id && @handler.stores.pending_logins.find(expected_id), :poll]
164
+ end
165
+ return nil if login.nil? || expected_id.nil? || !Util.secure_compare(login.id.to_s, expected_id.to_s)
166
+ return nil unless login.authenticated? && !login.expired?(@config.now)
167
+ return nil if via == :poll && !poll_completable?(login)
168
+
169
+ identity = @handler.stores.identities.find(login.authenticated_idk)
170
+ return nil if identity.nil? || identity.disabled?
171
+
172
+ session.delete(@session_key)
173
+ session[@identity_key] = identity.idk_b64
174
+ Completion.new(identity: identity, pending_login: login, via: via)
175
+ end
176
+
177
+ # Whether a pending login may be completed without a CPS token.
178
+ def poll_completable?(login)
179
+ !@poll_requires_ip_match || login.ip_matched == true
180
+ end
181
+
182
+ # The identity currently logged in through SQRL in this browser session, or nil.
183
+ def current_identity(request)
184
+ session = request.env["rack.session"]
185
+ idk_b64 = session && session[@identity_key]
186
+ return nil unless idk_b64
187
+
188
+ @handler.stores.identities.find(Util.b64u_decode(idk_b64.to_s))
189
+ rescue FormatError
190
+ nil
191
+ end
192
+
193
+ # Forget the SQRL login and any pending login in this browser session.
194
+ def logout(request)
195
+ session = request.env["rack.session"]
196
+ return unless session
197
+
198
+ session.delete(@identity_key)
199
+ session.delete(@session_key)
200
+ nil
201
+ end
202
+
203
+ # Serve +sqrl.js+.
204
+ #
205
+ # @return [Array] a Rack response triple
206
+ def script(request)
207
+ etag = %("#{Assets.javascript_digest}")
208
+ if request.env["HTTP_IF_NONE_MATCH"] == etag
209
+ return [304, { "etag" => etag, "cache-control" => "public, max-age=86400" }, []]
210
+ end
211
+
212
+ [200, { "content-type" => "application/javascript; charset=utf-8", "etag" => etag,
213
+ "cache-control" => "public, max-age=86400" }, [Assets.javascript]]
214
+ end
215
+
216
+ # Browser-facing URLs, honouring the mount prefix (+SCRIPT_NAME+).
217
+ def status_url(request) = prefixed(request, @status_path)
218
+ # @return [String] path of the callback for this request (mount prefix included)
219
+ def callback_url(request) = prefixed(request, @callback_path)
220
+ # @return [String] path of +sqrl.js+ for this request (mount prefix included)
221
+ def script_url(request) = prefixed(request, @script_path)
222
+ # @return [String] path of the refresh endpoint for this request (mount prefix included)
223
+ def refresh_url(request) = prefixed(request, @refresh_path)
224
+
225
+ # The client IP a request comes from.
226
+ def client_ip(request)
227
+ (@ip ? @ip.call(request) : request.ip).to_s
228
+ end
229
+
230
+ private
231
+
232
+ # The POST body. Middleware upstream (Rack::Protection, parameter parsers) may
233
+ # already have consumed +rack.input+ without rewinding it, so a form body is rebuilt
234
+ # from Rack's cached form hash; SQRL values are base64url, which the form decoding
235
+ # leaves untouched.
236
+ def form_body(request)
237
+ if request.form_data? && request.POST.any?
238
+ request.POST.map { |k, v| "#{k}=#{v}" }.join("&")
239
+ else
240
+ body = request.body
241
+ body.rewind if body.respond_to?(:rewind)
242
+ data = body.read.to_s
243
+ body.rewind if body.respond_to?(:rewind)
244
+ data
245
+ end
246
+ rescue StandardError
247
+ ""
248
+ end
249
+
250
+ def prefixed(request, path)
251
+ "#{request.script_name}#{path}"
252
+ end
253
+
254
+ def session_of(request)
255
+ session = request.env["rack.session"]
256
+ return session if session
257
+
258
+ raise Error, "SQRL::Rack needs a session: add a session middleware (for example " \
259
+ "Rack::Session::Cookie) before SQRL::Rack::Middleware, or `enable :sessions` in Sinatra"
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rack
5
+ # Request-scoped view of SQRL for the application, available as
6
+ # +env["sqrl.server"]+ on every request the {Middleware} passes through.
7
+ class Facade
8
+ attr_reader :endpoint, :request
9
+
10
+ def initialize(endpoint, request)
11
+ @endpoint = endpoint
12
+ @request = request
13
+ end
14
+
15
+ # The login URL for this page view (issued once per request).
16
+ #
17
+ # @return [Server::LoginURL]
18
+ def login_url(return_to: nil, cancel_url: nil, link_user_ref: nil)
19
+ @login_url ||= @endpoint.issue(@request, return_to: return_to, cancel_url: cancel_url,
20
+ link_user_ref: link_user_ref)
21
+ end
22
+
23
+ # Full login markup (link, QR, status text, script tag). Options are those of
24
+ # {LoginBlock.render} plus +return_to:+, +link_user_ref:+ and +script:+ (false to
25
+ # leave the script tag out).
26
+ #
27
+ # When a +before_issue+ hook refuses the request ({Server::RateLimited}) the block
28
+ # degrades to a short "try again later" message instead of raising.
29
+ def login_block(return_to: nil, cancel_url: nil, link_user_ref: nil, script: true, **options)
30
+ login = login_url(return_to: return_to, cancel_url: cancel_url, link_user_ref: link_user_ref)
31
+ LoginBlock.render(login_url: login.url, status_url: @endpoint.status_url(@request),
32
+ cancel_url: cancel_url, script_url: script ? @endpoint.script_url(@request) : nil,
33
+ refresh_url: @endpoint.refresh_url(@request), **options)
34
+ rescue Server::RateLimited => e
35
+ LoginBlock.unavailable(message: e.message, css_class: options.fetch(:css_class, "sqrl-login"))
36
+ end
37
+
38
+ # @return [String] a +<script>+ tag loading +sqrl.js+ from this site
39
+ def script_tag
40
+ LoginBlock.script_tag(src: @endpoint.script_url(@request))
41
+ end
42
+
43
+ # @return [String] the login URL as an SVG QR code (see {QR.svg})
44
+ def qr_svg(**options)
45
+ QR.svg(login_url.url, **options)
46
+ end
47
+
48
+ # @return [String] see {Endpoint#status_url}
49
+ def status_url = @endpoint.status_url(@request)
50
+ # @return [String] see {Endpoint#callback_url}
51
+ def callback_url = @endpoint.callback_url(@request)
52
+ # @return [String] see {Endpoint#script_url}
53
+ def script_url = @endpoint.script_url(@request)
54
+ # @return [String] see {Endpoint#refresh_url}
55
+ def refresh_url = @endpoint.refresh_url(@request)
56
+
57
+ # The SQRL identity record logged in to this browser session, or nil.
58
+ def identity
59
+ return @identity if defined?(@identity)
60
+
61
+ @identity = @endpoint.current_identity(@request)
62
+ end
63
+
64
+ # @return [String, nil] the logged-in identity key as base64url
65
+ def idk_b64 = identity&.idk_b64
66
+
67
+ def authenticated?
68
+ !identity.nil?
69
+ end
70
+
71
+ # Forget the SQRL login in this session.
72
+ def logout!
73
+ @endpoint.logout(@request)
74
+ remove_instance_variable(:@identity) if defined?(@identity)
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rack
5
+ # Raised when a request's host may not be used to issue SQRL URLs. Integrations answer
6
+ # 400.
7
+ class HostNotAllowed < Error; end
8
+
9
+ # Decides which host (with port) and scheme a request's SQRL URLs are issued for, and
10
+ # bounds the set of hosts an integration keeps per-host state for.
11
+ #
12
+ # The host is the string every identity key is derived from, so a deployment should
13
+ # configure it explicitly. When it is not configured the request's +Host+ header is
14
+ # used: a warning is logged once, the host must pass +allowed_hosts+ when that list is
15
+ # given, and at most +limit+ distinct hosts are accepted per process; further ones
16
+ # raise {HostNotAllowed} rather than growing the per-host caches without bound.
17
+ #
18
+ # {#fetch} is the bounded, thread-safe cache integrations keep their per-host handler
19
+ # or endpoint in; a configured host never counts against the limit.
20
+ class HostResolver
21
+ # Default number of distinct derived hosts the handler cache holds.
22
+ DEFAULT_LIMIT = 16
23
+
24
+ attr_reader :host, :scheme, :limit
25
+
26
+ # @param host [String, nil] the configured public host (with port), or nil to derive
27
+ # @param scheme [String, nil] +sqrl+ (the default and the only standard scheme) or
28
+ # +qrl+ for the loopback-only +insecure_http+ testing mode; never derived from the request
29
+ # @param allowed_hosts [Array<String, Regexp>, nil] accepted derived hosts
30
+ # @param limit [Integer] maximum number of distinct derived hosts kept
31
+ # @param logger [#warn, nil] receives the one-time warning; +Kernel#warn+ when nil
32
+ def initialize(host: nil, scheme: nil, allowed_hosts: nil, limit: DEFAULT_LIMIT, logger: nil)
33
+ @host = host&.to_s
34
+ @scheme = scheme&.to_s
35
+ @allowed_hosts = allowed_hosts
36
+ @limit = limit || DEFAULT_LIMIT
37
+ @logger = logger
38
+ @mutex = Mutex.new
39
+ @cache = {}
40
+ @warned = false
41
+ end
42
+
43
+ # @return [Boolean] whether the host comes from configuration rather than requests
44
+ def configured?
45
+ !@host.nil? && !@host.empty?
46
+ end
47
+
48
+ # The host (with port) and scheme for a request.
49
+ #
50
+ # @param request [Rack::Request]
51
+ # @return [Array(String, String)]
52
+ # @raise [HostNotAllowed] when a derived host is not allowed
53
+ def resolve(request)
54
+ host = configured? ? @host : derived_host(request)
55
+ [host, @scheme || "sqrl"]
56
+ end
57
+
58
+ # Fetch (or build with the block) the cached object for +key+, whose first element
59
+ # must be the host. Once +limit+ distinct derived hosts are cached, a key for a new
60
+ # host raises {HostNotAllowed} (keys for already-known hosts, for example other
61
+ # mount prefixes, are still accepted).
62
+ def fetch(key)
63
+ @mutex.synchronize do
64
+ return @cache[key] if @cache.key?(key)
65
+
66
+ host = host_of(key)
67
+ if !configured? && known_hosts.size >= @limit && !known_hosts.include?(host)
68
+ raise HostNotAllowed, "too many distinct request hosts (limit #{@limit}); configure host: explicitly"
69
+ end
70
+
71
+ @cache[key] = yield
72
+ end
73
+ end
74
+
75
+ # Distinct hosts currently cached.
76
+ def known_hosts
77
+ @cache.keys.map { |k| host_of(k) }.uniq
78
+ end
79
+
80
+ # Number of cached entries.
81
+ def size
82
+ @mutex.synchronize { @cache.size }
83
+ end
84
+
85
+ # Whether +host+ (with or without port) passes +allowed_hosts+ (always when none).
86
+ def allowed?(host)
87
+ Server::Config.new(host: "placeholder.invalid", allowed_hosts: @allowed_hosts).host_allowed?(host)
88
+ end
89
+
90
+ private
91
+
92
+ def host_of(key)
93
+ key.is_a?(Array) ? key.first : key
94
+ end
95
+
96
+ def derived_host(request)
97
+ host = request.host_with_port.to_s
98
+ raise HostNotAllowed, "request has no Host" if host.empty?
99
+ raise HostNotAllowed, "host #{host.inspect} is not in allowed_hosts" unless allowed?(host)
100
+
101
+ warn_once(host)
102
+ host
103
+ end
104
+
105
+ def warn_once(host)
106
+ return if @warned
107
+
108
+ @warned = true
109
+ message = "[sqrl] host not configured; deriving it from the request (#{host}). Identities are keyed " \
110
+ "by host, so set host: explicitly in production."
111
+ @logger.respond_to?(:warn) ? @logger.warn(message) : Kernel.warn(message)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module SQRL
6
+ module Rack
7
+ # Framework-neutral HTML for a SQRL login: the link, the QR code and the status text
8
+ # that +sqrl.js+ drives. Every framework integration renders this.
9
+ module LoginBlock
10
+ # Default status texts, overridable through +texts:+.
11
+ DEFAULT_TEXTS = {
12
+ message: "Scan the code with your SQRL client, or click the link if it is on this device.",
13
+ cps: "Local SQRL client detected.",
14
+ done: "Logged in, redirecting…",
15
+ expired: "This login code expired. Reload the page to get a new one.",
16
+ refresh: "Get a new code",
17
+ unavailable: "SQRL login is temporarily unavailable. Please try again in a moment."
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ # @param login_url [String, #to_s] the +sqrl://+ URL (a {Server::LoginURL} works)
23
+ # @param status_url [String] cookie-bound poll endpoint
24
+ # @param cancel_url [String, nil] renders a cancel link when given
25
+ # @param qr [Boolean] include the QR code
26
+ # @param qr_options [Hash] passed to {QR.svg}
27
+ # @param link_text [String]
28
+ # @param cancel_text [String]
29
+ # @param texts [Hash] overrides for {DEFAULT_TEXTS} (+:message+, +:cps+, +:done+, +:expired+)
30
+ # @param css_class [String] class of the root element (BEM children derive from it)
31
+ # @param poll_ms [Integer] poll interval
32
+ # @param script_url [String, nil] when given, a deferred +<script src>+ is appended
33
+ # @param refresh_url [String, nil] JSON endpoint +sqrl.js+ calls to replace an
34
+ # expired login URL in place (renders a hidden "Get a new code" button)
35
+ # @param extra_attrs [Hash] extra attributes on the root element
36
+ # @return [String] HTML
37
+ def render(login_url:, status_url:, cancel_url: nil, qr: true, qr_options: {}, link_text: "Sign in with SQRL",
38
+ cancel_text: "Cancel", texts: {}, css_class: "sqrl-login", poll_ms: 2000, script_url: nil,
39
+ refresh_url: nil, extra_attrs: {})
40
+ url = login_url.to_s
41
+ t = DEFAULT_TEXTS.merge(texts)
42
+ attrs = {
43
+ "class" => css_class, "data-sqrl-login" => "", "data-sqrl-url" => url, "data-sqrl-status" => status_url,
44
+ "data-sqrl-poll" => poll_ms.to_i, "data-sqrl-text-message" => t[:message], "data-sqrl-text-cps" => t[:cps],
45
+ "data-sqrl-text-done" => t[:done], "data-sqrl-text-expired" => t[:expired]
46
+ }
47
+ attrs["data-sqrl-refresh"] = refresh_url if refresh_url
48
+ attrs.merge!(extra_attrs.transform_keys(&:to_s))
49
+ html = "<div#{attributes(attrs)}>\n"
50
+ html << %( <a class="#{h(css_class)}__link" data-sqrl-link href="#{h(url)}" rel="nofollow">#{h(link_text)}</a>\n)
51
+ html << %( <div class="#{h(css_class)}__qr" data-sqrl-qr>#{QR.svg(url, **qr_options)}</div>\n) if qr
52
+ html << %( <p class="#{h(css_class)}__message" data-sqrl-message aria-live="polite">#{h(t[:message])}</p>\n)
53
+ if refresh_url
54
+ html << %( <button type="button" class="#{h(css_class)}__refresh" data-sqrl-refresh-button hidden>#{h(t[:refresh])}</button>\n)
55
+ end
56
+ html << %( <a class="#{h(css_class)}__cancel" href="#{h(cancel_url)}">#{h(cancel_text)}</a>\n) if cancel_url
57
+ html << "</div>"
58
+ html << "\n" << script_tag(src: script_url) if script_url
59
+ html
60
+ end
61
+
62
+ # The markup shown instead of the login block when issuing a login URL was refused
63
+ # (for example by a rate-limit hook).
64
+ def unavailable(message: DEFAULT_TEXTS[:unavailable], css_class: "sqrl-login")
65
+ %(<div class="#{h(css_class)} #{h(css_class)}--unavailable" data-sqrl-unavailable role="status">) \
66
+ "#{h(message)}</div>"
67
+ end
68
+
69
+ # A script tag for +sqrl.js+: external when +src+ is given, inline otherwise.
70
+ def script_tag(src: nil)
71
+ if src
72
+ %(<script src="#{h(src)}" defer></script>)
73
+ else
74
+ "<script>\n#{Assets.javascript}\n</script>"
75
+ end
76
+ end
77
+
78
+ # Attribute names are escaped too; a name with characters outside the HTML attribute
79
+ # name grammar is dropped rather than rendered.
80
+ def attributes(hash)
81
+ hash.map do |k, v|
82
+ name = k.to_s
83
+ next "" unless name.match?(/\A[A-Za-z_:][-A-Za-z0-9_:.]*\z/)
84
+
85
+ v.to_s.empty? && name.start_with?("data-") ? " #{name}" : %( #{name}="#{h(v)}")
86
+ end.join
87
+ end
88
+ private_class_method :attributes
89
+
90
+ def h(str)
91
+ CGI.escapeHTML(str.to_s)
92
+ end
93
+ private_class_method :h
94
+ end
95
+ end
96
+ end