Compare commits

..
Author SHA1 Message Date
Stanko K.R. 094ef7b462 Replace Redis with the Solid trifecta 2026-07-21 11:42:37 +02:00
86 changed files with 914 additions and 1469 deletions

No files matched your search

+3 -2
View File
@@ -20,12 +20,13 @@ updates:
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
exclude:
- "brakeman"
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
cooldown:
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
+2 -18
View File
@@ -60,10 +60,10 @@ jobs:
persist-credentials: false
- name: Run actionlint
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
- name: Run zizmor
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
with:
advanced-security: false
@@ -71,12 +71,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
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:
- name: Install packages
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
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -101,12 +93,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
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:
- name: Install packages
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
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
ruby-version: .ruby-version
bundler-cache: true
-13
View File
@@ -67,17 +67,6 @@ jobs:
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
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
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
@@ -105,8 +94,6 @@ jobs:
build-args: |
OCI_SOURCE=${{ env.SOURCE_URL }}
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
APP_VERSION=${{ steps.version.outputs.version }}
GIT_REVISION=${{ github.sha }}
platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+1 -4
View File
@@ -9,7 +9,7 @@ WORKDIR /rails
# Install base packages
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 && \
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 /rails /rails
# Install ONCE backup/restore hooks
COPY --chmod=755 hooks /hooks
# Set version and revision
ARG APP_VERSION
ENV APP_VERSION=$APP_VERSION
+4 -6
View File
@@ -9,14 +9,14 @@ gem "benchmark"
# Drivers
gem "sqlite3"
gem "redis", "~> 5.4"
# Deployment
gem "puma", "~> 7.2", ">= 7.2.1"
# Jobs
gem "resque", "~> 2.7.0"
gem "resque-pool", "~> 0.7.1"
# Jobs, caching, WebSockets
gem "solid_queue"
gem "solid_cache"
gem "solid_cable"
# Assets
gem "propshaft", github: "rails/propshaft"
@@ -41,8 +41,6 @@ gem "rails_autolink"
gem "geared_pagination"
gem "jbuilder"
gem "net-http-persistent"
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
gem "kredis"
gem "platform_agent"
gem "thruster"
+59 -83
View File
@@ -1,14 +1,8 @@
GIT
remote: https://github.com/basecamp/surfguard.git
revision: 910be917fd0ab782c5ea939698d44f26694a501d
specs:
surfguard (0.1.0)
GIT
remote: https://github.com/hotwired/turbo-rails.git
revision: 37530c08780fa6f6dbb56a633de4b81169bdd174
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
specs:
turbo-rails (2.0.23)
turbo-rails (2.0.16)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
@@ -142,8 +136,8 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (4.1.2)
brakeman (8.0.6)
bigdecimal (3.3.1)
brakeman (8.0.5)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -159,7 +153,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.8)
concurrent-ruby (1.3.7)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -170,48 +164,48 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.3)
erb (6.0.7)
erb (6.0.4)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
faker (3.5.2)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
fugit (1.13.0)
et-orbi (~> 1.4)
raabro (~> 1.4)
geared_pagination (1.2.0)
activesupport (>= 5.0)
addressable (>= 2.5.0)
globalid (1.3.0)
activesupport (>= 6.1)
hashdiff (1.2.0)
i18n (1.15.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.9.2)
irb (1.18.0)
io-console (0.8.1)
irb (1.15.3)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.21.2)
json (2.20.0)
jwt (3.2.0)
base64
kredis (1.8.0)
activemodel (>= 6.0.0)
activesupport (>= 6.0.0)
redis (>= 4.2, < 6)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
@@ -222,15 +216,9 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
minitest (5.26.2)
mocha (2.7.1)
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)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.6.4.1)
@@ -260,25 +248,25 @@ GEM
platform_agent (1.0.1)
activesupport (>= 5.2.0)
useragent (~> 0.16.3)
pp (0.6.4)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
prism (1.4.0)
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
puma (7.2.1)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.7)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack (3.2.6)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rackup (2.2.1)
rack (>= 3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
@@ -292,33 +280,14 @@ GEM
activesupport (> 3.1)
railties (> 3.1)
rainbow (3.1.1)
rake (13.4.2)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
rake (13.3.1)
rdoc (6.16.1)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
psych (>= 4.0.0)
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)
reline (0.7.0)
reline (0.6.3)
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)
rqrcode (3.2.0)
chunky_png (~> 1.0)
@@ -371,25 +340,34 @@ GEM
sentry-ruby (5.26.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.6-x86_64-darwin)
sqlite3 (2.9.6-x86_64-linux-gnu)
solid_cable (4.0.2)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.10)
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-arm64-darwin)
sqlite3 (2.9.5-x86_64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
thor (1.5.0)
thruster (0.1.23-aarch64-linux)
thruster (0.1.23-arm64-darwin)
thruster (0.1.23-x86_64-darwin)
thruster (0.1.23-x86_64-linux)
tilt (2.6.1)
stringio (3.1.8)
thor (1.4.0)
thruster (0.1.15-aarch64-linux)
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
@@ -413,7 +391,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.8.3)
zeitwerk (2.7.3)
PLATFORMS
aarch64-linux
@@ -433,7 +411,6 @@ DEPENDENCIES
image_processing (>= 1.2)
importmap-rails!
jbuilder
kredis
mocha
net-http-persistent
ostruct
@@ -442,17 +419,16 @@ DEPENDENCIES
puma (~> 7.2, >= 7.2.1)
rails!
rails_autolink
redis (~> 5.4)
resque (~> 2.7.0)
resque-pool (~> 0.7.1)
rqrcode
rubocop-rails-omakase
selenium-webdriver
sentry-rails
sentry-ruby
solid_cable
solid_cache
solid_queue
sqlite3
stimulus-rails
surfguard!
thruster
turbo-rails!
web-push
+1 -2
View File
@@ -1,3 +1,2 @@
web: bundle exec thrust bin/start-app
redis: redis-server config/redis.conf
workers: FORK_PER_JOB=false INTERVAL=0.1 bundle exec resque-pool
workers: bundle exec bin/jobs
-4
View File
@@ -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.
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
View File
@@ -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
-55
View File
@@ -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 -8
View File
@@ -1,12 +1,5 @@
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
stream_from self.class.stream_name_for(current_user.id)
stream_from "unread_rooms"
end
end
+8 -4
View File
@@ -8,8 +8,9 @@ class Accounts::LogosController < ApplicationController
if stale?(etag: Current.account)
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
if (logo_variant = Current.account&.logo_variant(logo_size))
send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
if Current.account&.logo&.attached?
logo = Current.account.logo.variant(logo_variant).processed
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
else
send_stock_icon
end
@@ -22,6 +23,9 @@ class Accounts::LogosController < ApplicationController
end
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)
send_file path, content_type: "image/png", disposition: :inline
end
@@ -34,8 +38,8 @@ class Accounts::LogosController < ApplicationController
end
end
def logo_size
small_logo? ? :small : :large
def logo_variant
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
end
def small_logo?
@@ -1,12 +0,0 @@
module RawRequestBody
extend ActiveSupport::Concern
private
def raw_request_body
request.body.rewind
request.body.read.force_encoding("UTF-8")
ensure
request.body.rewind
end
end
@@ -1,39 +0,0 @@
class Messages::Boosts::ByBotsController < Messages::BoostsController
include RawRequestBody
allow_bot_access only: %i[ create destroy ]
before_action :ensure_content_present, only: :create
def create
@boost = @message.boosts.create!(boost_params)
broadcast_create
render :show, status: :created
end
private
def set_message
if room = Current.user.rooms.find_by(id: params[:room_id])
@message = room.messages.find_by(id: params[:message_id])
end
head :not_found unless @message
end
def set_boost
super
rescue ActiveRecord::RecordNotFound
head :not_found
end
def ensure_content_present
if raw_request_body.blank?
head :unprocessable_content
end
end
def boost_params
{ content: raw_request_body }
end
end
@@ -1,6 +1,5 @@
class Messages::BoostsController < ApplicationController
before_action :set_message
before_action :set_boost, only: :destroy
def index
end
@@ -16,6 +15,7 @@ class Messages::BoostsController < ApplicationController
end
def destroy
@boost = Current.user.boosts.find(params[:id])
@boost.destroy!
broadcast_remove
@@ -26,10 +26,6 @@ class Messages::BoostsController < ApplicationController
@message = Current.user.reachable_messages.find(params[:message_id])
end
def set_boost
@boost = @message.boosts.find_by!(id: params[:id], booster: Current.user)
end
def boost_params
params.require(:boost).permit(:content)
end
+9 -49
View File
@@ -1,64 +1,24 @@
class Messages::ByBotsController < MessagesController
include RawRequestBody
allow_bot_access only: %i[ index create update destroy ]
before_action :set_room
before_action :set_message, only: %i[ update destroy ]
before_action :ensure_can_administer, only: %i[ update destroy ]
before_action :ensure_body_or_attachment_present, only: :create
def index
@messages = find_paged_messages
set_pagination_headers
end
allow_bot_access only: :create
def create
super
head :created, location: message_url(@message)
end
def destroy
super
head :no_content
end
private
def set_room
@room = Current.user.rooms.find_by(id: params[:room_id])
head :not_found unless @room
end
def ensure_body_or_attachment_present
if params[:attachment].blank? && raw_request_body.blank?
head :unprocessable_content
end
end
def set_pagination_headers
headers["X-Total-Count"] = @room.messages.count.to_s
if next_page = next_page_params
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
end
end
def next_page_params
if @messages.any?
if params[:after].present?
{ after: @messages.last.id } if @room.messages.after(@messages.last).exists?
else
{ before: @messages.first.id } if @room.messages.before(@messages.first).exists?
end
end
end
def message_params
if params[:attachment]
params.permit(:attachment)
else
{ body: raw_request_body }
reading(request.body) { |body| { body: body } }
end
end
def reading(io)
io.rewind
yield io.read.force_encoding("UTF-8")
ensure
io.rewind
end
end
+1 -5
View File
@@ -37,11 +37,7 @@ class MessagesController < ApplicationController
@message.update!(message_params)
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
respond_to do |format|
format.html { redirect_to room_message_url(@room, @message) }
format.json { render :show }
end
redirect_to room_message_url(@room, @message)
end
def destroy
@@ -42,12 +42,6 @@ class Rooms::ClosedsController < RoomsController
@room = @room.becomes!(Rooms::Closed)
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
User.where(id: grantee_ids)
end
+1 -6
View File
@@ -29,13 +29,8 @@ class Rooms::DirectsController < RoomsController
end
end
# All users in a direct room can administer it. Only direct rooms, though: this
# relaxation is why room_scope below has to keep every other type out of reach.
# All users in a direct room can administer it
def ensure_can_administer
true
end
def room_scope
Current.user.rooms.directs
end
end
@@ -40,12 +40,6 @@ class Rooms::OpensController < RoomsController
@room = @room.becomes!(Rooms::Open)
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)
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
end
+1 -7
View File
@@ -20,19 +20,13 @@ class RoomsController < ApplicationController
private
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
else
redirect_to root_url, alert: "Room not found or inaccessible"
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
head :forbidden unless Current.user.can_administer?(@room)
end
+4 -1
View File
@@ -9,7 +9,8 @@ class Users::AvatarsController < ApplicationController
if stale?(etag: @user)
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
elsif @user.bot?
render_default_bot
@@ -25,6 +26,8 @@ class Users::AvatarsController < ApplicationController
end
private
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
def send_webp_blob_file(key)
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
end
+1 -1
View File
@@ -1,3 +1,3 @@
module ContentFilters
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags, SanitizeAttributes)
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
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 -9
View File
@@ -1,14 +1,6 @@
class Account < ApplicationRecord
include Joinable
has_one_attached :logo do |attachable|
attachable.variant :large, resize_to_limit: [ 512, 512 ], format: :png
attachable.variant :small, resize_to_limit: [ 192, 192 ], format: :png
end
has_one_attached :logo
has_json :settings, restrict_room_creation_to_administrators: false
def logo_variant(size)
logo.variant(size).processed if logo.variable?
end
end
+1 -10
View File
@@ -1,19 +1,10 @@
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
broadcast_unread_room
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end
def broadcast_remove
broadcast_remove_to room, :messages
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
-11
View File
@@ -22,8 +22,6 @@ class Room < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
validate :direct_rooms_keep_their_type, on: :update
scope :opens, -> { where(type: "Rooms::Open") }
scope :closeds, -> { where(type: "Rooms::Closed") }
scope :directs, -> { where(type: "Rooms::Direct") }
@@ -67,15 +65,6 @@ class Room < ApplicationRecord
end
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)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end
+1 -7
View File
@@ -2,9 +2,7 @@ module User::Avatar
extend ActiveSupport::Concern
included do
has_one_attached :avatar do |attachable|
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
end
has_one_attached :avatar
end
class_methods do
@@ -16,8 +14,4 @@ module User::Avatar
def avatar_token
signed_id(purpose: :avatar)
end
def avatar_variant
avatar.variant(:square).processed if avatar.variable?
end
end
-2
View File
@@ -24,8 +24,6 @@ class Webhook < ApplicationRecord
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
end
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
# and operators legitimately point bots at their own internal services.
def http
Net::HTTP.new(uri.host, uri.port).tap do |http|
http.use_ssl = (uri.scheme == "https")
+1 -2
View File
@@ -1,7 +1,6 @@
<%# 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, "presentation-v2" ] do %>
<% cache message do %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
-18
View File
@@ -1,18 +0,0 @@
json.cache! message do
json.(message, :id)
json.created_at message.created_at.utc
json.body do
json.plain_text message.plain_text_body
json.html message.body.to_s
end
json.creator message.creator, partial: "users/user", as: :user
json.room do
json.id message.room_id
end
json.url room_message_url(message.room, message)
end
@@ -1,12 +0,0 @@
json.cache! boost do
json.(boost, :id, :content)
json.created_at boost.created_at.utc
json.booster boost.booster, partial: "users/user", as: :user
json.message do
json.id boost.message_id
json.url room_message_url(boost.message.room, boost.message)
end
end
@@ -1 +0,0 @@
json.partial! "messages/boosts/boost", boost: @boost
@@ -1 +0,0 @@
json.array! @messages, partial: "messages/message", as: :message
@@ -1 +0,0 @@
json.partial! "messages/message", message: @message
+1 -1
View File
@@ -17,7 +17,7 @@
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
<%= turbo_stream_from @room, :messages %>
<%= button_to_jump_to_newest_message %>
<% end %>
-5
View File
@@ -1,5 +0,0 @@
json.cache! user do
json.(user, :id, :name, :role)
json.avatar_url fresh_user_avatar_url(user)
end
Executable
+186
View File
@@ -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]}/"
+160
View File
@@ -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 }
+1 -1
View File
@@ -2,6 +2,6 @@
require "rubygems"
require "bundler/setup"
ARGV.unshift("--ensure-latest", "15")
ARGV.unshift("--ensure-latest")
load Gem.bin_path("brakeman", "brakeman")
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
require "solid_queue/cli"
SolidQueue::Cli.start(ARGV)
+10 -17
View File
@@ -15,7 +15,6 @@ include SSHKit::DSL
ARCH = "linux/amd64,linux/arm64"
BUILDER_NAME = "campfire-builder"
GIT_SHA = `git rev-parse HEAD`.strip
MANUAL_NOTES = ARGV.delete("--notes")
VERSION = ARGV[0]
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_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
if current_branch != "main"
@@ -58,25 +57,19 @@ on(:local) do
end
announcing "Creating GitHub release v#{VERSION}..." do
notes_file = "tmp/release_notes.md"
on(:local) do
if MANUAL_NOTES
notes_file = "tmp/release_notes.md"
execute :mkdir, "-p", "tmp"
execute :mkdir, "-p", "tmp"
File.write(notes_file, "# What's Changed\n\n- \n")
system ENV["EDITOR"] || "vim", notes_file
File.write(notes_file, "# What's Changed\n\n- \n")
system ENV["EDITOR"] || "vim", notes_file
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
abort "Failed to create release; check that the version doesn't already exist"
end
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
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
abort "Failed to create release; check that the version doesn't already exist"
end
execute :rm, notes_file
end
end
-22
View File
@@ -8,9 +8,6 @@ app_root="$(
)"
export PATH="$app_root/bin:$PATH"
REDIS_PORT=6379
REDIS_HOST=localhost
if [ "$RAILS_ENV" = "production" ]; then
echo "RAILS_ENV is production; bailing out"
exit 1
@@ -45,10 +42,6 @@ step() {
return $exit_code
}
redis_running() {
nc -z "$REDIS_HOST" "$REDIS_PORT" 2>/dev/null
}
echo
gum style --foreground 214 " ) "
gum style --foreground 208 " ) \\ campfire"
@@ -88,21 +81,6 @@ if [[ $* == *--reset* ]]; then
fi
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
for tool in actionlint shellcheck zizmor; do
if ! command -v "$tool" &> /dev/null; then
+17 -10
View File
@@ -1,18 +1,25 @@
default: &default
adapter: redis
url: redis://localhost:6379
# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# 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:
<<: *default
channel_prefix: campfire_development
adapter: async
test:
adapter: test
performance:
<<: *default
channel_prefix: campfire_performance
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.05.seconds
message_retention: 1.day
production:
<<: *default
channel_prefix: campfire_production
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.05.seconds
message_retention: 1.day
+21
View File
@@ -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
+28
View File
@@ -14,6 +14,10 @@ development:
primary:
<<: *default
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
# re-generated from your development database when you run "rake".
@@ -27,8 +31,32 @@ performance:
primary:
<<: *default
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:
primary:
<<: *default
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
+2 -2
View File
@@ -21,7 +21,7 @@ Rails.application.configure do
config.action_controller.perform_caching = 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 = {
"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
# 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.
config.active_job.verbose_enqueue_logs = true
+3 -2
View File
@@ -68,7 +68,7 @@ Rails.application.configure do
config.active_support.report_deprecations = false
# Cache in memory for now
config.cache_store = :redis_cache_store
config.cache_store = :solid_cache_store
# Assets are cacheable
config.public_file_server.headers = {
@@ -92,5 +92,6 @@ Rails.application.configure do
# Only use :id for inspections in production.
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
@@ -1,3 +0,0 @@
Rails.application.config.to_prepare do
Turbo::StreamsChannel.prepend RoomStreamsAreAuthorized
end
+1 -1
View File
@@ -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"]
-9
View File
@@ -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 ]
-2
View File
@@ -35,8 +35,6 @@ pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
worker_count = (Concurrent.processor_count * 0.666).ceil
workers ENV.fetch("WEB_CONCURRENCY") { worker_count }
ENV["JOB_CONCURRENCY"] ||= worker_count.to_s
# Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code
# before forking the application. This takes advantage of Copy On Write
+21
View File
@@ -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
+15
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
port 6379
daemonize no
appendonly no
save ""
-1
View File
@@ -1 +0,0 @@
default: <%= (Concurrent.processor_count * 0.5).ceil %>
+1 -7
View File
@@ -62,13 +62,7 @@ Rails.application.routes.draw do
resources :rooms do
resources :messages
nested do
scope path: ":bot_key", as: :bot, defaults: { format: :json } do
resources :messages, controller: "messages/by_bots", only: %i[ index create update destroy ] do
resources :boosts, controller: "messages/boosts/by_bots", only: %i[ create destroy ]
end
end
end
post ":bot_key/messages", to: "messages/by_bots#create", as: :bot_messages
scope module: "rooms" do
resource :refresh, only: :show
+11
View File
@@ -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
+12
View File
@@ -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
+129
View File
@@ -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
-12
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
exec /rails/script/admin/prepare-backup
+11 -15
View File
@@ -1,29 +1,25 @@
require "surfguard"
require "resolv"
module RestrictedHTTP
class Violation < StandardError; end
# The address policy lives in the surfguard gem so this app, Basecamp, HEY and
# Fizzy classify "internal" the same way instead of each keeping a copy that
# drifts. The callers here hand a bare hostname in and pin the address that
# comes back, so this stays a hostname-in, address-out shim.
module PrivateNetworkGuard
extend self
# A hostname that resolves to nothing (NXDOMAIN, timeout, empty answer)
# raises Surfguard::Unresolvable, which we let propagate as a lookup failure
# -- the same way the old Resolv.getaddress guard raised Resolv::ResolvError,
# and the callers here already treat it as a fetch failure. The Violation is
# reserved for a host that resolves to a blocked address (an empty list back
# from resolve_public_ips), so a transient DNS miss is never misreported as
# an SSRF attempt.
LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network
def resolve(hostname)
Surfguard.resolve_public_ips(hostname).first or
raise Violation.new("Attempt to access private IP via #{hostname}")
Resolv.getaddress(hostname).tap do |ip|
raise Violation.new("Attempt to access private IP via #{hostname}") if ip && private_ip?(ip)
end
end
def private_ip?(ip)
Surfguard.blocked_address?(ip)
IPAddr.new(ip).then do |ipaddr|
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr)
end
rescue IPAddr::InvalidAddressError
true
end
end
end
-12
View File
@@ -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
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
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
@@ -1,112 +0,0 @@
require "test_helper"
class Messages::Boosts::ByBotsControllerTest < ActionDispatch::IntegrationTest
setup do
@room = rooms(:watercooler)
@message = messages(:fourth)
@bot = users(:bender)
end
test "create adds a boost to the message and returns it" do
assert_difference -> { @message.boosts.count }, +1 do
post room_bot_message_boosts_url(@room, @bot.bot_key, @message), params: +"👀"
assert_response :created
end
boost = @message.boosts.last
assert_equal "👀", boost.content
assert_equal @bot, boost.booster
json = JSON.parse(response.body)
assert_equal boost.id, json["id"]
assert_equal "👀", json["content"]
assert_equal @bot.id, json["booster"]["id"]
assert_equal @message.id, json["message"]["id"]
assert_equal room_message_url(@room, @message), json["message"]["url"]
end
test "create with text content" do
assert_difference -> { Boost.count }, +1 do
post room_bot_message_boosts_url(@room, @bot.bot_key, @message), params: +"Nice!"
assert_response :created
end
assert_equal "Nice!", @message.boosts.last.content
end
test "create broadcasts the boost" do
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
post room_bot_message_boosts_url(@room, @bot.bot_key, @message), params: +"👍"
end
end
test "create without content" do
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(@room, @bot.bot_key, @message)
assert_response :unprocessable_content
post room_bot_message_boosts_url(@room, @bot.bot_key, @message), params: +" "
assert_response :unprocessable_content
end
end
test "create requires a valid bot key" do
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(@room, "invalid-bot-key", @message), params: +"👀"
end
assert_response :redirect
end
test "create is not found for a room the bot is not a member of" do
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(rooms(:designers), @bot.bot_key, messages(:first)), params: +"👀"
end
assert_response :not_found
end
test "create is not found for a message outside the room" do
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(@room, @bot.bot_key, messages(:first)), params: +"👀"
end
assert_response :not_found
end
test "create can't be abused to post boosts as a regular user" do
bot_key = "#{users(:kevin).id}-"
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(@room, bot_key, @message), params: +"👀"
end
assert_response :redirect
end
test "destroy removes the bot's own boost" do
assert_difference -> { Boost.count }, -1 do
delete room_bot_message_boost_url(@room, @bot.bot_key, @message, boosts(:fourth_by_bender))
end
assert_response :no_content
end
test "destroy broadcasts the removal" do
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
delete room_bot_message_boost_url(@room, @bot.bot_key, @message, boosts(:fourth_by_bender))
end
end
test "destroy can't touch a boost the bot did not make" do
assert_no_difference -> { Boost.count } do
delete room_bot_message_boost_url(@room, @bot.bot_key, messages(:thirteenth), boosts(:thirteenth))
end
assert_response :not_found
end
test "destroy requires a valid bot key" do
assert_no_difference -> { Boost.count } do
delete room_bot_message_boost_url(@room, "invalid-bot-key", @message, boosts(:fourth_by_bender))
end
assert_response :redirect
end
end
@@ -1,6 +1,6 @@
require "test_helper"
class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
class Messages::ByBotsControlleTest < ActionDispatch::IntegrationTest
setup do
@room = rooms(:watercooler)
end
@@ -40,16 +40,6 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
end
end
test "create without a body or attachment" do
assert_no_difference -> { Message.count } do
post room_bot_messages_url(@room, users(:bender).bot_key)
assert_response :unprocessable_content
post room_bot_messages_url(@room, users(:bender).bot_key), params: +" "
assert_response :unprocessable_content
end
end
test "create can't be abused to post messages as any user" do
user = users(:kevin)
bot_key = "#{user.id}-"
@@ -61,175 +51,8 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
assert_response :redirect
end
test "index returns the room's messages in the order they were sent" do
get room_bot_messages_url(@room, users(:bender).bot_key)
assert_response :success
json = JSON.parse(response.body)
assert_equal @room.messages.ordered.map(&:id), json.map { it["id"] }
end
test "index includes message details" do
post room_bot_messages_url(@room, users(:bender).bot_key), params: +"Hello from Bender!"
get room_bot_messages_url(@room, users(:bender).bot_key)
assert_response :success
message = Message.last
json_message = JSON.parse(response.body).last
assert_equal message.id, json_message["id"]
assert_equal "Hello from Bender!", json_message["body"]["plain_text"]
assert_includes json_message["body"]["html"], "Hello from Bender!"
assert_equal message.created_at.utc.iso8601(3), json_message["created_at"]
assert_equal users(:bender).id, json_message["creator"]["id"]
assert_equal "Bender Bot", json_message["creator"]["name"]
assert_equal "bot", json_message["creator"]["role"]
assert_equal @room.id, json_message["room"]["id"]
assert_equal room_message_url(@room, message), json_message["url"]
end
test "index pages through older messages with the Link header" do
(Message::PAGE_SIZE - @room.messages.count + 1).times do |i|
@room.messages.create!(body: "Filler #{i}", creator: users(:jason), client_message_id: "filler-#{i}")
end
get room_bot_messages_url(@room, users(:bender).bot_key)
assert_response :success
json = JSON.parse(response.body)
assert_equal Message::PAGE_SIZE, json.size
assert_equal "41", response.headers["X-Total-Count"]
assert_not_includes json.map { it["id"] }, messages(:fourth).id
get response.headers["Link"][/<(.*)>/, 1]
assert_response :success
json = JSON.parse(response.body)
assert_equal [ messages(:fourth).id ], json.map { it["id"] }
assert_nil response.headers["Link"]
end
test "index pages newer messages with after" do
get room_bot_messages_url(@room, users(:bender).bot_key, after: messages(:tenth).id)
assert_response :success
json = JSON.parse(response.body)
assert_equal %i[ eleventh twelfth thirteenth ].map { messages(it).id }, json.map { it["id"] }
assert_nil response.headers["Link"]
end
test "index in a room with no messages" do
get room_bot_messages_url(rooms(:bender_and_kevin), users(:bender).bot_key)
assert_response :success
assert_equal [], JSON.parse(response.body)
assert_equal "0", response.headers["X-Total-Count"]
assert_nil response.headers["Link"]
end
test "index requires a valid bot key" do
get room_bot_messages_url(@room, "invalid-bot-key")
assert_response :redirect
end
test "index is not found for a room the bot is not a member of" do
get room_bot_messages_url(rooms(:designers), users(:bender).bot_key)
assert_response :not_found
end
test "create is not found for a room the bot is not a member of" do
assert_no_difference -> { Message.count } do
post room_bot_messages_url(rooms(:designers), users(:bender).bot_key), params: +"Hello!"
end
assert_response :not_found
end
test "regular messages index remains denied for bots" do
get room_messages_url(@room, bot_key: users(:bender).bot_key)
test "denied index" do
get room_messages_url(@room, bot_key: users(:bender).bot_key, format: :json)
assert_response :forbidden
end
test "update" do
message = post_bot_message "Deploying..."
assert_no_difference -> { Message.count } do
patch room_bot_message_url(@room, users(:bender).bot_key, message), params: +"Deployed."
end
assert_response :ok
assert_equal "Deployed.", message.reload.plain_text_body
json = JSON.parse(response.body)
assert_equal message.id, json["id"]
assert_equal "Deployed.", json["body"]["plain_text"]
assert_equal users(:bender).id, json["creator"]["id"]
assert_equal room_message_url(@room, message), json["url"]
end
test "update with UTF-8 content" do
message = post_bot_message "Deploying..."
patch room_bot_message_url(@room, users(:bender).bot_key, message), params: +"Deployed 🚀!"
assert_response :ok
assert_equal "Deployed 🚀!", message.reload.plain_text_body
assert_equal "Deployed 🚀!", JSON.parse(response.body)["body"]["plain_text"]
end
test "update can't touch a message the bot did not create" do
message = messages(:fourth)
original = message.plain_text_body
patch room_bot_message_url(@room, users(:bender).bot_key, message), params: +"Hijacked!"
assert_response :forbidden
assert_equal original, message.reload.plain_text_body
end
test "update is not found for a room the bot is not a member of" do
message = messages(:first)
original = message.plain_text_body
patch room_bot_message_url(rooms(:designers), users(:bender).bot_key, message), params: +"Hijacked!"
assert_response :not_found
assert_equal original, message.reload.plain_text_body
end
test "update can't be abused to edit messages as any user" do
message = messages(:fourth)
bot_key = "#{users(:jz).id}-"
original = message.plain_text_body
patch room_bot_message_url(@room, bot_key, message), params: +"Hijacked!"
assert_response :redirect
assert_equal original, message.reload.plain_text_body
end
test "destroy" do
message = post_bot_message "Deploying..."
assert_difference -> { Message.count }, -1 do
delete room_bot_message_url(@room, users(:bender).bot_key, message)
end
assert_response :no_content
end
test "destroy can't touch a message the bot did not create" do
message = messages(:fourth)
assert_no_difference -> { Message.count } do
delete room_bot_message_url(@room, users(:bender).bot_key, message)
end
assert_response :forbidden
end
private
def post_bot_message(body)
post room_bot_messages_url(@room, users(:bender).bot_key), params: +body
Message.last
end
end
+3 -16
View File
@@ -57,22 +57,9 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
end
end
test "creating a message broadcasts unread room to each member" do
@room.users.each do |member|
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 } }
end
test "creating a message broadcasts unread room" do
assert_broadcasts "unread_rooms", 1 do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
end
end
@@ -66,18 +66,6 @@ class Rooms::ClosedsControllerTest < ActionDispatch::IntegrationTest
assert rooms(:designers).reload.name, "Designers"
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
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 ] })
@@ -29,34 +29,4 @@ class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_url
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
@@ -57,23 +57,4 @@ class Rooms::OpensControllerTest < ActionDispatch::IntegrationTest
put rooms_open_url(rooms(:designers)), params: { room: { name: "Doesn't matter" } }
assert_equal rooms(:designers).memberships.count, User.count
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
@@ -18,14 +18,6 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
assert_equal "image/webp", @response.content_type
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
get user_avatar_url("not-a-valid-token")
-4
View File
@@ -6,7 +6,3 @@ thirteenth:
message: thirteenth
booster: jason
content: 💯
fourth_by_bender:
message: fourth
booster: bender
content: 👀
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 B

-69
View File
@@ -61,75 +61,6 @@ class ContentFiltersTest < ActionView::TestCase
assert_equal "Hello World", filtered.to_html
end
test "message with a link using an unsafe URI scheme" do
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match /javascript:/, filtered.to_html
assert_match /<a>x<\/a>/, filtered.to_html
end
test "message with an event handler attribute on an allowed tag" do
message = Message.create! room: rooms(:pets), body: '<div><a href="/x" onmouseover="alert(1)">x</a> <span onclick="alert(2)">y</span></div>', client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match /onmouseover/, filtered.to_html
assert_no_match /onclick/, filtered.to_html
assert_match /<a href="\/x">x<\/a>/, filtered.to_html
assert_match /<span>y<\/span>/, filtered.to_html
end
test "message with a data URI link" do
message = Message.create! room: rooms(:pets), body: '<div><a href="data:text/html,pwned">x</a></div>', client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match /data:/, filtered.to_html
assert_match /<a>x<\/a>/, filtered.to_html
end
test "message with a safe link and formatting is preserved" do
body = '<div><a href="https://example.com">example</a> <strong>bold</strong> <code>code</code><ul><li>one</li><li>two</li></ul></div>'
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_match /<a href="https:\/\/example\.com">example<\/a>/, filtered.to_html
assert_match /<strong>bold<\/strong>/, filtered.to_html
assert_match /<code>code<\/code>/, filtered.to_html
assert_match /<ul>\s*<li>one<\/li>\s*<li>two<\/li>\s*<\/ul>/, filtered.to_html
end
test "SanitizeAttributes uses an isolated sanitizer rather than the shared ActionText instance" do
filter = ContentFilters::SanitizeAttributes.new(ActionText::Content.new("<div>x</div>"))
isolated = filter.send(:sanitizer)
assert_not_same ActionText::ContentHelper.sanitizer, isolated,
"must not reuse the process-wide ActionText sanitizer, whose permit scrubber is mutated per call"
assert_not_same isolated, filter.send(:sanitizer),
"each call must build a fresh sanitizer so concurrent requests never share scrubber state"
assert_kind_of ActionText::ContentHelper.sanitizer.class, isolated
end
test "SanitizeAttributes neutralizes unsafe input and preserves benign content on its isolated sanitizer" do
body = <<~HTML.squish
<div><a href="javascript:alert(1)" onclick="x()">link</a>
<a href="data:text/html,pwned">data</a>
<span class="cf-twitter-avatar" onmouseover="y()">avatar</span>
<img src="https://evil.example/x.svg"> Hey #{mention_attachment_for(:david)}</div>
HTML
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::SanitizeAttributes.apply(message.body.body).to_html
assert_no_match /javascript:/, filtered
assert_no_match /data:text\/html/, filtered
assert_no_match /onclick/, filtered
assert_no_match /onmouseover/, filtered
assert_no_match /evil\.example/, filtered
assert_match /<span class="cf-twitter-avatar">avatar<\/span>/, filtered
assert_match />link</, filtered
assert_match /<action-text-attachment sgid="#{users(:david).attachable_sgid}"/, filtered
end
test "message with a mention attachment" do
message = Message.create! room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason)
-27
View File
@@ -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,89 +64,23 @@ class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase
assert_private_ip "::93.184.216.34"
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
# The local-use block is refused whole rather than decoded. A Pref64 inside it
# can be any length from /32 to /96 and the position is not recoverable from
# the address alone (RFC 6052 §2.2), so reading the low 32 bits reads the
# wrong octets -- 64:ff9b:1::808:808 only looks like 8.8.8.8 under a /96
# reading. The block is never globally routed, so nothing legitimate is lost.
test "private_ip? returns true for the whole local-use NAT64 block (RFC8215)" do
assert_private_ip "64:ff9b:1::a00:1" # reads as 10.0.0.1 under a /96
assert_private_ip "64:ff9b:1::808:808" # reads as 8.8.8.8 under a /96
assert_private_ip "64:ff9b:1:ffff::1"
end
# SIIT is the third way an IPv4 address rides inside an IPv6 one and the only
# one Ruby has no predicate for: ipv4_mapped?, ipv4_compat?, private?,
# loopback? and link_local? are all false here. Note the extra group --
# ::ffff:0:0:0/96 is not the IPv4-mapped ::ffff:0:0/96, and they do not
# overlap -- so this reached the metadata endpoint unrecognised.
test "private_ip? returns true for SIIT IPv4-translated addresses (RFC2765)" do
assert_private_ip "::ffff:0:169.254.169.254"
assert_private_ip "::ffff:0:a9fe:a9fe" # the same address, hex spelling
assert_private_ip "::ffff:0:127.0.0.1"
assert_private_ip "::ffff:0:192.168.0.1"
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
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip")
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("")
end
test "resolve raises Violation for private hostname" do
Resolv.stubs(:getaddresses).returns([ "192.168.1.1" ])
Resolv.stubs(:getaddress).returns("192.168.1.1")
assert_raises RestrictedHTTP::Violation do
RestrictedHTTP::PrivateNetworkGuard.resolve("private.example.com")
end
end
test "resolve returns IP for public hostname" do
Resolv.stubs(:getaddresses).returns([ "93.184.216.34" ])
Resolv.stubs(:getaddress).returns("93.184.216.34")
assert_equal "93.184.216.34", RestrictedHTTP::PrivateNetworkGuard.resolve("example.com")
end
test "resolve raises Unresolvable, not Violation, when the host resolves to nothing" do
Resolv.stubs(:getaddresses).returns([])
assert_raises Surfguard::Unresolvable do
RestrictedHTTP::PrivateNetworkGuard.resolve("nxdomain.example.com")
end
end
private
def assert_private_ip(address)
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?(address),
-118
View File
@@ -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
-16
View File
@@ -15,20 +15,4 @@ class AccountTest < ActiveSupport::TestCase
accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" })
assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
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
+7 -7
View File
@@ -37,7 +37,7 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
WebMock.stub_request(:get, "https://www.other.com/")
.to_return(status: 200, body: "<body>ok<body>", headers: { content_type: "text/html" })
Resolv.stubs(:getaddresses).with("www.other.com").returns([ "127.0.0.1" ])
Resolv.stubs(:getaddress).with("www.other.com").returns("127.0.0.1")
assert_raises RestrictedHTTP::Violation do
@fetch.fetch_document(@url, ip: "1.2.3.4")
@@ -48,9 +48,9 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
# Allow but interrupt a real connection to demonstrate that we connect
# to a resolved IP, not a hostname to re-resolve.
WebMock.disable_net_connect! allow: [ @url.host ]
Resolv.stubs(:getaddresses).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 { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
assert_throws :dns_not_rebound do
@fetch.fetch_document(@url)
@@ -65,9 +65,9 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
# Allow but interrupt a real connection to demonstrate that we connect
# to a resolved IP, not a hostname to re-resolve.
WebMock.disable_net_connect! allow: [ @url.host ]
Resolv.stubs(:getaddresses).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 { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
assert_throws :dns_not_rebound do
@fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")
+4 -4
View File
@@ -14,7 +14,7 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
end
test "private network urls" do
Resolv.stubs(:getaddresses).with("www.example.com").returns([ "172.16.0.0" ])
Resolv.stubs(:getaddress).with("www.example.com").returns("172.16.0.0")
location = Opengraph::Location.new("https://www.example.com")
assert_not location.valid?
@@ -22,7 +22,7 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
end
test "link-local addresses are blocked" do
Resolv.stubs(:getaddresses).with("metadata.internal").returns([ "169.254.169.254" ])
Resolv.stubs(:getaddress).with("metadata.internal").returns("169.254.169.254")
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
@@ -30,13 +30,13 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
end
test "IPv6 addresses mapped to IPv4 addresses are blocked" do
Resolv.stubs(:getaddresses).with("metadata.internal").returns([ "::ffff:192.168.1.1" ])
Resolv.stubs(:getaddress).with("metadata.internal").returns("::ffff:192.168.1.1")
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
assert_equal [ "is not public" ], location.errors[:url]
Resolv.stubs(:getaddresses).with("metadata.internal").returns([ "::ffff:c0a8:0101" ])
Resolv.stubs(:getaddress).with("metadata.internal").returns("::ffff:c0a8:0101")
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
-19
View File
@@ -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
+158
View File
@@ -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);
}
}