mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
138
Commits
No files matched your search
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ main, "release-*" ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
@@ -48,6 +48,29 @@ jobs:
|
||||
- name: Lint code for consistent style
|
||||
run: bin/rubocop
|
||||
|
||||
herb:
|
||||
name: Herb check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Check that every HTML template compiles through Herb
|
||||
run: bin/rails herb:check
|
||||
|
||||
lint-actions:
|
||||
name: GitHub Actions audit
|
||||
runs-on: ubuntu-latest
|
||||
@@ -79,7 +102,7 @@ jobs:
|
||||
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
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -87,7 +110,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
with:
|
||||
@@ -97,6 +120,9 @@ jobs:
|
||||
- name: Run tests
|
||||
run: bin/rails db:setup test
|
||||
|
||||
- name: Test benchmark response validation
|
||||
run: bundle exec ruby bench/test_http_client.rb
|
||||
|
||||
test_system:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -109,7 +135,7 @@ jobs:
|
||||
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
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -117,7 +143,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
with:
|
||||
|
||||
+14
-1
@@ -9,10 +9,23 @@ 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 ca-certificates curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
|
||||
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
|
||||
|
||||
# Naming ca-certificates above takes the distribution's current trusted roots rather than the
|
||||
# ones frozen into the base image. Fail the build if either of these withdrawn roots is still
|
||||
# where Ruby's OpenSSL looks for trusted certificates.
|
||||
RUN ruby -ropenssl -e ' \
|
||||
file = ENV[OpenSSL::X509::DEFAULT_CERT_FILE_ENV] || OpenSSL::X509::DEFAULT_CERT_FILE; \
|
||||
dir = ENV[OpenSSL::X509::DEFAULT_CERT_DIR_ENV] || OpenSSL::X509::DEFAULT_CERT_DIR; \
|
||||
pems = [file, *Dir.glob(File.join(dir, "*"))].select { |path| File.file?(path) } \
|
||||
.flat_map { |path| File.read(path).scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m) }; \
|
||||
subjects = pems.map { |pem| OpenSSL::X509::Certificate.new(pem).subject.to_s }.uniq; \
|
||||
abort "Only #{subjects.size} roots in #{file} and #{dir}" if subjects.size < 100; \
|
||||
withdrawn = subjects.grep(%r{/CN=(emSign ECC Root CA - G3|AAA Certificate Services)\z}); \
|
||||
abort "Withdrawn roots still trusted: #{withdrawn}" if withdrawn.any?'
|
||||
|
||||
# Set production environment
|
||||
ENV RAILS_ENV="production" \
|
||||
BUNDLE_DEPLOYMENT="1" \
|
||||
|
||||
@@ -43,7 +43,6 @@ gem "rqrcode"
|
||||
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"
|
||||
|
||||
+53
-40
@@ -23,16 +23,16 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/propshaft.git
|
||||
revision: e49a9de659ff27462015e54dd832e86e762a6ddc
|
||||
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623
|
||||
specs:
|
||||
propshaft (1.2.1)
|
||||
propshaft (1.3.2)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
|
||||
revision: e3d5c569d217c56c022b04acb127a8c24dbe9685
|
||||
branch: main
|
||||
specs:
|
||||
actioncable (8.2.0.alpha)
|
||||
@@ -63,10 +63,10 @@ GIT
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
rails-html-sanitizer (~> 1.7)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.2.0.alpha)
|
||||
action_text-trix (~> 2.1.15)
|
||||
action_text-trix (~> 2.1.16)
|
||||
actionpack (= 8.2.0.alpha)
|
||||
activerecord (= 8.2.0.alpha)
|
||||
activestorage (= 8.2.0.alpha)
|
||||
@@ -77,11 +77,12 @@ GIT
|
||||
activesupport (= 8.2.0.alpha)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
herb (>= 0.10)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
rails-html-sanitizer (~> 1.7)
|
||||
activejob (8.2.0.alpha)
|
||||
activesupport (= 8.2.0.alpha)
|
||||
globalid (>= 0.3.6)
|
||||
globalid (>= 1.4.0)
|
||||
activemodel (8.2.0.alpha)
|
||||
activesupport (= 8.2.0.alpha)
|
||||
activerecord (8.2.0.alpha)
|
||||
@@ -93,7 +94,7 @@ GIT
|
||||
activejob (= 8.2.0.alpha)
|
||||
activerecord (= 8.2.0.alpha)
|
||||
activesupport (= 8.2.0.alpha)
|
||||
marcel (~> 1.0)
|
||||
marcel (~> 2.0)
|
||||
activesupport (8.2.0.alpha)
|
||||
base64
|
||||
bigdecimal
|
||||
@@ -104,6 +105,8 @@ GIT
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
psych (>= 4)
|
||||
ractor-dispatch (>= 0.3.0)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
@@ -142,8 +145,8 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (3.3.1)
|
||||
brakeman (8.0.6)
|
||||
bigdecimal (4.1.3)
|
||||
brakeman (8.1.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.3)
|
||||
@@ -159,7 +162,7 @@ GEM
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.7)
|
||||
concurrent-ruby (1.3.8)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -170,9 +173,9 @@ GEM
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
drb (2.2.3)
|
||||
erb (6.0.4)
|
||||
erb (6.0.7)
|
||||
erubi (1.13.1)
|
||||
faker (3.5.2)
|
||||
faker (3.8.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.17.2)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
@@ -182,17 +185,22 @@ GEM
|
||||
geared_pagination (1.2.0)
|
||||
activesupport (>= 5.0)
|
||||
addressable (>= 2.5.0)
|
||||
globalid (1.3.0)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
hashdiff (1.2.0)
|
||||
i18n (1.14.7)
|
||||
herb (0.11.0-aarch64-linux-gnu)
|
||||
herb (0.11.0-arm64-darwin)
|
||||
herb (0.11.0-x86_64-darwin)
|
||||
herb (0.11.0-x86_64-linux-gnu)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.3)
|
||||
io-console (0.9.4)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
@@ -219,28 +227,28 @@ GEM
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.1.0)
|
||||
marcel (2.1.0)
|
||||
matrix (0.4.3)
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.26.2)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
mocha (3.1.0)
|
||||
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)
|
||||
net-imap (0.6.7)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
net-protocol (0.4.0)
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-smtp (0.5.2)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
@@ -260,18 +268,18 @@ GEM
|
||||
platform_agent (1.0.1)
|
||||
activesupport (>= 5.2.0)
|
||||
useragent (~> 0.16.3)
|
||||
pp (0.6.3)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
psych (5.2.6)
|
||||
prism (1.9.0)
|
||||
psych (5.5.0)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.6)
|
||||
rack (3.2.7)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
@@ -281,8 +289,9 @@ GEM
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
ractor-dispatch (0.3.0)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -295,19 +304,24 @@ GEM
|
||||
activesupport (> 3.1)
|
||||
railties (> 3.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (6.16.1)
|
||||
rake (13.4.2)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.1.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
redis (5.4.1)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.25.2)
|
||||
redis-client (0.30.1)
|
||||
connection_pool
|
||||
redis-namespace (1.11.0)
|
||||
redis (>= 4)
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.6.3)
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
resque (2.7.0)
|
||||
mono_logger (~> 1)
|
||||
@@ -355,9 +369,9 @@ GEM
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (3.4.0)
|
||||
rubyzip (3.7.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.35.0)
|
||||
selenium-webdriver (4.49.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -382,8 +396,8 @@ GEM
|
||||
sqlite3 (2.9.6-x86_64-linux-gnu)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.8)
|
||||
thor (1.4.0)
|
||||
stringio (3.2.0)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.23-aarch64-linux)
|
||||
thruster (0.1.23-arm64-darwin)
|
||||
thruster (0.1.23-x86_64-darwin)
|
||||
@@ -412,7 +426,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.3)
|
||||
zeitwerk (2.8.3)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -435,7 +449,6 @@ DEPENDENCIES
|
||||
kredis
|
||||
lexxy (~> 0.9.24)
|
||||
mocha
|
||||
net-http-persistent
|
||||
ostruct
|
||||
platform_agent
|
||||
propshaft!
|
||||
|
||||
@@ -49,6 +49,23 @@ If you'd rather run the Docker image yourself, you can read more about that in t
|
||||
> that people have someone to contact if they need help with their account. If that bothers you, put in any
|
||||
> email address you want and create yourself a new admin account.
|
||||
|
||||
## Other implementations
|
||||
|
||||
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
|
||||
|
||||
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| Room page | 230 | 62 | 760 | 2,622 | 942 | 31,673 | 35,484 | 141,834 |
|
||||
| Messages page | 402 | 70 | 924 | 3,245 | 1,267 | 30,746 | 40,674 | 151,564 |
|
||||
| Sidebar | 468 | 229 | 1,383 | 34,938 | 2,515 | 18,586 | 34,479 | 159,850 |
|
||||
| Search | 399 | 118 | 1,135 | 6,613 | 1,814 | 29,765 | 34,432 | 155,456 |
|
||||
| Post a message | 248 | 112 | 498 | 2,088 | 1,400 | 9,073 | 8,998 | 7,460 |
|
||||
|
||||
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
|
||||
|
||||
Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395 with 32 GB RAM,
|
||||
with four hardware cores allocated to each app.
|
||||
|
||||
## Development
|
||||
|
||||
You are welcome - and encouraged - to modify Campfire to your liking.
|
||||
|
||||
@@ -2,19 +2,42 @@ module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
include Authentication::SessionLookup
|
||||
|
||||
SESSION_CHECK_INTERVAL = 1.minute
|
||||
|
||||
identified_by :current_user
|
||||
|
||||
def connect
|
||||
self.current_user = find_verified_user
|
||||
@session_checked_at = Time.now
|
||||
end
|
||||
|
||||
# Signing out or changing the password ends sessions and then disconnects their connections, but a connection can
|
||||
# miss that: one that found its session just before isn't listening yet. So on the server's heartbeat, which
|
||||
# reaches every open connection, each one looks its session up again now and then, and closes once it's gone. A
|
||||
# rejected connection has no session, and stays registered until its socket closes.
|
||||
def beat
|
||||
super
|
||||
check_session if @session_checked_at&.before?(SESSION_CHECK_INTERVAL.ago)
|
||||
end
|
||||
|
||||
private
|
||||
def find_verified_user
|
||||
if verified_session = find_session_by_cookie
|
||||
verified_session.user
|
||||
if @verified_session = find_session_by_cookie
|
||||
@verified_session.user
|
||||
else
|
||||
reject_unauthorized_connection
|
||||
end
|
||||
end
|
||||
|
||||
def check_session
|
||||
@session_checked_at = Time.now
|
||||
perform_work -> { close_unless_session_remains }, :call
|
||||
end
|
||||
|
||||
def close_unless_session_remains
|
||||
unless Session.exists?(@verified_session.id)
|
||||
close reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -22,6 +22,6 @@ class PresenceChannel < RoomChannel
|
||||
end
|
||||
|
||||
def broadcast_read_room
|
||||
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
|
||||
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id, at: Time.current.to_f }
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ class Accounts::UsersController < ApplicationController
|
||||
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
|
||||
|
||||
def index
|
||||
set_page_and_extract_portion_from User.active.ordered.without_bots, per_page: 500
|
||||
set_page_and_extract_portion_from User.visible_to(Current.user).ordered.member, per_page: 500
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -3,9 +3,9 @@ class AccountsController < ApplicationController
|
||||
before_action :set_account
|
||||
|
||||
def edit
|
||||
users = account_users.ordered.without_bots
|
||||
@administrators, @members = users.partition(&:administrator?)
|
||||
set_page_and_extract_portion_from users, per_page: 500
|
||||
users = User.visible_to(Current.user).ordered
|
||||
@administrators = users.administrator
|
||||
set_page_and_extract_portion_from users.member, per_page: 500
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -21,12 +21,4 @@ class AccountsController < ApplicationController
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :logo, settings: {})
|
||||
end
|
||||
|
||||
def account_users
|
||||
if Current.user.can_administer?
|
||||
User.where(status: [ :active, :banned ])
|
||||
else
|
||||
User.active
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes
|
||||
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
||||
end
|
||||
@@ -57,7 +57,7 @@ module Authentication
|
||||
end
|
||||
|
||||
def start_new_session_for(user)
|
||||
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||
authenticated_as session
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Allows the scripts the importmap helper emits and nothing else: the inline
|
||||
# importmap and its entry-point module by content hash, and the asset
|
||||
# directory its URLs point into. Not the whole origin, because uploads are
|
||||
# served from this origin too. The hashes change only when the importmap does
|
||||
# (a deploy), so cached HTML stays valid, unlike a per-request nonce.
|
||||
module InlineScriptHashes
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ENTRY_POINT_SCRIPT = %(import "application")
|
||||
|
||||
included do
|
||||
# With the policy turned off there's nothing to add to, so leave the
|
||||
# request without one rather than starting an empty policy
|
||||
content_security_policy if: -> { request.content_security_policy } do |policy|
|
||||
if sources = policy.directives["script-src"]
|
||||
importmap = Rails.application.importmap.to_json(resolver: helpers)
|
||||
policy.directives["script-src"] = (sources - [ "'self'" ]) | script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)
|
||||
policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# The importmap is resolved by Rails' asset helpers, so its URLs already
|
||||
# carry any relative root and asset host, however the host is written:
|
||||
# with or without a scheme, protocol-relative, a %d set, or a proc. Each
|
||||
# is allowed up to its asset directory, the last one in its path, since an
|
||||
# asset host's own path may contain one too; a URL outside it is allowed
|
||||
# exactly.
|
||||
#
|
||||
# This origin's own sources leave out the scheme, so they match the page's
|
||||
# scheme even behind a proxy that terminates TLS without saying so.
|
||||
#
|
||||
# A path only limits the first request: browsers check just the origin
|
||||
# after a redirect. Nothing under the asset directory redirects.
|
||||
def script_locations(importmap)
|
||||
assets = File.join(config.relative_url_root.to_s, Rails.application.config.assets.prefix, "")
|
||||
|
||||
JSON.parse(importmap)["imports"].values.map do |url|
|
||||
url = URI.join(request.base_url, url)
|
||||
origin = url.origin == request.base_url ? request.host_with_port : url.origin
|
||||
directory = url.path[0, url.path.rindex(assets) + assets.length] if url.path.include?(assets)
|
||||
"#{origin}#{directory || url.path}"
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def script_hashes(*scripts)
|
||||
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
|
||||
end
|
||||
end
|
||||
@@ -8,6 +8,6 @@ module SetCurrentRequest
|
||||
end
|
||||
|
||||
def default_url_options
|
||||
{ host: Current.request_host, protocol: Current.request_protocol }.compact_blank
|
||||
{ host: Current.request_host, port: Current.request&.optional_port, protocol: Current.request_protocol }.compact_blank
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,7 @@ class Messages::BoostsController < ApplicationController
|
||||
|
||||
def broadcast_create
|
||||
@boost.broadcast_append_to @boost.message.room, :messages,
|
||||
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
||||
target: helpers.dom_id(@boost.message, :boosts), partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
||||
end
|
||||
|
||||
def broadcast_remove
|
||||
|
||||
@@ -62,11 +62,11 @@ class MessagesController < ApplicationController
|
||||
def find_paged_messages
|
||||
case
|
||||
when params[:before].present?
|
||||
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
|
||||
@room.messages.with_presentation.page_before(@room.messages.find(params[:before]))
|
||||
when params[:after].present?
|
||||
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
|
||||
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
|
||||
else
|
||||
@room.messages.with_creator.last_page
|
||||
@room.messages.with_presentation.last_page
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
|
||||
before_action :set_last_updated_at
|
||||
|
||||
def show
|
||||
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
|
||||
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
|
||||
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at)
|
||||
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -12,7 +12,7 @@ class RoomsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@room.destroy
|
||||
@room.destroy_later
|
||||
|
||||
broadcast_remove_room
|
||||
redirect_to root_url
|
||||
@@ -44,9 +44,9 @@ class RoomsController < ApplicationController
|
||||
end
|
||||
|
||||
def find_messages
|
||||
messages = @room.messages.with_creator.with_attachment_details.with_boosts
|
||||
messages = @room.messages.with_presentation
|
||||
|
||||
if show_first_message = messages.find_by(id: params[:message_id])
|
||||
if params[:message_id].present? && (show_first_message = messages.find_by(id: params[:message_id]))
|
||||
@messages = messages.page_around(show_first_message)
|
||||
else
|
||||
@messages = messages.last_page
|
||||
|
||||
@@ -20,7 +20,7 @@ class SearchesController < ApplicationController
|
||||
private
|
||||
def set_messages
|
||||
if query.present?
|
||||
@messages = Current.user.reachable_messages.search(query).last(100)
|
||||
@messages = Message.search_reachable(Current.user, query)
|
||||
else
|
||||
@messages = Message.none
|
||||
end
|
||||
|
||||
@@ -11,5 +11,7 @@ class Sessions::TransfersController < ApplicationController
|
||||
else
|
||||
head :bad_request
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,8 @@ class SessionsController < ApplicationController
|
||||
else
|
||||
render_rejection :unauthorized
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
render_rejection :unauthorized
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
class UnfurlLinksController < ApplicationController
|
||||
def create
|
||||
opengraph = Opengraph::Metadata.from_url(url_param)
|
||||
# The pasted link decides which hosts an unfurl looks up and fetches from, and how quickly they answer.
|
||||
DEADLINE = 10.seconds
|
||||
|
||||
if opengraph.valid?
|
||||
def create
|
||||
if opengraph = unfurl(url_param)
|
||||
render json: opengraph
|
||||
else
|
||||
head :no_content
|
||||
@@ -13,4 +14,14 @@ class UnfurlLinksController < ApplicationController
|
||||
def url_param
|
||||
params.require(:url)
|
||||
end
|
||||
|
||||
# One deadline for everything the link leads to: DNS lookups, redirects, the page, the image check.
|
||||
def unfurl(url)
|
||||
Timeout.timeout(DEADLINE) do
|
||||
Opengraph::Metadata.from_url(url).then { |opengraph| opengraph if opengraph.valid? }
|
||||
end
|
||||
rescue Timeout::Error
|
||||
Rails.logger.warn "Gave up unfurling #{url} after #{DEADLINE.inspect}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -1,14 +1,26 @@
|
||||
class Users::ProfilesController < ApplicationController
|
||||
before_action :set_user
|
||||
|
||||
# Limited like signing in, so the current password can't be guessed here instead.
|
||||
rate_limit to: 10, within: 3.minutes, only: :update, if: :answering_password_challenge?,
|
||||
with: -> { redirect_to user_profile_url, alert: "Too many attempts. Try again in a few minutes." }
|
||||
|
||||
def show
|
||||
@direct_memberships, @shared_memberships =
|
||||
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
|
||||
end
|
||||
|
||||
def update
|
||||
@user.update user_params
|
||||
redirect_to user_profile_url, notice: update_notice
|
||||
if @user.update user_params
|
||||
start_new_session_for @user if @user.saved_change_to_password_digest?
|
||||
|
||||
redirect_to user_profile_url, notice: update_notice
|
||||
else
|
||||
redirect_to user_profile_url, alert: update_alert
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
# The password changed again before this browser's new session could start, and that change signed it out.
|
||||
redirect_to new_session_url
|
||||
end
|
||||
|
||||
private
|
||||
@@ -16,11 +28,35 @@ class Users::ProfilesController < ApplicationController
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
# Always with the challenge, so the model checks it whenever the save would change the password, however the
|
||||
# request spelled the new one.
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
|
||||
.merge(password_challenge: password_challenge)
|
||||
end
|
||||
|
||||
# Only a string is a challenge, so the rate limit counts exactly what the model checks.
|
||||
def password_challenge
|
||||
challenge = params.dig(:user, :password_challenge)
|
||||
challenge.is_a?(String) ? challenge : ""
|
||||
end
|
||||
|
||||
# Guessing the current password means sending one, so this is what the rate limit counts.
|
||||
def answering_password_challenge?
|
||||
!password_challenge.empty?
|
||||
end
|
||||
|
||||
def update_notice
|
||||
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
|
||||
end
|
||||
|
||||
def update_alert
|
||||
if !@user.errors.include?(:password_challenge)
|
||||
@user.errors.full_messages.to_sentence
|
||||
elsif password_challenge.empty?
|
||||
"Enter your current password to change it."
|
||||
else
|
||||
"That isn't your current password."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,17 +5,14 @@ class Users::PushSubscriptionsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
if subscription = @push_subscriptions.find_by(push_subscription_params)
|
||||
# Existing endpoints must pass current validations
|
||||
if subscription.valid?
|
||||
subscription.touch
|
||||
head :ok
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params)
|
||||
subscription.user_agent = request.user_agent if subscription.new_record?
|
||||
|
||||
# Existing endpoints must pass current validations too
|
||||
if subscription.valid?
|
||||
head register_while_signed_in(subscription) ? :ok : :unauthorized
|
||||
else
|
||||
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
|
||||
head subscription.persisted? ? :ok : :unprocessable_entity
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,6 +26,24 @@ class Users::PushSubscriptionsController < ApplicationController
|
||||
@push_subscriptions = Current.user.push_subscriptions
|
||||
end
|
||||
|
||||
# A password change ends every session and subscription. A request that authenticated just before it mustn't
|
||||
# put one back, so the session is checked under the same write lock as the save, and the endpoint is looked up
|
||||
# again there, past the query cache, so two tabs posting at once register it once. Validation, which resolves
|
||||
# the endpoint's address, runs before taking the lock.
|
||||
def register_while_signed_in(subscription)
|
||||
Push::Subscription.uncached do
|
||||
Push::Subscription.transaction do
|
||||
if Session.exists?(Current.session.id)
|
||||
if existing = @push_subscriptions.find_by(push_subscription_params)
|
||||
existing.touch
|
||||
else
|
||||
subscription.save!(validate: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def push_subscription_params
|
||||
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
|
||||
end
|
||||
|
||||
@@ -2,18 +2,14 @@ class Users::SidebarsController < ApplicationController
|
||||
DIRECT_PLACEHOLDERS = 20
|
||||
|
||||
def show
|
||||
all_memberships = Current.user.memberships.visible.with_ordered_room
|
||||
@direct_memberships = extract_direct_memberships(all_memberships)
|
||||
@other_memberships = all_memberships.without(@direct_memberships)
|
||||
visible_memberships = Current.user.memberships.visible
|
||||
@direct_memberships = visible_memberships.with_direct_rooms
|
||||
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms
|
||||
|
||||
@direct_placeholder_users = find_direct_placeholder_users
|
||||
end
|
||||
|
||||
private
|
||||
def extract_direct_memberships(all_memberships)
|
||||
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
|
||||
end
|
||||
|
||||
def find_direct_placeholder_users
|
||||
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
|
||||
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
|
||||
|
||||
@@ -5,7 +5,7 @@ module ContentFilters
|
||||
#
|
||||
# * SanitizeTags strips disallowed markup from the message body
|
||||
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
|
||||
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
|
||||
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
|
||||
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
|
||||
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
|
||||
|
||||
|
||||
@@ -17,11 +17,16 @@ class Messages::AttachmentPresentation
|
||||
attr_reader :message, :context
|
||||
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
|
||||
|
||||
# Previews are made when the message is posted, within limits. One that wasn't, because the file was too large or
|
||||
# the preview failed, isn't attempted again from here: its URL would make it on every view.
|
||||
def render_preview
|
||||
if message.attachment.video?
|
||||
case
|
||||
when message.attachment.video?
|
||||
video_preview_tag
|
||||
else
|
||||
when message.attachment.representation(:thumb).processed?
|
||||
lightboxed_image_preview_tag
|
||||
else
|
||||
render_link
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,11 +35,17 @@ class Messages::AttachmentPresentation
|
||||
|
||||
inline_media_dimension_constraints(width, height) do
|
||||
tag.video \
|
||||
src: rails_blob_path(message.attachment), poster: url_for(message.attachment.preview(format: :webp, resize_to_limit: [ Message::THUMBNAIL_MAX_WIDTH, Message::THUMBNAIL_MAX_HEIGHT ])),
|
||||
src: rails_blob_path(message.attachment), poster: video_poster_url,
|
||||
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
|
||||
end
|
||||
end
|
||||
|
||||
# A preview is processed once the frame is drawn, but the poster is a variant of that frame, which can fail on its own.
|
||||
def video_poster_url
|
||||
poster = message.attachment.preview(:poster)
|
||||
url_for(poster) if poster.processed? && poster.image.variant(poster.variation).processed?
|
||||
end
|
||||
|
||||
def lightboxed_image_preview_tag
|
||||
width, height = preview_dimensions
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
module MessagesHelper
|
||||
# auto_link re-sanitizes with Rails' default safe list, which lacks the
|
||||
# formatting the rich text editor produces
|
||||
# Rendered text is re-sanitized before linking. Rails' default safe list
|
||||
# lacks the formatting the rich text editor produces
|
||||
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
|
||||
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
|
||||
|
||||
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
|
||||
LINKABLE_TEXT = /:|@|www\./i
|
||||
|
||||
def message_area_tag(room, &)
|
||||
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
||||
controller: "messages presence drop-target",
|
||||
@@ -36,6 +39,7 @@ module MessagesHelper
|
||||
controller: "reply",
|
||||
user_id: message.creator_id,
|
||||
message_id: message.id,
|
||||
client_message_id: message.client_message_id,
|
||||
message_timestamp: message_timestamp_milliseconds,
|
||||
message_updated_at: message.updated_at.to_fs(:epoch),
|
||||
sort_value: message_timestamp_milliseconds,
|
||||
@@ -62,8 +66,7 @@ module MessagesHelper
|
||||
when "sound"
|
||||
message_sound_presentation(message)
|
||||
else
|
||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
|
||||
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
|
||||
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
|
||||
end
|
||||
rescue Exception => e
|
||||
Sentry.capture_exception(e, extra: { message: message })
|
||||
@@ -73,6 +76,24 @@ module MessagesHelper
|
||||
end
|
||||
|
||||
private
|
||||
# auto_link finds URLs and email addresses with regular expressions, which
|
||||
# can't tell an attribute value from text. Hand it one text node at a time,
|
||||
# escaped as the serializer writes text, so a link can only land between tags.
|
||||
def auto_link_text(html)
|
||||
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
|
||||
|
||||
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
|
||||
if node.text.match?(LINKABLE_TEXT)
|
||||
text = node.to_html
|
||||
linked = auto_link(text, html: { target: "_blank" })
|
||||
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
|
||||
end
|
||||
end
|
||||
|
||||
# A <pre> starting with a newline needs an extra one written out to survive the next parse
|
||||
fragment.to_html(preserve_newline: true).html_safe
|
||||
end
|
||||
|
||||
def messages_actions
|
||||
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ module TranslationsHelper
|
||||
TRANSLATIONS = {
|
||||
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
|
||||
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
|
||||
current_password: { "🇺🇸": "Enter your current password", "🇪🇸": "Introduce tu contraseña actual", "🇫🇷": "Saisissez votre mot de passe actuel", "🇮🇳": "अपना वर्तमान पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr aktuelles Passwort ein", "🇧🇷": "Insira sua senha atual", "🇯🇵": "現在のパスワードを入力してください" },
|
||||
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
|
||||
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
|
||||
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module Users::PushSubscriptionsHelper
|
||||
# Names the session without revealing its token, so the browser can tell when it's in a new one. The creation
|
||||
# time keeps an id reused by a new database from looking like the same session.
|
||||
def push_subscription_session_marker
|
||||
Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session
|
||||
end
|
||||
end
|
||||
@@ -46,6 +46,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
messageTargetConnected(target) {
|
||||
this.#reconcilePendingMessage(target)
|
||||
this.#formatter.format(target, ThreadStyle.thread)
|
||||
}
|
||||
|
||||
@@ -125,6 +126,25 @@ export default class extends Controller {
|
||||
|
||||
// Internal
|
||||
|
||||
// The composer inserts a pending message client-side using the client_message_id
|
||||
// as its DOM id. The server broadcast now renders the real message with a DOM id
|
||||
// derived from the record's primary key, so it no longer replaces the pending
|
||||
// element by id. Reconcile by matching the server message's client_message_id and
|
||||
// dropping our own pending placeholder. Only elements flagged data-pending-message
|
||||
// (client-side placeholders, never broadcast) are removed, so a message another
|
||||
// member posted reusing this client_message_id can never displace a real one.
|
||||
#reconcilePendingMessage(target) {
|
||||
const clientMessageId = target.dataset.clientMessageId
|
||||
|
||||
if (target.dataset.messageId && clientMessageId) {
|
||||
const selector = `[data-pending-message][data-client-message-id="${CSS.escape(clientMessageId)}"]`
|
||||
|
||||
this.messagesTarget.querySelectorAll(selector).forEach((pending) => {
|
||||
if (pending !== target) pending.remove()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async #ensureUpToDate() {
|
||||
if (!this.#paginator.upToDate) {
|
||||
await this.#paginator.resetToLastPage()
|
||||
|
||||
@@ -106,7 +106,8 @@ export default class extends Controller {
|
||||
|
||||
async #syncPushSubscription(subscription) {
|
||||
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
|
||||
if (!response.ok) subscription.unsubscribe()
|
||||
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
|
||||
if (response.unprocessableEntity) subscription.unsubscribe()
|
||||
}
|
||||
|
||||
async #requestPermissionAndSubscribe(registration) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { post } from "@rails/request.js"
|
||||
|
||||
const SYNCED_SESSION_KEY = "push-subscription-synced-session"
|
||||
|
||||
// A password change removes the account's push subscriptions, the one this browser holds included. Post it again
|
||||
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
|
||||
// browser's own subscription alone: the next session tries again.
|
||||
export default class extends Controller {
|
||||
static values = { url: String, session: String }
|
||||
|
||||
async connect() {
|
||||
if (this.#allowed && this.sessionValue && localStorage.getItem(SYNCED_SESSION_KEY) !== this.sessionValue) {
|
||||
const registration = await navigator.serviceWorker.getRegistration(window.location.origin)
|
||||
const subscription = await registration?.pushManager?.getSubscription()
|
||||
|
||||
if (Notification.permission === "granted" && subscription) {
|
||||
await post(this.urlValue, { body: this.#payload(subscription), responseKind: "turbo-stream" })
|
||||
localStorage.setItem(SYNCED_SESSION_KEY, this.sessionValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get #allowed() {
|
||||
return navigator.serviceWorker && window.Notification
|
||||
}
|
||||
|
||||
#payload(subscription) {
|
||||
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
|
||||
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default class extends Controller {
|
||||
})
|
||||
}
|
||||
|
||||
#read = ({ room_id }) => {
|
||||
this.dispatch("read", { detail: { roomId: room_id } })
|
||||
#read = ({ room_id, at }) => {
|
||||
this.dispatch("read", { detail: { roomId: room_id, at } })
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
#stripMentionAttachments(node) {
|
||||
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
|
||||
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
|
||||
return node
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,13 @@ export default class extends Controller {
|
||||
static classes = [ "unread" ]
|
||||
|
||||
#disconnected = true
|
||||
#connection = 0
|
||||
#discardDirectEditor = false
|
||||
#readAt = new Map()
|
||||
|
||||
async connect() {
|
||||
this.element.addEventListener("turbo:click", this.#directNavigation)
|
||||
this.element.addEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
|
||||
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
|
||||
connected: this.#channelConnected.bind(this),
|
||||
disconnected: this.#channelDisconnected.bind(this),
|
||||
@@ -17,7 +22,11 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("turbo:click", this.#directNavigation)
|
||||
this.element.removeEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
|
||||
this.#discardDirectEditor = false
|
||||
ignoringBriefDisconnects(this.element, () => {
|
||||
this.#channelDisconnected()
|
||||
this.channel?.unsubscribe()
|
||||
this.channel = null
|
||||
})
|
||||
@@ -27,19 +36,58 @@ export default class extends Controller {
|
||||
this.read({ detail: { roomId: Current.room.id } })
|
||||
}
|
||||
|
||||
read({ detail: { roomId } }) {
|
||||
read({ detail: { roomId, at } }) {
|
||||
const room = this.#findRoomTarget(roomId)
|
||||
|
||||
if (at) {
|
||||
this.#readAt.set(Number(roomId), Math.max(Number(at), this.#readAt.get(Number(roomId)) ?? 0))
|
||||
}
|
||||
|
||||
if (room) {
|
||||
room.classList.remove(this.unreadClass)
|
||||
this.dispatch("read", { detail: { targetId: roomId } })
|
||||
}
|
||||
}
|
||||
|
||||
#channelConnected() {
|
||||
// Preserve the live picker through background refreshes, but allow deliberate navigation.
|
||||
#directNavigation = event => {
|
||||
if (event.target.closest("a")?.dataset.turboFrame === this.element.id) {
|
||||
this.#discardDirectEditor = true
|
||||
}
|
||||
}
|
||||
|
||||
#preserveDirectEditor = event => {
|
||||
if (event.target !== this.element) return
|
||||
const discard = this.#discardDirectEditor
|
||||
this.#discardDirectEditor = false
|
||||
if (discard) return
|
||||
|
||||
const current = this.element.querySelector("#direct_rooms_control")
|
||||
const incoming = event.detail.newFrame.querySelector("#direct_rooms_control")
|
||||
if (!current?.querySelector('[data-autocomplete-target="input"]') || !incoming) return
|
||||
|
||||
// Turbo's Bardo moves the actual element, keeping input, selections and controllers.
|
||||
const controls = [ current, incoming ].map(element => [ element, element.hasAttribute("data-turbo-permanent") ])
|
||||
for (const [ element ] of controls) element.setAttribute("data-turbo-permanent", "")
|
||||
const render = event.detail.render
|
||||
event.detail.render = (...args) => {
|
||||
try {
|
||||
return render(...args)
|
||||
} finally {
|
||||
for (const [ element, permanent ] of controls) element.toggleAttribute("data-turbo-permanent", permanent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #channelConnected() {
|
||||
if (this.#disconnected) {
|
||||
this.#disconnected = false
|
||||
this.element.reload()
|
||||
const connection = ++this.#connection
|
||||
// Reloading an unfinished frame aborts its response body reader.
|
||||
await this.element.loaded
|
||||
if (this.element.isConnected && !this.#disconnected && connection === this.#connection) {
|
||||
this.element.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +95,11 @@ export default class extends Controller {
|
||||
this.#disconnected = true
|
||||
}
|
||||
|
||||
#unread({ roomId }) {
|
||||
#unread({ roomId, at }) {
|
||||
const unreadRoom = this.#findRoomTarget(roomId)
|
||||
|
||||
if (unreadRoom) {
|
||||
if (Current.room.id != roomId) {
|
||||
if (Current.room.id != roomId && !this.#readSince(roomId, at)) {
|
||||
unreadRoom.classList.add(this.unreadClass)
|
||||
}
|
||||
|
||||
@@ -59,6 +107,12 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// Notices fan out one member at a time, so one can arrive after the member has already
|
||||
// read the room in another tab. It still reorders the room, but doesn't mark it unread.
|
||||
#readSince(roomId, at) {
|
||||
return Number(at) <= this.#readAt.get(Number(roomId))
|
||||
}
|
||||
|
||||
#findRoomTarget(roomId) {
|
||||
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
|
||||
|
||||
#fetchAutocompletables(url) {
|
||||
if (url) {
|
||||
return fetch(url, { as: "json" }).then(response => response.json())
|
||||
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class Message::BroadcastUnreadRoomJob < ApplicationJob
|
||||
def perform(message)
|
||||
message.broadcast_unread_room
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class Room::DestroyJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(room, former_member_ids)
|
||||
room.reset_remote_connections_of(former_member_ids)
|
||||
room.destroy_one_message_at_a_time
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,8 @@ class Membership < ApplicationRecord
|
||||
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
|
||||
|
||||
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
|
||||
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
|
||||
scope :with_direct_rooms, -> { includes(:room).joins(:room).merge(Room.directs).order(rooms: { updated_at: :desc }) }
|
||||
scope :without_direct_rooms, -> { joins(:room).merge(Room.without_directs) }
|
||||
|
||||
scope :visible, -> { where.not(involvement: :invisible) }
|
||||
scope :unread, -> { where.not(unread_at: nil) }
|
||||
|
||||
@@ -14,20 +14,16 @@ class Message < ApplicationRecord
|
||||
scope :ordered, -> { order(:created_at) }
|
||||
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
||||
scope :with_attachment_details, -> {
|
||||
with_rich_text_body_and_embeds
|
||||
with_attached_attachment
|
||||
with_rich_text_body_and_embeds.with_attached_attachment
|
||||
.includes(attachment_blob: :variant_records)
|
||||
}
|
||||
scope :with_boosts, -> { includes(boosts: :booster) }
|
||||
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
|
||||
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
|
||||
|
||||
def plain_text_body
|
||||
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
||||
end
|
||||
|
||||
def to_key
|
||||
[ client_message_id ]
|
||||
end
|
||||
|
||||
def content_type
|
||||
case
|
||||
when attachment? then "attachment"
|
||||
|
||||
@@ -4,9 +4,14 @@ module Message::Attachment
|
||||
THUMBNAIL_MAX_WIDTH = 1200
|
||||
THUMBNAIL_MAX_HEIGHT = 800
|
||||
|
||||
# Decoding a picture, or a video's frame, costs in proportion to its pixels, however small the file. The largest
|
||||
# phone photos have 200 million.
|
||||
THUMBNAIL_MAX_PIXELS = 250_000_000
|
||||
|
||||
included do
|
||||
has_one_attached :attachment do |attachable|
|
||||
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
||||
attachable.variant :poster, format: :webp, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,12 +35,25 @@ module Message::Attachment
|
||||
attachment&.analyze
|
||||
end
|
||||
|
||||
# A file that ffmpeg or libvips can't decode is still the message: post it without a preview.
|
||||
def process_attachment_thumbnail
|
||||
return if too_many_pixels_to_preview?
|
||||
|
||||
case
|
||||
when attachment.video?
|
||||
attachment.preview(format: :webp).processed
|
||||
attachment.preview(:poster).processed
|
||||
when attachment.representable?
|
||||
attachment.representation(:thumb).processed
|
||||
end
|
||||
rescue ActiveStorage::PreviewError, Vips::Error => error
|
||||
Rails.logger.warn "Posted #{attachment.filename} without a preview: #{error.class}: #{error.message.lines.first&.chomp}"
|
||||
end
|
||||
|
||||
# Without a size, the analyzer couldn't read the file's header, and the previewer wouldn't either.
|
||||
def too_many_pixels_to_preview?
|
||||
if attachment.image? || attachment.video?
|
||||
width, height = attachment.metadata.values_at(:width, :height)
|
||||
width.nil? || height.nil? || width * height > THUMBNAIL_MAX_PIXELS
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,19 +1,29 @@
|
||||
module Message::Broadcasts
|
||||
def broadcast_create
|
||||
broadcast_append_to room, :messages, target: [ room, :messages ]
|
||||
broadcast_unread_room
|
||||
broadcast_unread_room_later
|
||||
end
|
||||
|
||||
def broadcast_remove
|
||||
broadcast_remove_to room, :messages
|
||||
end
|
||||
|
||||
# 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. Of those,
|
||||
# only the ones who still have it unread or are in it now: by the time this runs,
|
||||
# someone may have opened the room and moved on, and would see it marked unread again.
|
||||
def broadcast_unread_room
|
||||
payload = ActiveSupport::JSON.encode(roomId: room.id, at: created_at.to_f)
|
||||
|
||||
room.memberships.unread.or(room.memberships.connected).pluck(:user_id).each do |user_id|
|
||||
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Fanned out to the room's members rather than published on one global stream, so
|
||||
# that the timing of activity in a room only reaches people who are in it.
|
||||
def broadcast_unread_room
|
||||
room.memberships.pluck(:user_id).each do |user_id|
|
||||
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
|
||||
end
|
||||
# The fanout is one publish per member, which in a big room takes far longer than the
|
||||
# rest of posting a message, so it runs in a job rather than while the poster waits.
|
||||
def broadcast_unread_room_later
|
||||
Message::BroadcastUnreadRoomJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,31 @@ module Message::Pagination
|
||||
|
||||
PAGE_SIZE = 40
|
||||
|
||||
# Expose Rails' collection-preloading hook while preserving Array pagination and
|
||||
# validators. The renderer passes only cache misses to preload_associations.
|
||||
class Page < Array
|
||||
def self.load(relation, direction, size)
|
||||
new(relation.skip_preloading!.public_send(direction, size), relation)
|
||||
end
|
||||
|
||||
def initialize(records, relation)
|
||||
super(records)
|
||||
@relation = relation
|
||||
end
|
||||
|
||||
def loaded?
|
||||
true
|
||||
end
|
||||
|
||||
def preload_associations(records)
|
||||
@relation.preload_associations(records)
|
||||
end
|
||||
end
|
||||
|
||||
included do
|
||||
scope :last_page, -> { ordered.last(PAGE_SIZE) }
|
||||
scope :first_page, -> { ordered.first(PAGE_SIZE) }
|
||||
# Keep presentation data lazy until the collection cache knows which messages missed.
|
||||
scope :last_page, -> { last_page_of(PAGE_SIZE) }
|
||||
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
|
||||
|
||||
scope :before, ->(message) { where("created_at < ?", message.created_at) }
|
||||
scope :after, ->(message) { where("created_at > ?", message.created_at) }
|
||||
@@ -14,16 +36,23 @@ module Message::Pagination
|
||||
scope :page_after, ->(message) { after(message).first_page }
|
||||
|
||||
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
|
||||
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
|
||||
# Sorting on +created_at, which no index covers, has SQLite find the few messages updated since
|
||||
# through (room_id, updated_at) and sort just those, instead of walking the whole room by
|
||||
# (room_id, created_at) looking for them.
|
||||
scope :page_updated_since, ->(time) { Page.load(where("updated_at > ?", time).reorder(Arel.sql("+messages.created_at")), :last, PAGE_SIZE) }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def last_page_of(size)
|
||||
Page.load(ordered, :last, size)
|
||||
end
|
||||
|
||||
def page_around(message)
|
||||
page_before(message) + [ message ] + page_after(message)
|
||||
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
|
||||
end
|
||||
|
||||
def paged?
|
||||
count > PAGE_SIZE
|
||||
offset(PAGE_SIZE).exists?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,10 +3,45 @@ module Message::Searchable
|
||||
|
||||
included do
|
||||
after_create_commit :create_in_index
|
||||
after_update_commit :update_in_index
|
||||
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
|
||||
after_update_commit :update_in_index, if: :indexed_text_changed?
|
||||
after_destroy_commit :remove_from_index
|
||||
|
||||
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
|
||||
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
# Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than
|
||||
# parsing them as operators, which fails on a query like "AND" or "salt AND".
|
||||
def match_terms(query)
|
||||
query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ")
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def search_reachable(user, query, size: 100)
|
||||
relation = user.reachable_messages.search(query).with_presentation
|
||||
probe = connection.select_rows(sanitize_sql([ <<~SQL, user.id, match_terms(query) ]))
|
||||
SELECT m.id, mm.user_id IS NOT NULL FROM message_search_index idx
|
||||
JOIN messages m ON m.id = idx.rowid
|
||||
LEFT JOIN memberships mm ON mm.room_id = m.room_id AND mm.user_id = ?
|
||||
WHERE idx.body MATCH ? ORDER BY idx.rowid DESC LIMIT 1000
|
||||
SQL
|
||||
ids = probe.filter_map { |id, reachable| id if reachable == 1 }.first(size)
|
||||
|
||||
# A scoped fallback preserves older reachable hits behind a large private history.
|
||||
if ids.size < size && probe.size == 1000
|
||||
relation.last_page_of_matches(size)
|
||||
else
|
||||
Message::Pagination::Page.load(relation.where(id: ids).reorder(:id), :last, size)
|
||||
end
|
||||
end
|
||||
|
||||
# Orders by the index's rowid, which is the message id, so SQLite walks the full-text index
|
||||
# newest first and stops at the page. Ordering by created_at sorted every match before paging.
|
||||
def last_page_of_matches(size)
|
||||
Message::Pagination::Page.load(reorder("idx.rowid"), :last, size)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -14,6 +49,12 @@ module Message::Searchable
|
||||
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
|
||||
end
|
||||
|
||||
# Boosts touch their message too: rewrite the index only when the body was saved or the attachment replaced.
|
||||
# Active Storage clears attachment_changes in its own after_commit, before this one runs.
|
||||
def indexed_text_changed?
|
||||
association(:rich_text_body).target&.saved_change_to_body? || @attachment_replaced
|
||||
end
|
||||
|
||||
def update_in_index
|
||||
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@ class Opengraph::Fetch
|
||||
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
|
||||
MAX_BODY_SIZE = 5.megabytes
|
||||
MAX_REDIRECTS = 10
|
||||
TIMEOUT = 7.seconds # to connect (TLS included), and for each read and write, as Webhook does
|
||||
|
||||
class TooManyRedirectsError < StandardError; end
|
||||
class RedirectDeniedError < StandardError; end
|
||||
@@ -22,9 +23,14 @@ class Opengraph::Fetch
|
||||
end
|
||||
|
||||
private
|
||||
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
|
||||
# would no longer go to the checked IP.
|
||||
#
|
||||
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
|
||||
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
|
||||
def request(url, request_class, ip:)
|
||||
MAX_REDIRECTS.times do
|
||||
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") do |http|
|
||||
Net::HTTP.start(url.host, url.port, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
|
||||
http.request request_class.new(url) do |response|
|
||||
if response.is_a?(Net::HTTPRedirection)
|
||||
url, ip = resolve_redirect(response["location"])
|
||||
@@ -38,6 +44,11 @@ class Opengraph::Fetch
|
||||
raise TooManyRedirectsError
|
||||
end
|
||||
|
||||
# Without max_retries: 0, Net::HTTP sends a GET or HEAD that timed out a second time.
|
||||
def timeouts
|
||||
{ open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT, max_retries: 0 }
|
||||
end
|
||||
|
||||
def resolve_redirect(location)
|
||||
url = URI.parse(location)
|
||||
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
|
||||
|
||||
@@ -14,9 +14,9 @@ class Push::Subscription < ApplicationRecord
|
||||
validates :endpoint, presence: true
|
||||
validate :validate_endpoint_url
|
||||
|
||||
def notification(**params)
|
||||
def notification(badge: user.memberships.unread.count, **params)
|
||||
# Defer DNS lookup to the delivery worker to prevent rebinding
|
||||
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
|
||||
WebPush::Notification.new(**params, badge: badge, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
|
||||
end
|
||||
|
||||
# Validate at point of use, not just when saved.
|
||||
|
||||
@@ -45,6 +45,34 @@ class Room < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Takes the room away from its members at once, and leaves its messages to Room::DestroyJob. Destroying them in
|
||||
# the room's own transaction held the database's write lock, and stopped every other write, until the last one.
|
||||
# An open room is closed first, so that no one who joins the account before the job runs is let in.
|
||||
def destroy_later
|
||||
room = open? ? becomes!(Rooms::Closed) : self
|
||||
|
||||
former_member_ids = transaction do
|
||||
room.save!
|
||||
room.memberships.pluck(:user_id).tap { room.memberships.delete_all }
|
||||
end
|
||||
|
||||
Room::DestroyJob.perform_later(room, former_member_ids)
|
||||
end
|
||||
|
||||
# Deleting the memberships skips the reset that revoking one does, so the former members get it here, all at once,
|
||||
# before the messages go: their connections stop receiving the room's streams. It's done in the job, not the
|
||||
# request, because it costs a Redis round trip per member.
|
||||
def reset_remote_connections_of(user_ids)
|
||||
User.where(id: user_ids).find_each(&:reset_remote_connections)
|
||||
end
|
||||
|
||||
# Each message is destroyed in its own transaction, so other writes get through in between. Any message posted
|
||||
# meanwhile goes with the room.
|
||||
def destroy_one_message_at_a_time
|
||||
messages.find_each(&:destroy)
|
||||
destroy
|
||||
end
|
||||
|
||||
def receive(message)
|
||||
unread_memberships(message)
|
||||
push_later(message)
|
||||
|
||||
@@ -53,9 +53,12 @@ class Room::MessagePusher
|
||||
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
|
||||
end
|
||||
|
||||
# Banning keeps the user's subscriptions, so that unbanning brings their notifications back,
|
||||
# but nobody who can't sign in should be sent what's said in their rooms meanwhile.
|
||||
def relevant_subscriptions
|
||||
Push::Subscription
|
||||
.joins(user: :memberships)
|
||||
.merge(User.active)
|
||||
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
|
||||
end
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ class Rooms::Direct < Room
|
||||
end
|
||||
|
||||
private
|
||||
# FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
|
||||
# which could be to store the membership id list as a hash on the room, and use that for lookup.
|
||||
# Among the first user's rooms, the one whose members are exactly these users: as many
|
||||
# memberships as users, and all of them theirs.
|
||||
def find_for(users)
|
||||
all.joins(:users).detect do |room|
|
||||
Set.new(room.user_ids) == Set.new(users.pluck(:id))
|
||||
end
|
||||
user_ids = users.pluck(:id).uniq
|
||||
|
||||
where(id: Membership.where(user_id: user_ids.first).select(:room_id))
|
||||
.joins(:memberships).group(:id)
|
||||
.having("COUNT(*) = :size AND COUNT(CASE WHEN memberships.user_id IN (:user_ids) THEN 1 END) = :size", size: user_ids.size, user_ids: user_ids)
|
||||
.first
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+12
-3
@@ -7,13 +7,22 @@ class Session < ApplicationRecord
|
||||
|
||||
before_create { self.last_active_at ||= Time.now }
|
||||
|
||||
def self.start!(user_agent:, ip_address:)
|
||||
create! user_agent: user_agent, ip_address: ip_address
|
||||
end
|
||||
validate :user_password_unchanged, on: :create
|
||||
|
||||
def resume(user_agent:, ip_address:)
|
||||
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
|
||||
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# A password change ends every session, so one can't start on a password checked before the change. Validated in
|
||||
# the save's transaction, which holds the database's write lock from the start, so a change commits either before
|
||||
# this check, and fails it, or after the insert, and ends this session with the others. Read past the query cache,
|
||||
# and compared here rather than in the query, which logs its values.
|
||||
def user_password_unchanged
|
||||
unless User.uncached { User.where(id: user_id).pick(:password_digest) } == user.password_digest
|
||||
errors.add :user, :invalid
|
||||
end
|
||||
end
|
||||
end
|
||||
+38
-1
@@ -18,8 +18,10 @@ class User < ApplicationRecord
|
||||
enum :status, %i[ active deactivated banned ], default: :active
|
||||
|
||||
has_secure_password validations: false
|
||||
validate :password_challenge_matches, if: :will_save_change_to_password_digest?, unless: -> { password_challenge.nil? }
|
||||
|
||||
after_create_commit :grant_membership_to_open_rooms
|
||||
after_update :sign_out_all_sessions, if: :saved_change_to_password_digest?
|
||||
|
||||
scope :ordered, -> { order("LOWER(name)") }
|
||||
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
|
||||
@@ -50,8 +52,43 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
# The profile always sets password_challenge, and it's checked whenever the save would change the password; scripts
|
||||
# that reset one don't set it.
|
||||
def password_challenge_matches
|
||||
unless password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password_challenge)
|
||||
errors.add :password_challenge, :invalid
|
||||
end
|
||||
end
|
||||
|
||||
# A new password is how someone shuts out whoever else got into their account, so it ends every session, copies
|
||||
# of the changing browser's cookie included, and every push subscription; browsers still signed in post theirs
|
||||
# again. Connections close once that commits, registered on the transaction because a later save in it would
|
||||
# replace the saved change an after_commit condition reads.
|
||||
def sign_out_all_sessions
|
||||
sessions.delete_all
|
||||
push_subscriptions.delete_all
|
||||
self.class.current_transaction.after_commit { disconnect_signed_out_sessions }
|
||||
end
|
||||
|
||||
# The password is saved by now, so a realtime service that can't be reached mustn't fail the change.
|
||||
def disconnect_signed_out_sessions
|
||||
reset_remote_connections
|
||||
rescue => error
|
||||
Rails.logger.warn "Could not disconnect remote connections after a password change: #{error.class}"
|
||||
end
|
||||
|
||||
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
|
||||
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
|
||||
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
|
||||
# as insert_all did.
|
||||
def grant_membership_to_open_rooms
|
||||
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
|
||||
now = Time.current
|
||||
|
||||
Membership.connection.execute Membership.sanitize_sql([ <<~SQL, id, now, now ])
|
||||
insert into memberships (room_id, user_id, created_at, updated_at)
|
||||
select id, ?, ?, ? from rooms where type = 'Rooms::Open'
|
||||
on conflict do nothing
|
||||
SQL
|
||||
end
|
||||
|
||||
def deactived_email_address
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
module User::Bannable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# Administrators still see banned users, so they can lift the ban.
|
||||
scope :visible_to, ->(user) { user.can_administer? ? where(status: %i[ active banned ]) : active }
|
||||
end
|
||||
|
||||
def ban
|
||||
transaction do
|
||||
create_bans_from_sessions
|
||||
|
||||
@@ -11,6 +11,11 @@ module User::Mentionable
|
||||
"users/mention"
|
||||
end
|
||||
|
||||
# How a mention appears inside the editor, matching the prompt's editor template.
|
||||
def to_editor_content_attachment_partial_path
|
||||
"users/mention"
|
||||
end
|
||||
|
||||
def attachable_plain_text_representation(caption)
|
||||
"@#{name}"
|
||||
end
|
||||
|
||||
@@ -3,13 +3,20 @@ module User::Transferable
|
||||
|
||||
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
|
||||
|
||||
included do
|
||||
# Bound to the password, so changing it also ends sign-in links made before.
|
||||
generates_token_for :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION do
|
||||
password_salt&.last(10)
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def find_by_transfer_id(id)
|
||||
find_signed(id, purpose: :transfer)
|
||||
find_by_token_for(:transfer, id)
|
||||
end
|
||||
end
|
||||
|
||||
def transfer_id
|
||||
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
|
||||
generate_token_for(:transfer)
|
||||
end
|
||||
end
|
||||
@@ -63,7 +63,7 @@ class Webhook < ApplicationRecord
|
||||
end
|
||||
|
||||
def extract_attachment_from(response)
|
||||
if response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
||||
if response.code == "200" && response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
||||
ActiveStorage::Blob.create_and_upload! \
|
||||
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
|
||||
end
|
||||
|
||||
@@ -76,10 +76,9 @@
|
||||
<% end %>
|
||||
|
||||
<label class="switch">
|
||||
<input type="checkbox"
|
||||
class="switch__input"
|
||||
<%= "checked" if Current.account.settings.restrict_room_creation_to_administrators? %>
|
||||
data-action="change->form#submit">
|
||||
<%= tag.input type: "checkbox", class: "switch__input",
|
||||
checked: Current.account.settings.restrict_room_creation_to_administrators?,
|
||||
data: { action: "change->form#submit" } %>
|
||||
<span class="switch__btn round"></span>
|
||||
<span class="for-screen-reader">
|
||||
Must be admin to create new rooms
|
||||
@@ -102,11 +101,11 @@
|
||||
<turbo-frame id="account_users">
|
||||
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
|
||||
|
||||
<% if @administrators.any? && @members.any? %>
|
||||
<% if @administrators.any? && @page.records.any? %>
|
||||
<hr class="separator full-width" style="--border-style: solid">
|
||||
<% end %>
|
||||
|
||||
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
|
||||
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
|
||||
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
|
||||
</turbo-frame>
|
||||
</menu>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<%# Files are posted as a message's own attachment, which gets its preview when it's posted; the composer adds none to
|
||||
the rich text. One that's there anyway is shown by its name, with no preview: Action Text's partial links to a URL
|
||||
that makes one on view, and nothing else in the app would make it. %>
|
||||
<figure class="attachment attachment--file attachment--<%= blob.filename.extension %>">
|
||||
<figcaption class="attachment__caption">
|
||||
<% if caption = blob.try(:caption) %>
|
||||
<%= caption %>
|
||||
<% else %>
|
||||
<span class="attachment__name"><%= blob.filename %></span>
|
||||
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
|
||||
<% end %>
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -40,9 +40,9 @@
|
||||
<div class="flash" data-controller="element-removal" data-action="animationend->element-removal#remove">
|
||||
<div class="flash__inner shadow" style="<%= "--flash-background: var(--color-negative)" if flash[:alert] %>">
|
||||
<% if flash[:alert] %>
|
||||
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
|
||||
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
|
||||
<% else %>
|
||||
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
|
||||
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="for-screen-reader" role="alert" aria-atomic="true"><%= notice %></span>
|
||||
@@ -63,6 +63,11 @@
|
||||
|
||||
<%= render "layouts/lightbox" %>
|
||||
|
||||
<% if Current.session %>
|
||||
<%= tag.div hidden: true, data: { controller: "push-subscription-sync",
|
||||
push_subscription_sync_url_value: user_push_subscriptions_path, push_subscription_sync_session_value: push_subscription_session_marker } %>
|
||||
<% end %>
|
||||
|
||||
<a href="https://once.com" id="app-logo" target="_blank" aria-label="Once software from 37signals home page">
|
||||
<%= image_tag "campfire-icon.png", alt: "Campfire logo", width: 256, height: 216 %>
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# 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-v3" ] do %>
|
||||
<% cache [ message, "presentation-v7" ] do %>
|
||||
<%= message_tag message do %>
|
||||
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<div class="message message--me $messageClasses$"
|
||||
id="message_$clientMessageId$"
|
||||
data-format-message-target="message"
|
||||
data-pending-message="true"
|
||||
data-client-message-id="$clientMessageId$"
|
||||
data-user-id="<%= Current.user.id %>"
|
||||
data-message-timestamp="$messageTimestamp$"
|
||||
data-messages-target="message">
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<% cache boost do %>
|
||||
<div id="<%= dom_id(boost) %>"
|
||||
class="boost boost-item flex-inline postion--relative max-width align-center fill-white gap"
|
||||
data-controller="boost-delete" data-boost-delete-perform-class="boost--deleting" data-boost-delete-reveal-class="expanded" data-boost-delete-booster-id-value="<%= boost.booster.id %>">
|
||||
<figure class="avatar boost__avatar flex-item-no-shrink">
|
||||
<%= avatar_tag boost.booster, aria: { label: "#{boost.booster.name} boosted #{boost.content}" } %>
|
||||
</figure>
|
||||
<div id="<%= dom_id(boost) %>"
|
||||
class="boost boost-item flex-inline postion--relative max-width align-center fill-white gap"
|
||||
data-controller="boost-delete" data-boost-delete-perform-class="boost--deleting" data-boost-delete-reveal-class="expanded" data-boost-delete-booster-id-value="<%= boost.booster.id %>">
|
||||
<figure class="avatar boost__avatar flex-item-no-shrink">
|
||||
<%= avatar_tag boost.booster, aria: { label: "#{boost.booster.name} boosted #{boost.content}" } %>
|
||||
</figure>
|
||||
|
||||
<%= tag.span boost.content, role: "button",
|
||||
class: [ "txt-small", { "txt-medium": boost.content.all_emoji? } ],
|
||||
data: { action: "click->boost-delete#reveal keydown.enter->boost-delete#reveal:prevent", boost_delete_target: "content" } %>
|
||||
<%= tag.span boost.content, role: "button",
|
||||
class: [ "txt-small", { "txt-medium": boost.content.all_emoji? } ],
|
||||
data: { action: "click->boost-delete#reveal keydown.enter->boost-delete#reveal:prevent", boost_delete_target: "content" } %>
|
||||
|
||||
<%= button_to message_boost_path(boost.message, boost), method: :delete, data: { action: "boost-delete#perform", boost_delete_target: "button" },
|
||||
class: "btn btn--negative flex-item-justify-end boost__delete" do %>
|
||||
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
|
||||
<span class="for-screen-reader">Delete this boost</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<span id="delete_boost_accessible_label" class="for-screen-reader">Press enter to delete this boost</span>
|
||||
<% end %>
|
||||
<%= button_to message_boost_path(boost.message, boost), method: :delete, data: { action: "boost-delete#perform", boost_delete_target: "button" },
|
||||
class: "btn btn--negative flex-item-justify-end boost__delete" do %>
|
||||
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
|
||||
<span class="for-screen-reader">Delete this boost</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<span id="delete_boost_accessible_label" class="for-screen-reader">Press enter to delete this boost</span>
|
||||
@@ -2,12 +2,12 @@
|
||||
<div class="boosts flex flex-wrap align-center gap full-width" style="--column-gap: 0.4ch; --row-gap: 0"
|
||||
data-controller="turbo-streaming" data-action="turbo:submit-start->turbo-streaming#unsubscribe">
|
||||
<div class="flex-inline flex-wrap gap" id="<%= dom_id(message, :boosts) %>" data-turbo-streaming-target="container">
|
||||
<%= render partial: "messages/boosts/boost", collection: message.boosts.ordered, cached: true %>
|
||||
<%= render partial: "messages/boosts/boost", collection: message.boosts.loaded? ? message.boosts.sort_by(&:created_at) : message.boosts.ordered.includes(:booster) %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag message, :new_boost do %>
|
||||
<div class="flex-inline message__boost-inline" data-controller="soft-keyboard">
|
||||
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", action: "soft-keyboard#open" do %>
|
||||
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", data: { action: "soft-keyboard#open" } do %>
|
||||
<%= image_tag "boost.svg", size: 20, aria: { hidden: "true" } %>
|
||||
<span class="for-screen-reader">Add a boost</span>
|
||||
<% end %>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<%= image_tag "disclosure.svg", aria: { hidden: "true" }, size: 10, class: "disclosure" %>
|
||||
</summary>
|
||||
|
||||
<% case
|
||||
when platform.firefox? && platform.android? %>
|
||||
<% case %>
|
||||
<% when platform.firefox? && platform.android? %>
|
||||
<ol>
|
||||
<li>Tap <em><%= image_tag "lock.svg", alt: "the View site information button", size: 20 %></em> in the address bar.</li>
|
||||
<li>Tap <em>Notification</em> to change to <em>Allowed</em>.</li>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<%= image_tag "disclosure.svg", aria: { hidden: "true" }, size: 10, class: "disclosure" %>
|
||||
</summary>
|
||||
|
||||
<% case
|
||||
when platform.edge? %>
|
||||
<% case %>
|
||||
<% when platform.edge? %>
|
||||
<ol>
|
||||
<li>Click <em><%= image_tag "install-edge.svg", alt: "the app available - install Campfire chat button", size: 16 %></em>in the address bar.</li>
|
||||
<li>Click <em><%= image_tag "external/install-edge.svg", alt: "the app available - install Campfire chat button", size: 16 %></em>in the address bar.</li>
|
||||
<li>Click <em>Install</em>.</li>
|
||||
</ol>
|
||||
<% when platform.chrome? && platform.android? %>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<%= image_tag "disclosure.svg", aria: { hidden: "true" }, size: 10, class: "disclosure" %>
|
||||
</summary>
|
||||
|
||||
<% case
|
||||
when platform.firefox? && platform.android? %>
|
||||
<% case %>
|
||||
<% when platform.firefox? && platform.android? %>
|
||||
<ol>
|
||||
<li>Tap the <em><%= image_tag "menu-dots-vertical.svg", alt: "More options", size: 16 %></em> menu button.</li>
|
||||
<li>Tap <em>Settings</em>.</li>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
|
||||
<%= search_results_tag do %>
|
||||
<%= render @messages %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<%= auto_submit_form_with method: :put %>
|
||||
<%= auto_submit_form_with method: :put do %>
|
||||
<% end %>
|
||||
@@ -69,6 +69,16 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex align-center gap">
|
||||
<%= translation_button(:current_password) %>
|
||||
|
||||
<label class="flex align-center gap flex-item-grow input input--actor">
|
||||
<%= form.password_field :password_challenge, class: "input txt-large", autocomplete: "current-password",
|
||||
placeholder: "Current password", required: false, maxlength: 72 %>
|
||||
<%= image_tag "password.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex align-center gap">
|
||||
<%= translation_button(:update_password) %>
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Message benchmarks
|
||||
|
||||
The drivers use Ruby's standard library and Docker; no separate load generator is required.
|
||||
Provide a frozen baseline source directory, an isolated seed containing `db/`, `storage/`
|
||||
and `labels.json`, and a matching application image with compiled assets.
|
||||
|
||||
```sh
|
||||
ruby bench/compare_message_hot_paths.rb --baseline PATH --baseline-ref SHA --seed SEED
|
||||
ruby bench/compare_http.rb --baseline PATH --seed SEED
|
||||
ruby bench/compare_http.rb --baseline PATH --seed SEED --paths sidebar,search --concurrencies 16 --duration 10
|
||||
```
|
||||
|
||||
Both drivers alternate before/after order and reset fixture storage for each run.
|
||||
Results default to ignored `tmp/rails-optimization/results/`; override with `--output PATH`.
|
||||
Use `--image` to override `campfire-reference:app`, and `--cpus` to override server CPUs
|
||||
`8-11`. HTTP clients use CPUs `12-15` by default (`--client-cpus`). `--help` lists options.
|
||||
|
||||
The rendering probe checks exact response bodies, selected headers and unread payloads,
|
||||
and records timing, queries and allocations with MemoryStore, frozen time and fixture-only
|
||||
CSRF disabling. Unread fanout excludes adapter I/O.
|
||||
|
||||
The HTTP driver uses production Puma/Redis with one worker and five threads. Ruby threads
|
||||
each maintain a keep-alive connection, request uncompressed responses, and consume the
|
||||
whole body. Login uses normal CSRF protection; all warmup and measured responses must be
|
||||
HTTP 200 without transport errors, complete HTML, exact independently seeded message windows
|
||||
and the visible sidebar rooms; this applies to every warmup and measured response. Measurements exclude Thruster, TLS and gzip. Client
|
||||
CPU, JIT warmup and GC can affect throughput; repeat runs and check client saturation.
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Compare warm production Puma/Redis requests using isolated seeded containers.
|
||||
require "socket"
|
||||
require_relative "support"
|
||||
require_relative "http_client"
|
||||
require_relative "response_contract"
|
||||
|
||||
include BenchmarkSupport
|
||||
options = parse_options("Compare HTTP throughput with Ruby keep-alive clients; every response must be HTTP 200.",
|
||||
duration: 3.0, paths: "room,messages,sidebar,search", concurrencies: "1,16", client_cpus: "12-15",
|
||||
output: File.join(WORK, "results/http"))
|
||||
labels = JSON.parse(File.read(File.join(options[:seed], "labels.json")))
|
||||
paths = {
|
||||
"room" => "/rooms/#{labels.fetch('rooms.watercooler')}",
|
||||
"messages" => "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
|
||||
"sidebar" => "/users/me/sidebar", "search" => "/searches?q=coffee"
|
||||
}.slice(*options[:paths].split(","))
|
||||
abort "--paths must select room,messages,sidebar,search" unless paths.size == options[:paths].split(",").size && !paths.empty?
|
||||
concurrencies = options[:concurrencies].split(",").map { |value| Integer(value) }
|
||||
abort "--duration and --concurrencies must be positive" unless options[:duration].positive? && !concurrencies.empty? && concurrencies.all?(&:positive?)
|
||||
run("taskset", "-pc", options[:client_cpus], Process.pid.to_s)
|
||||
work = File.join(WORK, "http")
|
||||
assets = prepare_assets(options[:image])
|
||||
network = "cf-ruby-bench-#{Process.pid}"
|
||||
redis = "#{network}-redis"
|
||||
app = "#{network}-app"
|
||||
|
||||
begin
|
||||
run("docker", "network", "create", network)
|
||||
run("docker", "run", "-d", "--name", redis, "--network", network, "redis:7-alpine")
|
||||
options[:rounds].times do |iteration|
|
||||
sides = iteration.even? ? %w[before after] : %w[after before]
|
||||
sides.each do |side|
|
||||
remove_container(app)
|
||||
data = File.join(work, "data")
|
||||
prepare_storage(options[:seed], File.join(data, "storage"))
|
||||
FileUtils.mkdir_p([ File.join(data, "tmp/pids"), File.join(data, "log") ])
|
||||
run("docker", "exec", redis, "redis-cli", "FLUSHALL")
|
||||
port = TCPServer.open("127.0.0.1", 0) { |socket| socket.addr[1] }
|
||||
client = BenchmarkHTTPClient.new("http://127.0.0.1:#{port}")
|
||||
source = side == "before" ? options[:baseline] : ROOT
|
||||
command = [ "docker", "run", "-d", "--name", app, "--entrypoint", "", "--network", network,
|
||||
"--cpuset-cpus", options[:cpus], "-p", "127.0.0.1:#{port}:3000" ]
|
||||
command.concat mounts(source => "/rails", File.join(data, "storage") => "/rails/storage",
|
||||
File.join(data, "tmp") => "/rails/tmp", File.join(data, "log") => "/rails/log", assets => "/rails/public/assets")
|
||||
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
|
||||
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", WEB_CONCURRENCY: 1, JOB_CONCURRENCY: 1, RAILS_MAX_THREADS: 5,
|
||||
REDIS_URL: "redis://#{redis}:6379/0")
|
||||
command.concat [ options[:image], "bundle", "exec", "puma", "-C", "config/puma.rb" ]
|
||||
run(*command)
|
||||
deadline = clock + 45
|
||||
until client.ready?
|
||||
if clock > deadline
|
||||
File.write(File.join(work, "server.log"), run("docker", "logs", app))
|
||||
raise "server did not become ready; see #{work}/server.log"
|
||||
end
|
||||
sleep 0.1
|
||||
end
|
||||
cookie = client.login(labels)
|
||||
results = {}
|
||||
paths.each do |name, path|
|
||||
contract = BenchmarkResponseContract.new(name, File.join(data, "storage/db/production.sqlite3"), labels)
|
||||
client.measure(path, cookie, concurrency: 1, duration: 3, contract: contract)
|
||||
concurrencies.each do |concurrency|
|
||||
results["#{name}_#{concurrency}"] = client.measure(path, cookie, concurrency: concurrency, duration: options[:duration], contract: contract)
|
||||
end
|
||||
end
|
||||
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), results)
|
||||
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
|
||||
end
|
||||
end
|
||||
ensure
|
||||
remove_container(app)
|
||||
remove_container(redis)
|
||||
Open3.capture3("docker", "network", "rm", network)
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Compare frozen Rails sources with isolated fixtures and an existing Ruby image.
|
||||
require "digest"
|
||||
require_relative "support"
|
||||
|
||||
include BenchmarkSupport
|
||||
options = parse_options("Compare rendering queries, allocations, timing and response parity.",
|
||||
baseline_ref: nil, rounds: 4, output: File.join(WORK, "results/requests"))
|
||||
work = File.join(WORK, "runtime")
|
||||
FileUtils.mkdir_p([ File.join(work, "tmp"), File.join(work, "log") ])
|
||||
assets = prepare_assets(options[:image])
|
||||
runs = { "before" => [], "after" => [] }
|
||||
order = []
|
||||
options[:rounds].times do |iteration|
|
||||
sides = iteration.even? ? %w[before after] : %w[after before]
|
||||
order << sides
|
||||
sides.each do |side|
|
||||
storage = File.join(work, "storage")
|
||||
prepare_storage(options[:seed], storage)
|
||||
source = side == "before" ? options[:baseline] : ROOT
|
||||
command = [ "docker", "run", "--rm", "--entrypoint", "", "--cpuset-cpus", options[:cpus] ]
|
||||
command.concat mounts(source => "/rails", storage => "/rails/storage", File.join(work, "tmp") => "/rails/tmp",
|
||||
File.join(work, "log") => "/rails/log", assets => "/rails/public/assets", File.join(ROOT, "bench") => "/bench",
|
||||
File.join(options[:seed], "labels.json") => "/bench-labels.json")
|
||||
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
|
||||
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", BENCH_LABELS: "/bench-labels.json")
|
||||
command.concat [ options[:image], "bundle", "exec", "ruby", "-r", "/rails/config/environment.rb", "/bench/message_hot_paths.rb" ]
|
||||
data = JSON.parse(run(*command))
|
||||
runs[side] << data
|
||||
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), data)
|
||||
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
|
||||
end
|
||||
end
|
||||
|
||||
summary = {}
|
||||
runs["before"].first.fetch("results").each_key do |name|
|
||||
values = runs.transform_values { |rows| rows.map { |row| row.fetch("results").fetch(name) } }
|
||||
%w[body_sha256 headers payload_sha256].each do |field|
|
||||
expected = values["before"].first[field]
|
||||
raise "#{name}: #{field} differs; no winning summary written" unless values.values.flatten.all? { |row| row[field] == expected }
|
||||
end
|
||||
summary[name] = values.transform_values do |rows|
|
||||
fields = %w[milliseconds allocations queries].select { |field| rows.first.key?(field) }
|
||||
fields.to_h { |field| [ field, median(rows.map { |row| median(row.fetch(field)) }) ] }
|
||||
.merge("round_medians_ms" => rows.map { |row| median(row.fetch("milliseconds")) })
|
||||
end
|
||||
summary[name]["speedup"] = summary[name]["before"]["milliseconds"] / summary[name]["after"]["milliseconds"]
|
||||
end
|
||||
metadata = {
|
||||
baseline_sha: options[:baseline_ref], candidate_head: run("git", "-C", ROOT, "rev-parse", "HEAD").strip,
|
||||
candidate_app_diff_sha256: Digest::SHA256.hexdigest(run("git", "-C", ROOT, "diff", "--", "app")),
|
||||
platform: RUBY_PLATFORM, cpus: options[:cpus], image: options[:image],
|
||||
image_id: run("docker", "image", "inspect", "-f", "{{.Id}}", options[:image]).strip,
|
||||
body_and_selected_header_parity: "exact across all measured baseline/candidate requests",
|
||||
order: order, ruby: runs["before"].first.fetch("ruby"), rails: runs["before"].first.fetch("rails"),
|
||||
limits: "In-process Rails requests, MemoryStore, frozen clock and fixture-controller CSRF disabled. Fanout excludes adapter I/O. Not network throughput or connection capacity."
|
||||
}
|
||||
write_json(File.join(options[:output], "summary.json"), metadata: metadata, results: summary)
|
||||
summary.each do |name, value|
|
||||
puts "%s: %.2f -> %.2f ms (%.2fx)" % [ name, value["before"]["milliseconds"], value["after"]["milliseconds"], value["speedup"] ]
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
require "cgi"
|
||||
require "net/http"
|
||||
|
||||
class BenchmarkHTTPClient
|
||||
def initialize(base)
|
||||
@base = URI(base)
|
||||
end
|
||||
|
||||
def ready?
|
||||
connection.start { |http| http.get("/up").code == "200" }
|
||||
rescue IOError, SystemCallError, Timeout::Error, SocketError
|
||||
false
|
||||
end
|
||||
|
||||
def login(labels)
|
||||
cookies = {}
|
||||
connection.start do |http|
|
||||
response = http.get("/session/new", "Accept-Encoding" => "identity")
|
||||
raise "sign-in page: HTTP #{response.code}" unless response.code == "200"
|
||||
merge_cookies(cookies, response)
|
||||
token = response.body[/<meta name="csrf-token" content="([^"]*)"/, 1]
|
||||
raise "sign-in page has no CSRF token" unless token
|
||||
request = Net::HTTP::Post.new("/session")
|
||||
request["Cookie"] = cookie_header(cookies)
|
||||
request["Origin"] = @base.to_s
|
||||
request["Sec-Fetch-Site"] = "same-origin"
|
||||
request.set_form_data(email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all"),
|
||||
authenticity_token: CGI.unescapeHTML(token))
|
||||
response = http.request(request)
|
||||
merge_cookies(cookies, response)
|
||||
raise "login failed: HTTP #{response.code}" unless response.code == "302" && cookies.key?("session_token")
|
||||
end
|
||||
cookie_header(cookies)
|
||||
end
|
||||
|
||||
def measure(path, cookie, concurrency:, duration:, contract:)
|
||||
start = clock
|
||||
deadline = start + duration
|
||||
workers = Array.new(concurrency) do
|
||||
Thread.new do
|
||||
result = { latencies: [], statuses: Hash.new(0), bytes: 0, errors: 0, invalid_responses: 0 }
|
||||
while clock < deadline
|
||||
begin
|
||||
connection.start do |http|
|
||||
while clock < deadline
|
||||
requested = clock
|
||||
response = http.get(path, "Cookie" => cookie, "Accept-Encoding" => "identity")
|
||||
result[:latencies] << (clock - requested) * 1000
|
||||
result[:statuses][response.code] += 1
|
||||
result[:bytes] += response.body.bytesize
|
||||
result[:invalid_responses] += 1 unless contract.valid?(response)
|
||||
end
|
||||
end
|
||||
rescue IOError, SystemCallError, Timeout::Error, SocketError, Net::HTTPBadResponse
|
||||
result[:errors] += 1
|
||||
sleep 0.01
|
||||
end
|
||||
end
|
||||
result
|
||||
end
|
||||
end
|
||||
samples = workers.map(&:value)
|
||||
elapsed = clock - start
|
||||
latencies = samples.flat_map { |sample| sample[:latencies] }.sort
|
||||
statuses = Hash.new(0)
|
||||
samples.each { |sample| sample[:statuses].each { |status, count| statuses[status] += count } }
|
||||
errors = samples.sum { |sample| sample[:errors] }
|
||||
invalid = samples.sum { |sample| sample[:invalid_responses] }
|
||||
raise "#{path}: HTTP statuses #{statuses}, #{errors} transport errors, #{invalid} invalid bodies" unless errors.zero? && invalid.zero? && statuses.keys == [ "200" ]
|
||||
{ path: path, conc: concurrency, gzip: false, secs: elapsed, rps: latencies.size / elapsed,
|
||||
ok: latencies.size, statuses: statuses, errors: errors, invalid_responses: invalid, validation: "route-contract-v1",
|
||||
avg_bytes: samples.sum { |sample| sample[:bytes] } / latencies.size,
|
||||
latency_ms: { p50: percentile(latencies, 0.50), p95: percentile(latencies, 0.95), p99: percentile(latencies, 0.99) } }
|
||||
end
|
||||
|
||||
private
|
||||
def connection
|
||||
Net::HTTP.new(@base.host, @base.port, nil).tap do |http|
|
||||
http.open_timeout = 1
|
||||
http.read_timeout = 5
|
||||
http.write_timeout = 5
|
||||
http.max_retries = 0
|
||||
end
|
||||
end
|
||||
|
||||
def merge_cookies(cookies, response)
|
||||
response.get_fields("set-cookie").to_a.each do |header|
|
||||
name, value = header.split(";", 2).first.split("=", 2)
|
||||
cookies[name] = value
|
||||
end
|
||||
end
|
||||
|
||||
def cookie_header(cookies)
|
||||
cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
|
||||
end
|
||||
|
||||
def percentile(values, fraction)
|
||||
values[(values.size * fraction).ceil - 1]
|
||||
end
|
||||
|
||||
def clock
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
# Run through compare_message_hot_paths.rb against isolated production fixtures.
|
||||
require "json"
|
||||
require "digest"
|
||||
require "active_support/testing/time_helpers"
|
||||
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
travel_to Time.utc(2026, 10, 4, 12)
|
||||
|
||||
# Keep framework/rendering measurements independent of Redis/network variance.
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
ActionView::PartialRenderer.collection_cache = Rails.cache
|
||||
ApplicationController.cache_store = Rails.cache
|
||||
ApplicationController.descendants.each do |controller|
|
||||
controller.cache_store = Rails.cache
|
||||
controller.allow_forgery_protection = false
|
||||
end
|
||||
ApplicationController.allow_forgery_protection = false
|
||||
|
||||
labels = JSON.parse(File.read(ENV.fetch("BENCH_LABELS")))
|
||||
client = ActionDispatch::Integration::Session.new(Rails.application)
|
||||
client.host! "campfire-benchmark.test"
|
||||
client.post "/session", params: { email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all") }
|
||||
raise "login failed" unless client.response.redirect?
|
||||
|
||||
paths = {
|
||||
room: "/rooms/#{labels.fetch('rooms.watercooler')}",
|
||||
messages: "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
|
||||
sidebar: Rails.application.routes.url_helpers.user_sidebar_path,
|
||||
search: "/searches?q=coffee"
|
||||
}
|
||||
queries = []
|
||||
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, event|
|
||||
queries << event[:sql] unless event[:cached] || event[:name] == "SCHEMA"
|
||||
end
|
||||
results = {}
|
||||
paths.each do |name, path|
|
||||
%w[cold warm].each do |cache|
|
||||
2.times { client.get path }
|
||||
times, counts, allocations, hashes, headers = [], [], [], [], []
|
||||
Integer(ENV.fetch("BENCH_ITERATIONS", "20")).times do
|
||||
Rails.cache.clear if cache == "cold"
|
||||
queries.clear
|
||||
start_allocations = GC.stat(:total_allocated_objects)
|
||||
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
client.get path
|
||||
times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
|
||||
allocations << GC.stat(:total_allocated_objects) - start_allocations
|
||||
raise "#{name}: HTTP #{client.response.status}" unless client.response.status == 200
|
||||
counts << queries.length
|
||||
hashes << Digest::SHA256.hexdigest(client.response.body)
|
||||
headers << client.response.headers.slice("content-type", "etag", "cache-control", "vary")
|
||||
end
|
||||
results["#{name}_#{cache}"] = { milliseconds: times, queries: counts, allocations: allocations,
|
||||
body_sha256: hashes.uniq, headers: headers.uniq, body_bytes: client.response.body.bytesize }
|
||||
end
|
||||
end
|
||||
ActiveSupport::Notifications.unsubscribe(subscriber)
|
||||
|
||||
# Real Action Cable encoding/instrumentation, with adapter I/O removed. Keep fanout
|
||||
# separate from network delivery capacity, which this probe does not measure.
|
||||
room = Room.find(labels.fetch("rooms.watercooler"))
|
||||
class BenchmarkPubsub
|
||||
attr_reader :messages
|
||||
def initialize
|
||||
@messages = []
|
||||
end
|
||||
def broadcast(stream, payload)
|
||||
@messages << [ stream, payload ]
|
||||
end
|
||||
end
|
||||
adapter = BenchmarkPubsub.new
|
||||
ActionCable.server.instance_variable_set(:@pubsub, adapter)
|
||||
message = Message.new(room: room)
|
||||
recipient_ids = (1..1000).to_a
|
||||
memberships = Object.new
|
||||
memberships.define_singleton_method(:pluck) { |_| recipient_ids }
|
||||
room.define_singleton_method(:memberships) { memberships }
|
||||
fanout_times, fanout_allocations = [], []
|
||||
20.times do
|
||||
adapter.messages.clear
|
||||
allocated = GC.stat(:total_allocated_objects)
|
||||
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
message.send(:broadcast_unread_room)
|
||||
fanout_times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
|
||||
fanout_allocations << GC.stat(:total_allocated_objects) - allocated
|
||||
raise "wrong fanout count" unless adapter.messages.size == recipient_ids.size
|
||||
raise "wrong payload" unless adapter.messages.all? { |_, body| JSON.parse(body) == { "roomId" => room.id } }
|
||||
end
|
||||
results["unread_fanout_1000"] = { milliseconds: fanout_times, allocations: fanout_allocations,
|
||||
payload_sha256: Digest::SHA256.hexdigest(adapter.messages.first.last) }
|
||||
puts JSON.pretty_generate({ ruby: RUBY_VERSION, rails: Rails.version, iterations: ENV.fetch("BENCH_ITERATIONS", "20").to_i,
|
||||
cache: "MemoryStore", results: results })
|
||||
@@ -0,0 +1,43 @@
|
||||
require "json"
|
||||
require "open3"
|
||||
require "cgi"
|
||||
|
||||
# Independent seed SQL determines the required result window before any response is sampled.
|
||||
class BenchmarkResponseContract
|
||||
def initialize(kind, database, labels)
|
||||
@kind = kind
|
||||
room = Integer(labels.fetch("rooms.watercooler"))
|
||||
user = labels.fetch("emails.david").gsub("'", "''")
|
||||
@ids = case kind
|
||||
when "room"
|
||||
query(database, "SELECT id FROM messages WHERE room_id=#{room} ORDER BY created_at DESC LIMIT 40").reverse.map { |row| row.fetch("id") }
|
||||
when "messages"
|
||||
before = Integer(labels.fetch("messages.busy_060"))
|
||||
query(database, "SELECT id FROM messages WHERE room_id=#{room} AND created_at<(SELECT created_at FROM messages WHERE id=#{before}) ORDER BY created_at DESC LIMIT 40").reverse.map { |row| row.fetch("id") }
|
||||
when "search"
|
||||
query(database, "SELECT m.id FROM message_search_index idx JOIN messages m ON m.id=idx.rowid JOIN memberships mm ON mm.room_id=m.room_id JOIN users u ON u.id=mm.user_id WHERE u.email_address='#{user}' AND idx.body MATCH 'coffee' ORDER BY m.id DESC LIMIT 100").reverse.map { |row| row.fetch("id") }
|
||||
when "sidebar"
|
||||
@names = query(database, "SELECT r.name FROM rooms r JOIN memberships mm ON mm.room_id=r.id JOIN users u ON u.id=mm.user_id WHERE u.email_address='#{user}' AND r.type='Rooms::Open' AND mm.involvement<>'invisible'").map { |row| CGI.escapeHTML(row.fetch("name")) }
|
||||
nil
|
||||
else
|
||||
raise "Unknown route contract #{kind}"
|
||||
end
|
||||
end
|
||||
|
||||
def valid?(response)
|
||||
body = response.body.to_s
|
||||
return false unless response.code == "200" && response["content-type"].to_s.split(";").first == "text/html" &&
|
||||
[ nil, "identity" ].include?(response["content-encoding"]) && !body.empty? && body.dup.force_encoding("UTF-8").valid_encoding?
|
||||
return false if @kind != "messages" && !(body.lstrip.start_with?("<!DOCTYPE html>") && body.rstrip.end_with?("</html>"))
|
||||
return false if @ids && body.scan(/data-message-id="(\d+)"/).flatten.map(&:to_i) != @ids
|
||||
return false if @names && (!body.include?("shared_rooms") || @names.any? { |name| !body.include?(name) })
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
def query(database, sql)
|
||||
output, errors, status = Open3.capture3("sqlite3", "-readonly", "-json", database, sql)
|
||||
raise "Cannot read benchmark seed: #{errors}" unless status.success?
|
||||
JSON.parse(output)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require "open3"
|
||||
require "optparse"
|
||||
|
||||
module BenchmarkSupport
|
||||
ROOT = File.expand_path("..", __dir__)
|
||||
WORK = File.join(ROOT, "tmp/rails-optimization")
|
||||
|
||||
def parse_options(description, defaults)
|
||||
options = { baseline: nil, seed: nil, image: "campfire-reference:app", cpus: "8-11", rounds: 2 }.merge(defaults)
|
||||
parser = OptionParser.new do |parser|
|
||||
parser.banner = "Usage: ruby #{$PROGRAM_NAME} --baseline PATH --seed PATH [options]\n#{description}"
|
||||
options.each do |name, default|
|
||||
type = case default
|
||||
when Integer then Integer
|
||||
when Float then Float
|
||||
else String
|
||||
end
|
||||
parser.on("--#{name.to_s.tr('_', '-')} VALUE", type) { |value| options[name] = value }
|
||||
end
|
||||
parser.on("-h", "--help") { puts parser; exit }
|
||||
end
|
||||
parser.parse!
|
||||
raise OptionParser::InvalidArgument, "unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
|
||||
raise OptionParser::MissingArgument, "--baseline and --seed are required" unless options[:baseline] && options[:seed]
|
||||
raise OptionParser::InvalidArgument, "--rounds must be even and at least 2" unless options[:rounds] >= 2 && options[:rounds].even?
|
||||
%i[baseline seed output].each { |name| options[name] = File.expand_path(options.fetch(name)) }
|
||||
options
|
||||
rescue OptionParser::ParseError => error
|
||||
abort "#{error.message}\n#{parser}"
|
||||
end
|
||||
|
||||
def run(*command, input: nil)
|
||||
output, errors, status = Open3.capture3(*command, stdin_data: input, binmode: true)
|
||||
raise "#{command.first} failed (#{status.exitstatus}): #{errors}" unless status.success?
|
||||
output
|
||||
end
|
||||
|
||||
def remove_container(name)
|
||||
Open3.capture3("docker", "rm", "-f", name)
|
||||
end
|
||||
|
||||
def prepare_storage(seed, destination)
|
||||
FileUtils.rm_rf(destination)
|
||||
FileUtils.mkdir_p(destination)
|
||||
FileUtils.cp_r(File.join(seed, "db"), File.join(destination, "db"))
|
||||
FileUtils.cp_r(File.join(seed, "storage"), File.join(destination, "files"))
|
||||
end
|
||||
|
||||
def prepare_assets(image)
|
||||
assets = File.join(WORK, "runtime/assets")
|
||||
FileUtils.mkdir_p(assets)
|
||||
if Dir.empty?(assets)
|
||||
archive = run("docker", "run", "--rm", "--entrypoint", "", image,
|
||||
"tar", "-C", "/rails/public/assets", "-cf", "-", ".")
|
||||
run("tar", "-xf", "-", "-C", assets, input: archive)
|
||||
end
|
||||
assets
|
||||
end
|
||||
|
||||
def mounts(paths)
|
||||
paths.flat_map { |host, target| [ "-v", "#{host}:#{target}" ] }
|
||||
end
|
||||
|
||||
def environment(values)
|
||||
values.flat_map { |name, value| [ "-e", "#{name}=#{value}" ] }
|
||||
end
|
||||
|
||||
def write_json(path, data)
|
||||
FileUtils.mkdir_p(File.dirname(path))
|
||||
File.write(path, JSON.pretty_generate(data) + "\n")
|
||||
end
|
||||
|
||||
def median(values)
|
||||
values = values.sort
|
||||
middle = values.length / 2
|
||||
values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
|
||||
end
|
||||
|
||||
def clock
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
require "minitest/autorun"
|
||||
require "fileutils"
|
||||
require "socket"
|
||||
require "sqlite3"
|
||||
require "tmpdir"
|
||||
require_relative "http_client"
|
||||
require_relative "response_contract"
|
||||
|
||||
class BenchmarkHTTPClientTest < Minitest::Test
|
||||
def setup
|
||||
@directory = Dir.mktmpdir("campfire-benchmark-test")
|
||||
database = File.join(@directory, "seed.sqlite3")
|
||||
SQLite3::Database.new(database).tap do |db|
|
||||
db.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY, room_id INTEGER, created_at TEXT)")
|
||||
db.execute("INSERT INTO messages VALUES (42, 1, '2026-10-01'), (43, 1, '2026-10-02')")
|
||||
db.close
|
||||
end
|
||||
@contract = BenchmarkResponseContract.new("room", database, "rooms.watercooler" => 1, "emails.david" => "david@example.com")
|
||||
@body = '<!DOCTYPE html><html><article data-message-id="42">coffee</article><article data-message-id="43">meeting</article></html>'
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@directory)
|
||||
end
|
||||
|
||||
def test_seeded_contract_rejects_error_pages_truncation_wrong_windows_and_encoding
|
||||
assert @contract.valid?(response(@body))
|
||||
refute @contract.valid?(response("Something went wrong"))
|
||||
refute @contract.valid?(response(@body.delete_suffix("</html>")))
|
||||
refute @contract.valid?(response(@body.sub('data-message-id="42"', 'data-message-id="99"')))
|
||||
refute @contract.valid?(response(@body, "text/plain"))
|
||||
refute @contract.valid?(response(@body + "\xFF".b))
|
||||
refute @contract.valid?(response(@body, "text/html", "500"))
|
||||
end
|
||||
|
||||
def test_intermittent_200_error_page_rejects_the_loaded_run
|
||||
server = TCPServer.new("127.0.0.1", 0)
|
||||
worker = Thread.new do
|
||||
loop do
|
||||
socket = server.accept
|
||||
count = 0
|
||||
while socket.gets
|
||||
while (line = socket.gets) && line != "\r\n"
|
||||
end
|
||||
body = count % 8 == 0 ? "Something went wrong" : @body
|
||||
count += 1
|
||||
socket.write("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
|
||||
end
|
||||
socket.close
|
||||
end
|
||||
rescue IOError, SystemCallError
|
||||
nil
|
||||
end
|
||||
client = BenchmarkHTTPClient.new("http://127.0.0.1:#{server.addr[1]}")
|
||||
error = assert_raises(RuntimeError) { client.measure("/rooms/1", "", concurrency: 1, duration: 0.1, contract: @contract) }
|
||||
assert_match(/HTTP statuses \{"200"\s*=>\s*\d+\}, 0 transport errors, [1-9]\d* invalid bodies/, error.message)
|
||||
ensure
|
||||
server&.close
|
||||
worker&.kill
|
||||
worker&.join
|
||||
end
|
||||
|
||||
private
|
||||
def response(body, type = "text/html", status = "200")
|
||||
Net::HTTPResponse.new("1.1", status, "OK").tap do |result|
|
||||
result["content-type"] = type
|
||||
result.instance_variable_set(:@body, body)
|
||||
result.instance_variable_set(:@read, true)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -80,5 +80,5 @@ Rails.application.configure do
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
# Visit /rails/locks to see the locks
|
||||
config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks
|
||||
config.middleware.insert_before ActionDispatch::Executor, ActionDispatch::DebugLocks
|
||||
end
|
||||
@@ -1,5 +1,20 @@
|
||||
require "rails_ext/time_limited_video_previewer"
|
||||
|
||||
ActiveSupport.on_load(:active_storage_blob) do
|
||||
ActiveStorage::DiskController.after_action only: :show do
|
||||
response.set_header("Cache-Control", "max-age=3600, public")
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.configure do
|
||||
# Rails' filter takes the second of the frames it selects (the first one, keyframes, scene changes), so a video
|
||||
# with a single keyframe and no scene change is decoded to its end. Selecting any frame from 5 seconds on stops
|
||||
# it there.
|
||||
config.active_storage.video_preview_arguments =
|
||||
"-vf 'select=eq(n\\,0)+eq(key\\,1)+gt(scene\\,0.015)+gte(t\\,5),loop=loop=-1:size=2,trim=start_frame=1'" \
|
||||
" -frames:v 1 -f image2"
|
||||
|
||||
config.active_storage.previewers = config.active_storage.previewers.map do |previewer|
|
||||
previewer == ActiveStorage::Previewer::VideoPreviewer ? TimeLimitedVideoPreviewer : previewer
|
||||
end
|
||||
end
|
||||
@@ -1,25 +1,21 @@
|
||||
# Be sure to restart your server when you modify this file.
|
||||
|
||||
# Define an application-wide content security policy.
|
||||
# See the Securing Rails Applications Guide for more information:
|
||||
# https://guides.rubyonrails.org/security.html#content-security-policy-header
|
||||
|
||||
# Rails.application.configure do
|
||||
# config.content_security_policy do |policy|
|
||||
# policy.default_src :self, :https
|
||||
# policy.font_src :self, :https, :data
|
||||
# policy.img_src :self, :https, :data
|
||||
# policy.object_src :none
|
||||
# policy.script_src :self, :https
|
||||
# policy.style_src :self, :https
|
||||
# # Specify URI for violation reports
|
||||
# # policy.report_uri "/csp-violation-report-endpoint"
|
||||
# end
|
||||
# Enforcing baseline policy. Application pages narrow script-src from this
|
||||
# origin to its asset directory, since uploads are served from this origin
|
||||
# too, and allow the two inline scripts the importmap helper emits by hash
|
||||
# (InlineScriptHashes). There is no per-request nonce, so rendered HTML stays
|
||||
# cacheable. 'self' remains for responses outside ApplicationController, none
|
||||
# of which render user content.
|
||||
#
|
||||
# # Generate session nonces for permitted importmap and inline scripts
|
||||
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
||||
# config.content_security_policy_nonce_directives = %w(script-src)
|
||||
# Nothing else is restricted: images, media, fonts, styles (including an
|
||||
# account's custom styles) and connections behave as before.
|
||||
#
|
||||
# # Report violations without enforcing the policy.
|
||||
# # config.content_security_policy_report_only = true
|
||||
# end
|
||||
# DISABLE_CONTENT_SECURITY_POLICY=true is the escape hatch for an install whose
|
||||
# proxy or customization injects scripts the policy would block.
|
||||
unless ENV["DISABLE_CONTENT_SECURITY_POLICY"] == "true"
|
||||
Rails.application.configure do
|
||||
config.content_security_policy do |policy|
|
||||
policy.script_src :self
|
||||
policy.object_src :none
|
||||
policy.base_uri :self
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,3 +6,11 @@ if Rails.env.production? && ENV["SKIP_TELEMETRY"].blank?
|
||||
config.release = ENV["GIT_REVISION"]
|
||||
end
|
||||
end
|
||||
|
||||
# sentry-rails declares its Action Cable handle_open and handle_close wrappers private,
|
||||
# but Rails 8.2 calls them from outside the connection, so every /cable connection fails.
|
||||
# Remove once sentry-rails ships getsentry/sentry-ruby#2972 (issue #2975).
|
||||
ActiveSupport.on_load(:action_cable_connection) do
|
||||
require "sentry/rails/action_cable"
|
||||
Sentry::Rails::ActionCableExtensions::Connection.send(:public, :handle_open, :handle_close)
|
||||
end
|
||||
@@ -1,2 +1,2 @@
|
||||
# Used to match JavaScripts (new Date).getTime() for sorting
|
||||
Time::DATE_FORMATS[:epoch] = ->(time) { (time.to_f * 1000).to_i }
|
||||
ActiveSupport::TimeFormats.register(:epoch, ->(time) { (time.to_f * 1000).to_i })
|
||||
@@ -0,0 +1,34 @@
|
||||
# Uploaded files are served from this origin. Application pages only allow
|
||||
# scripts from the asset directory (InlineScriptHashes), so an upload can't
|
||||
# load as a script whatever type it's served with. This guard is the second
|
||||
# layer: browsers accept sixteen spellings of a JavaScript type, and Disk URLs
|
||||
# sign the type they were minted with, so instead of refusing a list of types,
|
||||
# anything Active Storage serves that isn't plainly media goes out as a
|
||||
# download, whatever its URL says. A multi-range response keeps its
|
||||
# multipart/byteranges type and boundary, which no browser runs as a script.
|
||||
# Only reads are touched: uploading (direct upload JSON, Disk PUT) answers in
|
||||
# its own types.
|
||||
#
|
||||
# A cache that stored an upload under a script type before this shipped can
|
||||
# still serve it that way until the entry expires (an hour for Disk URLs, and
|
||||
# never for blob proxy URLs, which Rails caches as immutable). The policy's
|
||||
# path restriction is what keeps such a copy from running.
|
||||
class UploadedScriptGuard
|
||||
SERVABLE = %r{\A(image|video|audio)/|\A(application/pdf|application/octet-stream|text/plain|multipart/byteranges)\z}
|
||||
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
status, headers, body = @app.call(env)
|
||||
|
||||
if env["REQUEST_METHOD"].in?(%w[ GET HEAD ]) && env["PATH_INFO"].start_with?("#{ActiveStorage.routes_prefix}/") && (type = headers["content-type"])
|
||||
headers["content-type"] = ActiveStorage.binary_content_type unless type.split(";").first.strip.downcase.match?(SERVABLE)
|
||||
end
|
||||
|
||||
[ status, headers, body ]
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.config.middleware.use UploadedScriptGuard
|
||||
@@ -1,4 +1,5 @@
|
||||
require "web-push"
|
||||
require "web_push/connections"
|
||||
require "web_push/pool"
|
||||
require "web_push/notification"
|
||||
|
||||
@@ -17,43 +18,31 @@ end
|
||||
|
||||
module WebPush::PersistentRequest
|
||||
def perform
|
||||
if endpoint_ip = @options[:endpoint_ip]
|
||||
# Pin the connection to the public IP resolved (and guarded) by
|
||||
# Push::Subscription so delivery can't be rebound to a private address
|
||||
# between resolution and connect. Bypasses the shared persistent pool,
|
||||
# which would re-resolve the host itself.
|
||||
#
|
||||
# The explicit nil proxy address disables proxy discovery from
|
||||
# http_proxy/https_proxy. An egress proxy would open the TCP connection
|
||||
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
|
||||
# pin the destination and the DNS-rebinding guarantee would be lost. This
|
||||
# path is already committed to a direct connection (it bypasses the pool);
|
||||
# push delivery to public vendor endpoints goes direct.
|
||||
http = Net::HTTP.new(uri.host, uri.port, nil)
|
||||
http.ipaddr = endpoint_ip
|
||||
http.use_ssl = true
|
||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
||||
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
||||
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
|
||||
elsif @options[:connection]
|
||||
http = @options[:connection]
|
||||
else
|
||||
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
|
||||
http.use_ssl = true
|
||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
||||
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
||||
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
|
||||
end
|
||||
# Pin the connection to the public IP resolved (and guarded) by
|
||||
# Push::Subscription so delivery can't be rebound to a private address
|
||||
# between resolution and connect. There is no unpinned path: a delivery
|
||||
# without a resolved IP is not sent.
|
||||
endpoint_ip = @options[:endpoint_ip] or raise ArgumentError, "Push deliveries must be pinned to a resolved endpoint IP"
|
||||
|
||||
# The explicit nil proxy address disables proxy discovery from
|
||||
# http_proxy/https_proxy. An egress proxy would open the TCP connection
|
||||
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
|
||||
# pin the destination and the DNS-rebinding guarantee would be lost.
|
||||
# Push delivery to public vendor endpoints goes direct.
|
||||
http = Net::HTTP.new(uri.host, uri.port, nil)
|
||||
http.ipaddr = endpoint_ip
|
||||
http.use_ssl = true
|
||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
||||
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
||||
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
|
||||
|
||||
req = Net::HTTP::Post.new(uri.request_uri, headers)
|
||||
req.body = body
|
||||
|
||||
if http.is_a?(Net::HTTP::Persistent)
|
||||
response = http.request uri, req
|
||||
else
|
||||
resp = http.request(req)
|
||||
verify_response(resp)
|
||||
end
|
||||
# WebPush::Connections reuses an open connection only for this same host
|
||||
# and pinned IP, so the guarantee holds for every request it sends.
|
||||
resp = @options[:connection] ? @options[:connection].request(http, req) : http.request(req)
|
||||
verify_response(resp)
|
||||
|
||||
resp
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_index :messages, %i[ room_id created_at ], if_not_exists: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddRoomIdAndUpdatedAtIndexToMessages < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_index :messages, %i[ room_id updated_at ], if_not_exists: true
|
||||
end
|
||||
end
|
||||
Generated
+3
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
|
||||
create_table "accounts", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.text "custom_styles"
|
||||
@@ -101,6 +101,8 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
|
||||
t.integer "room_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["creator_id"], name: "index_messages_on_creator_id"
|
||||
t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at"
|
||||
t.index ["room_id", "updated_at"], name: "index_messages_on_room_id_and_updated_at"
|
||||
t.index ["room_id"], name: "index_messages_on_room_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Rails waits for ffmpeg however long it takes, and a video's preview is drawn inside the request that posts it.
|
||||
# This previewer gives ffmpeg a wall-clock limit, kills it past that, and reports a failed preview, so the message
|
||||
# is posted without one.
|
||||
class TimeLimitedVideoPreviewer < ActiveStorage::Previewer::VideoPreviewer
|
||||
TIME_LIMIT = 10 # seconds
|
||||
|
||||
private
|
||||
def capture(*argv, to:)
|
||||
to.binmode
|
||||
|
||||
open_tempfile do |err|
|
||||
IO.popen(argv, in: IO::NULL, err: err) do |out|
|
||||
Timeout.timeout(TIME_LIMIT) { IO.copy_stream(out, to) }
|
||||
rescue Timeout::Error
|
||||
Process.kill :KILL, out.pid
|
||||
raise ActiveStorage::PreviewError, "#{argv.first} took longer than #{TIME_LIMIT} seconds"
|
||||
end
|
||||
err.rewind
|
||||
|
||||
unless $?.success?
|
||||
raise ActiveStorage::PreviewError, "#{argv.first} failed (status #{$?.exitstatus}): #{err.read.to_s.chomp}"
|
||||
end
|
||||
end
|
||||
|
||||
to.rewind
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,120 @@
|
||||
# This is in lib so we can use it in a thread pool without the Rails executor
|
||||
#
|
||||
# Keeps TLS connections to push services open between deliveries, so a push doesn't pay for a new TCP and TLS
|
||||
# handshake every time. Each connection is pinned to the address Push::Subscription resolved and guarded for the
|
||||
# delivery that opened it, and is handed out again only to a delivery whose own resolution, just made, returned that
|
||||
# same address for the same host. Net::HTTP reconnects to its pinned address, never to a new lookup of the host.
|
||||
class WebPush::Connections
|
||||
class ConnectionLost < StandardError; end
|
||||
class StaleConnection < StandardError; end
|
||||
|
||||
# Where a request failed. Before writing it, Net::HTTP checks an idle connection (:checking) and connects again
|
||||
# if the push service closed it (:connecting); then it writes (:sent). Only a failed check means a dead idle
|
||||
# connection that a new one can replace without sending the push twice.
|
||||
module Stages
|
||||
attr_reader :stage
|
||||
|
||||
private
|
||||
def begin_transport(...)
|
||||
@stage = :checking
|
||||
super.tap { @stage = :sent }
|
||||
end
|
||||
|
||||
def connect(...)
|
||||
@stage = :connecting if @stage == :checking
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(keep_alive_timeout: 30, max_idle: 150)
|
||||
@keep_alive_timeout = keep_alive_timeout
|
||||
@max_idle = max_idle
|
||||
@idle = Hash.new { |idle, address| idle[address] = [] }
|
||||
@mutex = Mutex.new
|
||||
@pid = Process.pid
|
||||
end
|
||||
|
||||
def request(http, request)
|
||||
unless http.ipaddr && !http.proxy? && http.use_ssl? && !http.started?
|
||||
raise ArgumentError, "Only new, direct TLS connections pinned to an address are pooled"
|
||||
end
|
||||
address = [ http.address, http.port, http.ipaddr ]
|
||||
|
||||
if idle = checkout(address)
|
||||
begin
|
||||
return send_over(idle, request, reused: true).tap { checkin(address, idle) }
|
||||
rescue StaleConnection
|
||||
# The push service had closed it: a new connection takes the push
|
||||
end
|
||||
end
|
||||
|
||||
http.extend Stages
|
||||
http.keep_alive_timeout = @keep_alive_timeout
|
||||
http.start
|
||||
send_over(http, request, reused: false).tap { checkin(address, http) }
|
||||
end
|
||||
|
||||
def shutdown
|
||||
@mutex.synchronize do
|
||||
forget_after_fork
|
||||
@shut_down = true
|
||||
@idle.each_value { |connections| connections.each { |http, _| close(http) } }
|
||||
@idle.clear
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def send_over(http, request, reused:)
|
||||
http.request(request)
|
||||
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => error
|
||||
close(http)
|
||||
# Only a connection that was idle can have been dead already: on a new one the error is the push service's
|
||||
raise StaleConnection if reused && http.stage == :checking
|
||||
# The push service may have it: don't send it twice, and don't report a dropped connection as a TLS failure
|
||||
raise ConnectionLost, "#{error.class}: #{error.message}" if http.stage == :sent
|
||||
raise
|
||||
end
|
||||
|
||||
def checkout(address)
|
||||
@mutex.synchronize do
|
||||
forget_after_fork
|
||||
close_expired
|
||||
@idle[address].pop&.first
|
||||
end
|
||||
end
|
||||
|
||||
def checkin(address, http)
|
||||
@mutex.synchronize do
|
||||
close_expired
|
||||
if @shut_down || @idle.values.sum(&:size) >= @max_idle
|
||||
close(http)
|
||||
else
|
||||
@idle[address].push [ http, now ]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# A forked process must not write on its parent's TLS sessions. They're left to the parent, not closed.
|
||||
def forget_after_fork
|
||||
unless @pid == Process.pid
|
||||
@idle = Hash.new { |idle, address| idle[address] = [] }
|
||||
@pid = Process.pid
|
||||
end
|
||||
end
|
||||
|
||||
def close_expired
|
||||
@idle.each_value do |connections|
|
||||
connections.reject! { |http, idle_since| (now - idle_since > @keep_alive_timeout).tap { |expired| close(http) if expired } }
|
||||
end
|
||||
@idle.delete_if { |_, connections| connections.empty? }
|
||||
end
|
||||
|
||||
def close(http)
|
||||
http.finish if http.started?
|
||||
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
||||
end
|
||||
|
||||
def now
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
end
|
||||
@@ -5,13 +5,17 @@ class WebPush::Pool
|
||||
def initialize(invalid_subscription_handler:)
|
||||
@delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, max_queue: 10000)
|
||||
@invalidation_pool = Concurrent::FixedThreadPool.new(1)
|
||||
@connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150)
|
||||
@connection = WebPush::Connections.new
|
||||
@invalid_subscription_handler = invalid_subscription_handler
|
||||
end
|
||||
|
||||
def queue(payload, subscriptions)
|
||||
subscriptions.find_each do |subscription|
|
||||
deliver_later(payload, subscription)
|
||||
subscriptions.find_in_batches do |batch|
|
||||
unread_counts = Membership.unread.where(user_id: batch.map(&:user_id)).group(:user_id).count
|
||||
|
||||
batch.each do |subscription|
|
||||
deliver_later(payload, subscription, badge: unread_counts.fetch(subscription.user_id, 0))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,9 +26,9 @@ class WebPush::Pool
|
||||
end
|
||||
|
||||
private
|
||||
def deliver_later(payload, subscription)
|
||||
def deliver_later(payload, subscription, badge:)
|
||||
# Ensure any AR operations happen before we post to the thread pool
|
||||
notification = subscription.notification(**payload)
|
||||
notification = subscription.notification(**payload, badge: badge)
|
||||
subscription_id = subscription.id
|
||||
|
||||
delivery_pool.post do
|
||||
|
||||
@@ -18,4 +18,53 @@ class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
|
||||
|
||||
assert_reject_connection { connect }
|
||||
end
|
||||
|
||||
test "closes once its session has ended without it hearing" do
|
||||
cookies.signed[:session_token] = sessions(:david_safari).token
|
||||
connect
|
||||
sessions(:david_safari).destroy
|
||||
|
||||
travel 2.minutes
|
||||
connection.beat
|
||||
|
||||
assert socket.closed
|
||||
assert_equal "disconnect", socket.transmissions.last[:type]
|
||||
assert socket.transmissions.last[:reconnect]
|
||||
end
|
||||
|
||||
test "keeps checking its session while open" do
|
||||
cookies.signed[:session_token] = sessions(:david_safari).token
|
||||
connect
|
||||
|
||||
travel 2.minutes
|
||||
connection.beat
|
||||
assert_not socket.closed
|
||||
|
||||
sessions(:david_safari).destroy
|
||||
travel 2.minutes
|
||||
connection.beat
|
||||
assert socket.closed
|
||||
end
|
||||
|
||||
test "checks its session at most once a minute" do
|
||||
cookies.signed[:session_token] = sessions(:david_safari).token
|
||||
connect
|
||||
sessions(:david_safari).destroy
|
||||
|
||||
travel 30.seconds
|
||||
connection.beat
|
||||
|
||||
assert_not socket.closed
|
||||
end
|
||||
|
||||
test "a rejected connection's heartbeat doesn't fail" do
|
||||
request = ActionCable::Connection::TestSocket.build_request(ActionCable.server.config.mount_path || "/cable", cookies: cookies)
|
||||
rejected = ApplicationCable::Connection.new \
|
||||
ActionCable::Connection::TestServer.new(ActionCable.server), ActionCable::Connection::TestSocket.new(request)
|
||||
rejected.handle_open
|
||||
|
||||
travel 2.minutes
|
||||
|
||||
assert_nothing_raised { rejected.beat }
|
||||
end
|
||||
end
|
||||
@@ -40,6 +40,16 @@ class PresenceChannelTest < ActionCable::Channel::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "subscribing tells the user's other tabs when they read the room" do
|
||||
membership = users(:david).memberships.first
|
||||
|
||||
freeze_time do
|
||||
assert_broadcast_on "user_#{users(:david).id}_reads", { room_id: membership.room_id, at: Time.current.to_f } do
|
||||
subscribe room_id: membership.room_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "unsubscribing marks the membership as disconnected" do
|
||||
membership = users(:david).memberships.first
|
||||
subscribe room_id: membership.room_id
|
||||
|
||||
@@ -8,7 +8,7 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
|
||||
|
||||
assert subscription.confirmed?
|
||||
assert_has_stream "user_#{users(:jz).id}_unreads"
|
||||
assert_not_includes subscription.streams, "unread_rooms"
|
||||
assert_not_includes subscription.stream_names, "unread_rooms"
|
||||
end
|
||||
|
||||
test "an outsider is not told about activity in a room they can't see" do
|
||||
@@ -16,7 +16,9 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
|
||||
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
|
||||
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
|
||||
direct.messages.create!(body: "Private", creator: users(:kevin), client_message_id: "outsider").broadcast_create
|
||||
end
|
||||
end
|
||||
|
||||
assert_empty broadcasts
|
||||
@@ -24,12 +26,16 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
|
||||
|
||||
test "a member is told about activity in their own room" do
|
||||
direct = rooms(:bender_and_kevin)
|
||||
message = nil
|
||||
|
||||
broadcasts = capture_unread_broadcasts_for(users(:kevin)) do
|
||||
direct.messages.create!(body: "Private", creator: users(:bender), client_message_id: "member").broadcast_create
|
||||
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
|
||||
message = direct.messages.create!(body: "Private", creator: users(:bender), client_message_id: "member")
|
||||
message.broadcast_create
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal [ direct.id ], broadcasts.collect { |broadcast| broadcast["roomId"] }
|
||||
assert_equal [ { "roomId" => direct.id, "at" => message.created_at.to_f } ], broadcasts
|
||||
end
|
||||
|
||||
private
|
||||
@@ -37,7 +43,7 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
|
||||
stub_connection(current_user: user)
|
||||
subscribe
|
||||
|
||||
stream = subscription.streams.sole
|
||||
stream = subscription.stream_names.sole
|
||||
before = ActionCable.server.pubsub.broadcasts(stream).size
|
||||
|
||||
yield
|
||||
|
||||
@@ -10,6 +10,16 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :ok
|
||||
end
|
||||
|
||||
test "edit shows whether room creation is restricted to administrators" do
|
||||
get edit_account_url
|
||||
assert_select "label.switch input.switch__input[type=checkbox]:not([checked])"
|
||||
|
||||
accounts(:signal).update!(settings: { restrict_room_creation_to_administrators: true })
|
||||
|
||||
get edit_account_url
|
||||
assert_select "label.switch input.switch__input[type=checkbox][checked][data-action='change->form#submit']"
|
||||
end
|
||||
|
||||
test "edit groups administrators separately from members with a divider" do
|
||||
get edit_account_url
|
||||
|
||||
@@ -42,6 +52,19 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "edit lists a page of members and the next page carries on from it" do
|
||||
User.insert_all 501.times.map { |i| { name: "Member #{i}", email_address: "member#{i}@37signals.com" } }
|
||||
users(:kevin).banned!
|
||||
|
||||
get edit_account_url
|
||||
first_page = listed_user_ids
|
||||
get account_users_url(page: 2, format: :turbo_stream)
|
||||
next_page = listed_user_ids
|
||||
|
||||
assert_not_empty next_page
|
||||
assert_equal User.where(status: [ :active, :banned ]).without_bots.ids.sort, (first_page + next_page).sort
|
||||
end
|
||||
|
||||
test "update" do
|
||||
assert users(:david).administrator?
|
||||
|
||||
@@ -58,4 +81,9 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
put account_url, params: { account: { name: "Different" } }
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
private
|
||||
def listed_user_ids
|
||||
response.body.scan(%r{href="/users/(\d+)"}).flatten.map(&:to_i)
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,22 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
|
||||
@message = messages(:first)
|
||||
end
|
||||
|
||||
test "index wires the new boost link to the soft keyboard" do
|
||||
get message_boosts_url(@message)
|
||||
|
||||
assert_response :success
|
||||
assert_select ".message__boost-inline a.boost__action[data-action='soft-keyboard#open']"
|
||||
end
|
||||
|
||||
test "index looks up the boosters of all boosts at once" do
|
||||
@message.boosts.create! booster: users(:jason), content: "🎉"
|
||||
queries_with_two_boosts = count_queries { get message_boosts_url(@message) }
|
||||
|
||||
boost = @message.boosts.create! booster: users(:kevin), content: "👀"
|
||||
assert_equal queries_with_two_boosts, count_queries { get message_boosts_url(@message) }
|
||||
assert_select "#" + dom_id(boost)
|
||||
end
|
||||
|
||||
test "create" do
|
||||
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
|
||||
assert_difference -> { @message.boosts.count }, 1 do
|
||||
@@ -23,4 +39,12 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def count_queries(&block)
|
||||
count = 0
|
||||
counter = ->(*, payload) { count += 1 unless payload[:name] == "SCHEMA" || payload[:cached] }
|
||||
ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block)
|
||||
count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
require "test_helper"
|
||||
require "active_record/testing/query_assertions"
|
||||
|
||||
class MessagesCachingTest < ActionDispatch::IntegrationTest
|
||||
include ActiveRecord::Assertions::QueryAssertions
|
||||
|
||||
setup do
|
||||
sign_in :david
|
||||
end
|
||||
|
||||
test "cached pages skip presentation queries and refresh edited messages" do
|
||||
with_memory_cache do
|
||||
get room_messages_url(rooms(:watercooler))
|
||||
assert_response :success
|
||||
original = response.body
|
||||
|
||||
assert_no_queries_match(/action_text_rich_texts|active_storage_attachments|boosts/) do
|
||||
get room_messages_url(rooms(:watercooler))
|
||||
end
|
||||
assert_response :success
|
||||
assert_equal original, response.body
|
||||
|
||||
messages(:fourth).update! body: "Updated cached message"
|
||||
get room_messages_url(rooms(:watercooler))
|
||||
assert_response :success
|
||||
assert_select "#" + dom_id(messages(:fourth)), text: /Updated cached message/
|
||||
end
|
||||
end
|
||||
|
||||
test "boosts are cached inside their message instead of one fragment each" do
|
||||
with_memory_cache do
|
||||
cache_keys = []
|
||||
subscriber = ActiveSupport::Notifications.subscribe(/\Acache_(read|read_multi|write|write_multi)\.active_support\z/) do |*, payload|
|
||||
cache_keys.concat(payload[:key].is_a?(Hash) ? payload[:key].keys : Array(payload[:key]))
|
||||
end
|
||||
|
||||
get room_messages_url(rooms(:watercooler))
|
||||
assert_response :success
|
||||
assert_select "#" + dom_id(boosts(:fourth_by_bender))
|
||||
assert_select "#" + dom_id(boosts(:thirteenth))
|
||||
assert_empty cache_keys.grep(%r{messages/boosts/_boost})
|
||||
|
||||
boost = messages(:fourth).boosts.create! booster: users(:jason), content: "🎉"
|
||||
get room_messages_url(rooms(:watercooler))
|
||||
refreshed = response.body
|
||||
|
||||
Rails.cache.clear
|
||||
get room_messages_url(rooms(:watercooler))
|
||||
assert_equal refreshed, response.body
|
||||
assert_select "##{dom_id(messages(:fourth))} ##{dom_id(boost)}", text: /🎉/
|
||||
ensure
|
||||
ActiveSupport::Notifications.unsubscribe(subscriber)
|
||||
end
|
||||
end
|
||||
|
||||
test "messages render their preloaded boosts in order without querying them again" do
|
||||
earlier = messages(:fourth).boosts.create! booster: users(:jason), content: "🥇", created_at: 1.day.ago
|
||||
in_order = [ dom_id(earlier), dom_id(boosts(:fourth_by_bender)) ]
|
||||
|
||||
assert_no_queries_match(/ORDER BY "boosts"/) do
|
||||
get room_messages_url(rooms(:watercooler))
|
||||
end
|
||||
assert_response :success
|
||||
assert_equal in_order, css_select("##{dom_id(messages(:fourth), :boosts)} .boost").map { it["id"] }
|
||||
|
||||
get message_boosts_url(messages(:fourth))
|
||||
assert_equal in_order, css_select("##{dom_id(messages(:fourth), :boosts)} .boost").map { it["id"] }
|
||||
end
|
||||
|
||||
test "a page of messages loads whether their previews were made along with the messages, not one at a time" do
|
||||
room = rooms(:watercooler)
|
||||
2.times do |copy|
|
||||
{ "moon.jpg" => "image/jpeg", "alpha-centuri.mov" => "video/quicktime" }.each do |file, content_type|
|
||||
room.messages.create_with_attachment! creator: users(:david), client_message_id: "#{copy}-#{file}", attachment: fixture_file_upload(file, content_type)
|
||||
end
|
||||
end
|
||||
|
||||
assert_no_queries_match(/"active_storage_\w+"\."(id|blob_id|record_id)" = \?/) do
|
||||
get room_messages_url(room)
|
||||
end
|
||||
assert_response :success
|
||||
assert_select "img[src*='moon.jpg']", count: 2
|
||||
assert_select "video[poster]", count: 2
|
||||
end
|
||||
|
||||
private
|
||||
def with_memory_cache
|
||||
old_cache = Rails.cache
|
||||
old_collection_cache = ActionView::PartialRenderer.collection_cache
|
||||
old_controller_cache = MessagesController.cache_store
|
||||
old_caching = MessagesController.perform_caching
|
||||
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
ActionView::PartialRenderer.collection_cache = Rails.cache
|
||||
MessagesController.cache_store = Rails.cache
|
||||
MessagesController.perform_caching = true
|
||||
yield
|
||||
ensure
|
||||
Rails.cache = old_cache
|
||||
ActionView::PartialRenderer.collection_cache = old_collection_cache
|
||||
MessagesController.cache_store = old_controller_cache
|
||||
MessagesController.perform_caching = old_caching
|
||||
end
|
||||
end
|
||||
@@ -57,21 +57,89 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "message copy links preserve the request origin in broadcasts and regular reads" do
|
||||
[
|
||||
[ "once.campfire.test:38683", false, "http://once.campfire.test:38683" ],
|
||||
[ "once.campfire.test:8443", true, "https://once.campfire.test:8443" ],
|
||||
[ "once.campfire.test:80", false, "http://once.campfire.test" ],
|
||||
[ "once.campfire.test:443", true, "https://once.campfire.test" ]
|
||||
].each_with_index do |(host, secure, origin), index|
|
||||
host! host
|
||||
https! secure
|
||||
|
||||
post room_messages_path(@room, format: :turbo_stream), params: { message: {
|
||||
body: "Request origin #{index}", client_message_id: "request-origin-#{index}" } }
|
||||
assert_response :success
|
||||
message = Message.last
|
||||
expected_url = "#{origin}/rooms/#{@room.id}/@#{message.id}"
|
||||
|
||||
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
|
||||
assert_copy_link_button expected_url
|
||||
end
|
||||
|
||||
get room_message_path(@room, message)
|
||||
assert_response :success
|
||||
assert_copy_link_button expected_url
|
||||
end
|
||||
end
|
||||
|
||||
test "creating a message with an image that can't be decoded broadcasts the message to the room" do
|
||||
webp = Vips::Image.new_from_file(file_fixture("moon.jpg").to_s).webpsave_buffer
|
||||
broken = Rack::Test::UploadedFile.new(StringIO.new(webp.byteslice(0, webp.bytesize / 2)), "image/webp", original_filename: "broken.webp")
|
||||
|
||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { attachment: broken, client_message_id: 999 } }
|
||||
|
||||
assert_response :success
|
||||
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
|
||||
assert_select ".message__body a[href*='broken.webp']"
|
||||
end
|
||||
end
|
||||
|
||||
test "creating a message broadcasts unread room to each member" do
|
||||
memberships(:david_watercooler).present # the poster is in the room
|
||||
|
||||
@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 } }
|
||||
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
|
||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one #{member.id}", client_message_id: member.id } }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "creating a message leaves the unread fanout to a job" do
|
||||
member = @room.users.excluding(users(:david)).first
|
||||
|
||||
assert_no_broadcasts UnreadRoomsChannel.stream_name_for(member.id) do
|
||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
|
||||
end
|
||||
|
||||
assert_enqueued_with job: Message::BroadcastUnreadRoomJob, args: [ Message.last ]
|
||||
end
|
||||
|
||||
test "the unread fanout skips a member who has opened the room since" do
|
||||
membership = memberships(:jason_watercooler)
|
||||
|
||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
|
||||
assert membership.reload.unread?
|
||||
|
||||
membership.present
|
||||
membership.reload.disconnected
|
||||
|
||||
assert_no_broadcasts UnreadRoomsChannel.stream_name_for(membership.user_id) do
|
||||
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob
|
||||
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 } }
|
||||
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
|
||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -148,6 +216,20 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "messages sharing a client_message_id render as distinct elements" do
|
||||
victim = @messages.last
|
||||
|
||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "Collision", client_message_id: victim.client_message_id } }
|
||||
colliding = @room.messages.ordered.last
|
||||
assert_not_equal victim, colliding
|
||||
|
||||
get room_messages_url(@room)
|
||||
|
||||
ids = css_select(".message[data-message-id]").map { it["id"] }
|
||||
assert_equal ids.uniq, ids
|
||||
ensure_messages_present victim, colliding
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_messages_present(*messages, count: 1)
|
||||
messages.each do |message|
|
||||
|
||||
@@ -25,8 +25,10 @@ class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
|
||||
sign_in :kevin
|
||||
|
||||
assert_difference -> { Room.count }, -1 do
|
||||
delete rooms_direct_url(rooms(:david_and_kevin))
|
||||
assert_redirected_to root_url
|
||||
perform_enqueued_jobs do
|
||||
delete rooms_direct_url(rooms(:david_and_kevin))
|
||||
assert_redirected_to root_url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -15,6 +15,21 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "show renders notification help for each platform" do
|
||||
{
|
||||
"Firefox on Android" => "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0",
|
||||
"Chrome on Android" => "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36",
|
||||
"Firefox on desktop" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
|
||||
"Chrome on desktop" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
||||
"Safari on macOS" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15"
|
||||
}.each do |platform, user_agent|
|
||||
get room_url(users(:david).rooms.last), headers: { "User-Agent" => user_agent }
|
||||
|
||||
assert_response :success, platform
|
||||
assert_select ".notifications-help ol", { minimum: 1 }, platform
|
||||
end
|
||||
end
|
||||
|
||||
test "shows records the last room visited in a cookie" do
|
||||
get room_url(users(:david).rooms.last)
|
||||
assert response.cookies[:last_room] = users(:david).rooms.last.id
|
||||
@@ -68,11 +83,24 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "destroy" do
|
||||
assert_turbo_stream_broadcasts :rooms, count: 1 do
|
||||
assert_difference -> { Room.count }, -1 do
|
||||
delete room_url(rooms(:designers))
|
||||
perform_enqueued_jobs { delete room_url(rooms(:designers)) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "destroy takes the room away from its members at once and leaves its messages to a job" do
|
||||
room = rooms(:designers)
|
||||
member_ids = room.memberships.pluck(:user_id)
|
||||
|
||||
assert_enqueued_with(job: Room::DestroyJob, args: [ room, member_ids ]) do
|
||||
assert_no_difference -> { Message.count } do
|
||||
delete room_url(room)
|
||||
assert_redirected_to root_url
|
||||
end
|
||||
end
|
||||
assert_empty room.memberships.reload
|
||||
end
|
||||
|
||||
test "destroy only allowed for creators or those who can administer" do
|
||||
sign_in :jz
|
||||
|
||||
@@ -84,7 +112,7 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
|
||||
rooms(:designers).update! creator: users(:jz)
|
||||
|
||||
assert_difference -> { Room.count }, -1 do
|
||||
delete room_url(rooms(:designers))
|
||||
perform_enqueued_jobs { delete room_url(rooms(:designers)) }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -20,6 +20,24 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_select ".message", text: /Hello world!/
|
||||
end
|
||||
|
||||
test "operator words are searched for as words" do
|
||||
rooms(:designers).messages.create! body: "Salt and pepper", client_message_id: "operators", creator: users(:david)
|
||||
|
||||
get searches_url, params: { q: "salt AND" }
|
||||
|
||||
assert_response :success
|
||||
assert_select ".message", text: /Salt and pepper/
|
||||
end
|
||||
|
||||
test "searching for an operator word alone doesn't fail" do
|
||||
%w[ AND OR NOT ].each do |word|
|
||||
get searches_url, params: { q: word }
|
||||
|
||||
assert_response :success
|
||||
assert_select ".message", count: 0
|
||||
end
|
||||
end
|
||||
|
||||
test "unreachable messages are not found" do
|
||||
memberships(:david_designers).destroy!
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
|
||||
get session_transfer_url("some-token")
|
||||
|
||||
assert_response :success
|
||||
assert_select "form[data-controller='auto-submit']", count: 1
|
||||
assert_select "input[name='_method'][value='put']", count: 1
|
||||
assert_equal response.body.scan(/<form\b/).size, response.body.scan("</form>").size
|
||||
assert_match(/<form\b[^>]*data-controller="auto-submit"[^>]*>(?:\s*<input\b[^>]*>)*\s*<\/form>/, response.body)
|
||||
end
|
||||
|
||||
test "update establishes a session when the code is valid" do
|
||||
@@ -15,4 +19,30 @@ class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_redirected_to root_url
|
||||
assert parsed_cookies.signed[:session_token]
|
||||
end
|
||||
|
||||
test "update rejects a link made before the password changed" do
|
||||
user = users(:david)
|
||||
transfer_id = user.transfer_id
|
||||
user.update!(password: "a new secret123456")
|
||||
|
||||
put session_transfer_url(transfer_id)
|
||||
|
||||
assert_response :bad_request
|
||||
assert_nil parsed_cookies.signed[:session_token]
|
||||
end
|
||||
|
||||
test "update is refused when the password changes after the link was checked" do
|
||||
user = users(:david)
|
||||
transfer_id = user.transfer_id
|
||||
stale = User.find(user.id)
|
||||
User.find(user.id).update!(password: "a new secret123456")
|
||||
User.stubs(:find_by_transfer_id).with(transfer_id).returns(stale)
|
||||
|
||||
assert_no_difference -> { Session.count } do
|
||||
put session_transfer_url(transfer_id)
|
||||
end
|
||||
|
||||
assert_response :bad_request
|
||||
assert_nil parsed_cookies.signed[:session_token]
|
||||
end
|
||||
end
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user