Compare commits

..
Author SHA1 Message Date
Jeremy Daer d1376a97ee Serve Trix from the action_text-trix gem instead of a vendored copy
The importmap pinned a hand-vendored copy of Trix 2.1.19
(vendor/javascript/trix.esm.min.js) that dependency tooling can't see.
The action_text-trix gem already in the bundle ships the same 2.1.19
release as app/assets/javascripts/trix.js and adds it to the asset
precompile list, so pin against that instead and drop the vendored blob.
Trix updates now arrive through ordinary gem bumps, visible to
Dependabot and security advisories.

The gem's build is UMD rather than ESM, which makes no difference here:
application.js imports "trix" only for its side effect of registering
the trix-editor and trix-toolbar custom elements, and the UMD build does
that via its globalThis branch. No bindings are imported from the
module, and Trix styling remains app-owned in
app/assets/stylesheets/actiontext.css.
2026-08-10 20:58:41 -07:00
Jeremy Daer d3f22d78e1 Harden message content filtering + bump thruster to 0.1.23 (#237)
* Bump thruster 0.1.15 → 0.1.23

* Scrub disallowed attributes on allowed tags in message rendering

SanitizeTags removes disallowed tags from message presentation, but
attributes on the tags it allows passed through untouched. Extend the
content-filter chain with a SanitizeAttributes filter that runs Rails'
safe-list sanitizer over the remaining markup, stripping event-handler
attributes and unsafe URI schemes as defense-in-depth alongside the
existing Content-Security-Policy.

Running it after SanitizeTags with the same allowed-tags list makes the
sanitizer's tag pass a no-op, preserving SanitizeTags' remove-not-unwrap
semantics while scrubbing attributes. The attribute allowlist is the
standard ActionText set (which keeps attachments intact) plus class,
which presentation styling relies on.
2026-08-10 15:55:02 -07:00
14 changed files with 169 additions and 261 deletions

No files matched your search

+4 -4
View File
@@ -375,10 +375,10 @@ GEM
railties (>= 6.0.0)
stringio (3.1.8)
thor (1.4.0)
thruster (0.1.15-aarch64-linux)
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
thruster (0.1.23-aarch64-linux)
thruster (0.1.23-arm64-darwin)
thruster (0.1.23-x86_64-darwin)
thruster (0.1.23-x86_64-linux)
tilt (2.6.1)
timeout (0.6.1)
tsort (0.2.0)
@@ -2,7 +2,6 @@ class Accounts::LogosController < ApplicationController
include ActiveStorage::Streaming, ActionView::Helpers::AssetUrlHelper
allow_unauthenticated_access only: :show
content_security_policy false, only: :show
before_action :ensure_can_administer, only: :destroy
def show
-1
View File
@@ -1,6 +1,5 @@
class QrCodeController < ApplicationController
allow_unauthenticated_access
content_security_policy false
def show
url = Base64.urlsafe_decode64(params[:id])
@@ -1,8 +1,6 @@
class Users::AvatarsController < ApplicationController
include ActiveStorage::Streaming
content_security_policy false, only: :show
rescue_from(ActiveSupport::MessageVerifier::InvalidSignature) { head :not_found }
def show
+1 -1
View File
@@ -1,3 +1,3 @@
module ContentFilters
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags, SanitizeAttributes)
end
@@ -0,0 +1,47 @@
class ContentFilters::SanitizeAttributes < ActionText::Content::Filter
def applicable?
true
end
# Scrub attributes on the tags that SanitizeTags allows, using Rails' safe-list
# sanitizer so unsafe URI schemes and event-handler attributes are stripped.
# Runs after SanitizeTags, so passing the same allowed tags makes the tag pass
# a no-op and only attributes are scrubbed.
def apply
sanitizer.sanitize fragment.to_html, tags: allowed_tags, attributes: allowed_attributes
end
private
# Presentation styling relies on class attributes (e.g. unfurled link embeds),
# which the standard ActionText set doesn't include.
EXTRA_ALLOWED_ATTRIBUTES = %w[ class ]
# ActionText::ContentHelper.sanitizer is a single process-wide instance, and
# rails-html-sanitizer reuses one mutable permit scrubber that it reconfigures
# from the tags/attributes on every #sanitize call. Sharing it here would race
# our stricter tag set against ActionText's default rendering on concurrent
# requests, so use a dedicated instance of the same sanitizer class to keep the
# scrubber isolated.
def sanitizer
sanitizer_class.new
end
def sanitizer_class
ActionText::ContentHelper.sanitizer.class
end
def allowed_tags
ContentFilters::SanitizeTags::ALLOWED_TAGS
end
def allowed_attributes
standard_allowed_attributes + EXTRA_ALLOWED_ATTRIBUTES
end
# Mirrors ActionText::ContentHelper#sanitizer_allowed_attributes, which isn't
# exposed at the module level.
def standard_allowed_attributes
ActionText::ContentHelper.allowed_attributes ||
(sanitizer_class.allowed_attributes + ActionText::Attachment::ATTRIBUTES).to_a
end
end
+2 -1
View File
@@ -1,6 +1,7 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<% cache message do %>
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
<% cache [ message, "presentation-v2" ] do %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
+1 -1
View File
@@ -5,7 +5,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@hotwired/turbo-rails", to: "turbo.js"
pin "@rails/actioncable", to: "actioncable.esm.js"
pin "@rails/request.js", to: "@rails--request.js" # @0.0.8
pin "trix", to: "trix.esm.min.js" # @2.1.19
pin "trix", to: "trix.js"
pin "@rails/actiontext", to: "actiontext.js"
pin "highlight.js", to: "highlight.js/core.js"
-2
View File
@@ -1,6 +1,4 @@
ActiveSupport.on_load(:active_storage_blob) do
ActiveStorage::DiskController.content_security_policy false, only: :show
ActiveStorage::DiskController.after_action only: :show do
response.set_header("Cache-Control", "max-age=3600, public")
end
+18 -113
View File
@@ -1,120 +1,25 @@
# Be sure to restart your server when you modify this file.
# Baseline application-wide Content-Security-Policy, deployed in Report-Only
# mode: browsers evaluate the policy and report violations (once a report
# endpoint is wired up) but enforce nothing, so rendering cannot break.
# Tune the policy against observed violations, then flip
# `content_security_policy_report_only` off to enforce it.
#
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Session-stable nonce for permitted inline scripts (the importmap JSON + shim,
# auto-nonced by importmap-rails).
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# The nonce is stable across a session's requests so Turbo snapshot restores
# don't replay a stale nonce and trip CSP. It's the HMAC of a stable cookie
# value keyed by the server secret: the cookie is client-settable, but the
# client can't predict the resulting nonce without knowing secret_key_base.
# # Generate session nonces for permitted importmap and inline scripts
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src)
#
# Campfire sets no per-session verification cookie, so the lightweight
# nonce_id cookie — set on first visit, present for every session including
# unauthenticated ones — is the sole identifier.
#
# The generator runs for every response that carries the policy, so the cookie
# write would otherwise land on public, shared-cacheable responses (QR codes,
# logos, avatars, disk-served blobs) where a cache could replay one client's
# identifier and nonce to others. Those endpoints opt out of the policy with
# `content_security_policy false`; the nonce is only meaningful on documents.
module CSP
module Nonce
COOKIE = "campfire_csp_nonce_id"
def self.generate(request)
hmac(nonce_id(request))
end
def self.hmac(identifier)
OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secret_key_base, identifier)
end
# Read or initialize a stable nonce identifier cookie.
def self.nonce_id(request)
request.cookies[COOKIE] || set_nonce_id(request)
end
def self.set_nonce_id(request)
value = SecureRandom.base64(16)
request.cookie_jar[COOKIE] = { value: value, httponly: true, same_site: :lax }
value
end
end
# Per-install CSP extras.
#
# Campfire is a ONCE product: each customer self-hosts it on their own domain
# and an admin may embed or connect to external hosts — video/embed providers,
# image CDNs, analytics, form or webhook endpoints — that vary per install and
# are unknown at build time. `:self` already tracks this install's own origin;
# these ENV knobs let an admin allow additional hosts without editing this file
# (and without which enforcement would break their legitimate integrations).
#
# Each is a comma-, semicolon-, or whitespace-separated list of CSP source
# expressions, e.g.
#
# CSP_EXTRA_FRAME_SRC="https://www.youtube.com https://player.vimeo.com"
#
# Leave them unset (the default) to keep each directive at :self only.
EXTRA_ENV = {
script_src: "CSP_EXTRA_SCRIPT_SRC",
style_src: "CSP_EXTRA_STYLE_SRC",
img_src: "CSP_EXTRA_IMG_SRC",
connect_src: "CSP_EXTRA_CONNECT_SRC",
frame_src: "CSP_EXTRA_FRAME_SRC",
form_action: "CSP_EXTRA_FORM_ACTION"
}.freeze
# Parse one ENV knob into a list of extra host sources.
#
# Semicolons are tokenized like commas/whitespace: because the nonce forces a
# per-request policy build, a stray `;` in a value (a plausible operator paste,
# e.g. "https://youtube.com; https://vimeo.com") would otherwise land inside a
# single source token and make Rails raise InvalidDirectiveError on every
# request — a site-wide 500 even in report-only mode. Splitting on `;` yields
# valid tokens instead; Rails still validates each one, so no injection is
# introduced (a token with an embedded space still fails validation).
def self.extra(directive)
ENV[EXTRA_ENV.fetch(directive)].to_s.split(/[,;\s]+/).reject(&:blank?)
end
# Build the baseline policy. Kept as a reusable method so it can be exercised
# in isolation by tests as well as at boot.
def self.apply(policy)
policy.default_src :self
policy.script_src :self, *extra(:script_src) # nonce auto-appended via nonce_directives below
# unsafe_inline retained: many style="…" attributes across the message
# composer and ActionText content can't be nonced yet.
policy.style_src :self, :unsafe_inline, *extra(:style_src)
# frame_src / img_src / connect_src start at :self plus any per-install extras
# and get tuned against violation reports during the report-only window.
policy.img_src :self, :data, :blob, *extra(:img_src)
policy.connect_src :self, *extra(:connect_src)
policy.frame_src :self, *extra(:frame_src)
policy.frame_ancestors :self
policy.base_uri :self
policy.form_action :self, *extra(:form_action)
policy.object_src :none
# Specify URI for violation reports once a report sink is available
# policy.report_uri "/csp-violation-report-endpoint"
end
end
Rails.application.configure do
config.content_security_policy { |policy| CSP.apply(policy) }
config.content_security_policy_nonce_generator = ->(request) { CSP::Nonce.generate(request) }
config.content_security_policy_nonce_directives = %w[ script-src ]
# Report violations without enforcing the policy.
config.content_security_policy_report_only = true
end
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
+69
View File
@@ -61,6 +61,75 @@ class ContentFiltersTest < ActionView::TestCase
assert_equal "Hello World", filtered.to_html
end
test "message with a link using an unsafe URI scheme" do
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match /javascript:/, filtered.to_html
assert_match /<a>x<\/a>/, filtered.to_html
end
test "message with an event handler attribute on an allowed tag" do
message = Message.create! room: rooms(:pets), body: '<div><a href="/x" onmouseover="alert(1)">x</a> <span onclick="alert(2)">y</span></div>', client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match /onmouseover/, filtered.to_html
assert_no_match /onclick/, filtered.to_html
assert_match /<a href="\/x">x<\/a>/, filtered.to_html
assert_match /<span>y<\/span>/, filtered.to_html
end
test "message with a data URI link" do
message = Message.create! room: rooms(:pets), body: '<div><a href="data:text/html,pwned">x</a></div>', client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match /data:/, filtered.to_html
assert_match /<a>x<\/a>/, filtered.to_html
end
test "message with a safe link and formatting is preserved" do
body = '<div><a href="https://example.com">example</a> <strong>bold</strong> <code>code</code><ul><li>one</li><li>two</li></ul></div>'
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_match /<a href="https:\/\/example\.com">example<\/a>/, filtered.to_html
assert_match /<strong>bold<\/strong>/, filtered.to_html
assert_match /<code>code<\/code>/, filtered.to_html
assert_match /<ul>\s*<li>one<\/li>\s*<li>two<\/li>\s*<\/ul>/, filtered.to_html
end
test "SanitizeAttributes uses an isolated sanitizer rather than the shared ActionText instance" do
filter = ContentFilters::SanitizeAttributes.new(ActionText::Content.new("<div>x</div>"))
isolated = filter.send(:sanitizer)
assert_not_same ActionText::ContentHelper.sanitizer, isolated,
"must not reuse the process-wide ActionText sanitizer, whose permit scrubber is mutated per call"
assert_not_same isolated, filter.send(:sanitizer),
"each call must build a fresh sanitizer so concurrent requests never share scrubber state"
assert_kind_of ActionText::ContentHelper.sanitizer.class, isolated
end
test "SanitizeAttributes neutralizes unsafe input and preserves benign content on its isolated sanitizer" do
body = <<~HTML.squish
<div><a href="javascript:alert(1)" onclick="x()">link</a>
<a href="data:text/html,pwned">data</a>
<span class="cf-twitter-avatar" onmouseover="y()">avatar</span>
<img src="https://evil.example/x.svg"> Hey #{mention_attachment_for(:david)}</div>
HTML
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::SanitizeAttributes.apply(message.body.body).to_html
assert_no_match /javascript:/, filtered
assert_no_match /data:text\/html/, filtered
assert_no_match /onclick/, filtered
assert_no_match /onmouseover/, filtered
assert_no_match /evil\.example/, filtered
assert_match /<span class="cf-twitter-avatar">avatar<\/span>/, filtered
assert_match />link</, filtered
assert_match /<action-text-attachment sgid="#{users(:david).attachable_sgid}"/, filtered
end
test "message with a mention attachment" do
message = Message.create! room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason)
+27
View File
@@ -0,0 +1,27 @@
require "test_helper"
class MessagesHelperTest < ActionView::TestCase
test "message_presentation neutralizes unsafe URI schemes in links" do
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
presentation = view.message_presentation(message)
assert_no_match /javascript:/, presentation
assert_match /<a>x<\/a>/, presentation
end
test "message_presentation strips event handler attributes from allowed tags" do
message = Message.create! room: rooms(:pets), body: '<div><a href="/x" onmouseover="alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
presentation = view.message_presentation(message)
assert_no_match /onmouseover/, presentation
assert_match /<a href="\/x">x<\/a>/, presentation
end
test "message_presentation preserves safe links and formatting" do
message = Message.create! room: rooms(:pets), body: '<div><a href="https://example.com">example</a> <strong>bold</strong></div>', client_message_id: "0015", creator: users(:jason)
presentation = view.message_presentation(message)
assert_match /<a href="https:\/\/example\.com"[^>]*>example<\/a>/, presentation
assert_match /<strong>bold<\/strong>/, presentation
end
end
-128
View File
@@ -1,128 +0,0 @@
require "test_helper"
class CspNonceTest < ActionDispatch::IntegrationTest
test "policy is delivered Report-Only, not enforced" do
sign_in :david
get root_url
follow_redirect! # root redirects to the last room
assert_response :success
assert response.headers["Content-Security-Policy-Report-Only"].present?,
"Expected a Report-Only CSP header"
assert_nil response.headers["Content-Security-Policy"],
"Policy must not be enforced yet"
end
test "nonce is stable across requests so Turbo restores don't trip CSP" do
sign_in :david
get root_url
nonce1 = report_only_nonce
get root_url
nonce2 = report_only_nonce
assert nonce1.present?, "Expected a nonce in the Report-Only CSP header"
assert_equal nonce1, nonce2, "Nonce must be stable across requests"
end
test "client-set identifier still yields an unpredictable HMAC nonce" do
fake_id = "attacker-controlled-value"
cookies[CSP::Nonce::COOKIE] = fake_id
get root_url
nonce = report_only_nonce
assert_equal CSP::Nonce.hmac(fake_id), nonce,
"Nonce must be HMAC-SHA256 of the identifier keyed by secret_key_base"
end
test "importmap script tag carries the nonce" do
sign_in :david
get root_url
follow_redirect! # root redirects to the last room
assert_response :success
nonce = report_only_nonce
assert_select "script[type='importmap'][nonce=?]", nonce
end
test "a per-install ENV extra host is appended to its directive" do
with_env "CSP_EXTRA_FRAME_SRC" => "https://player.vimeo.com https://www.youtube.com",
"CSP_EXTRA_IMG_SRC" => "https://cdn.example.test" do
header = ActionDispatch::ContentSecurityPolicy.new { |p| CSP.apply(p) }.build
assert_match %r{frame-src[^;]*\bhttps://player\.vimeo\.com\b}, header
assert_match %r{frame-src[^;]*\bhttps://www\.youtube\.com\b}, header
assert_match %r{img-src[^;]*\bhttps://cdn\.example\.test\b}, header
# :self is preserved alongside the extras.
assert_match %r{frame-src 'self'}, header
end
end
test "a semicolon-separated ENV extra tokenizes into valid sources without raising" do
# A plausible operator paste separates hosts with "; ". Because the nonce
# forces a per-request policy build, a semicolon left inside a single source
# token would make Rails raise InvalidDirectiveError on every request — a
# site-wide 500 even in report-only mode. Splitting on ';' must yield both
# hosts as valid tokens and never raise.
with_env "CSP_EXTRA_FRAME_SRC" => "https://a.example; https://b.example" do
header = nil
assert_nothing_raised do
header = ActionDispatch::ContentSecurityPolicy.new { |p| CSP.apply(p) }.build
end
assert_match %r{frame-src[^;]*\bhttps://a\.example\b}, header
assert_match %r{frame-src[^;]*\bhttps://b\.example\b}, header
# Both hosts share the one frame-src directive; the semicolon did not leak
# a second directive into the policy.
assert_equal 1, header.scan(/(?:^|;\s*)frame-src\b/).size,
"Expected exactly one frame-src directive"
end
end
test "public cacheable responses carry neither the policy nor the nonce cookie" do
sign_in :david
blob = ActiveStorage::Blob.create_and_upload!(io: file_fixture("moon.jpg").open, filename: "moon.jpg", content_type: "image/jpeg")
get rails_blob_path(blob)
disk_blob_url = response.location
[
qr_code_path(Base64.urlsafe_encode64("http://example.com")),
account_logo_path,
user_avatar_path(users(:kevin).avatar_token),
disk_blob_url
].each do |path|
cookies.delete CSP::Nonce::COOKIE
get path
assert_response :success
assert response.cache_control[:public], "#{path} is expected to be publicly cacheable"
assert_nil response.headers["Content-Security-Policy-Report-Only"], "#{path} must not carry the policy"
assert_nil cookies[CSP::Nonce::COOKIE], "#{path} must not set the nonce cookie"
end
end
test "directives default to :self only when no ENV extras are set" do
with_env "CSP_EXTRA_FRAME_SRC" => nil, "CSP_EXTRA_IMG_SRC" => nil do
header = ActionDispatch::ContentSecurityPolicy.new { |p| CSP.apply(p) }.build
assert_match %r{frame-src 'self'(;|\z)}, header
end
end
private
def with_env(vars)
original = {}
vars.each_key { |k| original[k] = ENV.key?(k) ? ENV[k] : :__unset__ }
vars.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
yield
ensure
original.each { |k, v| v == :__unset__ ? ENV.delete(k) : ENV[k] = v }
end
def report_only_nonce
response.headers["Content-Security-Policy-Report-Only"].to_s[/'nonce-([^']+)'/, 1]
end
end
-7
View File
@@ -1,7 +0,0 @@
/*
Trix 2.1.19
Copyright © 2026 37signals, LLC
*/
var t="2.1.19";const e="[data-trix-attachment]",i={preview:{presentation:"gallery",caption:{name:!0,size:!0}},file:{caption:{size:!0}}},n={default:{tagName:"div",parse:!1},quote:{tagName:"blockquote",nestable:!0},heading1:{tagName:"h1",terminal:!0,breakOnReturn:!0,group:!1},code:{tagName:"pre",terminal:!0,htmlAttributes:["language"],text:{plaintext:!0}},bulletList:{tagName:"ul",parse:!1},bullet:{tagName:"li",listAttribute:"bulletList",group:!1,nestable:!0,test(t){return r(t.parentNode)===n[this.listAttribute].tagName}},numberList:{tagName:"ol",parse:!1},number:{tagName:"li",listAttribute:"numberList",group:!1,nestable:!0,test(t){return r(t.parentNode)===n[this.listAttribute].tagName}},attachmentGallery:{tagName:"div",exclusive:!0,terminal:!0,parse:!1,group:!1}},r=t=>{var e;return null==t||null===(e=t.tagName)||void 0===e?void 0:e.toLowerCase()},o=navigator.userAgent.match(/android\s([0-9]+.*Chrome)/i),s=o&&parseInt(o[1]);var a={composesExistingText:/Android.*Chrome/.test(navigator.userAgent),recentAndroid:s&&s>12,samsungAndroid:s&&navigator.userAgent.match(/Android.*SM-/),forcesObjectResizing:/Trident.*rv:11/.test(navigator.userAgent),supportsInputEvents:"undefined"!=typeof InputEvent&&["data","getTargetRanges","inputType"].every((t=>t in InputEvent.prototype))},l={ADD_ATTR:["language"],SAFE_FOR_XML:!1,RETURN_DOM:!0},c={attachFiles:"Attach Files",bold:"Bold",bullets:"Bullets",byte:"Byte",bytes:"Bytes",captionPlaceholder:"Add a caption…",code:"Code",heading1:"Heading",indent:"Increase Level",italic:"Italic",link:"Link",numbers:"Numbers",outdent:"Decrease Level",quote:"Quote",redo:"Redo",remove:"Remove",strike:"Strikethrough",undo:"Undo",unlink:"Unlink",url:"URL",urlPlaceholder:"Enter a URL…",GB:"GB",KB:"KB",MB:"MB",PB:"PB",TB:"TB"};const u=[c.bytes,c.KB,c.MB,c.GB,c.TB,c.PB];var h={prefix:"IEC",precision:2,formatter(t){switch(t){case 0:return"0 ".concat(c.bytes);case 1:return"1 ".concat(c.byte);default:let e;"SI"===this.prefix?e=1e3:"IEC"===this.prefix&&(e=1024);const i=Math.floor(Math.log(t)/Math.log(e)),n=(t/Math.pow(e,i)).toFixed(this.precision).replace(/0*$/,"").replace(/\.$/,"");return"".concat(n," ").concat(u[i])}}};const d="\ufeff",g=" ",m=function(t){for(const e in t){const i=t[e];this[e]=i}return this},p=document.documentElement,f=p.matches,b=function(t){let{onElement:e,matchingSelector:i,withCallback:n,inPhase:r,preventDefault:o,times:s}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const a=e||p,l=i,c="capturing"===r,u=function(t){null!=s&&0==--s&&u.destroy();const e=x(t.target,{matchingSelector:l});null!=e&&(null==n||n.call(e,t,e),o&&t.preventDefault())};return u.destroy=()=>a.removeEventListener(t,u,c),a.addEventListener(t,u,c),u},v=function(t){let{bubbles:e,cancelable:i,attributes:n}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};e=!1!==e,i=!1!==i;const r=document.createEvent("Events");return r.initEvent(t,e,i),null!=n&&m.call(r,n),r},A=function(t){let{onElement:e,bubbles:i,cancelable:n,attributes:r}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const o=null!=e?e:p,s=v(t,{bubbles:i,cancelable:n,attributes:r});return o.dispatchEvent(s)},y=function(t,e){if(1===(null==t?void 0:t.nodeType))return f.call(t,e)},x=function(t){let{matchingSelector:e,untilNode:i}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(;t&&t.nodeType!==Node.ELEMENT_NODE;)t=t.parentNode;if(null!=t){if(null==e)return t;if(t.closest&&null==i)return t.closest(e);for(;t&&t!==i;){if(y(t,e))return t;t=t.parentNode}}},C=t=>document.activeElement!==t&&E(t,document.activeElement),E=function(t,e){if(t&&e)for(;e;){if(e===t)return!0;e=e.parentNode}},S=function(t){var e;if(null===(e=t)||void 0===e||!e.parentNode)return;let i=0;for(t=t.previousSibling;t;)i++,t=t.previousSibling;return i},R=t=>{var e;return null==t||null===(e=t.parentNode)||void 0===e?void 0:e.removeChild(t)},T=function(t){let{onlyNodesOfType:e,usingFilter:i,expandEntityReferences:n}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const r=(()=>{switch(e){case"element":return NodeFilter.SHOW_ELEMENT;case"text":return NodeFilter.SHOW_TEXT;case"comment":return NodeFilter.SHOW_COMMENT;default:return NodeFilter.SHOW_ALL}})();return document.createTreeWalker(t,r,null!=i?i:null,!0===n)},k=t=>{var e;return null==t||null===(e=t.tagName)||void 0===e?void 0:e.toLowerCase()},w=function(t){let e,i,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};"object"==typeof t?(n=t,t=n.tagName):n={attributes:n};const r=document.createElement(t);if(null!=n.editable&&(null==n.attributes&&(n.attributes={}),n.attributes.contenteditable=n.editable),n.attributes)for(e in n.attributes)i=n.attributes[e],r.setAttribute(e,i);if(n.style)for(e in n.style)i=n.style[e],r.style[e]=i;if(n.data)for(e in n.data)i=n.data[e],r.dataset[e]=i;return n.className&&n.className.split(" ").forEach((t=>{r.classList.add(t)})),n.textContent&&(r.textContent=n.textContent),n.childNodes&&[].concat(n.childNodes).forEach((t=>{r.appendChild(t)})),r};let D;const L=funLine truncated
/*! @license DOMPurify 3.4.2 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.2/LICENSE */const{entries:oe,setPrototypeOf:se,isFrozen:ae,getPrototypeOf:le,getOwnPropertyDescriptor:ce}=Object;let{freeze:ue,seal:he,create:de}=Object,{apply:ge,construct:me}="undefined"!=typeof Reflect&&Reflect;ue||(ue=function(t){return t}),he||(he=function(t){return t}),ge||(ge=function(t,e){for(var i=arguments.length,n=new Array(i>2?i-2:0),r=2;r<i;r++)n[r-2]=arguments[r];return t.apply(e,n)}),me||(me=function(t){for(var e=arguments.length,i=new Array(e>1?e-1:0),n=1;n<e;n++)i[n-1]=arguments[n];return new t(...i)});const pe=Me(Array.prototype.forEach),fe=Me(Array.prototype.lastIndexOf),be=Me(Array.prototype.pop),ve=Me(Array.prototype.push),Ae=Me(Array.prototype.splice),ye=Array.isArray,xe=Me(String.prototype.toLowerCase),Ce=Me(String.prototype.toString),Ee=Me(String.prototype.match),Se=Me(String.prototype.replace),Re=Me(String.prototype.indexOf),Te=Me(String.prototype.trim),ke=Me(Number.prototype.toString),we=Me(Boolean.prototype.toString),De="undefined"==typeof BigInt?null:Me(BigInt.prototype.toString),Le="undefined"==typeof Symbol?null:Me(Symbol.prototype.toString),Ne=Me(Object.prototype.hasOwnProperty),Ie=Me(Object.prototype.toString),Oe=Me(RegExp.prototype.test),Fe=(Pe=TypeError,function(){for(var t=arguments.length,e=new Array(t),i=0;i<t;i++)e[i]=arguments[i];return me(Pe,e)});var Pe;function Me(t){return function(e){e instanceof RegExp&&(e.lastIndex=0);for(var i=arguments.length,n=new Array(i>1?i-1:0),r=1;r<i;r++)n[r-1]=arguments[r];return ge(t,e,n)}}function Be(t,e){let i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:xe;if(se&&se(t,null),!ye(e))return t;let n=e.length;for(;n--;){let r=e[n];if("string"==typeof r){const t=i(r);t!==r&&(ae(e)||(e[n]=t),r=t)}t[r]=!0}return t}function _e(t){for(let e=0;e<t.length;e++){Ne(t,e)||(t[e]=null)}return t}function je(t){const e=de(null);for(const[i,n]of oe(t)){Ne(t,i)&&(ye(n)?e[i]=_e(n):n&&"object"==typeof n&&n.constructor===Object?e[i]=je(n):e[i]=n)}return e}function We(t,e){for(;null!==t;){const i=ce(t,e);if(i){if(i.get)return Me(i.get);if("function"==typeof i.value)return Me(i.value)}t=le(t)}return function(){return null}}const Ue=ue(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","search","section","select","shadow","slot","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),Ve=ue(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","enterkeyhint","exportparts","filter","font","g","glyph","glyphref","hkern","image","inputmode","line","lineargradient","marker","mask","metadata","mpath","part","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),ze=ue(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),He=ue(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),qe=ue(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),Je=ue(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),Ke=ue(["#text"]),Ge=ue(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","contLine truncated
//# sourceMappingURL=trix.esm.min.js.map