mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 08:26:48 +09:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1d304999d |
No files matched your search
+4
-4
@@ -375,10 +375,10 @@ GEM
|
|||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.8)
|
stringio (3.1.8)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
thruster (0.1.23-aarch64-linux)
|
thruster (0.1.15-aarch64-linux)
|
||||||
thruster (0.1.23-arm64-darwin)
|
thruster (0.1.15-arm64-darwin)
|
||||||
thruster (0.1.23-x86_64-darwin)
|
thruster (0.1.15-x86_64-darwin)
|
||||||
thruster (0.1.23-x86_64-linux)
|
thruster (0.1.15-x86_64-linux)
|
||||||
tilt (2.6.1)
|
tilt (2.6.1)
|
||||||
timeout (0.6.1)
|
timeout (0.6.1)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# Prepended onto Turbo::StreamsChannel. The subscriber names the channel it wants, so
|
|
||||||
# authorizing room messages only in RoomMessagesChannel would leave the stock channel as
|
|
||||||
# a way around it: same signed stream name, no membership check. Turn those names away
|
|
||||||
# here and RoomMessagesChannel becomes the only door.
|
|
||||||
module RoomStreamsAreAuthorized
|
|
||||||
def subscribed
|
|
||||||
if RoomMessagesChannel.guarded_stream?(verified_stream_name_from_params)
|
|
||||||
reject
|
|
||||||
else
|
|
||||||
super
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Authorizes the room message stream when the subscription is made, so that revoking a
|
|
||||||
# membership actually stops delivery.
|
|
||||||
#
|
|
||||||
# Turbo's stock channel verifies only the signature on the stream name. That name carries
|
|
||||||
# no expiry and no binding to a user, so one harvested while a member keeps working after
|
|
||||||
# the membership is gone. Reconnecting makes it worse rather than better: revoking a
|
|
||||||
# membership disconnects the user with reconnect: true, and the client then replays its
|
|
||||||
# subscriptions on the fresh socket.
|
|
||||||
#
|
|
||||||
# The room is derived from the verified stream name rather than taken as a parameter, so
|
|
||||||
# there's nothing for a subscriber to point somewhere else. The subscriber also doesn't
|
|
||||||
# get to choose the channel: Turbo::StreamsChannel turns these stream names away, so this
|
|
||||||
# is the only way onto them. See config/initializers/turbo_streams_authorization.rb.
|
|
||||||
class RoomMessagesChannel < ApplicationCable::Channel
|
|
||||||
extend Turbo::Streams::StreamName
|
|
||||||
include Turbo::Streams::StreamName::ClassMethods
|
|
||||||
|
|
||||||
STREAM_SUFFIX = "messages"
|
|
||||||
|
|
||||||
class << self
|
|
||||||
# True for the stream names this channel exists to guard, whoever is asking.
|
|
||||||
def guarded_stream?(stream_name)
|
|
||||||
stream_name.to_s.split(":", 2).second == STREAM_SUFFIX
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribable_room(user, stream_name)
|
|
||||||
gid_param, suffix = stream_name.to_s.split(":", 2)
|
|
||||||
|
|
||||||
if suffix == STREAM_SUFFIX && room = room_from(gid_param)
|
|
||||||
user.rooms.find_by(id: room.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def room_from(gid_param)
|
|
||||||
GlobalID::Locator.locate gid_param, only: Room
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribed
|
|
||||||
if stream_name = authorized_stream_name
|
|
||||||
stream_from stream_name
|
|
||||||
else
|
|
||||||
reject
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def authorized_stream_name
|
|
||||||
stream_name = verified_stream_name_from_params
|
|
||||||
stream_name if stream_name.present? && self.class.subscribable_room(current_user, stream_name)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
class UnreadRoomsChannel < ApplicationCable::Channel
|
class UnreadRoomsChannel < ApplicationCable::Channel
|
||||||
# Scoped per user, like ReadRoomsChannel. A single global stream would tell every
|
|
||||||
# authenticated connection when any room on the account is active, including closed
|
|
||||||
# rooms and direct conversations they're not part of.
|
|
||||||
def self.stream_name_for(user_id)
|
|
||||||
"user_#{user_id}_unreads"
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_from self.class.stream_name_for(current_user.id)
|
stream_from "unread_rooms"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -42,12 +42,6 @@ class Rooms::ClosedsController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Closed)
|
@room = @room.becomes!(Rooms::Closed)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
|
||||||
# rooms never are: converting one would let its creator revise who's in it.
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms.without_directs
|
|
||||||
end
|
|
||||||
|
|
||||||
def grantees
|
def grantees
|
||||||
User.where(id: grantee_ids)
|
User.where(id: grantee_ids)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,13 +29,8 @@ class Rooms::DirectsController < RoomsController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# All users in a direct room can administer it. Only direct rooms, though: this
|
# All users in a direct room can administer it
|
||||||
# relaxation is why room_scope below has to keep every other type out of reach.
|
|
||||||
def ensure_can_administer
|
def ensure_can_administer
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms.directs
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -40,12 +40,6 @@ class Rooms::OpensController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Open)
|
@room = @room.becomes!(Rooms::Open)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
|
||||||
# rooms never are: promoting one would republish its history to the whole account.
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms.without_directs
|
|
||||||
end
|
|
||||||
|
|
||||||
def broadcast_create_room(room)
|
def broadcast_create_room(room)
|
||||||
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,19 +20,13 @@ class RoomsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
def set_room
|
||||||
if room = room_scope.find_by(id: params[:room_id] || params[:id])
|
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
|
||||||
@room = room
|
@room = room
|
||||||
else
|
else
|
||||||
redirect_to root_url, alert: "Room not found or inaccessible"
|
redirect_to root_url, alert: "Room not found or inaccessible"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Subclasses narrow this to the room types they're allowed to act on, so that one
|
|
||||||
# room namespace can't be used to reach another's rooms.
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_can_administer
|
def ensure_can_administer
|
||||||
head :forbidden unless Current.user.can_administer?(@room)
|
head :forbidden unless Current.user.can_administer?(@room)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
module ContentFilters
|
module ContentFilters
|
||||||
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags, SanitizeAttributes)
|
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
|
||||||
end
|
end
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
module Message::Broadcasts
|
module Message::Broadcasts
|
||||||
def broadcast_create
|
def broadcast_create
|
||||||
broadcast_append_to room, :messages, target: [ room, :messages ]
|
broadcast_append_to room, :messages, target: [ room, :messages ]
|
||||||
broadcast_unread_room
|
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_remove
|
def broadcast_remove
|
||||||
broadcast_remove_to room, :messages
|
broadcast_remove_to room, :messages
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
# Fanned out to the room's members rather than published on one global stream, so
|
|
||||||
# that the timing of activity in a room only reaches people who are in it.
|
|
||||||
def broadcast_unread_room
|
|
||||||
room.memberships.pluck(:user_id).each do |user_id|
|
|
||||||
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -22,8 +22,6 @@ class Room < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||||
|
|
||||||
validate :direct_rooms_keep_their_type, on: :update
|
|
||||||
|
|
||||||
scope :opens, -> { where(type: "Rooms::Open") }
|
scope :opens, -> { where(type: "Rooms::Open") }
|
||||||
scope :closeds, -> { where(type: "Rooms::Closed") }
|
scope :closeds, -> { where(type: "Rooms::Closed") }
|
||||||
scope :directs, -> { where(type: "Rooms::Direct") }
|
scope :directs, -> { where(type: "Rooms::Direct") }
|
||||||
@@ -67,15 +65,6 @@ class Room < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# Open and closed rooms convert into each other freely. A direct room can't become
|
|
||||||
# either: its participants agreed to a private conversation, not to one whose
|
|
||||||
# audience someone else gets to widen afterwards.
|
|
||||||
def direct_rooms_keep_their_type
|
|
||||||
if type_changed? && type_was == "Rooms::Direct"
|
|
||||||
errors.add :type, "can't be changed for a direct room"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def unread_memberships(message)
|
def unread_memberships(message)
|
||||||
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
||||||
|
|
||||||
<%# 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 do %>
|
||||||
<% cache [ message, "presentation-v2" ] do %>
|
|
||||||
<%= message_tag message do %>
|
<%= message_tag message do %>
|
||||||
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
|
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
|
<%= turbo_stream_from @room, :messages %>
|
||||||
<%= button_to_jump_to_newest_message %>
|
<%= button_to_jump_to_newest_message %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
+2
-9
@@ -15,7 +15,6 @@ include SSHKit::DSL
|
|||||||
ARCH = "linux/amd64,linux/arm64"
|
ARCH = "linux/amd64,linux/arm64"
|
||||||
BUILDER_NAME = "campfire-builder"
|
BUILDER_NAME = "campfire-builder"
|
||||||
GIT_SHA = `git rev-parse HEAD`.strip
|
GIT_SHA = `git rev-parse HEAD`.strip
|
||||||
MANUAL_NOTES = ARGV.delete("--notes")
|
|
||||||
VERSION = ARGV[0]
|
VERSION = ARGV[0]
|
||||||
|
|
||||||
DOCKER_IMAGE_NAME = "registry.once.com/campfire"
|
DOCKER_IMAGE_NAME = "registry.once.com/campfire"
|
||||||
@@ -29,7 +28,7 @@ EXPORT_REMOTE_USER = "app"
|
|||||||
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
|
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
|
||||||
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
|
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
|
||||||
|
|
||||||
abort "You must specify a version, e.g. `bin/release 1.0.0` (pass --notes to write release notes manually)" unless VERSION
|
abort "You must specify a version, e.g. `bin/release 1.0.0`" unless VERSION
|
||||||
|
|
||||||
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
|
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
|
||||||
if current_branch != "main"
|
if current_branch != "main"
|
||||||
@@ -58,10 +57,9 @@ on(:local) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
announcing "Creating GitHub release v#{VERSION}..." do
|
announcing "Creating GitHub release v#{VERSION}..." do
|
||||||
on(:local) do
|
|
||||||
if MANUAL_NOTES
|
|
||||||
notes_file = "tmp/release_notes.md"
|
notes_file = "tmp/release_notes.md"
|
||||||
|
|
||||||
|
on(:local) do
|
||||||
execute :mkdir, "-p", "tmp"
|
execute :mkdir, "-p", "tmp"
|
||||||
|
|
||||||
File.write(notes_file, "# What's Changed\n\n- \n")
|
File.write(notes_file, "# What's Changed\n\n- \n")
|
||||||
@@ -72,11 +70,6 @@ announcing "Creating GitHub release v#{VERSION}..." do
|
|||||||
end
|
end
|
||||||
|
|
||||||
execute :rm, notes_file
|
execute :rm, notes_file
|
||||||
else
|
|
||||||
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--generate-notes"
|
|
||||||
abort "Failed to create release; check that the version doesn't already exist"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
|||||||
pin "@hotwired/turbo-rails", to: "turbo.js"
|
pin "@hotwired/turbo-rails", to: "turbo.js"
|
||||||
pin "@rails/actioncable", to: "actioncable.esm.js"
|
pin "@rails/actioncable", to: "actioncable.esm.js"
|
||||||
pin "@rails/request.js", to: "@rails--request.js" # @0.0.8
|
pin "@rails/request.js", to: "@rails--request.js" # @0.0.8
|
||||||
pin "trix", to: "trix.js"
|
pin "trix", to: "trix.esm.min.js" # @2.1.19
|
||||||
pin "@rails/actiontext", to: "actiontext.js"
|
pin "@rails/actiontext", to: "actiontext.js"
|
||||||
pin "highlight.js", to: "highlight.js/core.js"
|
pin "highlight.js", to: "highlight.js/core.js"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
Rails.application.config.to_prepare do
|
|
||||||
Turbo::StreamsChannel.prepend RoomStreamsAreAuthorized
|
|
||||||
end
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
require "test_helper"
|
|
||||||
|
|
||||||
class RoomMessagesChannelTest < ActionCable::Channel::TestCase
|
|
||||||
tests RoomMessagesChannel
|
|
||||||
|
|
||||||
setup do
|
|
||||||
@room = rooms(:designers)
|
|
||||||
@signed_stream_name = Turbo::StreamsChannel.signed_stream_name [ @room, :messages ]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "a member may subscribe to a room's message stream" do
|
|
||||||
stub_connection(current_user: users(:kevin))
|
|
||||||
|
|
||||||
subscribe signed_stream_name: @signed_stream_name
|
|
||||||
|
|
||||||
assert subscription.confirmed?
|
|
||||||
assert_has_stream Turbo.signed_stream_verifier.verified(@signed_stream_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "a user who was never a member may not subscribe" do
|
|
||||||
stub_connection(current_user: users(:bender))
|
|
||||||
|
|
||||||
subscribe signed_stream_name: @signed_stream_name
|
|
||||||
|
|
||||||
assert subscription.rejected?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "a revoked member may not re-subscribe with a stream name harvested while a member" do
|
|
||||||
stub_connection(current_user: users(:kevin))
|
|
||||||
|
|
||||||
subscribe signed_stream_name: @signed_stream_name
|
|
||||||
assert subscription.confirmed?, "kevin must start out able to subscribe"
|
|
||||||
|
|
||||||
@room.memberships.revoke_from users(:kevin)
|
|
||||||
|
|
||||||
subscribe signed_stream_name: @signed_stream_name
|
|
||||||
|
|
||||||
assert subscription.rejected?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "an unsigned stream name is rejected" do
|
|
||||||
stub_connection(current_user: users(:kevin))
|
|
||||||
|
|
||||||
subscribe signed_stream_name: Turbo.signed_stream_verifier.verified(@signed_stream_name)
|
|
||||||
|
|
||||||
assert subscription.rejected?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "a missing stream name is rejected" do
|
|
||||||
stub_connection(current_user: users(:kevin))
|
|
||||||
|
|
||||||
subscribe
|
|
||||||
|
|
||||||
assert subscription.rejected?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "a validly signed stream name for another room the user isn't in is rejected" do
|
|
||||||
stub_connection(current_user: users(:bender))
|
|
||||||
|
|
||||||
subscribe signed_stream_name: Turbo::StreamsChannel.signed_stream_name([ rooms(:hq), :messages ])
|
|
||||||
|
|
||||||
assert subscription.rejected?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class RoomMessagesViaStockTurboChannelTest < ActionCable::Channel::TestCase
|
|
||||||
tests Turbo::StreamsChannel
|
|
||||||
|
|
||||||
# The subscriber picks the channel, so the stock channel has to turn these names away
|
|
||||||
# too. Otherwise a revoked member just names Turbo::StreamsChannel instead.
|
|
||||||
test "the stock turbo channel refuses to serve a room message stream" do
|
|
||||||
stub_connection(current_user: users(:kevin))
|
|
||||||
|
|
||||||
subscribe signed_stream_name: Turbo::StreamsChannel.signed_stream_name([ rooms(:designers), :messages ])
|
|
||||||
|
|
||||||
assert subscription.rejected?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "the stock turbo channel still serves the room list stream" do
|
|
||||||
stub_connection(current_user: users(:kevin))
|
|
||||||
|
|
||||||
subscribe signed_stream_name: Turbo::StreamsChannel.signed_stream_name([ :rooms ])
|
|
||||||
|
|
||||||
assert subscription.confirmed?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
require "test_helper"
|
|
||||||
|
|
||||||
class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
|
|
||||||
test "streams only the subscriber's own unread stream" do
|
|
||||||
stub_connection(current_user: users(:jz))
|
|
||||||
|
|
||||||
subscribe
|
|
||||||
|
|
||||||
assert subscription.confirmed?
|
|
||||||
assert_has_stream "user_#{users(:jz).id}_unreads"
|
|
||||||
assert_not_includes subscription.streams, "unread_rooms"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "an outsider is not told about activity in a room they can't see" do
|
|
||||||
direct = rooms(:bender_and_kevin)
|
|
||||||
assert_not direct.users.include?(users(:jz)), "jz must be an outsider for this test to mean anything"
|
|
||||||
|
|
||||||
broadcasts = capture_unread_broadcasts_for(users(:jz)) do
|
|
||||||
direct.messages.create!(body: "Private", creator: users(:kevin), client_message_id: "outsider").broadcast_create
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_empty broadcasts
|
|
||||||
end
|
|
||||||
|
|
||||||
test "a member is told about activity in their own room" do
|
|
||||||
direct = rooms(:bender_and_kevin)
|
|
||||||
|
|
||||||
broadcasts = capture_unread_broadcasts_for(users(:kevin)) do
|
|
||||||
direct.messages.create!(body: "Private", creator: users(:bender), client_message_id: "member").broadcast_create
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_equal [ direct.id ], broadcasts.collect { |broadcast| broadcast["roomId"] }
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def capture_unread_broadcasts_for(user)
|
|
||||||
stub_connection(current_user: user)
|
|
||||||
subscribe
|
|
||||||
|
|
||||||
stream = subscription.streams.sole
|
|
||||||
before = ActionCable.server.pubsub.broadcasts(stream).size
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
ActionCable.server.pubsub.broadcasts(stream).drop(before).collect { |broadcast| JSON.parse(broadcast) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -57,24 +57,11 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creating a message broadcasts unread room to each member" do
|
test "creating a message broadcasts unread room" do
|
||||||
@room.users.each do |member|
|
assert_broadcasts "unread_rooms", 1 do
|
||||||
assert_broadcasts UnreadRoomsChannel.stream_name_for(member.id), 1 do
|
|
||||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one #{member.id}", client_message_id: member.id } }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "creating a message doesn't broadcast unread room to non-members" do
|
|
||||||
outsiders = User.where.not(id: @room.users.map(&:id))
|
|
||||||
assert outsiders.any?, "need someone outside the room for this test to mean anything"
|
|
||||||
|
|
||||||
outsiders.each do |outsider|
|
|
||||||
assert_no_broadcasts UnreadRoomsChannel.stream_name_for(outsider.id) do
|
|
||||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
|
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
test "update updates a message belonging to the user" do
|
test "update updates a message belonging to the user" do
|
||||||
message = @room.messages.where(creator: users(:david)).first
|
message = @room.messages.where(creator: users(:david)).first
|
||||||
|
|||||||
@@ -66,18 +66,6 @@ class Rooms::ClosedsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert rooms(:designers).reload.name, "Designers"
|
assert rooms(:designers).reload.name, "Designers"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "a direct room can't be converted to closed and have its participants revised" do
|
|
||||||
sign_in :kevin
|
|
||||||
direct = rooms(:bender_and_kevin)
|
|
||||||
|
|
||||||
put rooms_closed_url(direct), params: {
|
|
||||||
room: { name: "Watercooler" }, user_ids: [ users(:kevin).id, users(:jz).id ]
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_equal "Rooms::Direct", Room.find(direct.id).type
|
|
||||||
assert_equal [ users(:bender).id, users(:kevin).id ].sort, Room.find(direct.id).user_ids.sort
|
|
||||||
end
|
|
||||||
|
|
||||||
test "remove yourself" do
|
test "remove yourself" do
|
||||||
assert_difference -> { users(:david).rooms.count }, -1 do
|
assert_difference -> { users(:david).rooms.count }, -1 do
|
||||||
put rooms_closed_url(rooms(:designers), params: { room: { name: "Designers" }, user_ids: [ users(:jason).id, users(:jz).id ] })
|
put rooms_closed_url(rooms(:designers), params: { room: { name: "Designers" }, user_ids: [ users(:jason).id, users(:jz).id ] })
|
||||||
|
|||||||
@@ -29,34 +29,4 @@ class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_redirected_to root_url
|
assert_redirected_to root_url
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "destroy can't reach a closed room the member didn't create" do
|
|
||||||
sign_in :kevin
|
|
||||||
|
|
||||||
assert_no_difference -> { Room.count } do
|
|
||||||
delete rooms_direct_url(rooms(:designers))
|
|
||||||
end
|
|
||||||
|
|
||||||
assert rooms(:designers).reload.persisted?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "destroy can't reach an open room the member didn't create" do
|
|
||||||
sign_in :kevin
|
|
||||||
|
|
||||||
assert_no_difference -> { Room.count } do
|
|
||||||
delete rooms_direct_url(rooms(:hq))
|
|
||||||
end
|
|
||||||
|
|
||||||
assert rooms(:hq).reload.persisted?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "destroy can't reach a room the member isn't in at all" do
|
|
||||||
sign_in :jz
|
|
||||||
|
|
||||||
assert_no_difference -> { Room.count } do
|
|
||||||
delete rooms_direct_url(rooms(:david_and_kevin))
|
|
||||||
end
|
|
||||||
|
|
||||||
assert rooms(:david_and_kevin).reload.persisted?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -57,23 +57,4 @@ class Rooms::OpensControllerTest < ActionDispatch::IntegrationTest
|
|||||||
put rooms_open_url(rooms(:designers)), params: { room: { name: "Doesn't matter" } }
|
put rooms_open_url(rooms(:designers)), params: { room: { name: "Doesn't matter" } }
|
||||||
assert_equal rooms(:designers).memberships.count, User.count
|
assert_equal rooms(:designers).memberships.count, User.count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "a direct room can't be promoted to open by its creator" do
|
|
||||||
sign_in :kevin
|
|
||||||
direct = rooms(:bender_and_kevin)
|
|
||||||
|
|
||||||
put rooms_open_url(direct), params: { room: { name: "Watercooler" } }
|
|
||||||
|
|
||||||
assert_equal "Rooms::Direct", Room.find(direct.id).type
|
|
||||||
assert_equal [ users(:bender).id, users(:kevin).id ].sort, Room.find(direct.id).user_ids.sort
|
|
||||||
end
|
|
||||||
|
|
||||||
test "a direct room can't be promoted to open by an administrator either" do
|
|
||||||
direct = rooms(:david_and_kevin)
|
|
||||||
|
|
||||||
put rooms_open_url(direct), params: { room: { name: "Watercooler" } }
|
|
||||||
|
|
||||||
assert_equal "Rooms::Direct", Room.find(direct.id).type
|
|
||||||
assert_equal [ users(:david).id, users(:kevin).id ].sort, Room.find(direct.id).user_ids.sort
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -61,75 +61,6 @@ class ContentFiltersTest < ActionView::TestCase
|
|||||||
assert_equal "Hello World", filtered.to_html
|
assert_equal "Hello World", filtered.to_html
|
||||||
end
|
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
|
test "message with a mention attachment" do
|
||||||
message = Message.create! room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason)
|
message = Message.create! room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason)
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/*
|
||||||
|
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
|
||||||
Reference in new issue
Block a user