Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot] 02a3922595 Initial plan 2026-10-05 14:25:10 +00:00
127 changed files with 272 additions and 3406 deletions

No files matched your search

+7 -33
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [ main ]
pull_request:
branches: [ main, "release-*" ]
branches: [ main ]
permissions: {}
@@ -21,7 +21,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.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@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -48,29 +48,6 @@ 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
@@ -102,7 +79,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 sqlite3 libvips curl ffmpeg
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -110,7 +87,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
env:
REDIS_URL: redis://localhost:6379/0
with:
@@ -120,9 +97,6 @@ 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:
@@ -135,7 +109,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 sqlite3 libvips curl ffmpeg
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -143,7 +117,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
env:
REDIS_URL: redis://localhost:6379/0
with:
+1 -14
View File
@@ -9,23 +9,10 @@ WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y ca-certificates curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
apt-get install --no-install-recommends -y 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" \
+1
View File
@@ -43,6 +43,7 @@ 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"
+40 -53
View File
@@ -23,16 +23,16 @@ GIT
GIT
remote: https://github.com/rails/propshaft.git
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623
revision: e49a9de659ff27462015e54dd832e86e762a6ddc
specs:
propshaft (1.3.2)
propshaft (1.2.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
GIT
remote: https://github.com/rails/rails.git
revision: e3d5c569d217c56c022b04acb127a8c24dbe9685
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
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.7)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.2.0.alpha)
action_text-trix (~> 2.1.16)
action_text-trix (~> 2.1.15)
actionpack (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activestorage (= 8.2.0.alpha)
@@ -77,12 +77,11 @@ GIT
activesupport (= 8.2.0.alpha)
builder (~> 3.1)
erubi (~> 1.11)
herb (>= 0.10)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.7)
rails-html-sanitizer (~> 1.6)
activejob (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
globalid (>= 1.4.0)
globalid (>= 0.3.6)
activemodel (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
activerecord (8.2.0.alpha)
@@ -94,7 +93,7 @@ GIT
activejob (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
marcel (~> 2.0)
marcel (~> 1.0)
activesupport (8.2.0.alpha)
base64
bigdecimal
@@ -105,8 +104,6 @@ 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)
@@ -145,8 +142,8 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (4.1.3)
brakeman (8.1.0)
bigdecimal (3.3.1)
brakeman (8.0.6)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -162,7 +159,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.8)
concurrent-ruby (1.3.7)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -173,9 +170,9 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.3)
erb (6.0.7)
erb (6.0.4)
erubi (1.13.1)
faker (3.8.0)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
@@ -185,22 +182,17 @@ GEM
geared_pagination (1.2.0)
activesupport (>= 5.0)
addressable (>= 2.5.0)
globalid (1.4.0)
globalid (1.3.0)
activesupport (>= 6.1)
hashdiff (1.2.0)
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)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.9.4)
irb (1.18.0)
io-console (0.8.1)
irb (1.15.3)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
@@ -227,28 +219,28 @@ GEM
net-imap
net-pop
net-smtp
marcel (2.1.0)
marcel (1.1.0)
matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
minitest (5.26.2)
mocha (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-imap (0.6.7)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.4.0)
net-protocol (0.2.2)
timeout
net-smtp (0.5.2)
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
@@ -268,18 +260,18 @@ GEM
platform_agent (1.0.1)
activesupport (>= 5.2.0)
useragent (~> 0.16.3)
pp (0.6.4)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
psych (5.5.0)
prism (1.4.0)
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
puma (7.2.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.7)
rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
@@ -289,9 +281,8 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rackup (2.2.1)
rack (>= 3)
ractor-dispatch (0.3.0)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -304,24 +295,19 @@ GEM
activesupport (> 3.1)
railties (> 3.1)
rainbow (3.1.1)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
rake (13.3.1)
rdoc (6.16.1)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
psych (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.30.1)
redis-client (0.25.2)
connection_pool
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.11.2)
reline (0.7.0)
reline (0.6.3)
io-console (~> 0.5)
resque (2.7.0)
mono_logger (~> 1)
@@ -369,9 +355,9 @@ GEM
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (3.7.0)
rubyzip (3.4.0)
securerandom (0.4.1)
selenium-webdriver (4.49.0)
selenium-webdriver (4.35.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -396,8 +382,8 @@ GEM
sqlite3 (2.9.6-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.5.0)
stringio (3.1.8)
thor (1.4.0)
thruster (0.1.23-aarch64-linux)
thruster (0.1.23-arm64-darwin)
thruster (0.1.23-x86_64-darwin)
@@ -426,7 +412,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.8.3)
zeitwerk (2.7.3)
PLATFORMS
aarch64-linux
@@ -449,6 +435,7 @@ DEPENDENCIES
kredis
lexxy (~> 0.9.24)
mocha
net-http-persistent
ostruct
platform_agent
propshaft!
+10 -12
View File
@@ -51,20 +51,18 @@ If you'd rather run the Docker image yourself, you can read more about that in t
## Other implementations
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
Campfire also has implementations in Django, Laravel, Express, Elixir, Go and Rust:
| 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 |
| 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) |
|---|---:|---:|---:|---:|---:|---:|---:|
| Room page | 241 | 170 | 164 | 559 | 722 | 3,860 | 36,260 |
| Messages page | 413 | 196 | 175 | 777 | 1,053 | 5,573 | 40,872 |
| Sidebar | 552 | 615 | 715 | 4,125 | 1,275 | 19,753 | 34,672 |
| Search | 435 | 315 | 305 | 1,294 | 1,156 | 7,053 | 33,299 |
| Post a message | 273 | 154 | 137 | 256 | 801 | 4,767 | 6,896 |
[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.
Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395,
with four hardware threads allocated to each app.
## Development
+2 -25
View File
@@ -2,42 +2,19 @@ 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
+1 -1
View File
@@ -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, at: Time.current.to_f }
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
end
end
+1 -1
View File
@@ -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.visible_to(Current.user).ordered.member, per_page: 500
set_page_and_extract_portion_from User.active.ordered.without_bots, per_page: 500
end
def update
+11 -3
View File
@@ -3,9 +3,9 @@ class AccountsController < ApplicationController
before_action :set_account
def edit
users = User.visible_to(Current.user).ordered
@administrators = users.administrator
set_page_and_extract_portion_from users.member, per_page: 500
users = account_users.ordered.without_bots
@administrators, @members = users.partition(&:administrator?)
set_page_and_extract_portion_from users, per_page: 500
end
def update
@@ -21,4 +21,12 @@ 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 -1
View File
@@ -1,4 +1,4 @@
class ApplicationController < ActionController::Base
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
end
+1 -1
View File
@@ -57,7 +57,7 @@ module Authentication
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
authenticated_as session
end
end
@@ -1,50 +0,0 @@
# 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, port: Current.request&.optional_port, protocol: Current.request_protocol }.compact_blank
{ host: Current.request_host, 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: helpers.dom_id(@boost.message, :boosts), partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
end
def broadcast_remove
+1 -1
View File
@@ -12,7 +12,7 @@ class RoomsController < ApplicationController
end
def destroy
@room.destroy_later
@room.destroy
broadcast_remove_room
redirect_to root_url
+1 -1
View File
@@ -20,7 +20,7 @@ class SearchesController < ApplicationController
private
def set_messages
if query.present?
@messages = Message.search_reachable(Current.user, query)
@messages = Current.user.reachable_messages.search(query).with_presentation.last_page_of(100)
else
@messages = Message.none
end
@@ -11,7 +11,5 @@ class Sessions::TransfersController < ApplicationController
else
head :bad_request
end
rescue ActiveRecord::RecordInvalid
head :bad_request
end
end
-2
View File
@@ -14,8 +14,6 @@ class SessionsController < ApplicationController
else
render_rejection :unauthorized
end
rescue ActiveRecord::RecordInvalid
render_rejection :unauthorized
end
def destroy
+3 -14
View File
@@ -1,9 +1,8 @@
class UnfurlLinksController < ApplicationController
# The pasted link decides which hosts an unfurl looks up and fetches from, and how quickly they answer.
DEADLINE = 10.seconds
def create
if opengraph = unfurl(url_param)
opengraph = Opengraph::Metadata.from_url(url_param)
if opengraph.valid?
render json: opengraph
else
head :no_content
@@ -14,14 +13,4 @@ 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
+2 -38
View File
@@ -1,26 +1,14 @@
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
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
@user.update user_params
redirect_to user_profile_url, notice: update_notice
end
private
@@ -28,35 +16,11 @@ 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,14 +5,17 @@ class Users::PushSubscriptionsController < ApplicationController
end
def create
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
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
else
head :unprocessable_entity
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
head subscription.persisted? ? :ok : :unprocessable_entity
end
end
@@ -26,24 +29,6 @@ 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 -3
View File
@@ -2,9 +2,8 @@ class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
def show
visible_memberships = Current.user.memberships.visible
@direct_memberships = visible_memberships.with_direct_rooms
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms
@direct_memberships, @other_memberships = Current.user.memberships.visible.with_ordered_room.partition { |membership| membership.room.direct? }
@direct_memberships = @direct_memberships.sort_by { |membership| membership.room.updated_at }.reverse
@direct_placeholder_users = find_direct_placeholder_users
end
+1 -1
View File
@@ -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)
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
@@ -17,16 +17,11 @@ 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
case
when message.attachment.video?
if message.attachment.video?
video_preview_tag
when message.attachment.representation(:thumb).processed?
lightboxed_image_preview_tag
else
render_link
lightboxed_image_preview_tag
end
end
@@ -35,17 +30,11 @@ class Messages::AttachmentPresentation
inline_media_dimension_constraints(width, height) do
tag.video \
src: rails_blob_path(message.attachment), poster: video_poster_url,
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 ])),
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
+4 -25
View File
@@ -1,12 +1,9 @@
module MessagesHelper
# Rendered text is re-sanitized before linking. Rails' default safe list
# lacks the formatting the rich text editor produces
# auto_link re-sanitizes with Rails' default safe list, which 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",
@@ -39,7 +36,6 @@ 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,
@@ -66,7 +62,8 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
@@ -76,24 +73,6 @@ 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
-1
View File
@@ -2,7 +2,6 @@ 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", "🇯🇵": "アカウントに名前を付ける" },
@@ -1,7 +0,0 @@
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,7 +46,6 @@ export default class extends Controller {
}
messageTargetConnected(target) {
this.#reconcilePendingMessage(target)
this.#formatter.format(target, ThreadStyle.thread)
}
@@ -126,25 +125,6 @@ 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,8 +106,7 @@ export default class extends Controller {
async #syncPushSubscription(subscription) {
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
if (response.unprocessableEntity) subscription.unsubscribe()
if (!response.ok) subscription.unsubscribe()
}
async #requestPermissionAndSubscribe(registration) {
@@ -1,32 +0,0 @@
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, at }) => {
this.dispatch("read", { detail: { roomId: room_id, at } })
#read = ({ room_id }) => {
this.dispatch("read", { detail: { roomId: room_id } })
}
}
@@ -28,7 +28,7 @@ export default class extends Controller {
}
#stripMentionAttachments(node) {
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
return node
}
@@ -7,13 +7,8 @@ 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),
@@ -22,11 +17,7 @@ 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
})
@@ -36,58 +27,19 @@ export default class extends Controller {
this.read({ detail: { roomId: Current.room.id } })
}
read({ detail: { roomId, at } }) {
read({ detail: { roomId } }) {
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 } })
}
}
// 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() {
#channelConnected() {
if (this.#disconnected) {
this.#disconnected = false
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()
}
this.element.reload()
}
}
@@ -95,11 +47,11 @@ export default class extends Controller {
this.#disconnected = true
}
#unread({ roomId, at }) {
#unread({ roomId }) {
const unreadRoom = this.#findRoomTarget(roomId)
if (unreadRoom) {
if (Current.room.id != roomId && !this.#readSince(roomId, at)) {
if (Current.room.id != roomId) {
unreadRoom.classList.add(this.unreadClass)
}
@@ -107,12 +59,6 @@ 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)
}
@@ -1,5 +0,0 @@
class Message::BroadcastUnreadRoomJob < ApplicationJob
def perform(message)
message.broadcast_unread_room
end
end
-8
View File
@@ -1,8 +0,0 @@
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
+1 -2
View File
@@ -9,8 +9,7 @@ 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 :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 :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
scope :visible, -> { where.not(involvement: :invisible) }
scope :unread, -> { where.not(unread_at: nil) }
+4
View File
@@ -24,6 +24,10 @@ class Message < ApplicationRecord
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"
+1 -19
View File
@@ -4,14 +4,9 @@ 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
@@ -35,25 +30,12 @@ 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(:poster).processed
attachment.preview(format: :webp).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
+9 -17
View File
@@ -1,29 +1,21 @@
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
broadcast_unread_room_later
broadcast_unread_room
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
# 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)
# 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
payload = ActiveSupport::JSON.encode(roomId: room.id)
room.memberships.pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
end
end
end
+2 -5
View File
@@ -36,10 +36,7 @@ module Message::Pagination
scope :page_after, ->(message) { after(message).first_page }
scope :page_created_since, ->(time) { where("created_at > ?", time).first_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) }
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
end
class_methods do
@@ -52,7 +49,7 @@ module Message::Pagination
end
def paged?
offset(PAGE_SIZE).exists?
count > PAGE_SIZE
end
end
end
+1 -34
View File
@@ -3,8 +3,7 @@ module Message::Searchable
included do
after_create_commit :create_in_index
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
after_update_commit :update_in_index, if: :indexed_text_changed?
after_update_commit :update_in_index
after_destroy_commit :remove_from_index
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
@@ -18,43 +17,11 @@ module Message::Searchable
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
def create_in_index
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
+1 -12
View File
@@ -5,7 +5,6 @@ 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
@@ -23,14 +22,9 @@ 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, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") do |http|
http.request request_class.new(url) do |response|
if response.is_a?(Net::HTTPRedirection)
url, ip = resolve_redirect(response["location"])
@@ -44,11 +38,6 @@ 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)
+2 -2
View File
@@ -14,9 +14,9 @@ class Push::Subscription < ApplicationRecord
validates :endpoint, presence: true
validate :validate_endpoint_url
def notification(badge: user.memberships.unread.count, **params)
def notification(**params)
# Defer DNS lookup to the delivery worker to prevent rebinding
WebPush::Notification.new(**params, badge: badge, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
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)
end
# Validate at point of use, not just when saved.
-28
View File
@@ -45,34 +45,6 @@ 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)
-3
View File
@@ -53,12 +53,9 @@ 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
+5 -8
View File
@@ -7,15 +7,12 @@ class Rooms::Direct < Room
end
private
# Among the first user's rooms, the one whose members are exactly these users: as many
# memberships as users, and all of them theirs.
# 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.
def find_for(users)
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
all.joins(:users).detect do |room|
Set.new(room.user_ids) == Set.new(users.pluck(:id))
end
end
end
+3 -12
View File
@@ -7,22 +7,13 @@ class Session < ApplicationRecord
before_create { self.last_active_at ||= Time.now }
validate :user_password_unchanged, on: :create
def self.start!(user_agent:, ip_address:)
create! user_agent: user_agent, ip_address: ip_address
end
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
+1 -38
View File
@@ -18,10 +18,8 @@ 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}%") }
@@ -52,43 +50,8 @@ 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
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
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
end
def deactived_email_address
-5
View File
@@ -1,11 +1,6 @@
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
-5
View File
@@ -11,11 +11,6 @@ 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
+2 -9
View File
@@ -3,20 +3,13 @@ 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_by_token_for(:transfer, id)
find_signed(id, purpose: :transfer)
end
end
def transfer_id
generate_token_for(:transfer)
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
end
end
+1 -1
View File
@@ -63,7 +63,7 @@ class Webhook < ApplicationRecord
end
def extract_attachment_from(response)
if response.code == "200" && response.content_type && mime_type = Mime::Type.lookup(response.content_type)
if 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
+6 -5
View File
@@ -76,9 +76,10 @@
<% end %>
<label class="switch">
<%= tag.input type: "checkbox", class: "switch__input",
checked: Current.account.settings.restrict_room_creation_to_administrators?,
data: { action: "change->form#submit" } %>
<input type="checkbox"
class="switch__input"
<%= "checked" if 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
@@ -101,11 +102,11 @@
<turbo-frame id="account_users">
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
<% if @administrators.any? && @page.records.any? %>
<% if @administrators.any? && @members.any? %>
<hr class="separator full-width" style="--border-style: solid">
<% end %>
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
</turbo-frame>
</menu>
@@ -1,13 +0,0 @@
<%# 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>
+2 -7
View File
@@ -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" %>
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
<% else %>
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
<% end %>
</div>
<span class="for-screen-reader" role="alert" aria-atomic="true"><%= notice %></span>
@@ -63,11 +63,6 @@
<%= 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 -1
View File
@@ -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-v7" ] do %>
<% cache [ message, "presentation-v3" ] do %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
-2
View File
@@ -2,8 +2,6 @@
<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">
+18 -16
View File
@@ -1,18 +1,20 @@
<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>
<% 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>
<%= 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>
<%= 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 %>
+1 -1
View File
@@ -2,7 +2,7 @@
<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.loaded? ? message.boosts.sort_by(&:created_at) : message.boosts.ordered.includes(:booster) %>
<%= render partial: "messages/boosts/boost", collection: message.boosts.ordered, cached: true %>
</div>
<%= turbo_frame_tag message, :new_boost do %>
+2 -2
View File
@@ -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>
+3 -3
View File
@@ -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 "external/install-edge.svg", alt: "the app available - install Campfire chat button", size: 16 %></em>in the address bar.</li>
<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>Install</em>.</li>
</ol>
<% when platform.chrome? && platform.android? %>
+2 -2
View File
@@ -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>
+1 -2
View File
@@ -1,2 +1 @@
<%= auto_submit_form_with method: :put do %>
<% end %>
<%= auto_submit_form_with method: :put %>
-10
View File
@@ -69,16 +69,6 @@
</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) %>
+1 -2
View File
@@ -22,6 +22,5 @@ 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
HTTP 200 without transport errors. Measurements exclude Thruster, TLS and gzip. Client
CPU, JIT warmup and GC can affect throughput; repeat runs and check client saturation.
+2 -4
View File
@@ -3,7 +3,6 @@
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.",
@@ -59,10 +58,9 @@ begin
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)
client.measure(path, cookie, concurrency: 1, duration: 3)
concurrencies.each do |concurrency|
results["#{name}_#{concurrency}"] = client.measure(path, cookie, concurrency: concurrency, duration: options[:duration], contract: contract)
results["#{name}_#{concurrency}"] = client.measure(path, cookie, concurrency: concurrency, duration: options[:duration])
end
end
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), results)
+4 -6
View File
@@ -33,12 +33,12 @@ class BenchmarkHTTPClient
cookie_header(cookies)
end
def measure(path, cookie, concurrency:, duration:, contract:)
def measure(path, cookie, concurrency:, duration:)
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 }
result = { latencies: [], statuses: Hash.new(0), bytes: 0, errors: 0 }
while clock < deadline
begin
connection.start do |http|
@@ -48,7 +48,6 @@ class BenchmarkHTTPClient
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
@@ -65,10 +64,9 @@ class BenchmarkHTTPClient
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" ]
raise "#{path}: HTTP statuses #{statuses}, #{errors} transport errors" unless errors.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",
ok: latencies.size, statuses: statuses, errors: errors,
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
-43
View File
@@ -1,43 +0,0 @@
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
-71
View File
@@ -1,71 +0,0 @@
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
+1 -1
View File
@@ -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 ActionDispatch::Executor, ActionDispatch::DebugLocks
config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks
end
-15
View File
@@ -1,20 +1,5 @@
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
+23 -19
View File
@@ -1,21 +1,25 @@
# 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.
# 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
#
# Nothing else is restricted: images, media, fonts, styles (including an
# account's custom styles) and connections behave as before.
# # 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)
#
# 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
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
-8
View File
@@ -6,11 +6,3 @@ 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 -1
View File
@@ -1,2 +1,2 @@
# Used to match JavaScripts (new Date).getTime() for sorting
ActiveSupport::TimeFormats.register(:epoch, ->(time) { (time.to_f * 1000).to_i })
Time::DATE_FORMATS[:epoch] = ->(time) { (time.to_f * 1000).to_i }
@@ -1,34 +0,0 @@
# 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
+33 -22
View File
@@ -1,5 +1,4 @@
require "web-push"
require "web_push/connections"
require "web_push/pool"
require "web_push/notification"
@@ -18,31 +17,43 @@ end
module WebPush::PersistentRequest
def perform
# 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?
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
req = Net::HTTP::Post.new(uri.request_uri, headers)
req.body = body
# 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)
if http.is_a?(Net::HTTP::Persistent)
response = http.request uri, req
else
resp = http.request(req)
verify_response(resp)
end
resp
end
@@ -1,5 +1,5 @@
class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2]
def change
add_index :messages, %i[ room_id created_at ], if_not_exists: true
add_index :messages, %i[ room_id created_at ]
end
end
@@ -1,5 +0,0 @@
class AddRoomIdAndUpdatedAtIndexToMessages < ActiveRecord::Migration[8.2]
def change
add_index :messages, %i[ room_id updated_at ], if_not_exists: true
end
end
Generated
+1 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
ActiveRecord::Schema[8.2].define(version: 2026_10_04_225000) do
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "custom_styles"
@@ -102,7 +102,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
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
@@ -1,27 +0,0 @@
# 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
-120
View File
@@ -1,120 +0,0 @@
# 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 -9
View File
@@ -5,17 +5,13 @@ 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 = WebPush::Connections.new
@connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150)
@invalid_subscription_handler = invalid_subscription_handler
end
def queue(payload, subscriptions)
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
subscriptions.find_each do |subscription|
deliver_later(payload, subscription)
end
end
@@ -26,9 +22,9 @@ class WebPush::Pool
end
private
def deliver_later(payload, subscription, badge:)
def deliver_later(payload, subscription)
# Ensure any AR operations happen before we post to the thread pool
notification = subscription.notification(**payload, badge: badge)
notification = subscription.notification(**payload)
subscription_id = subscription.id
delivery_pool.post do
@@ -18,53 +18,4 @@ 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
-10
View File
@@ -40,16 +40,6 @@ 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
+5 -11
View File
@@ -8,7 +8,7 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
assert subscription.confirmed?
assert_has_stream "user_#{users(:jz).id}_unreads"
assert_not_includes subscription.stream_names, "unread_rooms"
assert_not_includes subscription.streams, "unread_rooms"
end
test "an outsider is not told about activity in a room they can't see" do
@@ -16,9 +16,7 @@ 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
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
direct.messages.create!(body: "Private", creator: users(:kevin), client_message_id: "outsider").broadcast_create
end
direct.messages.create!(body: "Private", creator: users(:kevin), client_message_id: "outsider").broadcast_create
end
assert_empty broadcasts
@@ -26,16 +24,12 @@ 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
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
message = direct.messages.create!(body: "Private", creator: users(:bender), client_message_id: "member")
message.broadcast_create
end
direct.messages.create!(body: "Private", creator: users(:bender), client_message_id: "member").broadcast_create
end
assert_equal [ { "roomId" => direct.id, "at" => message.created_at.to_f } ], broadcasts
assert_equal [ direct.id ], broadcasts.collect { |broadcast| broadcast["roomId"] }
end
private
@@ -43,7 +37,7 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
stub_connection(current_user: user)
subscribe
stream = subscription.stream_names.sole
stream = subscription.streams.sole
before = ActionCable.server.pubsub.broadcasts(stream).size
yield
@@ -10,16 +10,6 @@ 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
@@ -52,19 +42,6 @@ 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?
@@ -81,9 +58,4 @@ 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
@@ -13,15 +13,6 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
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
@@ -39,12 +30,4 @@ 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
-56
View File
@@ -27,62 +27,6 @@ class MessagesCachingTest < ActionDispatch::IntegrationTest
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
+2 -84
View File
@@ -57,89 +57,21 @@ 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
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
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one #{member.id}", client_message_id: member.id } }
end
end
end
test "creating a message 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
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
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
end
end
end
@@ -216,20 +148,6 @@ 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,10 +25,8 @@ class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
sign_in :kevin
assert_difference -> { Room.count }, -1 do
perform_enqueued_jobs do
delete rooms_direct_url(rooms(:david_and_kevin))
assert_redirected_to root_url
end
delete rooms_direct_url(rooms(:david_and_kevin))
assert_redirected_to root_url
end
end
+2 -30
View File
@@ -15,21 +15,6 @@ 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
@@ -83,24 +68,11 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
test "destroy" do
assert_turbo_stream_broadcasts :rooms, count: 1 do
assert_difference -> { Room.count }, -1 do
perform_enqueued_jobs { delete room_url(rooms(:designers)) }
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
@@ -112,7 +84,7 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
rooms(:designers).update! creator: users(:jz)
assert_difference -> { Room.count }, -1 do
perform_enqueued_jobs { delete room_url(rooms(:designers)) }
delete room_url(rooms(:designers))
end
end
@@ -5,10 +5,6 @@ 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
@@ -19,30 +15,4 @@ 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
@@ -36,19 +36,6 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert parsed_cookies.signed[:session_token]
end
test "create is refused when the password changes after it was checked" do
user = User.find(users(:david).id)
User.find(user.id).update!(password: "a new secret123456")
User.stubs(:authenticate_by).returns(user)
assert_no_difference -> { Session.count } do
post session_url, params: { email_address: "david@37signals.com", password: "secret123456" }
end
assert_response :unauthorized
assert_nil parsed_cookies.signed[:session_token]
end
test "create with invalid credentials" do
post session_url, params: { email_address: "david@37signals.com", password: "wrong" }
@@ -75,94 +75,7 @@ class UnfurlLinksControllerTest < ActionDispatch::IntegrationTest
assert_equal "Hey!", JSON.parse(response.body)["title"]
end
test "create gives up on a host that accepts the connection and never answers" do
with_local_host do |url, connections|
stub_const(Opengraph::Fetch, :TIMEOUT, 0.2.seconds) do
assert_unfurls_nothing_within(1.second) { post unfurl_link_url, params: { url: url } }
end
assert_equal 1, connections.size, "the GET is not retried"
end
end
test "create gives up on headers that trickle in past the deadline" do
trickle_headers = ->(client) do
client.write "HTTP/1.1 200 OK\r\nX-Padding: "
100.times { client.write "x"; sleep 0.05 }
client.write "\r\nContent-Type: text/html\r\nContent-Length: 2\r\n\r\nok"
end
with_local_host(trickle_headers) do |url|
stub_const(UnfurlLinksController, :DEADLINE, 0.5.seconds) do
assert_unfurls_nothing_within(1.5.seconds) { post unfurl_link_url, params: { url: url } }
end
end
end
test "create gives up on a body that trickles in past the deadline" do
trickle_body = ->(client) do
client.write "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 100\r\n\r\n"
100.times { client.write "x"; sleep 0.05 }
end
with_local_host(trickle_body) do |url|
stub_const(UnfurlLinksController, :DEADLINE, 0.5.seconds) do
assert_unfurls_nothing_within(1.5.seconds) { post unfurl_link_url, params: { url: url } }
end
end
end
test "create gives up on redirects that keep coming past the deadline" do
stub_dns_resolution("1.2.3.4")
WebMock.stub_request(:get, "https://www.example.com/").to_return do
sleep 0.2
{ status: 302, headers: { location: "https://www.example.com/" } }
end
stub_const(UnfurlLinksController, :DEADLINE, 0.5.seconds) do
assert_unfurls_nothing_within(1.second) { post unfurl_link_url, params: { url: "https://www.example.com" } }
end
end
test "create gives up on a host name that takes too long to look up" do
Resolv.stubs(:getaddresses).with { sleep 2 }.returns([ "1.2.3.4" ]) # each lookup takes 2 s
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: "<html></html>", headers: { content_type: "text/html" })
stub_const(UnfurlLinksController, :DEADLINE, 0.5.seconds) do
assert_unfurls_nothing_within(1.second) { post unfurl_link_url, params: { url: "https://www.example.com" } }
end
end
private
# A real socket, because WebMock reads the whole response before the code under test sees any of it.
# The guard lets the local address through, as it would a public one.
def with_local_host(respond = ->(client) { })
server = TCPServer.new("127.0.0.1", 0)
connections = Queue.new
Thread.new do
loop do
client = server.accept
connections << client
client.readpartial(1024)
respond.call(client)
end
rescue IOError, SystemCallError
end
RestrictedHTTP::PrivateNetworkGuard.stubs(:resolve).returns("127.0.0.1")
WebMock.disable!
yield "http://www.example.com:#{server.addr[1]}/", connections
ensure
WebMock.enable!
server&.close
end
def assert_unfurls_nothing_within(limit)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
assert_response :no_content
assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, :<, limit
end
def stub_successful_request(url: "https://www.example.com/", title: "Hey!", description: "desc..")
WebMock.stub_request(:get, url).to_return(
status: 200,
@@ -11,13 +11,6 @@ class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "show includes install instructions for Edge" do
get user_profile_url, headers: { "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18363" }
assert_response :success
assert_select ".pwa__instructions img[src*='install-edge']"
end
test "update" do
put user_profile_url, params: { user: { name: "John Doe", bio: "Acrobat" } }
@@ -27,190 +20,9 @@ class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
assert_equal "david@37signals.com", users(:david).email_address
end
test "changing the password signs out the account's other devices but not this browser" do
other_device = open_session
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
other_device.get user_profile_url
other_device.assert_response :success
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
other_device.get user_profile_url
other_device.assert_redirected_to new_session_url
get user_profile_url
assert_response :success
assert_equal 1, users(:david).sessions.count
end
test "changing the password signs this browser out when it changes again before its new session starts" do
User.stubs(:uncached).returns(BCrypt::Password.create("someone else's secret", cost: BCrypt::Engine::MIN_COST))
assert_changes -> { users(:david).sessions.count }, to: 0 do
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
end
assert_redirected_to new_session_url
end
test "changing the password gives this browser a new session, so a copy of its cookie stops working" do
copied_cookie = cookies[:session_token]
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_not_equal copied_cookie, cookies[:session_token]
copy = open_session
copy.cookies[:session_token] = copied_cookie
copy.get user_profile_url
copy.assert_redirected_to new_session_url
end
test "changing the password along with the avatar still signs this browser back in and closes live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456", avatar: fixture_file_upload("moon.jpg", "image/jpeg") } }
assert_redirected_to user_profile_url
assert users(:david).reload.avatar.attached?
get user_profile_url
assert_response :success
end
test "changing the password closes the account's live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
end
test "changing the password still signs out other sessions when the realtime service is unreachable" do
other_session = users(:david).sessions.first
ActionCable.server.stubs(:remote_connections).raises(RuntimeError.new("cable down"))
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_not Session.exists?(other_session.id)
end
test "updating the profile without a new password leaves other sessions signed in" do
ActionCable.server.expects(:remote_connections).never
assert_no_difference -> { users(:david).sessions.count } do
put user_profile_url, params: { user: { name: "David", password: "" } }
end
assert_redirected_to user_profile_url
end
test "changing the password with the current one" do
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_equal "✓", flash[:notice]
assert users(:david).reload.authenticate("a new secret123456")
end
test "changing the password without the current one changes nothing" do
assert_password_change_refused "Enter your current password to change it.",
user: { name: "Someone else", password: "a new secret123456" }
end
test "changing the password with a wrong current one changes nothing" do
assert_password_change_refused "That isn't your current password.",
user: { name: "Someone else", password: "a new secret123456", password_challenge: "not my password" }
end
test "a new password needs the current one however the form spells it" do
assert_password_change_refused "Enter your current password to change it.",
user: { name: "Someone else", "password(1)" => "a new secret123456" }
end
test "editing the profile with an empty current password doesn't need it" do
put user_profile_url, params: { user: { name: "Dave", password: "", password_challenge: "" } }
assert_redirected_to user_profile_url
assert_equal "Dave", users(:david).reload.name
end
test "editing the profile without changing the password doesn't need the current one" do
put user_profile_url, params: { user: { name: "Dave", bio: "Acrobat", password: "" } }
assert_redirected_to user_profile_url
assert_equal "Dave", users(:david).reload.name
assert users(:david).authenticate("secret123456")
end
test "attempts to change the password are rate limited like signing in" do
Users::ProfilesController.cache_store.stubs(:increment).returns(11)
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_equal "Too many attempts. Try again in a few minutes.", flash[:alert]
assert users(:david).reload.authenticate("secret123456")
put user_profile_url, params: { user: { password: "a new secret123456" } }
assert_equal "Enter your current password to change it.", flash[:alert]
put user_profile_url, params: { user: { name: "Dave" } }
assert_equal "Dave", users(:david).reload.name
end
test "any current password sent counts toward the rate limit, however blank it looks" do
Users::ProfilesController.cache_store.stubs(:increment).returns(11)
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: " " } }
assert_equal "Too many attempts. Try again in a few minutes.", flash[:alert]
end
test "a current password that isn't text is no current password" do
[ false, [], {}, 0 ].each do |challenge|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: challenge } }, as: :json
assert_redirected_to user_profile_url
assert_equal "Enter your current password to change it.", flash[:alert]
assert users(:david).reload.authenticate("secret123456")
end
end
test "changing the password removes the other devices' push subscriptions, and this browser can register its own again" do
stub_web_push_dns_resolution
endpoint = push_subscriptions(:david_chrome).attributes.slice("endpoint", "p256dh_key", "auth_key")
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_empty users(:david).push_subscriptions
post user_push_subscriptions_url, params: { push_subscription: endpoint }
assert_response :ok
assert_equal [ endpoint["endpoint"] ], users(:david).push_subscriptions.pluck(:endpoint)
end
test "updates are limited to the current user" do
put user_profile_url(users(:jason)), params: { user: { name: "John Doe" } }
assert_equal "Jason", users(:jason).reload.name
end
private
def assert_password_change_refused(alert, params)
other_device = open_session
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
ActionCable.server.expects(:remote_connections).never
assert_no_changes -> { [ Session.count, Push::Subscription.count ] } do
put user_profile_url, params: params
end
assert_redirected_to user_profile_url
assert_equal alert, flash[:alert]
assert users(:david).reload.authenticate("secret123456")
assert_equal "David", users(:david).name
other_device.get user_profile_url
other_device.assert_response :success
end
end
@@ -74,98 +74,4 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to user_push_subscriptions_url
end
end
test "a signed-out session can't register a subscription" do
Session.delete_all
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_redirected_to new_session_url
end
test "a session signed out while its request is in flight doesn't register a subscription" do
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_response :unauthorized
end
test "a session signed out while its request is in flight doesn't keep an existing subscription alive" do
subscription = push_subscriptions(:david_chrome)
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
assert_no_changes -> { subscription.reload.updated_at } do
post user_push_subscriptions_url, params: { push_subscription: subscription.attributes.slice("endpoint", "p256dh_key", "auth_key") }
end
assert_response :unauthorized
end
test "pages name their session for the browser to post its subscription again in a new one" do
get user_profile_url
assert_equal user_push_subscriptions_path, css_select("[data-push-subscription-sync-url-value]").first["data-push-subscription-sync-url-value"]
get room_url(rooms(:watercooler))
first_marker = css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
assert_match(/\A\h{64}\z/, first_marker)
assert_not_includes response.body, users(:david).sessions.order(:id).last.token
sign_in :david
get room_url(rooms(:watercooler))
assert_not_equal first_marker, css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
end
test "two posts of the same new subscription at once register it once" do
Push::Subscription.any_instance.stubs(:valid?).with do
users(:david).push_subscriptions.build(new_subscription_params).save!(validate: false) unless Push::Subscription.exists?(endpoint: new_subscription_params["endpoint"])
true
end.returns(true)
assert_difference -> { Push::Subscription.where(endpoint: new_subscription_params["endpoint"]).count }, +1 do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_response :ok
end
private
def new_subscription_params
{ "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
end
end
class Users::PushSubscriptionsControllerForeignWriteTest < ActionDispatch::IntegrationTest
self.use_transactional_tests = false
setup do
sign_in :david
stub_web_push_dns_resolution
end
test "a subscription another connection registers while this one validates isn't registered twice" do
params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/foreign", "p256dh_key" => "123", "auth_key" => "456" }
Push::Subscription.any_instance.stubs(:valid?).with { foreign_insert(params); true }.returns(true)
post user_push_subscriptions_url, params: { push_subscription: params }
assert_response :ok
assert_equal 1, Push::Subscription.where(endpoint: params["endpoint"]).count
end
private
def foreign_insert(params)
unless @inserted
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
database.execute "INSERT INTO push_subscriptions (user_id, endpoint, p256dh_key, auth_key, created_at, updated_at) " \
"VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))", [ users(:david).id, *params.values ]
end
@inserted = true
end
end
end
@@ -27,25 +27,4 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
get user_sidebar_url
assert_select ".unread", count: users(:david).memberships.reject { |m| m.room.direct? || !m.unread? }.count
end
test "directs are ordered by room recency, not name" do
older = rooms(:david_and_jason)
newer = rooms(:david_and_kevin)
older.update_column :updated_at, 2.days.ago
newer.update_column :updated_at, 1.minute.ago
get user_sidebar_url
assert_operator @response.body.index(dom_id(newer, :list)), :<, @response.body.index(dom_id(older, :list))
end
test "shared rooms stay ordered by name" do
get user_sidebar_url
shared_ids = users(:david).rooms.without_directs.sort_by { |room| room.name.downcase }.map { |room| dom_id(room, :list) }
positions = shared_ids.map { |id| @response.body.index(id) }
assert positions.all?
assert_equal positions.sort, positions
end
end
+1 -1
View File
@@ -1,4 +1,4 @@
<% password_digest = BCrypt::Password.create("secret123456", cost: BCrypt::Engine::MIN_COST) %>
<% password_digest = BCrypt::Password.create("secret123456") %>
david:
name: David
+1 -1
View File
@@ -172,7 +172,7 @@ class ContentFiltersTest < ActionView::TestCase
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body).to_html
assert_dom_equal body, filtered
assert_equal body, filtered
assert_match %r{<table>.*<th><p>Name</p></th>.*<td><p>Jason</p></td>}m, message_presentation(message)
end
-123
View File
@@ -24,127 +24,4 @@ class MessagesHelperTest < ActionView::TestCase
assert_match /<a href="https:\/\/example\.com"[^>]*>example<\/a>/, presentation
assert_match /<strong>bold<\/strong>/, presentation
end
test "message_presentation links URLs and email addresses in text" do
message = Message.create! room: rooms(:pets), body: "<div>See https://example.com/a?b=1&amp;c=2, or write to me@example.com.</div>", client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ "https://example.com/a?b=1&c=2", "mailto:me@example.com" ], links.map { it["href"] }
assert_equal [ "_blank", "_blank" ], links.map { it["target"] }
assert_equal [ "https://example.com/a?b=1&c=2", "me@example.com" ], links.map(&:text)
end
test "message_presentation links an email address with an apostrophe whole" do
message = Message.create! room: rooms(:pets), body: "<div>Write to o'brien@example.com</div>", client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ [ "mailto:o%27brien@example.com", "o'brien@example.com" ] ], links.map { [ it["href"], it.text ] }
end
test "message_presentation leaves URLs and email addresses in attribute values as they are" do
[ "class", "title" ].product([ "https://example.com", "www.example.com", "me@example.com" ]).each do |attribute, linkable|
value = "a> #{linkable} <em>b</em> "
message = Message.create! room: rooms(:pets), body: %(<div><span #{attribute}="#{value}">c</span> https://example.org</div>), client_message_id: "0015", creator: users(:jason)
presentation = Nokogiri::HTML5.fragment(view.message_presentation(message))
assert_equal [ value ], presentation.css("span[#{attribute}]").map { it[attribute] }, "#{attribute} with #{linkable}"
assert_empty presentation.css("em"), "#{attribute} with #{linkable}"
assert_equal [ "https://example.org" ], presentation.css("a").map { it["href"] }, "#{attribute} with #{linkable}"
end
end
test "linking text doesn't eat a code block's leading blank line" do
linked = view.send(:auto_link_text, "<pre>\n\n\nfirst https://example.com</pre>".html_safe)
assert_equal "\nfirst https://example.com", Nokogiri::HTML5.fragment(linked).at_css("pre").text
end
test "message_presentation leaves an existing link's text alone" do
message = Message.create! room: rooms(:pets), body: '<div><a href="https://example.com/">https://example.org/</a></div>', client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ [ "https://example.com/", "https://example.org/" ] ], links.map { [ it["href"], it.text ] }
end
test "message_presentation shows an image's thumbnail made when it was posted" do
presentation = view.message_presentation(attachment_message("moon.jpg", "image/jpeg", processed: true))
assert_match %r{<img[^>]+src="[^"]*/representations/[^"]*moon\.jpg"}, presentation
end
test "message_presentation links an image whose thumbnail wasn't made, rather than making it on view" do
presentation = view.message_presentation(attachment_message("moon.jpg", "image/jpeg", processed: false))
assert_no_match %r{/representations/}, presentation
assert_match %r{<span>moon\.jpg</span>}, presentation
end
test "message_presentation gives a video the poster made when it was posted" do
presentation = view.message_presentation(attachment_message("alpha-centuri.mov", "video/quicktime", processed: true))
assert_match %r{<video[^>]+poster="[^"]*/representations/[^"]*alpha-centuri}, presentation
end
test "message_presentation shows a video whose poster wasn't made without one, rather than making it on view" do
presentation = view.message_presentation(attachment_message("alpha-centuri.mov", "video/quicktime", processed: false))
assert_match %r{<video[^>]+src="[^"]*alpha-centuri\.mov"}, presentation
assert_no_match %r{poster=|/representations/}, presentation
end
test "message_presentation shows a video whose frame was drawn but whose poster wasn't made without one" do
message = attachment_message("alpha-centuri.mov", "video/quicktime", processed: false)
message.attachment.preview(format: :jpg).processed
assert message.attachment.preview(:poster).processed?
presentation = view.message_presentation(message.reload)
assert_match %r{<video[^>]+src="[^"]*alpha-centuri\.mov"}, presentation
assert_no_match %r{poster=|/representations/}, presentation
end
test "message_presentation shows an image in the rich text as a file, rather than making its preview on view" do
message = Message.with_attachment_details.find(message_with_file_in_rich_text("moon.jpg", "image/jpeg").id)
presentation = nil
assert_no_queries_match(/active_storage_variant_records/) { presentation = view.message_presentation(message) }
assert_no_match %r{/representations/}, presentation
assert_match %r{<span class="attachment__name">moon\.jpg</span>}, presentation
end
test "message_presentation shows an image in the rich text as a file even when its preview was made" do
message = message_with_file_in_rich_text("moon.jpg", "image/jpeg")
message.body.embeds.first.blob.variant(resize_to_limit: [ 1024, 768 ]).processed
presentation = view.message_presentation(message.reload)
assert_no_match %r{/representations/}, presentation
assert_match %r{<span class="attachment__name">moon\.jpg</span>}, presentation
end
test "message_presentation shows a video in the rich text as a file" do
presentation = view.message_presentation(message_with_file_in_rich_text("alpha-centuri.mov", "video/quicktime"))
assert_no_match %r{/representations/}, presentation
assert_match %r{<span class="attachment__name">alpha-centuri\.mov</span>}, presentation
end
private
def attachment_message(file, content_type, processed:)
attributes = { creator: users(:jason), client_message_id: "0015", attachment: fixture_file_upload(file, content_type) }
if processed
rooms(:pets).messages.create_with_attachment!(attributes)
else
rooms(:pets).messages.create!(attributes)
end
end
def message_with_file_in_rich_text(file, content_type)
blob = ActiveStorage::Blob.create_and_upload!(io: file_fixture(file).open, filename: file, content_type: content_type)
body = %(<div>Here: <action-text-attachment sgid="#{blob.attachable_sgid}"></action-text-attachment></div>)
Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
end
end
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more