mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-30 01:04:05 +09:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
094ef7b462 |
No files matched your search
@@ -71,12 +71,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
||||||
@@ -88,8 +82,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
env:
|
|
||||||
REDIS_URL: redis://localhost:6379/0
|
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -101,12 +93,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
||||||
@@ -118,8 +104,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
env:
|
|
||||||
REDIS_URL: redis://localhost:6379/0
|
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|||||||
@@ -67,17 +67,6 @@ jobs:
|
|||||||
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
|
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
|
||||||
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
|
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Compute app version
|
|
||||||
id: version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
|
||||||
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Extract Docker metadata (tags, labels) with arch suffix
|
- name: Extract Docker metadata (tags, labels) with arch suffix
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||||
@@ -105,8 +94,6 @@ jobs:
|
|||||||
build-args: |
|
build-args: |
|
||||||
OCI_SOURCE=${{ env.SOURCE_URL }}
|
OCI_SOURCE=${{ env.SOURCE_URL }}
|
||||||
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
|
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
|
||||||
APP_VERSION=${{ steps.version.outputs.version }}
|
|
||||||
GIT_REVISION=${{ github.sha }}
|
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
+1
-4
@@ -9,7 +9,7 @@ WORKDIR /rails
|
|||||||
|
|
||||||
# Install base packages
|
# Install base packages
|
||||||
RUN apt-get update -qq && \
|
RUN apt-get update -qq && \
|
||||||
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
|
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg && \
|
||||||
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
||||||
rm -rf /var/lib/apt/lists /var/cache/apt/archive
|
rm -rf /var/lib/apt/lists /var/cache/apt/archive
|
||||||
|
|
||||||
@@ -66,9 +66,6 @@ ENV HTTP_WRITE_TIMEOUT=300
|
|||||||
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
|
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
|
||||||
COPY --from=build --chown=rails:rails /rails /rails
|
COPY --from=build --chown=rails:rails /rails /rails
|
||||||
|
|
||||||
# Install ONCE backup/restore hooks
|
|
||||||
COPY --chmod=755 hooks /hooks
|
|
||||||
|
|
||||||
# Set version and revision
|
# Set version and revision
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
ENV APP_VERSION=$APP_VERSION
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ gem "benchmark"
|
|||||||
|
|
||||||
# Drivers
|
# Drivers
|
||||||
gem "sqlite3"
|
gem "sqlite3"
|
||||||
gem "redis", "~> 5.4"
|
|
||||||
|
|
||||||
# Deployment
|
# Deployment
|
||||||
gem "puma", "~> 7.2", ">= 7.2.1"
|
gem "puma", "~> 7.2", ">= 7.2.1"
|
||||||
|
|
||||||
# Jobs
|
# Jobs, caching, WebSockets
|
||||||
gem "resque", "~> 2.7.0"
|
gem "solid_queue"
|
||||||
gem "resque-pool", "~> 0.7.1"
|
gem "solid_cache"
|
||||||
|
gem "solid_cable"
|
||||||
|
|
||||||
# Assets
|
# Assets
|
||||||
gem "propshaft", github: "rails/propshaft"
|
gem "propshaft", github: "rails/propshaft"
|
||||||
@@ -41,7 +41,6 @@ gem "rails_autolink"
|
|||||||
gem "geared_pagination"
|
gem "geared_pagination"
|
||||||
gem "jbuilder"
|
gem "jbuilder"
|
||||||
gem "net-http-persistent"
|
gem "net-http-persistent"
|
||||||
gem "kredis"
|
|
||||||
gem "platform_agent"
|
gem "platform_agent"
|
||||||
gem "thruster"
|
gem "thruster"
|
||||||
|
|
||||||
|
|||||||
+29
-42
@@ -166,12 +166,17 @@ GEM
|
|||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
erb (6.0.4)
|
erb (6.0.4)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
|
et-orbi (1.4.0)
|
||||||
|
tzinfo
|
||||||
faker (3.5.2)
|
faker (3.5.2)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-darwin)
|
ffi (1.17.2-x86_64-darwin)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
|
fugit (1.13.0)
|
||||||
|
et-orbi (~> 1.4)
|
||||||
|
raabro (~> 1.4)
|
||||||
geared_pagination (1.2.0)
|
geared_pagination (1.2.0)
|
||||||
activesupport (>= 5.0)
|
activesupport (>= 5.0)
|
||||||
addressable (>= 2.5.0)
|
addressable (>= 2.5.0)
|
||||||
@@ -194,10 +199,6 @@ GEM
|
|||||||
json (2.20.0)
|
json (2.20.0)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
kredis (1.8.0)
|
|
||||||
activemodel (>= 6.0.0)
|
|
||||||
activesupport (>= 6.0.0)
|
|
||||||
redis (>= 4.2, < 6)
|
|
||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
@@ -218,10 +219,6 @@ GEM
|
|||||||
minitest (5.26.2)
|
minitest (5.26.2)
|
||||||
mocha (2.7.1)
|
mocha (2.7.1)
|
||||||
ruby2_keywords (>= 0.0.5)
|
ruby2_keywords (>= 0.0.5)
|
||||||
mono_logger (1.1.2)
|
|
||||||
multi_json (1.17.0)
|
|
||||||
mustermann (3.0.4)
|
|
||||||
ruby2_keywords (~> 0.0.1)
|
|
||||||
net-http-persistent (4.0.6)
|
net-http-persistent (4.0.6)
|
||||||
connection_pool (~> 2.2, >= 2.2.4)
|
connection_pool (~> 2.2, >= 2.2.4)
|
||||||
net-imap (0.6.4.1)
|
net-imap (0.6.4.1)
|
||||||
@@ -261,12 +258,9 @@ GEM
|
|||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.2.1)
|
puma (7.2.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.6)
|
rack (3.2.6)
|
||||||
rack-protection (4.2.1)
|
|
||||||
base64 (>= 0.1.0)
|
|
||||||
logger (>= 1.6.0)
|
|
||||||
rack (>= 3.0.0, < 4)
|
|
||||||
rack-session (2.1.2)
|
rack-session (2.1.2)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
@@ -291,23 +285,9 @@ GEM
|
|||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
redis (5.4.1)
|
|
||||||
redis-client (>= 0.22.0)
|
|
||||||
redis-client (0.25.2)
|
|
||||||
connection_pool
|
|
||||||
redis-namespace (1.11.0)
|
|
||||||
redis (>= 4)
|
|
||||||
regexp_parser (2.11.2)
|
regexp_parser (2.11.2)
|
||||||
reline (0.6.3)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
resque (2.7.0)
|
|
||||||
mono_logger (~> 1)
|
|
||||||
multi_json (~> 1.0)
|
|
||||||
redis-namespace (~> 1.6)
|
|
||||||
sinatra (>= 0.9.2)
|
|
||||||
resque-pool (0.7.1)
|
|
||||||
rake (>= 10.0, < 14.0)
|
|
||||||
resque (>= 1.22, < 3)
|
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rqrcode (3.2.0)
|
rqrcode (3.2.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
@@ -360,13 +340,22 @@ GEM
|
|||||||
sentry-ruby (5.26.0)
|
sentry-ruby (5.26.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
sinatra (4.2.1)
|
solid_cable (4.0.2)
|
||||||
logger (>= 1.6.0)
|
actioncable (>= 7.2)
|
||||||
mustermann (~> 3.0)
|
activejob (>= 7.2)
|
||||||
rack (>= 3.0.0, < 4)
|
activerecord (>= 7.2)
|
||||||
rack-protection (= 4.2.1)
|
railties (>= 7.2)
|
||||||
rack-session (>= 2.0.0, < 3)
|
solid_cache (1.0.10)
|
||||||
tilt (~> 2.0)
|
activejob (>= 7.2)
|
||||||
|
activerecord (>= 7.2)
|
||||||
|
railties (>= 7.2)
|
||||||
|
solid_queue (1.4.0)
|
||||||
|
activejob (>= 7.1)
|
||||||
|
activerecord (>= 7.1)
|
||||||
|
concurrent-ruby (>= 1.3.1)
|
||||||
|
fugit (~> 1.11)
|
||||||
|
railties (>= 7.1)
|
||||||
|
thor (>= 1.3.1)
|
||||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||||
sqlite3 (2.9.5-arm64-darwin)
|
sqlite3 (2.9.5-arm64-darwin)
|
||||||
sqlite3 (2.9.5-x86_64-darwin)
|
sqlite3 (2.9.5-x86_64-darwin)
|
||||||
@@ -375,11 +364,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)
|
|
||||||
timeout (0.6.1)
|
timeout (0.6.1)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
@@ -423,7 +411,6 @@ DEPENDENCIES
|
|||||||
image_processing (>= 1.2)
|
image_processing (>= 1.2)
|
||||||
importmap-rails!
|
importmap-rails!
|
||||||
jbuilder
|
jbuilder
|
||||||
kredis
|
|
||||||
mocha
|
mocha
|
||||||
net-http-persistent
|
net-http-persistent
|
||||||
ostruct
|
ostruct
|
||||||
@@ -432,14 +419,14 @@ DEPENDENCIES
|
|||||||
puma (~> 7.2, >= 7.2.1)
|
puma (~> 7.2, >= 7.2.1)
|
||||||
rails!
|
rails!
|
||||||
rails_autolink
|
rails_autolink
|
||||||
redis (~> 5.4)
|
|
||||||
resque (~> 2.7.0)
|
|
||||||
resque-pool (~> 0.7.1)
|
|
||||||
rqrcode
|
rqrcode
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
sentry-rails
|
sentry-rails
|
||||||
sentry-ruby
|
sentry-ruby
|
||||||
|
solid_cable
|
||||||
|
solid_cache
|
||||||
|
solid_queue
|
||||||
sqlite3
|
sqlite3
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
thruster
|
thruster
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
web: bundle exec thrust bin/start-app
|
web: bundle exec thrust bin/start-app
|
||||||
redis: redis-server config/redis.conf
|
workers: bundle exec bin/jobs
|
||||||
workers: FORK_PER_JOB=false INTERVAL=0.1 bundle exec resque-pool
|
|
||||||
@@ -53,7 +53,3 @@ If you'd rather run the Docker image yourself, you can read more about that in t
|
|||||||
|
|
||||||
You are welcome - and encouraged - to modify Campfire to your liking.
|
You are welcome - and encouraged - to modify Campfire to your liking.
|
||||||
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
|
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
# Security
|
|
||||||
|
|
||||||
Report vulnerabilities through our [HackerOne program](https://hackerone.com/basecamp) or at
|
|
||||||
https://github.com/basecamp/once-campfire/security. Note that Campfire is in scope for our security
|
|
||||||
program, but is not bounty eligible.
|
|
||||||
|
|
||||||
## Trust model
|
|
||||||
|
|
||||||
Campfire is self-hosted and single-tenant, so the administrator is the server operator,
|
|
||||||
with shell, network, and database access already. Anything requiring the administrator
|
|
||||||
role grants nothing they do not already have, and is not a vulnerability.
|
|
||||||
|
|
||||||
We do want reports of anything a **non-administrator** can reach, and of any credential or
|
|
||||||
network path held by the Campfire process but not by the operator's own shell.
|
|
||||||
|
|
||||||
## Intentional behavior
|
|
||||||
|
|
||||||
Bot webhook URLs are unrestricted: an administrator can point one at any address,
|
|
||||||
including internal ones, because operators legitimately wire bots to their own services.
|
|
||||||
Link unfurling is different because any member can trigger it by pasting a URL, so it
|
|
||||||
validates destinations through `RestrictedHTTP::PrivateNetworkGuard`. The difference is who
|
|
||||||
picks the destination.
|
|
||||||
@@ -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
|
||||||
@@ -8,8 +8,9 @@ class Accounts::LogosController < ApplicationController
|
|||||||
if stale?(etag: Current.account)
|
if stale?(etag: Current.account)
|
||||||
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
|
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
|
||||||
|
|
||||||
if (logo_variant = Current.account&.logo_variant(logo_size))
|
if Current.account&.logo&.attached?
|
||||||
send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
|
logo = Current.account.logo.variant(logo_variant).processed
|
||||||
|
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
|
||||||
else
|
else
|
||||||
send_stock_icon
|
send_stock_icon
|
||||||
end
|
end
|
||||||
@@ -22,6 +23,9 @@ class Accounts::LogosController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
|
||||||
|
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
|
||||||
|
|
||||||
def send_png_file(path)
|
def send_png_file(path)
|
||||||
send_file path, content_type: "image/png", disposition: :inline
|
send_file path, content_type: "image/png", disposition: :inline
|
||||||
end
|
end
|
||||||
@@ -34,8 +38,8 @@ class Accounts::LogosController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def logo_size
|
def logo_variant
|
||||||
small_logo? ? :small : :large
|
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
|
||||||
end
|
end
|
||||||
|
|
||||||
def small_logo?
|
def small_logo?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ class Users::AvatarsController < ApplicationController
|
|||||||
if stale?(etag: @user)
|
if stale?(etag: @user)
|
||||||
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
|
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
|
||||||
|
|
||||||
if (avatar_variant = @user.avatar_variant)
|
if @user.avatar.attached?
|
||||||
|
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
|
||||||
send_webp_blob_file avatar_variant.key
|
send_webp_blob_file avatar_variant.key
|
||||||
elsif @user.bot?
|
elsif @user.bot?
|
||||||
render_default_bot
|
render_default_bot
|
||||||
@@ -25,6 +26,8 @@ class Users::AvatarsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
|
||||||
|
|
||||||
def send_webp_blob_file(key)
|
def send_webp_blob_file(key)
|
||||||
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
|
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
|
||||||
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,14 +1,6 @@
|
|||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
include Joinable
|
include Joinable
|
||||||
|
|
||||||
has_one_attached :logo do |attachable|
|
has_one_attached :logo
|
||||||
attachable.variant :large, resize_to_limit: [ 512, 512 ], format: :png
|
|
||||||
attachable.variant :small, resize_to_limit: [ 192, 192 ], format: :png
|
|
||||||
end
|
|
||||||
|
|
||||||
has_json :settings, restrict_room_creation_to_administrators: false
|
has_json :settings, restrict_room_creation_to_administrators: false
|
||||||
|
|
||||||
def logo_variant(size)
|
|
||||||
logo.variant(size).processed if logo.variable?
|
|
||||||
end
|
|
||||||
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
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ module User::Avatar
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
has_one_attached :avatar do |attachable|
|
has_one_attached :avatar
|
||||||
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
@@ -16,8 +14,4 @@ module User::Avatar
|
|||||||
def avatar_token
|
def avatar_token
|
||||||
signed_id(purpose: :avatar)
|
signed_id(purpose: :avatar)
|
||||||
end
|
end
|
||||||
|
|
||||||
def avatar_variant
|
|
||||||
avatar.variant(:square).processed if avatar.variable?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -24,8 +24,6 @@ class Webhook < ApplicationRecord
|
|||||||
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
|
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
|
||||||
end
|
end
|
||||||
|
|
||||||
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
|
|
||||||
# and operators legitimately point bots at their own internal services.
|
|
||||||
def http
|
def http
|
||||||
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
||||||
http.use_ssl = (uri.scheme == "https")
|
http.use_ssl = (uri.scheme == "https")
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
require "optparse"
|
||||||
|
require "json"
|
||||||
|
require "net/http"
|
||||||
|
require "fileutils"
|
||||||
|
require "etc"
|
||||||
|
require "time"
|
||||||
|
|
||||||
|
options = {
|
||||||
|
users: 3000, ramp: 480, hold: 120, cpus: 2, memory: "2g",
|
||||||
|
send_interval: 1, sample: 25, build: true, port: 3000, image: "campfire-bench"
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionParser.new do |parser|
|
||||||
|
parser.on("--label LABEL", "Run label (required), e.g. redis-2cpu-2g") { |v| options[:label] = v }
|
||||||
|
parser.on("--users N", Integer, "Ramp target (default 3000, max 10000)") { |v| options[:users] = v }
|
||||||
|
parser.on("--ramp SECONDS", Integer, "Ramp duration (default 480)") { |v| options[:ramp] = v }
|
||||||
|
parser.on("--hold SECONDS", Integer, "Hold duration at target (default 120)") { |v| options[:hold] = v }
|
||||||
|
parser.on("--cpus N", Integer, "App container CPU count via cpuset (default 2)") { |v| options[:cpus] = v }
|
||||||
|
parser.on("--memory SIZE", "App container memory limit (default 2g)") { |v| options[:memory] = v }
|
||||||
|
parser.on("--send-interval S", Float, "Seconds between messages (default 1)") { |v| options[:send_interval] = v }
|
||||||
|
parser.on("--sample N", Integer, "1-in-N receivers log latency lines (default 25)") { |v| options[:sample] = v }
|
||||||
|
parser.on("--port PORT", Integer, "Host port for the app (default 3000)") { |v| options[:port] = v }
|
||||||
|
parser.on("--image TAG", "Image tag (default campfire-bench)") { |v| options[:image] = v }
|
||||||
|
parser.on("--no-build", "Skip docker build") { options[:build] = false }
|
||||||
|
end.parse!
|
||||||
|
|
||||||
|
abort "--label is required" unless options[:label]
|
||||||
|
|
||||||
|
class Bench
|
||||||
|
APP_CONTAINER = "campfire-bench-app"
|
||||||
|
K6_CONTAINER = "campfire-bench-k6"
|
||||||
|
|
||||||
|
attr_reader :opts, :results_dir
|
||||||
|
|
||||||
|
def initialize(opts)
|
||||||
|
@opts = opts
|
||||||
|
@root = File.expand_path("..", __dir__)
|
||||||
|
@perf_dir = File.join(@root, "test/performance")
|
||||||
|
@results_dir = File.join(@root, "tmp/bench", opts[:label])
|
||||||
|
end
|
||||||
|
|
||||||
|
def run
|
||||||
|
FileUtils.mkdir_p(results_dir)
|
||||||
|
build if opts[:build]
|
||||||
|
write_meta
|
||||||
|
start_app
|
||||||
|
wait_for_app
|
||||||
|
sampler = start_stats_sampler
|
||||||
|
run_k6
|
||||||
|
ensure
|
||||||
|
stop(sampler)
|
||||||
|
capture_app_state
|
||||||
|
cleanup
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def build
|
||||||
|
sh "docker", "build", "-t", opts[:image], @root
|
||||||
|
end
|
||||||
|
|
||||||
|
def write_meta
|
||||||
|
meta = opts.merge(
|
||||||
|
git_revision: `git rev-parse HEAD`.strip,
|
||||||
|
git_branch: `git rev-parse --abbrev-ref HEAD`.strip,
|
||||||
|
host_cpus: Etc.nprocessors,
|
||||||
|
started_at: Time.now.utc.iso8601
|
||||||
|
)
|
||||||
|
File.write(File.join(results_dir, "meta.json"), JSON.pretty_generate(meta))
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_app
|
||||||
|
system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
|
||||||
|
sh "docker", "run", "-d", "--name", APP_CONTAINER,
|
||||||
|
"--cpuset-cpus", app_cpuset,
|
||||||
|
"--memory", opts[:memory], "--memory-swap", opts[:memory],
|
||||||
|
"-p", "#{opts[:port]}:80",
|
||||||
|
"-e", "SECRET_KEY_BASE=dummy",
|
||||||
|
"-e", "RAILS_ENV=performance",
|
||||||
|
opts[:image]
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_cpuset
|
||||||
|
(0...opts[:cpus]).to_a.join(",")
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_for_app
|
||||||
|
timeout_at = Time.now + 300
|
||||||
|
uri = URI("http://127.0.0.1:#{opts[:port]}/")
|
||||||
|
loop do
|
||||||
|
raise "App not up after 300s" if Time.now > timeout_at
|
||||||
|
begin
|
||||||
|
break if Net::HTTP.get_response(uri).code.to_i < 400
|
||||||
|
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError
|
||||||
|
end
|
||||||
|
puts "Waiting for app (seeding 10k users on first boot)..."
|
||||||
|
sleep 2
|
||||||
|
end
|
||||||
|
puts "App is up."
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_stats_sampler
|
||||||
|
stats_path = File.join(results_dir, "stats.csv")
|
||||||
|
fork do
|
||||||
|
File.open(stats_path, "w") do |file|
|
||||||
|
file.puts "epoch_ms,cpu_perc,mem_bytes"
|
||||||
|
file.sync = true
|
||||||
|
loop do
|
||||||
|
line = `docker stats --no-stream --format "{{.CPUPerc}} {{.MemUsage}}" #{APP_CONTAINER} 2>/dev/null`.strip
|
||||||
|
if line =~ /([\d.]+)%\s+([\d.]+)(\w+)/
|
||||||
|
file.puts "#{(Time.now.to_f * 1000).to_i},#{$1},#{to_bytes($2.to_f, $3)}"
|
||||||
|
end
|
||||||
|
sleep 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_bytes(value, unit)
|
||||||
|
factors = { "B" => 1, "KiB" => 1024, "MiB" => 1024**2, "GiB" => 1024**3,
|
||||||
|
"kB" => 1000, "MB" => 1000**2, "GB" => 1000**3 }
|
||||||
|
(value * factors.fetch(unit, 1)).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_k6
|
||||||
|
total = opts[:ramp] + opts[:hold]
|
||||||
|
puts "Running k6: ramp to #{opts[:users]} users over #{opts[:ramp]}s, hold #{opts[:hold]}s (total #{total}s)..."
|
||||||
|
system "docker", "rm", "-f", K6_CONTAINER, err: File::NULL, out: File::NULL
|
||||||
|
k6_log = File.join(results_dir, "k6.log")
|
||||||
|
summary = File.join(results_dir, "summary.json")
|
||||||
|
finished = system(
|
||||||
|
"docker", "run", "--rm", "--name", K6_CONTAINER,
|
||||||
|
"--network", "host",
|
||||||
|
"--cpuset-cpus", k6_cpuset,
|
||||||
|
"--ulimit", "nofile=262144:262144",
|
||||||
|
"-u", "#{Process.uid}:#{Process.gid}",
|
||||||
|
"-v", "#{@perf_dir}:/src",
|
||||||
|
"-v", "#{results_dir}:/results",
|
||||||
|
"-e", "HOST=127.0.0.1", "-e", "PORT=#{opts[:port]}",
|
||||||
|
"-e", "USERS=#{opts[:users]}",
|
||||||
|
"-e", "RAMP_S=#{opts[:ramp]}", "-e", "HOLD_S=#{opts[:hold]}",
|
||||||
|
"-e", "SEND_INTERVAL_S=#{opts[:send_interval]}",
|
||||||
|
"-e", "SAMPLE=#{opts[:sample]}",
|
||||||
|
"grafana/k6", "run",
|
||||||
|
"--summary-export", "/results/summary.json",
|
||||||
|
"--quiet",
|
||||||
|
"/src/ramp.js",
|
||||||
|
out: k6_log, err: [ k6_log, "a" ]
|
||||||
|
)
|
||||||
|
if finished
|
||||||
|
puts "k6 finished."
|
||||||
|
else
|
||||||
|
puts "k6 exited non-zero — results still captured in #{k6_log}."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def k6_cpuset
|
||||||
|
(opts[:cpus]...Etc.nprocessors).to_a.join(",")
|
||||||
|
end
|
||||||
|
|
||||||
|
def stop(sampler)
|
||||||
|
Process.kill("TERM", sampler)
|
||||||
|
Process.wait(sampler)
|
||||||
|
rescue Errno::ESRCH, Errno::ECHILD
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def capture_app_state
|
||||||
|
oom = `docker inspect --format '{{.State.OOMKilled}}' #{APP_CONTAINER} 2>/dev/null`.strip
|
||||||
|
File.write(File.join(results_dir, "oom.txt"), oom)
|
||||||
|
system "docker logs --tail 200 #{APP_CONTAINER} > #{File.join(results_dir, 'app.log')} 2>&1"
|
||||||
|
puts "App container was OOM-killed during the run." if oom == "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
def cleanup
|
||||||
|
system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
|
||||||
|
end
|
||||||
|
|
||||||
|
def sh(*cmd)
|
||||||
|
puts "+ #{cmd.join(' ')}"
|
||||||
|
system(*cmd) || abort("Command failed: #{cmd.join(' ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Bench.new(options).run
|
||||||
|
puts "Results in tmp/bench/#{options[:label]}/"
|
||||||
Executable
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
require "json"
|
||||||
|
require "csv"
|
||||||
|
|
||||||
|
BIN_MS = 10_000
|
||||||
|
SLO_P95_MS = 500
|
||||||
|
|
||||||
|
class Run
|
||||||
|
attr_reader :label, :dir
|
||||||
|
|
||||||
|
def initialize(label)
|
||||||
|
@label = label
|
||||||
|
@dir = File.expand_path("../tmp/bench/#{label}", __dir__)
|
||||||
|
abort "No results in tmp/bench/#{label}" unless File.exist?(File.join(dir, "k6.log"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def report
|
||||||
|
write_series
|
||||||
|
print_summary
|
||||||
|
end
|
||||||
|
|
||||||
|
def series
|
||||||
|
@series ||= build_series
|
||||||
|
end
|
||||||
|
|
||||||
|
def meta
|
||||||
|
@meta ||= JSON.parse(File.read(File.join(dir, "meta.json")))
|
||||||
|
end
|
||||||
|
|
||||||
|
def summary
|
||||||
|
@summary ||= JSON.parse(File.read(File.join(dir, "summary.json")))
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def capacity
|
||||||
|
breach = first_sustained_breach
|
||||||
|
if breach
|
||||||
|
breach[:connected]
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def build_series
|
||||||
|
events = parse_k6_log
|
||||||
|
stats = parse_stats
|
||||||
|
first_ts = events.map { |e| e[:ts] }.min
|
||||||
|
return [] unless first_ts
|
||||||
|
|
||||||
|
bins = Hash.new { |h, k| h[k] = { lats: [], conns: 0, sents: 0, errs: 0, cpu: [], mem: [] } }
|
||||||
|
events.each do |event|
|
||||||
|
bin = (event[:ts] - first_ts) / BIN_MS
|
||||||
|
case event[:kind]
|
||||||
|
when "CONN" then bins[bin][:conns] += 1
|
||||||
|
when "LAT" then bins[bin][:lats] << event[:value]
|
||||||
|
when "SENT" then bins[bin][:sents] += 1
|
||||||
|
when "ERR" then bins[bin][:errs] += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
stats.each do |stat|
|
||||||
|
bin = (stat[:ts] - first_ts) / BIN_MS
|
||||||
|
next if bin < 0
|
||||||
|
bins[bin][:cpu] << stat[:cpu]
|
||||||
|
bins[bin][:mem] << stat[:mem]
|
||||||
|
end
|
||||||
|
|
||||||
|
connected = 0
|
||||||
|
(0..bins.keys.max).map do |bin|
|
||||||
|
data = bins[bin]
|
||||||
|
connected = [ connected + data[:conns], meta["users"] ].min
|
||||||
|
{
|
||||||
|
elapsed_s: bin * BIN_MS / 1000,
|
||||||
|
connected: connected,
|
||||||
|
sent: data[:sents],
|
||||||
|
samples: data[:lats].size,
|
||||||
|
lat_p50: percentile(data[:lats], 50),
|
||||||
|
lat_p95: percentile(data[:lats], 95),
|
||||||
|
lat_max: data[:lats].max,
|
||||||
|
errors: data[:errs],
|
||||||
|
cpu_perc: average(data[:cpu]),
|
||||||
|
mem_mb: average(data[:mem])&.then { |bytes| (bytes / 1024.0 / 1024.0).round(1) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_k6_log
|
||||||
|
events = []
|
||||||
|
File.foreach(File.join(dir, "k6.log")) do |line|
|
||||||
|
if line =~ /BENCH (CONN|LAT|SENT|ERR) (\d+)(?: (\S+))?/
|
||||||
|
events << { kind: $1, ts: $2.to_i, value: $3.to_i }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
events
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_stats
|
||||||
|
path = File.join(dir, "stats.csv")
|
||||||
|
return [] unless File.exist?(path)
|
||||||
|
CSV.read(path, headers: true).map do |row|
|
||||||
|
{ ts: row["epoch_ms"].to_i, cpu: row["cpu_perc"].to_f, mem: row["mem_bytes"].to_i }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def percentile(values, pct)
|
||||||
|
return nil if values.empty?
|
||||||
|
sorted = values.sort
|
||||||
|
sorted[((pct / 100.0) * (sorted.size - 1)).round]
|
||||||
|
end
|
||||||
|
|
||||||
|
def average(values)
|
||||||
|
return nil if values.empty?
|
||||||
|
(values.sum / values.size.to_f).round(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def write_series
|
||||||
|
CSV.open(File.join(dir, "series.csv"), "w") do |csv|
|
||||||
|
csv << series.first.keys
|
||||||
|
series.each { |row| csv << row.values }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def first_sustained_breach
|
||||||
|
series.each_cons(2) do |a, b|
|
||||||
|
if breached?(a) && breached?(b)
|
||||||
|
return a
|
||||||
|
end
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def breached?(bin)
|
||||||
|
if bin[:samples] > 0 && bin[:lat_p95] && bin[:lat_p95] > SLO_P95_MS
|
||||||
|
true
|
||||||
|
else
|
||||||
|
bin[:errors] > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def print_summary
|
||||||
|
latency = summary.dig("metrics", "delivery_latency") || {}
|
||||||
|
puts
|
||||||
|
puts "== #{label} (#{meta['cpus']} CPU, #{meta['memory']} RAM, ramp to #{meta['users']}) =="
|
||||||
|
puts " delivery latency: p50=#{latency['med']&.round(1)}ms p95=#{latency['p(95)']&.round(1)}ms max=#{latency['max']&.round(1)}ms"
|
||||||
|
puts " messages received: #{summary.dig('metrics', 'bench_messages_received', 'count')}"
|
||||||
|
puts " socket errors: #{summary.dig('metrics', 'bench_socket_errors', 'count') || 0}"
|
||||||
|
peak = series.map { |bin| bin[:mem_mb] }.compact.max
|
||||||
|
puts " peak container memory: #{peak} MB"
|
||||||
|
if capacity
|
||||||
|
puts " capacity at SLO (p95 <= #{SLO_P95_MS}ms, no errors): ~#{capacity} concurrent users"
|
||||||
|
else
|
||||||
|
puts " capacity at SLO: not reached (>= #{series.last[:connected]} concurrent users)"
|
||||||
|
end
|
||||||
|
puts " series: tmp/bench/#{label}/series.csv"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
abort "Usage: bin/bench-report LABEL [LABEL...]" if ARGV.empty?
|
||||||
|
ARGV.each { |label| Run.new(label).report }
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
require_relative "../config/environment"
|
||||||
|
require "solid_queue/cli"
|
||||||
|
|
||||||
|
SolidQueue::Cli.start(ARGV)
|
||||||
+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
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ app_root="$(
|
|||||||
)"
|
)"
|
||||||
export PATH="$app_root/bin:$PATH"
|
export PATH="$app_root/bin:$PATH"
|
||||||
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
|
|
||||||
if [ "$RAILS_ENV" = "production" ]; then
|
if [ "$RAILS_ENV" = "production" ]; then
|
||||||
echo "RAILS_ENV is production; bailing out"
|
echo "RAILS_ENV is production; bailing out"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -45,10 +42,6 @@ step() {
|
|||||||
return $exit_code
|
return $exit_code
|
||||||
}
|
}
|
||||||
|
|
||||||
redis_running() {
|
|
||||||
nc -z "$REDIS_HOST" "$REDIS_PORT" 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
gum style --foreground 214 " ) "
|
gum style --foreground 214 " ) "
|
||||||
gum style --foreground 208 " ) \\ campfire"
|
gum style --foreground 208 " ) \\ campfire"
|
||||||
@@ -88,21 +81,6 @@ if [[ $* == *--reset* ]]; then
|
|||||||
fi
|
fi
|
||||||
step "Preparing the database" rails db:prepare
|
step "Preparing the database" rails db:prepare
|
||||||
|
|
||||||
# Start Redis if not running
|
|
||||||
if ! redis_running; then
|
|
||||||
if command -v docker &>/dev/null; then
|
|
||||||
if docker ps -aq -f name=campfire-redis | grep -q .; then
|
|
||||||
step "Starting Redis" docker start campfire-redis
|
|
||||||
else
|
|
||||||
step "Setting up Redis" docker run -d --name campfire-redis -p "$REDIS_PORT:$REDIS_PORT" redis:7
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Couldn't start Redis"
|
|
||||||
echo "Install either docker or redis and then run this command again"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install GitHub Actions linting tools
|
# Install GitHub Actions linting tools
|
||||||
for tool in actionlint shellcheck zizmor; do
|
for tool in actionlint shellcheck zizmor; do
|
||||||
if ! command -v "$tool" &> /dev/null; then
|
if ! command -v "$tool" &> /dev/null; then
|
||||||
|
|||||||
+17
-10
@@ -1,18 +1,25 @@
|
|||||||
default: &default
|
# Async adapter only works within the same process, so for manually triggering cable updates from a console,
|
||||||
adapter: redis
|
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
|
||||||
url: redis://localhost:6379
|
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
|
||||||
|
# to make the web console appear.
|
||||||
development:
|
development:
|
||||||
<<: *default
|
adapter: async
|
||||||
channel_prefix: campfire_development
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
adapter: test
|
adapter: test
|
||||||
|
|
||||||
performance:
|
performance:
|
||||||
<<: *default
|
adapter: solid_cable
|
||||||
channel_prefix: campfire_performance
|
connects_to:
|
||||||
|
database:
|
||||||
|
writing: cable
|
||||||
|
polling_interval: 0.05.seconds
|
||||||
|
message_retention: 1.day
|
||||||
|
|
||||||
production:
|
production:
|
||||||
<<: *default
|
adapter: solid_cable
|
||||||
channel_prefix: campfire_production
|
connects_to:
|
||||||
|
database:
|
||||||
|
writing: cable
|
||||||
|
polling_interval: 0.05.seconds
|
||||||
|
message_retention: 1.day
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
default: &default
|
||||||
|
store_options:
|
||||||
|
# Cap age of oldest cache entry to fulfill retention policies
|
||||||
|
# max_age: <%= 60.days.to_i %>
|
||||||
|
max_size: <%= 256.megabytes %>
|
||||||
|
namespace: <%= Rails.env %>
|
||||||
|
|
||||||
|
development:
|
||||||
|
database: cache
|
||||||
|
<<: *default
|
||||||
|
|
||||||
|
test:
|
||||||
|
<<: *default
|
||||||
|
|
||||||
|
performance:
|
||||||
|
database: cache
|
||||||
|
<<: *default
|
||||||
|
|
||||||
|
production:
|
||||||
|
database: cache
|
||||||
|
<<: *default
|
||||||
@@ -14,6 +14,10 @@ development:
|
|||||||
primary:
|
primary:
|
||||||
<<: *default
|
<<: *default
|
||||||
database: storage/db/development.sqlite3
|
database: storage/db/development.sqlite3
|
||||||
|
cache:
|
||||||
|
<<: *default
|
||||||
|
database: storage/db/development-cache.sqlite3
|
||||||
|
migrations_paths: db/cache_migrate
|
||||||
|
|
||||||
# Warning: The database defined as "test" will be erased and
|
# Warning: The database defined as "test" will be erased and
|
||||||
# re-generated from your development database when you run "rake".
|
# re-generated from your development database when you run "rake".
|
||||||
@@ -27,8 +31,32 @@ performance:
|
|||||||
primary:
|
primary:
|
||||||
<<: *default
|
<<: *default
|
||||||
database: storage/db/performance.sqlite3
|
database: storage/db/performance.sqlite3
|
||||||
|
queue:
|
||||||
|
<<: *default
|
||||||
|
database: storage/db/performance-queue.sqlite3
|
||||||
|
migrations_paths: db/queue_migrate
|
||||||
|
cache:
|
||||||
|
<<: *default
|
||||||
|
database: storage/db/performance-cache.sqlite3
|
||||||
|
migrations_paths: db/cache_migrate
|
||||||
|
cable:
|
||||||
|
<<: *default
|
||||||
|
database: storage/db/performance-cable.sqlite3
|
||||||
|
migrations_paths: db/cable_migrate
|
||||||
|
|
||||||
production:
|
production:
|
||||||
primary:
|
primary:
|
||||||
<<: *default
|
<<: *default
|
||||||
database: storage/db/production.sqlite3
|
database: storage/db/production.sqlite3
|
||||||
|
queue:
|
||||||
|
<<: *default
|
||||||
|
database: storage/db/production-queue.sqlite3
|
||||||
|
migrations_paths: db/queue_migrate
|
||||||
|
cache:
|
||||||
|
<<: *default
|
||||||
|
database: storage/db/production-cache.sqlite3
|
||||||
|
migrations_paths: db/cache_migrate
|
||||||
|
cable:
|
||||||
|
<<: *default
|
||||||
|
database: storage/db/production-cable.sqlite3
|
||||||
|
migrations_paths: db/cable_migrate
|
||||||
@@ -21,7 +21,7 @@ Rails.application.configure do
|
|||||||
config.action_controller.perform_caching = true
|
config.action_controller.perform_caching = true
|
||||||
config.action_controller.enable_fragment_cache_logging = true
|
config.action_controller.enable_fragment_cache_logging = true
|
||||||
|
|
||||||
config.cache_store = :redis_cache_store
|
config.cache_store = :solid_cache_store
|
||||||
config.public_file_server.headers = {
|
config.public_file_server.headers = {
|
||||||
"cache-control" => "public, max-age=#{2.days.to_i}"
|
"cache-control" => "public, max-age=#{2.days.to_i}"
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ Rails.application.configure do
|
|||||||
# config.action_cable.disable_request_forgery_protection = true
|
# config.action_cable.disable_request_forgery_protection = true
|
||||||
|
|
||||||
# Uncomment to test with production class job queue
|
# Uncomment to test with production class job queue
|
||||||
# config.active_job.queue_adapter = :resque
|
# config.active_job.queue_adapter = :solid_queue
|
||||||
|
|
||||||
# Highlight code that enqueued background job in logs.
|
# Highlight code that enqueued background job in logs.
|
||||||
config.active_job.verbose_enqueue_logs = true
|
config.active_job.verbose_enqueue_logs = true
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Rails.application.configure do
|
|||||||
config.active_support.report_deprecations = false
|
config.active_support.report_deprecations = false
|
||||||
|
|
||||||
# Cache in memory for now
|
# Cache in memory for now
|
||||||
config.cache_store = :redis_cache_store
|
config.cache_store = :solid_cache_store
|
||||||
|
|
||||||
# Assets are cacheable
|
# Assets are cacheable
|
||||||
config.public_file_server.headers = {
|
config.public_file_server.headers = {
|
||||||
@@ -92,5 +92,6 @@ Rails.application.configure do
|
|||||||
# Only use :id for inspections in production.
|
# Only use :id for inspections in production.
|
||||||
config.active_record.attributes_for_inspect = [ :id ]
|
config.active_record.attributes_for_inspect = [ :id ]
|
||||||
|
|
||||||
config.active_job.queue_adapter = :resque
|
config.active_job.queue_adapter = :solid_queue
|
||||||
|
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||||
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,2 +1,2 @@
|
|||||||
Rails.application.config.app_version = ENV["APP_VERSION"].presence || ENV["GIT_REVISION"].presence || "0"
|
Rails.application.config.app_version = ENV.fetch("APP_VERSION", "0")
|
||||||
Rails.application.config.git_revision = ENV["GIT_REVISION"]
|
Rails.application.config.git_revision = ENV["GIT_REVISION"]
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Disable unfuzzed libvips operations.
|
|
||||||
#
|
|
||||||
# To block loaders we need to call `Vips.block` after Rails and image_processing set their
|
|
||||||
# defaults. Force the order of operations by autoloading the file now.
|
|
||||||
ActiveStorage::Transformers::Vips
|
|
||||||
Vips.block_untrusted(true)
|
|
||||||
Vips.block("VipsForeignLoadOpenslide", true) # prevent sqlite segfault in forked parallel workers
|
|
||||||
Rails.application.config.active_storage.variable_content_types -=
|
|
||||||
%w[ image/bmp image/vnd.microsoft.icon image/vnd.adobe.photoshop ]
|
|
||||||
@@ -35,8 +35,6 @@ pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
|
|||||||
worker_count = (Concurrent.processor_count * 0.666).ceil
|
worker_count = (Concurrent.processor_count * 0.666).ceil
|
||||||
workers ENV.fetch("WEB_CONCURRENCY") { worker_count }
|
workers ENV.fetch("WEB_CONCURRENCY") { worker_count }
|
||||||
|
|
||||||
ENV["JOB_CONCURRENCY"] ||= worker_count.to_s
|
|
||||||
|
|
||||||
# Use the `preload_app!` method when specifying a `workers` number.
|
# Use the `preload_app!` method when specifying a `workers` number.
|
||||||
# This directive tells Puma to first boot the application and load code
|
# This directive tells Puma to first boot the application and load code
|
||||||
# before forking the application. This takes advantage of Copy On Write
|
# before forking the application. This takes advantage of Copy On Write
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
default: &default
|
||||||
|
dispatchers:
|
||||||
|
- polling_interval: 1
|
||||||
|
batch_size: 500
|
||||||
|
workers:
|
||||||
|
- queues: "*"
|
||||||
|
threads: 3
|
||||||
|
processes: <%= ENV.fetch("JOB_CONCURRENCY") { (Concurrent.processor_count * 0.5).ceil } %>
|
||||||
|
polling_interval: 0.1
|
||||||
|
|
||||||
|
development:
|
||||||
|
<<: *default
|
||||||
|
|
||||||
|
test:
|
||||||
|
<<: *default
|
||||||
|
|
||||||
|
performance:
|
||||||
|
<<: *default
|
||||||
|
|
||||||
|
production:
|
||||||
|
<<: *default
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# examples:
|
||||||
|
# periodic_cleanup:
|
||||||
|
# class: CleanSoftDeletedRecordsJob
|
||||||
|
# queue: background
|
||||||
|
# args: [ 1000, { batch_size: 500 } ]
|
||||||
|
# schedule: every hour
|
||||||
|
# periodic_cleanup_with_command:
|
||||||
|
# command: "SoftDeletedRecord.due.delete_all"
|
||||||
|
# priority: 2
|
||||||
|
# schedule: at 5am every day
|
||||||
|
|
||||||
|
production:
|
||||||
|
clear_solid_queue_finished_jobs:
|
||||||
|
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||||
|
schedule: every hour at minute 12
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
port 6379
|
|
||||||
daemonize no
|
|
||||||
appendonly no
|
|
||||||
save ""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
default: <%= (Concurrent.processor_count * 0.5).ceil %>
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ActiveRecord::Schema[7.1].define(version: 1) do
|
||||||
|
create_table "solid_cable_messages", force: :cascade do |t|
|
||||||
|
t.binary "channel", limit: 1024, null: false
|
||||||
|
t.binary "payload", limit: 536870912, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.integer "channel_hash", limit: 8, null: false
|
||||||
|
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
|
||||||
|
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
|
||||||
|
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
ActiveRecord::Schema[7.2].define(version: 1) do
|
||||||
|
create_table "solid_cache_entries", force: :cascade do |t|
|
||||||
|
t.binary "key", limit: 1024, null: false
|
||||||
|
t.binary "value", limit: 536870912, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.integer "key_hash", limit: 8, null: false
|
||||||
|
t.integer "byte_size", limit: 4, null: false
|
||||||
|
t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
|
||||||
|
t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
|
||||||
|
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
ActiveRecord::Schema[7.1].define(version: 1) do
|
||||||
|
create_table "solid_queue_blocked_executions", force: :cascade do |t|
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.integer "priority", default: 0, null: false
|
||||||
|
t.string "concurrency_key", null: false
|
||||||
|
t.datetime "expires_at", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
|
||||||
|
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
|
||||||
|
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_claimed_executions", force: :cascade do |t|
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.bigint "process_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
|
||||||
|
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_failed_executions", force: :cascade do |t|
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.text "error"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_jobs", force: :cascade do |t|
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.string "class_name", null: false
|
||||||
|
t.text "arguments"
|
||||||
|
t.integer "priority", default: 0, null: false
|
||||||
|
t.string "active_job_id"
|
||||||
|
t.datetime "scheduled_at"
|
||||||
|
t.datetime "finished_at"
|
||||||
|
t.string "concurrency_key"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
|
||||||
|
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
|
||||||
|
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
|
||||||
|
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
|
||||||
|
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_pauses", force: :cascade do |t|
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_processes", force: :cascade do |t|
|
||||||
|
t.string "kind", null: false
|
||||||
|
t.datetime "last_heartbeat_at", null: false
|
||||||
|
t.bigint "supervisor_id"
|
||||||
|
t.integer "pid", null: false
|
||||||
|
t.string "hostname"
|
||||||
|
t.text "metadata"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
|
||||||
|
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
|
||||||
|
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_ready_executions", force: :cascade do |t|
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.integer "priority", default: 0, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
|
||||||
|
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
|
||||||
|
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_recurring_executions", force: :cascade do |t|
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.string "task_key", null: false
|
||||||
|
t.datetime "run_at", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
|
||||||
|
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
|
||||||
|
t.string "key", null: false
|
||||||
|
t.string "schedule", null: false
|
||||||
|
t.string "command", limit: 2048
|
||||||
|
t.string "class_name"
|
||||||
|
t.text "arguments"
|
||||||
|
t.string "queue_name"
|
||||||
|
t.integer "priority", default: 0
|
||||||
|
t.boolean "static", default: true, null: false
|
||||||
|
t.text "description"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
|
||||||
|
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.integer "priority", default: 0, null: false
|
||||||
|
t.datetime "scheduled_at", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
|
||||||
|
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_semaphores", force: :cascade do |t|
|
||||||
|
t.string "key", null: false
|
||||||
|
t.integer "value", default: 1, null: false
|
||||||
|
t.datetime "expires_at", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
|
||||||
|
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
|
||||||
|
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
end
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
db_name="${RAILS_ENV:-production}.sqlite3"
|
|
||||||
snapshot="/rails/storage/backups/$db_name"
|
|
||||||
db="/rails/storage/db/$db_name"
|
|
||||||
|
|
||||||
if [ -f "$snapshot" ]; then
|
|
||||||
mkdir -p "$(dirname "$db")"
|
|
||||||
cp "$snapshot" "$db"
|
|
||||||
rm -f "$db-wal" "$db-shm"
|
|
||||||
fi
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
exec /rails/script/admin/prepare-backup
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
require "ipaddr"
|
|
||||||
require "resolv"
|
require "resolv"
|
||||||
|
|
||||||
module RestrictedHTTP
|
module RestrictedHTTP
|
||||||
@@ -7,32 +6,7 @@ module RestrictedHTTP
|
|||||||
module PrivateNetworkGuard
|
module PrivateNetworkGuard
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
# IPv4 special-use ranges (RFC 5735/6890) not already covered by the
|
LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network
|
||||||
# private?/loopback?/link_local? predicates in #disallowed_ipv4?.
|
|
||||||
DISALLOWED_IPV4 = %w[
|
|
||||||
0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24
|
|
||||||
198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
|
|
||||||
].map { |cidr| IPAddr.new(cidr) }.freeze
|
|
||||||
|
|
||||||
# IPv6 special-use ranges not caught by the predicates. 6to4 (2002::/16) and
|
|
||||||
# Teredo (2001::/32) are deprecated transition mechanisms with no legitimate
|
|
||||||
# fetch target, so they are blocked outright. ULA (fc00::/7, incl. the AWS
|
|
||||||
# IMDSv6 address fd00:ec2::254), link-local, and loopback are covered by the
|
|
||||||
# predicates in #disallowed_ipv6?.
|
|
||||||
DISALLOWED_IPV6 = %w[
|
|
||||||
::/128 100::/64 2001::/32 2001:2::/48 2001:db8::/32 2002::/16
|
|
||||||
fec0::/10 ff00::/8
|
|
||||||
].map { |cidr| IPAddr.new(cidr) }.freeze
|
|
||||||
|
|
||||||
# NAT64 prefixes: the well-known prefix (RFC 6052/6146) and the local-use
|
|
||||||
# prefix (RFC 8215). An address here embeds an IPv4 target in its low 32
|
|
||||||
# bits; we extract it and re-check against the IPv4 rules so NAT64 to a
|
|
||||||
# public address still resolves while NAT64 to an internal address is
|
|
||||||
# blocked.
|
|
||||||
NAT64_PREFIXES = [
|
|
||||||
IPAddr.new("64:ff9b::/96"),
|
|
||||||
IPAddr.new("64:ff9b:1::/48")
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
def resolve(hostname)
|
def resolve(hostname)
|
||||||
Resolv.getaddress(hostname).tap do |ip|
|
Resolv.getaddress(hostname).tap do |ip|
|
||||||
@@ -41,36 +15,11 @@ module RestrictedHTTP
|
|||||||
end
|
end
|
||||||
|
|
||||||
def private_ip?(ip)
|
def private_ip?(ip)
|
||||||
ipaddr = IPAddr.new(ip)
|
IPAddr.new(ip).then do |ipaddr|
|
||||||
|
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr)
|
||||||
# DNS never legitimately returns these embedded forms, so block them all
|
|
||||||
# regardless of the address they wrap.
|
|
||||||
if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat?
|
|
||||||
true
|
|
||||||
elsif ipaddr.ipv4?
|
|
||||||
disallowed_ipv4?(ipaddr)
|
|
||||||
elsif NAT64_PREFIXES.any? { |prefix| prefix.include?(ipaddr) }
|
|
||||||
disallowed_ipv4?(embedded_ipv4(ipaddr))
|
|
||||||
else
|
|
||||||
disallowed_ipv6?(ipaddr)
|
|
||||||
end
|
end
|
||||||
rescue IPAddr::InvalidAddressError
|
rescue IPAddr::InvalidAddressError
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
def disallowed_ipv4?(ipaddr)
|
|
||||||
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
|
|
||||||
DISALLOWED_IPV4.any? { |range| range.include?(ipaddr) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def disallowed_ipv6?(ipaddr)
|
|
||||||
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
|
|
||||||
DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def embedded_ipv4(ipaddr)
|
|
||||||
IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join("."))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
task "resque:setup" do
|
|
||||||
require_relative "../../config/environment"
|
|
||||||
end
|
|
||||||
|
|
||||||
task "resque:pool:setup" do
|
|
||||||
ActiveRecord::Base.connection.disconnect!
|
|
||||||
|
|
||||||
Resque::Pool.after_prefork do |job|
|
|
||||||
ActiveRecord::Base.establish_connection
|
|
||||||
Resque.redis.client.close
|
|
||||||
end
|
|
||||||
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
|
|
||||||
@@ -30,13 +30,6 @@ class Accounts::LogosControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_valid_png_response size: 192
|
assert_valid_png_response size: 192
|
||||||
end
|
end
|
||||||
|
|
||||||
test "show stock when custom logo cannot be resized" do
|
|
||||||
accounts(:signal).update! logo: fixture_file_upload("pixel.bmp", "image/bmp")
|
|
||||||
|
|
||||||
get account_logo_url
|
|
||||||
assert_valid_png_response size: 512
|
|
||||||
end
|
|
||||||
|
|
||||||
test "destroy" do
|
test "destroy" do
|
||||||
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
|
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,14 +18,6 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal "image/webp", @response.content_type
|
assert_equal "image/webp", @response.content_type
|
||||||
end
|
end
|
||||||
|
|
||||||
test "show initials when image cannot be resized" do
|
|
||||||
users(:kevin).update! avatar: fixture_file_upload("pixel.bmp", "image/bmp")
|
|
||||||
get user_avatar_url(users(:kevin).avatar_token)
|
|
||||||
|
|
||||||
assert_response :success
|
|
||||||
assert_select "text", text: "K"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "show image with invalid token responds 404" do
|
test "show image with invalid token responds 404" do
|
||||||
get user_avatar_url("not-a-valid-token")
|
get user_avatar_url("not-a-valid-token")
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 58 B |
@@ -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
|
|
||||||
@@ -64,50 +64,6 @@ class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase
|
|||||||
assert_private_ip "::93.184.216.34"
|
assert_private_ip "::93.184.216.34"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "private_ip? returns true for carrier-grade NAT addresses (RFC6598)" do
|
|
||||||
assert_private_ip "100.64.0.1"
|
|
||||||
assert_private_ip "100.127.255.255"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "private_ip? returns true for NAT64 addresses embedding a private IPv4" do
|
|
||||||
assert_private_ip "64:ff9b::a9fe:a9fe" # NAT64 -> 169.254.169.254 (AWS metadata)
|
|
||||||
assert_private_ip "64:ff9b::a00:5" # NAT64 -> 10.0.0.5
|
|
||||||
end
|
|
||||||
|
|
||||||
test "private_ip? returns true for local-use NAT64 addresses embedding a private IPv4 (RFC8215)" do
|
|
||||||
assert_private_ip "64:ff9b:1::a00:1" # local-use NAT64 -> 10.0.0.1
|
|
||||||
end
|
|
||||||
|
|
||||||
test "private_ip? returns false for local-use NAT64 addresses embedding a public IPv4 (RFC8215)" do
|
|
||||||
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b:1::808:808") # -> 8.8.8.8
|
|
||||||
end
|
|
||||||
|
|
||||||
test "private_ip? returns false for NAT64 addresses embedding a public IPv4" do
|
|
||||||
# DNS64 legitimately synthesizes these for public sites on IPv6-only hosts.
|
|
||||||
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b::808:808") # -> 8.8.8.8
|
|
||||||
end
|
|
||||||
|
|
||||||
test "private_ip? returns true for 6to4 and Teredo transition addresses" do
|
|
||||||
assert_private_ip "2002:a9fe:a9fe::" # 6to4 embedding 169.254.169.254
|
|
||||||
assert_private_ip "2001::1" # Teredo
|
|
||||||
end
|
|
||||||
|
|
||||||
test "private_ip? returns true for IPv6 loopback, ULA, and link-local" do
|
|
||||||
assert_private_ip "::1"
|
|
||||||
assert_private_ip "fd00:ec2::254" # AWS IMDSv6 (ULA)
|
|
||||||
assert_private_ip "fe80::1"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "private_ip? returns true for IPv6 multicast, documentation, and benchmarking ranges" do
|
|
||||||
assert_private_ip "ff02::1"
|
|
||||||
assert_private_ip "2001:db8::1"
|
|
||||||
assert_private_ip "2001:2::1" # benchmarking (RFC5180), matches 198.18.0.0/15
|
|
||||||
end
|
|
||||||
|
|
||||||
test "private_ip? returns false for public IPv6 addresses" do
|
|
||||||
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("2606:4700:4700::1111")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "private_ip? returns true for invalid addresses" do
|
test "private_ip? returns true for invalid addresses" do
|
||||||
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip")
|
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip")
|
||||||
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("")
|
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("")
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
require "test_helper"
|
|
||||||
require "vips"
|
|
||||||
require "tempfile"
|
|
||||||
|
|
||||||
# libvips selects a loader from a file's actual bytes, not from its declared content type. These
|
|
||||||
# tests pin which loader is selected for each file type under the app's configured loader policy
|
|
||||||
# (config/initializers/vips.rb).
|
|
||||||
class VipsLoaderPolicyTest < ActiveSupport::TestCase
|
|
||||||
# Header bytes are enough for libvips to identify a format; native types are encoded live, exotic
|
|
||||||
# ones are represented by their magic bytes.
|
|
||||||
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
|
|
||||||
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
|
|
||||||
BMP = "BM" + [ 0, 0, 54 ].pack("V3") + "\x00" * 40
|
|
||||||
PSD = "8BPS" + [ 1 ].pack("n") + "\x00" * 26
|
|
||||||
ICO = "\x00\x00\x01\x00\x01\x00" + "\x00" * 16
|
|
||||||
SVG = %q(<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>)
|
|
||||||
|
|
||||||
test "loads PNG" do
|
|
||||||
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
|
|
||||||
end
|
|
||||||
|
|
||||||
test "loads GIF" do
|
|
||||||
assert_equal "VipsForeignLoadNsgifFile", loader_for(encode("gif"))
|
|
||||||
end
|
|
||||||
|
|
||||||
test "loads JPEG" do
|
|
||||||
assert_equal "VipsForeignLoadJpegFile", loader_for(encode("jpg"))
|
|
||||||
end
|
|
||||||
|
|
||||||
test "loads TIFF" do
|
|
||||||
assert_equal "VipsForeignLoadTiffFile", loader_for(encode("tif"))
|
|
||||||
end
|
|
||||||
|
|
||||||
test "loads WebP" do
|
|
||||||
assert_equal "VipsForeignLoadWebpFile", loader_for(encode("webp"))
|
|
||||||
end
|
|
||||||
|
|
||||||
test "loads AVIF" do
|
|
||||||
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_AVIF)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "loads HEIC" do
|
|
||||||
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies BMP through magickload" do
|
|
||||||
assert_nil loader_for(BMP)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies PSD through magickload" do
|
|
||||||
assert_nil loader_for(PSD)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies ICO through magickload" do
|
|
||||||
assert_nil loader_for(ICO)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies SVG through svgload" do
|
|
||||||
assert_nil loader_for(SVG)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies OpenSlide files through openslideload" do
|
|
||||||
# OpenSlide files can segfault the embedded sqlite in forked parallel workers
|
|
||||||
assert_loader_blocked :openslideload, ".svs"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies FITS files through fitsload" do
|
|
||||||
assert_loader_blocked :fitsload, ".fits"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies MATLAB files through matload" do
|
|
||||||
assert_loader_blocked :matload, ".mat"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies NIFTI files through niftiload" do
|
|
||||||
assert_loader_blocked :niftiload, ".nii"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies RAW files through dcrawload" do
|
|
||||||
assert_loader_blocked :dcrawload, ".raw"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "denies VIPS files through vipsload" do
|
|
||||||
assert_loader_blocked :vipsload, ".vips"
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
# Invoke a specific libvips loader directly and assert it is refused because the
|
|
||||||
# operation is blocked (rather than because the bytes are not a valid image).
|
|
||||||
def assert_loader_blocked(operation, extension)
|
|
||||||
Tempfile.create([ "blocked_loader", extension ], binmode: true) do |file|
|
|
||||||
file.write "not an image"
|
|
||||||
file.flush
|
|
||||||
|
|
||||||
error = assert_raises(Vips::Error) { Vips::Image.public_send(operation, file.path) }
|
|
||||||
actual = error.message.chomp
|
|
||||||
|
|
||||||
# note that exception message may include multiple errors on separate lines,
|
|
||||||
# so `^` and `$` anchors are used instead of `\A` and `\z`.
|
|
||||||
if actual =~ /^VipsOperation: class \"#{operation}\" not found$/
|
|
||||||
skip "libvips does not support #{operation} on this system"
|
|
||||||
end
|
|
||||||
assert_match(/^#{operation}: operation is blocked$/, actual)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def encode(ext)
|
|
||||||
Vips::Image.black(8, 8).add(128).cast("uchar").write_to_buffer(".#{ext}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def loader_for(bytes)
|
|
||||||
Tempfile.create(%w[loader_probe .img], binmode: true) do |file|
|
|
||||||
file.write bytes
|
|
||||||
file.flush
|
|
||||||
Vips.vips_foreign_find_load(file.path)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -15,20 +15,4 @@ class AccountTest < ActiveSupport::TestCase
|
|||||||
accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" })
|
accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" })
|
||||||
assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
|
assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "logo_variant is a resized variant of a variable logo" do
|
|
||||||
accounts(:signal).logo.attach io: file_fixture("moon.jpg").open, filename: "moon.jpg", content_type: "image/jpeg"
|
|
||||||
|
|
||||||
assert_kind_of ActiveStorage::VariantWithRecord, accounts(:signal).logo_variant(:large)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "logo_variant is nil when the logo cannot be resized" do
|
|
||||||
accounts(:signal).logo.attach io: file_fixture("pixel.bmp").open, filename: "pixel.bmp", content_type: "image/bmp"
|
|
||||||
|
|
||||||
assert_nil accounts(:signal).logo_variant(:large)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "logo_variant is nil without a logo" do
|
|
||||||
assert_nil accounts(:signal).logo_variant(:large)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -49,8 +49,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
|
|||||||
# to a resolved IP, not a hostname to re-resolve.
|
# to a resolved IP, not a hostname to re-resolve.
|
||||||
WebMock.disable_net_connect! allow: [ @url.host ]
|
WebMock.disable_net_connect! allow: [ @url.host ]
|
||||||
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
|
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
|
||||||
TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
|
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never
|
||||||
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
|
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
|
||||||
|
|
||||||
assert_throws :dns_not_rebound do
|
assert_throws :dns_not_rebound do
|
||||||
@fetch.fetch_document(@url)
|
@fetch.fetch_document(@url)
|
||||||
@@ -66,8 +66,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
|
|||||||
# to a resolved IP, not a hostname to re-resolve.
|
# to a resolved IP, not a hostname to re-resolve.
|
||||||
WebMock.disable_net_connect! allow: [ @url.host ]
|
WebMock.disable_net_connect! allow: [ @url.host ]
|
||||||
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
|
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
|
||||||
TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
|
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never
|
||||||
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
|
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
|
||||||
|
|
||||||
assert_throws :dns_not_rebound do
|
assert_throws :dns_not_rebound do
|
||||||
@fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")
|
@fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
require "test_helper"
|
|
||||||
|
|
||||||
class User::AvatarTest < ActiveSupport::TestCase
|
|
||||||
test "avatar_variant is a resized variant of a variable avatar" do
|
|
||||||
users(:kevin).avatar.attach io: file_fixture("moon.jpg").open, filename: "moon.jpg", content_type: "image/jpeg"
|
|
||||||
|
|
||||||
assert_kind_of ActiveStorage::VariantWithRecord, users(:kevin).avatar_variant
|
|
||||||
end
|
|
||||||
|
|
||||||
test "avatar_variant is nil when the avatar cannot be resized" do
|
|
||||||
users(:kevin).avatar.attach io: file_fixture("pixel.bmp").open, filename: "pixel.bmp", content_type: "image/bmp"
|
|
||||||
|
|
||||||
assert_nil users(:kevin).avatar_variant
|
|
||||||
end
|
|
||||||
|
|
||||||
test "avatar_variant is nil without an avatar" do
|
|
||||||
assert_nil users(:kevin).avatar_variant
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import ws from 'k6/ws';
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { sleep } from 'k6';
|
||||||
|
import { Trend, Counter } from 'k6/metrics';
|
||||||
|
import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
|
||||||
|
// Stream names signed with the performance environment's SECRET_KEY_BASE=dummy:
|
||||||
|
// the rooms list, room 1 messages, and user 1 rooms.
|
||||||
|
const turboSignedStreamNames = [
|
||||||
|
"InJvb21zIg==--54acd827f0a7db144c75316a9fc488c0a949f9635b1e47956ce1bd9d1cf2c41d",
|
||||||
|
"IloybGtPaTh2WTJGdGNHWnBjbVV2VW05dmJYTTZPa05zYjNObFpDOHg6bWVzc2FnZXMi--84f0f3dde5d23eb0fdb410746c2fb76813a4ddff1e2798aac4be0c3d969702ba",
|
||||||
|
"IloybGtPaTh2WTJGdGNHWnBjbVV2VlhObGNpOHg6cm9vbXMi--df547a679cd41f7531b53d9e48f9883c02481a4da0d862453106441d8546d084"
|
||||||
|
];
|
||||||
|
|
||||||
|
const dummyCookies = new SharedArray('cookies', function () {
|
||||||
|
return papaparse.parse(open('cookies.txt'), { header: false }).data;
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = __ENV.HOST || "127.0.0.1";
|
||||||
|
let port = ":3000";
|
||||||
|
if (__ENV.PORT) {
|
||||||
|
port = `:${__ENV.PORT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = parseInt(__ENV.USERS || "3000");
|
||||||
|
const rampS = parseInt(__ENV.RAMP_S || "480");
|
||||||
|
const holdS = parseInt(__ENV.HOLD_S || "120");
|
||||||
|
const sendIntervalS = parseFloat(__ENV.SEND_INTERVAL_S || "1");
|
||||||
|
const sample = parseInt(__ENV.SAMPLE || "25");
|
||||||
|
const totalS = rampS + holdS;
|
||||||
|
|
||||||
|
const deliveryLatency = new Trend('delivery_latency', true);
|
||||||
|
const messagesReceived = new Counter('bench_messages_received');
|
||||||
|
const socketErrors = new Counter('bench_socket_errors');
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
discardResponseBodies: true,
|
||||||
|
scenarios: {
|
||||||
|
receivers: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
exec: 'receiver',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: `${rampS}s`, target: users },
|
||||||
|
{ duration: `${holdS}s`, target: users },
|
||||||
|
],
|
||||||
|
gracefulRampDown: '0s',
|
||||||
|
gracefulStop: '5s',
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
executor: 'constant-vus',
|
||||||
|
exec: 'sender',
|
||||||
|
vus: 1,
|
||||||
|
duration: `${totalS}s`,
|
||||||
|
gracefulStop: '10s',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function receiver() {
|
||||||
|
const cookie = dummyCookies[(__VU - 1) % dummyCookies.length][0];
|
||||||
|
const url = `ws://${host}${port}/cable`;
|
||||||
|
const params = {
|
||||||
|
headers: { 'Origin': 'http://localhost', 'Cookie': `session_token=${cookie}` }
|
||||||
|
};
|
||||||
|
const subscriptions = [
|
||||||
|
'{"channel":"PresenceChannel", "room_id":1}',
|
||||||
|
'{"channel":"UnreadRoomsChannel"}',
|
||||||
|
'{"channel":"HeartbeatChannel"}',
|
||||||
|
...turboSignedStreamNames.map((name) =>
|
||||||
|
`{"channel":"Turbo::StreamsChannel", "signed_stream_name":"${name}"}`)
|
||||||
|
];
|
||||||
|
|
||||||
|
const res = ws.connect(url, params, function (socket) {
|
||||||
|
let confirmed = 0;
|
||||||
|
|
||||||
|
socket.on('open', function () {
|
||||||
|
subscriptions.forEach((identifier) => {
|
||||||
|
socket.send(JSON.stringify({ command: 'subscribe', identifier: identifier }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('message', function (message) {
|
||||||
|
if (message.includes('confirm_subscription')) {
|
||||||
|
confirmed++;
|
||||||
|
if (confirmed === subscriptions.length) {
|
||||||
|
console.log(`BENCH CONN ${Date.now()} ${__VU}`);
|
||||||
|
}
|
||||||
|
} else if (message.includes('reject_subscription')) {
|
||||||
|
socketErrors.add(1);
|
||||||
|
console.log(`BENCH ERR ${Date.now()} reject_subscription`);
|
||||||
|
} else if (message.includes('bench:')) {
|
||||||
|
const match = message.match(/bench:(\d+):(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
const latency = Date.now() - parseInt(match[1]);
|
||||||
|
deliveryLatency.add(latency);
|
||||||
|
messagesReceived.add(1);
|
||||||
|
if (__VU % sample === 0) {
|
||||||
|
console.log(`BENCH LAT ${Date.now()} ${latency}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', function (e) {
|
||||||
|
if (e.error() != 'websocket: close sent') {
|
||||||
|
socketErrors.add(1);
|
||||||
|
console.log(`BENCH ERR ${Date.now()} ${e.error()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.setTimeout(() => socket.close(), totalS * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res || res.status >= 400) {
|
||||||
|
socketErrors.add(1);
|
||||||
|
let status = "nil";
|
||||||
|
if (res) {
|
||||||
|
status = res.status;
|
||||||
|
}
|
||||||
|
console.log(`BENCH ERR ${Date.now()} connect_status_${status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sender() {
|
||||||
|
const cookie = `session_token=${dummyCookies[0][0]}`;
|
||||||
|
|
||||||
|
const page = http.get(`http://${host}${port}/rooms/1`, {
|
||||||
|
headers: { "Cookie": cookie }, responseType: "text"
|
||||||
|
});
|
||||||
|
const csrfToken = page.body.match(/<meta name="csrf-token" content="([^"]*)"/i)[1];
|
||||||
|
|
||||||
|
const postHeaders = {
|
||||||
|
"Cookie": cookie,
|
||||||
|
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const deadline = Date.now() + totalS * 1000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
seq++;
|
||||||
|
const payload = {
|
||||||
|
"message[body]": `bench:${Date.now()}:${seq}`,
|
||||||
|
"message[client_message_id]": `bench-${seq}-${Math.random().toString(36)}`,
|
||||||
|
"authenticity_token": csrfToken
|
||||||
|
};
|
||||||
|
const res = http.post(`http://${host}${port}/rooms/1/messages`, payload, {
|
||||||
|
headers: postHeaders, responseType: "none", tags: { name: 'send_message' }
|
||||||
|
});
|
||||||
|
if (res.status >= 400) {
|
||||||
|
console.log(`BENCH ERR ${Date.now()} post_status_${res.status}`);
|
||||||
|
} else {
|
||||||
|
console.log(`BENCH SENT ${Date.now()} ${seq}`);
|
||||||
|
}
|
||||||
|
sleep(sendIntervalS);
|
||||||
|
}
|
||||||
|
}
|
||||||
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