mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
164
Commits
No files matched your search
@@ -21,7 +21,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
@@ -48,6 +48,29 @@ jobs:
|
||||
- name: Lint code for consistent style
|
||||
run: bin/rubocop
|
||||
|
||||
herb:
|
||||
name: Herb check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Check that every HTML template compiles through Herb
|
||||
run: bin/rails herb:check
|
||||
|
||||
lint-actions:
|
||||
name: GitHub Actions audit
|
||||
runs-on: ubuntu-latest
|
||||
@@ -79,7 +102,7 @@ jobs:
|
||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -87,7 +110,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
with:
|
||||
@@ -97,6 +120,9 @@ jobs:
|
||||
- name: Run tests
|
||||
run: bin/rails db:setup test
|
||||
|
||||
- name: Test benchmark response validation
|
||||
run: bundle exec ruby bench/test_http_client.rb
|
||||
|
||||
test_system:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -109,7 +135,7 @@ jobs:
|
||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -117,7 +143,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
with:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.10
|
||||
4.0.7
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.4.10
|
||||
ARG RUBY_VERSION=4.0.7
|
||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
# Rails app lives here
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.4.10
|
||||
ARG RUBY_VERSION=4.0.7
|
||||
FROM ruby:$RUBY_VERSION-slim as base
|
||||
|
||||
# Install the tools we need
|
||||
|
||||
@@ -12,7 +12,7 @@ gem "sqlite3"
|
||||
gem "redis", "~> 5.4"
|
||||
|
||||
# Deployment
|
||||
gem "puma", "~> 7.2", ">= 7.2.1"
|
||||
gem "puma", "~> 8.0"
|
||||
|
||||
# Jobs
|
||||
gem "resque", "~> 2.7.0"
|
||||
@@ -43,7 +43,6 @@ gem "rqrcode"
|
||||
gem "rails_autolink"
|
||||
gem "geared_pagination"
|
||||
gem "jbuilder"
|
||||
gem "net-http-persistent"
|
||||
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
|
||||
gem "kredis"
|
||||
gem "platform_agent"
|
||||
|
||||
+66
-53
@@ -1,6 +1,6 @@
|
||||
GIT
|
||||
remote: https://github.com/basecamp/surfguard.git
|
||||
revision: 59e278c01a537755f22791429051891949231ead
|
||||
revision: 71cac69a592d9ef63ce27600eb26bdf94803a3eb
|
||||
specs:
|
||||
surfguard (0.2.0)
|
||||
|
||||
@@ -23,16 +23,16 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/propshaft.git
|
||||
revision: e49a9de659ff27462015e54dd832e86e762a6ddc
|
||||
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623
|
||||
specs:
|
||||
propshaft (1.2.1)
|
||||
propshaft (1.3.2)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
|
||||
revision: e3d5c569d217c56c022b04acb127a8c24dbe9685
|
||||
branch: main
|
||||
specs:
|
||||
actioncable (8.2.0.alpha)
|
||||
@@ -63,10 +63,10 @@ GIT
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
rails-html-sanitizer (~> 1.7)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.2.0.alpha)
|
||||
action_text-trix (~> 2.1.15)
|
||||
action_text-trix (~> 2.1.16)
|
||||
actionpack (= 8.2.0.alpha)
|
||||
activerecord (= 8.2.0.alpha)
|
||||
activestorage (= 8.2.0.alpha)
|
||||
@@ -77,11 +77,12 @@ GIT
|
||||
activesupport (= 8.2.0.alpha)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
herb (>= 0.10)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
rails-html-sanitizer (~> 1.7)
|
||||
activejob (8.2.0.alpha)
|
||||
activesupport (= 8.2.0.alpha)
|
||||
globalid (>= 0.3.6)
|
||||
globalid (>= 1.4.0)
|
||||
activemodel (8.2.0.alpha)
|
||||
activesupport (= 8.2.0.alpha)
|
||||
activerecord (8.2.0.alpha)
|
||||
@@ -93,7 +94,7 @@ GIT
|
||||
activejob (= 8.2.0.alpha)
|
||||
activerecord (= 8.2.0.alpha)
|
||||
activesupport (= 8.2.0.alpha)
|
||||
marcel (~> 1.0)
|
||||
marcel (~> 2.0)
|
||||
activesupport (8.2.0.alpha)
|
||||
base64
|
||||
bigdecimal
|
||||
@@ -104,6 +105,8 @@ GIT
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
psych (>= 4)
|
||||
ractor-dispatch (>= 0.3.0)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
@@ -142,7 +145,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (3.3.1)
|
||||
bigdecimal (4.1.3)
|
||||
brakeman (8.0.6)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
@@ -159,7 +162,7 @@ GEM
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.7)
|
||||
concurrent-ruby (1.3.8)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -170,9 +173,9 @@ GEM
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
drb (2.2.3)
|
||||
erb (6.0.4)
|
||||
erb (6.0.7)
|
||||
erubi (1.13.1)
|
||||
faker (3.5.2)
|
||||
faker (3.8.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.17.2)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
@@ -182,17 +185,22 @@ GEM
|
||||
geared_pagination (1.2.0)
|
||||
activesupport (>= 5.0)
|
||||
addressable (>= 2.5.0)
|
||||
globalid (1.3.0)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
hashdiff (1.2.0)
|
||||
i18n (1.14.7)
|
||||
herb (0.11.0-aarch64-linux-gnu)
|
||||
herb (0.11.0-arm64-darwin)
|
||||
herb (0.11.0-x86_64-darwin)
|
||||
herb (0.11.0-x86_64-linux-gnu)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.3)
|
||||
io-console (0.9.4)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
@@ -219,28 +227,28 @@ GEM
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.1.0)
|
||||
marcel (2.1.0)
|
||||
matrix (0.4.3)
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.26.2)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
mocha (3.1.0)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
mono_logger (1.1.2)
|
||||
multi_json (1.17.0)
|
||||
mustermann (3.0.4)
|
||||
ruby2_keywords (~> 0.0.1)
|
||||
net-http-persistent (4.0.6)
|
||||
connection_pool (~> 2.2, >= 2.2.4)
|
||||
net-imap (0.6.4.1)
|
||||
net-imap (0.6.7)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
net-protocol (0.4.0)
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-smtp (0.5.2)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
@@ -260,18 +268,18 @@ GEM
|
||||
platform_agent (1.0.1)
|
||||
activesupport (>= 5.2.0)
|
||||
useragent (~> 0.16.3)
|
||||
pp (0.6.3)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
psych (5.2.6)
|
||||
prism (1.9.0)
|
||||
psych (5.5.0)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (7.2.1)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.6)
|
||||
rack (3.2.7)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
@@ -281,8 +289,9 @@ GEM
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
ractor-dispatch (0.3.0)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -295,19 +304,24 @@ GEM
|
||||
activesupport (> 3.1)
|
||||
railties (> 3.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (6.16.1)
|
||||
rake (13.4.2)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.1.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
redis (5.4.1)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.25.2)
|
||||
redis-client (0.30.1)
|
||||
connection_pool
|
||||
redis-namespace (1.11.0)
|
||||
redis (>= 4)
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.6.3)
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
resque (2.7.0)
|
||||
mono_logger (~> 1)
|
||||
@@ -322,29 +336,29 @@ GEM
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.1.0)
|
||||
rubocop (1.80.2)
|
||||
json (~> 2.3)
|
||||
rubocop (1.91.0)
|
||||
json (>= 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parallel (>= 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.46.0, < 2.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.46.0)
|
||||
rubocop-ast (1.50.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.25.0)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.27.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.33.3)
|
||||
rubocop (>= 1.89.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.38.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop (>= 1.89.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rails-omakase (1.1.0)
|
||||
rubocop (>= 1.72)
|
||||
@@ -355,9 +369,9 @@ GEM
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (3.4.0)
|
||||
rubyzip (3.7.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.35.0)
|
||||
selenium-webdriver (4.49.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -382,8 +396,8 @@ GEM
|
||||
sqlite3 (2.9.6-x86_64-linux-gnu)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.8)
|
||||
thor (1.4.0)
|
||||
stringio (3.2.0)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.23-aarch64-linux)
|
||||
thruster (0.1.23-arm64-darwin)
|
||||
thruster (0.1.23-x86_64-darwin)
|
||||
@@ -412,7 +426,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.3)
|
||||
zeitwerk (2.8.3)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -435,11 +449,10 @@ DEPENDENCIES
|
||||
kredis
|
||||
lexxy (~> 0.9.24)
|
||||
mocha
|
||||
net-http-persistent
|
||||
ostruct
|
||||
platform_agent
|
||||
propshaft!
|
||||
puma (~> 7.2, >= 7.2.1)
|
||||
puma (~> 8.0)
|
||||
rails!
|
||||
rails_autolink
|
||||
redis (~> 5.4)
|
||||
|
||||
@@ -49,6 +49,32 @@ If you'd rather run the Docker image yourself, you can read more about that in t
|
||||
> that people have someone to contact if they need help with their account. If that bothers you, put in any
|
||||
> email address you want and create yourself a new admin account.
|
||||
|
||||
Authenticated room, message, sidebar and search pages use a bounded 64 MiB cache per worker.
|
||||
Set `CAMPFIRE_RESPONSE_CACHE_MB=0` to disable it. Every request still checks authentication
|
||||
and room access; commits from any SQLite writer invalidate complete cached HTML and gzip bodies.
|
||||
Native HTML, JSON and stream fragments have a separate 64 MiB memory limit per worker;
|
||||
shared rate limits retain their existing store.
|
||||
|
||||
## Other implementations
|
||||
|
||||
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
|
||||
|
||||
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| Room page | 4,101 | 1,507 | 3,872 | 42,636 | 5,350 | 53,060 | 106,494 | 137,524 |
|
||||
| Messages page | 4,115 | 1,596 | 3,995 | 74,362 | 5,712 | 54,800 | 102,697 | 144,642 |
|
||||
| Sidebar | 4,333 | 1,873 | 4,493 | 94,329 | 5,949 | 59,144 | 120,294 | 152,002 |
|
||||
| Search | 4,282 | 1,862 | 4,172 | 84,665 | 5,848 | 60,509 | 121,378 | 149,487 |
|
||||
| Post a message | 330 | 262 | 794 | 2,155 | 1,278 | 9,021 | 8,037 | 7,530 |
|
||||
| Backend KLOC | 5.5 | 5.3 | 4.1 | 6.8 | 11.6 | 23.2 | 30.7 | 94.8 |
|
||||
|
||||
KLOC counts backend code plus executable code in templates; excludes plain HTML, frontend, tests, dependencies and generated files.
|
||||
|
||||
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
|
||||
|
||||
Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395 with 32 GB RAM,
|
||||
with four hardware cores allocated to each app.
|
||||
|
||||
## Development
|
||||
|
||||
You are welcome - and encouraged - to modify Campfire to your liking.
|
||||
@@ -57,3 +83,7 @@ Please see our [development guide](docs/development.md) for how to get Campfire
|
||||
## Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
|
||||
|
||||
Browser writes use Rails’ `Sec-Fetch-Site` header-only protection and `Origin` check.
|
||||
HTTPS requires browser metadata; plain HTTP retains the missing-header fallback.
|
||||
Forms contain no CSRF tokens. Existing cookies, bot APIs and signed uploads remain compatible.
|
||||
@@ -196,13 +196,14 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
|
||||
color: var(--color-link);
|
||||
}
|
||||
|
||||
ul,
|
||||
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */
|
||||
ul:not(.lexxy-prompt-menu),
|
||||
ol {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
li {
|
||||
li:not(.lexxy-prompt-menu__item) {
|
||||
margin: 0 0 0 var(--inline-space-double) !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@ class PresenceChannel < RoomChannel
|
||||
end
|
||||
|
||||
def broadcast_read_room
|
||||
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
|
||||
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id, at: Time.current.to_f }
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ class Accounts::UsersController < ApplicationController
|
||||
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
|
||||
|
||||
def index
|
||||
set_page_and_extract_portion_from User.active.ordered.without_bots, per_page: 500
|
||||
set_page_and_extract_portion_from User.visible_to(Current.user).ordered.member, per_page: 500
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -3,9 +3,9 @@ class AccountsController < ApplicationController
|
||||
before_action :set_account
|
||||
|
||||
def edit
|
||||
users = account_users.ordered.without_bots
|
||||
@administrators, @members = users.partition(&:administrator?)
|
||||
set_page_and_extract_portion_from users, per_page: 500
|
||||
users = User.visible_to(Current.user).ordered
|
||||
@administrators = users.administrator
|
||||
set_page_and_extract_portion_from users.member, per_page: 500
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -21,12 +21,4 @@ class AccountsController < ApplicationController
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :logo, settings: {})
|
||||
end
|
||||
|
||||
def account_users
|
||||
if Current.user.can_administer?
|
||||
User.where(status: [ :active, :banned ])
|
||||
else
|
||||
User.active
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses
|
||||
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
||||
end
|
||||
@@ -7,7 +7,7 @@ module Authentication
|
||||
before_action :deny_bots
|
||||
helper_method :signed_in?
|
||||
|
||||
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
|
||||
protect_from_forgery using: :header_only, with: :exception, unless: -> { authenticated_by.bot_key? }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
module Authentication::SessionLookup
|
||||
def find_session_by_cookie
|
||||
if token = cookies.signed[:session_token]
|
||||
Session.find_by(token: token)
|
||||
version = @response_cache_version if @response_cache_version && (request.get? || request.head?)
|
||||
session, user = RecordCache.fetch([ "session", Digest::SHA256.hexdigest(token.to_s) ], version) do
|
||||
session = Session.find_by(token: token)
|
||||
[ session, session&.user ]
|
||||
end
|
||||
session&.tap { |record| record.user = user }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
require "zlib"
|
||||
|
||||
module CachedResponses
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
CACHE_HEADERS = %w[ content-type content-encoding cache-control etag last-modified vary ].freeze
|
||||
|
||||
included do
|
||||
prepend_before_action :capture_response_cache_version
|
||||
end
|
||||
|
||||
def perform_caching
|
||||
return false unless super && @response_cache_version.present? && !FragmentCache.transaction_open?
|
||||
|
||||
# Check again after authentication, before the first native fragment lookup.
|
||||
# Later renders retain this immutable namespace and cannot poison a new epoch.
|
||||
unless defined?(@fragment_cache_snapshot_valid)
|
||||
@fragment_cache_snapshot_valid = @response_cache_version == ResponseCache.instance.version
|
||||
end
|
||||
@fragment_cache_snapshot_valid
|
||||
end
|
||||
|
||||
# Keep the class store (including shared rate limits) and Rails.cache unchanged.
|
||||
def cache_store
|
||||
FragmentCache.store
|
||||
end
|
||||
|
||||
def combined_fragment_cache_key(key)
|
||||
@fragment_cache_context ||= [
|
||||
request.base_url, request.script_name, request.format.to_s, I18n.locale,
|
||||
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session)
|
||||
].freeze
|
||||
version = @response_cache_version unless Array(key).flatten.any? { |part| part.is_a?(FragmentCache::ContentKey) }
|
||||
super([ version, @fragment_cache_context, key ])
|
||||
end
|
||||
|
||||
private
|
||||
def read_record_cache_version
|
||||
@response_cache_version if request.get? || request.head?
|
||||
end
|
||||
|
||||
def capture_response_cache_version
|
||||
# Capture for native HTML/JSON/stream renders too, even with page reuse off.
|
||||
# Detached renderers do not run callbacks and therefore render uncached.
|
||||
@response_cache_version = ResponseCache.instance.version
|
||||
end
|
||||
|
||||
# Register after room authorization, but before presentation queries.
|
||||
def cache_read_response
|
||||
if cacheable_read_request?
|
||||
encoding = Rack::Utils.select_best_encoding(%w[ gzip identity ], Rack::Utils.q_values(request.headers["Accept-Encoding"]))
|
||||
return yield unless encoding
|
||||
|
||||
key = response_cache_key(encoding)
|
||||
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
|
||||
|
||||
entry = ResponseCache.instance.read(key, @response_cache_version)
|
||||
rendered = false
|
||||
unless entry
|
||||
ResponseCache.instance.synchronize_render(key, @response_cache_version) do
|
||||
entry = ResponseCache.instance.read(key, @response_cache_version)
|
||||
if !entry && ResponseCache.instance.version == @response_cache_version
|
||||
original_session = session.to_hash.deep_dup
|
||||
yield
|
||||
rendered = true
|
||||
entry = cache_completed_response(key, original_session, encoding)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if entry
|
||||
response.headers.merge!(entry[:headers])
|
||||
response.headers.delete("Content-Length")
|
||||
self.response_body = entry[:body]
|
||||
elsif !rendered
|
||||
# A queued request retains its pre-auth snapshot. If it has expired,
|
||||
# render outside the stripe instead of blocking the next generation.
|
||||
yield
|
||||
end
|
||||
else
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def cache_completed_response(key, original_session, encoding)
|
||||
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session && !response.headers["Content-Encoding"]
|
||||
body = encoding == "gzip" ? Zlib.gzip(response.body) : response.body
|
||||
unless body.empty? || response.headers["ETag"] || response.headers["Last-Modified"]
|
||||
# Match Rack::ETag once, rather than hashing the same bytes on each hit.
|
||||
response.headers["ETag"] = %(W/"#{Digest::SHA256.hexdigest(body).byteslice(0, 32)}")
|
||||
end
|
||||
response.headers["Content-Encoding"] = "gzip" if encoding == "gzip"
|
||||
response.headers["Vary"] = (response.headers["Vary"].to_s.split(/,\s*/) | [ "Accept-Encoding" ]).join(", ")
|
||||
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
|
||||
entry = { body: body.freeze, headers: headers }.freeze
|
||||
ResponseCache.instance.write(key, @response_cache_version, entry)
|
||||
entry
|
||||
end
|
||||
end
|
||||
|
||||
def cacheable_read_request?
|
||||
ResponseCache.instance.budget.positive? && @response_cache_version && request.get? && request.format.html? && Current.session &&
|
||||
!authenticated_by.bot_key? && flash.empty? &&
|
||||
!request.headers["If-None-Match"] && !request.headers["If-Modified-Since"] &&
|
||||
!Rails.application.config.content_security_policy_nonce_generator &&
|
||||
!FragmentCache.transaction_open?
|
||||
end
|
||||
|
||||
def response_cache_key(encoding)
|
||||
ActiveSupport::JSON.encode([
|
||||
controller_path, request.fullpath, request.base_url, request.user_agent, encoding,
|
||||
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
|
||||
# Old token-bearing installation cookies remain valid without affecting HTML.
|
||||
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
|
||||
cookies.to_h.except("_campfire_session", "session_token")
|
||||
])
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,10 @@ module RoomScoped
|
||||
|
||||
private
|
||||
def set_room
|
||||
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
|
||||
@room = @membership.room
|
||||
@membership, @room = RecordCache.fetch([ "membership", Current.user.id, params[:room_id] ], read_record_cache_version) do
|
||||
membership = Current.user.memberships.find_by!(room_id: params[:room_id])
|
||||
[ membership, membership.room ]
|
||||
end
|
||||
@membership.room = @room
|
||||
end
|
||||
end
|
||||
@@ -8,6 +8,6 @@ module SetCurrentRequest
|
||||
end
|
||||
|
||||
def default_url_options
|
||||
{ host: Current.request_host, protocol: Current.request_protocol }.compact_blank
|
||||
{ host: Current.request_host, port: Current.request&.optional_port, protocol: Current.request_protocol }.compact_blank
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,7 @@ class Messages::BoostsController < ApplicationController
|
||||
|
||||
def broadcast_create
|
||||
@boost.broadcast_append_to @boost.message.room, :messages,
|
||||
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
||||
target: helpers.dom_id(@boost.message, :boosts), partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
||||
end
|
||||
|
||||
def broadcast_remove
|
||||
|
||||
@@ -37,7 +37,7 @@ class Messages::ByBotsController < MessagesController
|
||||
end
|
||||
|
||||
def set_pagination_headers
|
||||
headers["X-Total-Count"] = @room.messages.count.to_s
|
||||
headers["X-Total-Count"] = @room.messages_count.to_s
|
||||
|
||||
if next_page = next_page_params
|
||||
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
|
||||
|
||||
@@ -4,6 +4,7 @@ class MessagesController < ApplicationController
|
||||
before_action :set_room, except: :create
|
||||
before_action :set_message, only: %i[ show edit update destroy ]
|
||||
before_action :ensure_can_administer, only: %i[ edit update destroy ]
|
||||
around_action :cache_read_response, only: :index
|
||||
|
||||
layout false, only: :index
|
||||
|
||||
@@ -11,7 +12,13 @@ class MessagesController < ApplicationController
|
||||
@messages = find_paged_messages
|
||||
|
||||
if @messages.any?
|
||||
fresh_when @messages
|
||||
body = render_to_string(:index)
|
||||
# Creator, body and boost edits can change HTML without touching messages.
|
||||
fresh_when etag: Digest::SHA256.hexdigest(body), template: false
|
||||
unless performed?
|
||||
response.content_type = "text/html"
|
||||
self.response_body = body
|
||||
end
|
||||
else
|
||||
head :no_content
|
||||
end
|
||||
@@ -21,7 +28,9 @@ class MessagesController < ApplicationController
|
||||
set_room
|
||||
@message = @room.messages.create_with_attachment!(message_params)
|
||||
|
||||
@message.broadcast_create
|
||||
# Both deliveries contain the same token-free, viewer-independent markup.
|
||||
@message_html = render_to_string partial: "messages/message", formats: :html, locals: { message: @message }
|
||||
@message.broadcast_create(html: @message_html)
|
||||
deliver_webhooks_to_bots
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render action: :room_not_found
|
||||
@@ -62,11 +71,11 @@ class MessagesController < ApplicationController
|
||||
def find_paged_messages
|
||||
case
|
||||
when params[:before].present?
|
||||
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
|
||||
@room.messages.with_presentation.page_before(@room.messages.find(params[:before]))
|
||||
when params[:after].present?
|
||||
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
|
||||
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
|
||||
else
|
||||
@room.messages.with_creator.last_page
|
||||
@room.messages.with_presentation.last_page
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -7,5 +7,7 @@ class QrCodeController < ApplicationController
|
||||
|
||||
expires_in 1.year, public: true
|
||||
render plain: qr_code, content_type: "image/svg+xml"
|
||||
rescue ArgumentError, RQRCodeCore::QRCodeRunTimeError
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
|
||||
before_action :set_last_updated_at
|
||||
|
||||
def show
|
||||
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
|
||||
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
|
||||
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at)
|
||||
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,6 +2,7 @@ class RoomsController < ApplicationController
|
||||
before_action :set_room, only: %i[ show destroy ]
|
||||
before_action :ensure_can_administer, only: %i[ destroy ]
|
||||
before_action :remember_last_room_visited, only: :show
|
||||
around_action :cache_read_response, only: :show
|
||||
|
||||
def index
|
||||
redirect_to room_url(Current.user.rooms.last)
|
||||
@@ -12,7 +13,7 @@ class RoomsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@room.destroy
|
||||
@room.destroy_later
|
||||
|
||||
broadcast_remove_room
|
||||
redirect_to root_url
|
||||
@@ -20,7 +21,10 @@ class RoomsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_room
|
||||
if room = room_scope.find_by(id: params[:room_id] || params[:id])
|
||||
room = RecordCache.fetch([ "room", self.class.name, Current.user.id, params[:room_id] || params[:id] ], read_record_cache_version) do
|
||||
[ room_scope.find_by(id: params[:room_id] || params[:id]) ]
|
||||
end.first
|
||||
if room
|
||||
@room = room
|
||||
else
|
||||
redirect_to root_url, alert: "Room not found or inaccessible"
|
||||
@@ -44,9 +48,9 @@ class RoomsController < ApplicationController
|
||||
end
|
||||
|
||||
def find_messages
|
||||
messages = @room.messages.with_creator.with_attachment_details.with_boosts
|
||||
messages = @room.messages.with_presentation
|
||||
|
||||
if show_first_message = messages.find_by(id: params[:message_id])
|
||||
if params[:message_id].present? && (show_first_message = messages.find_by(id: params[:message_id]))
|
||||
@messages = messages.page_around(show_first_message)
|
||||
else
|
||||
@messages = messages.last_page
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class SearchesController < ApplicationController
|
||||
around_action :cache_read_response, only: :index
|
||||
before_action :set_messages
|
||||
|
||||
def index
|
||||
@@ -20,7 +21,7 @@ class SearchesController < ApplicationController
|
||||
private
|
||||
def set_messages
|
||||
if query.present?
|
||||
@messages = Current.user.reachable_messages.search(query).last(100)
|
||||
@messages = Message.search_reachable(Current.user, query)
|
||||
else
|
||||
@messages = Message.none
|
||||
end
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
class UnfurlLinksController < ApplicationController
|
||||
def create
|
||||
opengraph = Opengraph::Metadata.from_url(url_param)
|
||||
# The pasted link decides which hosts an unfurl looks up and fetches from, and how quickly they answer.
|
||||
DEADLINE = 10.seconds
|
||||
|
||||
if opengraph.valid?
|
||||
def create
|
||||
if opengraph = unfurl(url_param)
|
||||
render json: opengraph
|
||||
else
|
||||
head :no_content
|
||||
@@ -13,4 +14,14 @@ class UnfurlLinksController < ApplicationController
|
||||
def url_param
|
||||
params.require(:url)
|
||||
end
|
||||
|
||||
# One deadline for everything the link leads to: DNS lookups, redirects, the page, the image check.
|
||||
def unfurl(url)
|
||||
Timeout.timeout(DEADLINE) do
|
||||
Opengraph::Metadata.from_url(url).then { |opengraph| opengraph if opengraph.valid? }
|
||||
end
|
||||
rescue Timeout::Error
|
||||
Rails.logger.warn "Gave up unfurling #{url} after #{DEADLINE.inspect}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -1,19 +1,16 @@
|
||||
class Users::SidebarsController < ApplicationController
|
||||
DIRECT_PLACEHOLDERS = 20
|
||||
around_action :cache_read_response, only: :show
|
||||
|
||||
def show
|
||||
all_memberships = Current.user.memberships.visible.with_ordered_room
|
||||
@direct_memberships = extract_direct_memberships(all_memberships)
|
||||
@other_memberships = all_memberships.without(@direct_memberships)
|
||||
visible_memberships = Current.user.memberships.visible
|
||||
@direct_memberships = visible_memberships.with_direct_rooms
|
||||
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms
|
||||
|
||||
@direct_placeholder_users = find_direct_placeholder_users
|
||||
end
|
||||
|
||||
private
|
||||
def extract_direct_memberships(all_memberships)
|
||||
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
|
||||
end
|
||||
|
||||
def find_direct_placeholder_users
|
||||
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
|
||||
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
|
||||
|
||||
@@ -36,6 +36,11 @@ module ApplicationHelper
|
||||
end
|
||||
|
||||
private
|
||||
# Header-only forgery protection needs no secret in forms or cached HTML.
|
||||
def token_tag(*)
|
||||
""
|
||||
end
|
||||
|
||||
def admin_body_class
|
||||
"admin" if Current.user&.can_administer?
|
||||
end
|
||||
|
||||
@@ -17,11 +17,16 @@ class Messages::AttachmentPresentation
|
||||
attr_reader :message, :context
|
||||
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
|
||||
|
||||
# Previews are made when the message is posted, within limits. One that wasn't, because the file was too large or
|
||||
# the preview failed, isn't attempted again from here: its URL would make it on every view.
|
||||
def render_preview
|
||||
if message.attachment.video?
|
||||
case
|
||||
when message.attachment.video?
|
||||
video_preview_tag
|
||||
else
|
||||
when message.attachment.representation(:thumb).processed?
|
||||
lightboxed_image_preview_tag
|
||||
else
|
||||
render_link
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,11 +35,17 @@ class Messages::AttachmentPresentation
|
||||
|
||||
inline_media_dimension_constraints(width, height) do
|
||||
tag.video \
|
||||
src: rails_blob_path(message.attachment), poster: url_for(message.attachment.preview(format: :webp, resize_to_limit: [ Message::THUMBNAIL_MAX_WIDTH, Message::THUMBNAIL_MAX_HEIGHT ])),
|
||||
src: rails_blob_path(message.attachment), poster: video_poster_url,
|
||||
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
|
||||
end
|
||||
end
|
||||
|
||||
# A preview is processed once the frame is drawn, but the poster is a variant of that frame, which can fail on its own.
|
||||
def video_poster_url
|
||||
poster = message.attachment.preview(:poster)
|
||||
url_for(poster) if poster.processed? && poster.image.variant(poster.variation).processed?
|
||||
end
|
||||
|
||||
def lightboxed_image_preview_tag
|
||||
width, height = preview_dimensions
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ module MessagesHelper
|
||||
controller: "reply",
|
||||
user_id: message.creator_id,
|
||||
message_id: message.id,
|
||||
client_message_id: message.client_message_id,
|
||||
message_timestamp: message_timestamp_milliseconds,
|
||||
message_updated_at: message.updated_at.to_fs(:epoch),
|
||||
sort_value: message_timestamp_milliseconds,
|
||||
@@ -62,8 +63,7 @@ module MessagesHelper
|
||||
when "sound"
|
||||
message_sound_presentation(message)
|
||||
else
|
||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
|
||||
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
|
||||
text_message_presentation(message.body.body)
|
||||
end
|
||||
rescue Exception => e
|
||||
Sentry.capture_exception(e, extra: { message: message })
|
||||
@@ -72,7 +72,50 @@ module MessagesHelper
|
||||
""
|
||||
end
|
||||
|
||||
# These controls contain URLs and static markup, but no viewer or token state.
|
||||
# Keep them across database commits; attachment controls still render afresh.
|
||||
def cache_message_actions(message, &block)
|
||||
return capture(&block) unless controller.perform_caching && !message.attachment?
|
||||
|
||||
key = fragment_name_with_digest([
|
||||
"message-actions-v1", request.base_url, request.script_name, I18n.locale, message.id, message.room_id
|
||||
], nil)
|
||||
FragmentCache.store.fetch(key) { capture(&block) }
|
||||
end
|
||||
|
||||
def message_fragment_cache_key(message)
|
||||
return message unless controller.perform_caching &&
|
||||
%i[room creator rich_text_body boosts attachment_attachment].all? { |name| message.association(name).loaded? }
|
||||
|
||||
room = message.room
|
||||
body = message.body.body
|
||||
return message if room.direct? || message.attachment? || !body || body.to_html.include?("<action-text-attachment") ||
|
||||
message.boosts.any? { |boost| !boost.association(:booster).loaded? }
|
||||
|
||||
# Collection caching preloads these rows before deriving keys. Read their
|
||||
# actual content: foreign SQL writers need not touch Rails timestamps.
|
||||
dependencies = [ message.attributes, room.attributes.slice("id", "type", "name"), message.creator.attributes,
|
||||
body.to_html, message.boosts.sort_by(&:created_at).map { |boost| [ boost.attributes, boost.booster.attributes ] } ]
|
||||
FragmentCache::ContentKey.new(Digest::SHA256.hexdigest(ActiveSupport::JSON.encode(dependencies)))
|
||||
end
|
||||
|
||||
private
|
||||
def text_message_presentation(body)
|
||||
render = -> do
|
||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(body)),
|
||||
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
|
||||
end
|
||||
|
||||
# Embedded attachments render database-backed metadata and signed URLs.
|
||||
# Plain text HTML depends only on its content, even after a foreign edit.
|
||||
html = body.to_html
|
||||
if controller.perform_caching && !html.include?("<action-text-attachment") && html.bytesize <= ResponseCache::MAX_ENTRY_BYTES
|
||||
FragmentCache.store.fetch([ "text-presentation-v1", I18n.locale, Digest::SHA256.hexdigest(html) ]) { render.call }
|
||||
else
|
||||
render.call
|
||||
end
|
||||
end
|
||||
|
||||
def messages_actions
|
||||
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
||||
end
|
||||
|
||||
@@ -46,6 +46,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
messageTargetConnected(target) {
|
||||
this.#reconcilePendingMessage(target)
|
||||
this.#formatter.format(target, ThreadStyle.thread)
|
||||
}
|
||||
|
||||
@@ -125,6 +126,25 @@ export default class extends Controller {
|
||||
|
||||
// Internal
|
||||
|
||||
// The composer inserts a pending message client-side using the client_message_id
|
||||
// as its DOM id. The server broadcast now renders the real message with a DOM id
|
||||
// derived from the record's primary key, so it no longer replaces the pending
|
||||
// element by id. Reconcile by matching the server message's client_message_id and
|
||||
// dropping our own pending placeholder. Only elements flagged data-pending-message
|
||||
// (client-side placeholders, never broadcast) are removed, so a message another
|
||||
// member posted reusing this client_message_id can never displace a real one.
|
||||
#reconcilePendingMessage(target) {
|
||||
const clientMessageId = target.dataset.clientMessageId
|
||||
|
||||
if (target.dataset.messageId && clientMessageId) {
|
||||
const selector = `[data-pending-message][data-client-message-id="${CSS.escape(clientMessageId)}"]`
|
||||
|
||||
this.messagesTarget.querySelectorAll(selector).forEach((pending) => {
|
||||
if (pending !== target) pending.remove()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async #ensureUpToDate() {
|
||||
if (!this.#paginator.upToDate) {
|
||||
await this.#paginator.resetToLastPage()
|
||||
|
||||
@@ -16,7 +16,7 @@ export default class extends Controller {
|
||||
})
|
||||
}
|
||||
|
||||
#read = ({ room_id }) => {
|
||||
this.dispatch("read", { detail: { roomId: room_id } })
|
||||
#read = ({ room_id, at }) => {
|
||||
this.dispatch("read", { detail: { roomId: room_id, at } })
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,13 @@ export default class extends Controller {
|
||||
static classes = [ "unread" ]
|
||||
|
||||
#disconnected = true
|
||||
#connection = 0
|
||||
#discardDirectEditor = false
|
||||
#readAt = new Map()
|
||||
|
||||
async connect() {
|
||||
this.element.addEventListener("turbo:click", this.#directNavigation)
|
||||
this.element.addEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
|
||||
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
|
||||
connected: this.#channelConnected.bind(this),
|
||||
disconnected: this.#channelDisconnected.bind(this),
|
||||
@@ -17,7 +22,11 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("turbo:click", this.#directNavigation)
|
||||
this.element.removeEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
|
||||
this.#discardDirectEditor = false
|
||||
ignoringBriefDisconnects(this.element, () => {
|
||||
this.#channelDisconnected()
|
||||
this.channel?.unsubscribe()
|
||||
this.channel = null
|
||||
})
|
||||
@@ -27,19 +36,58 @@ export default class extends Controller {
|
||||
this.read({ detail: { roomId: Current.room.id } })
|
||||
}
|
||||
|
||||
read({ detail: { roomId } }) {
|
||||
read({ detail: { roomId, at } }) {
|
||||
const room = this.#findRoomTarget(roomId)
|
||||
|
||||
if (at) {
|
||||
this.#readAt.set(Number(roomId), Math.max(Number(at), this.#readAt.get(Number(roomId)) ?? 0))
|
||||
}
|
||||
|
||||
if (room) {
|
||||
room.classList.remove(this.unreadClass)
|
||||
this.dispatch("read", { detail: { targetId: roomId } })
|
||||
}
|
||||
}
|
||||
|
||||
#channelConnected() {
|
||||
// Preserve the live picker through background refreshes, but allow deliberate navigation.
|
||||
#directNavigation = event => {
|
||||
if (event.target.closest("a")?.dataset.turboFrame === this.element.id) {
|
||||
this.#discardDirectEditor = true
|
||||
}
|
||||
}
|
||||
|
||||
#preserveDirectEditor = event => {
|
||||
if (event.target !== this.element) return
|
||||
const discard = this.#discardDirectEditor
|
||||
this.#discardDirectEditor = false
|
||||
if (discard) return
|
||||
|
||||
const current = this.element.querySelector("#direct_rooms_control")
|
||||
const incoming = event.detail.newFrame.querySelector("#direct_rooms_control")
|
||||
if (!current?.querySelector('[data-autocomplete-target="input"]') || !incoming) return
|
||||
|
||||
// Turbo's Bardo moves the actual element, keeping input, selections and controllers.
|
||||
const controls = [ current, incoming ].map(element => [ element, element.hasAttribute("data-turbo-permanent") ])
|
||||
for (const [ element ] of controls) element.setAttribute("data-turbo-permanent", "")
|
||||
const render = event.detail.render
|
||||
event.detail.render = (...args) => {
|
||||
try {
|
||||
return render(...args)
|
||||
} finally {
|
||||
for (const [ element, permanent ] of controls) element.toggleAttribute("data-turbo-permanent", permanent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #channelConnected() {
|
||||
if (this.#disconnected) {
|
||||
this.#disconnected = false
|
||||
this.element.reload()
|
||||
const connection = ++this.#connection
|
||||
// Reloading an unfinished frame aborts its response body reader.
|
||||
await this.element.loaded
|
||||
if (this.element.isConnected && !this.#disconnected && connection === this.#connection) {
|
||||
this.element.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +95,11 @@ export default class extends Controller {
|
||||
this.#disconnected = true
|
||||
}
|
||||
|
||||
#unread({ roomId }) {
|
||||
#unread({ roomId, at }) {
|
||||
const unreadRoom = this.#findRoomTarget(roomId)
|
||||
|
||||
if (unreadRoom) {
|
||||
if (Current.room.id != roomId) {
|
||||
if (Current.room.id != roomId && !this.#readSince(roomId, at)) {
|
||||
unreadRoom.classList.add(this.unreadClass)
|
||||
}
|
||||
|
||||
@@ -59,6 +107,12 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// Notices fan out one member at a time, so one can arrive after the member has already
|
||||
// read the room in another tab. It still reorders the room, but doesn't mark it unread.
|
||||
#readSince(roomId, at) {
|
||||
return Number(at) <= this.#readAt.get(Number(roomId))
|
||||
}
|
||||
|
||||
#findRoomTarget(roomId) {
|
||||
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
|
||||
|
||||
#fetchAutocompletables(url) {
|
||||
if (url) {
|
||||
return fetch(url, { as: "json" }).then(response => response.json())
|
||||
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ export default class FileUploader {
|
||||
|
||||
const req = new XMLHttpRequest()
|
||||
req.open("POST", this.url)
|
||||
req.setRequestHeader("X-CSRF-Token", document.querySelector("meta[name=csrf-token]").content)
|
||||
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
|
||||
|
||||
const result = new Promise((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class Message::BroadcastUnreadRoomJob < ApplicationJob
|
||||
def perform(message)
|
||||
message.broadcast_unread_room
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class Room::DestroyJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(room, former_member_ids)
|
||||
room.reset_remote_connections_of(former_member_ids)
|
||||
room.destroy_one_message_at_a_time
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
# Native view caches share one byte budget across every database generation.
|
||||
class FragmentCache
|
||||
# Only callers that fingerprint every rendered dependency may omit the epoch.
|
||||
ContentKey = Data.define(:digest) do
|
||||
def cache_key
|
||||
digest
|
||||
end
|
||||
end
|
||||
|
||||
STORE = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes)
|
||||
private_constant :STORE
|
||||
|
||||
def self.store
|
||||
STORE
|
||||
end
|
||||
|
||||
def self.transaction_open?
|
||||
ActiveRecord::Base.connection_pool.with_connection(&:transaction_open?)
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,8 @@ class Membership < ApplicationRecord
|
||||
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
|
||||
|
||||
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
|
||||
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
|
||||
scope :with_direct_rooms, -> { includes(:room).joins(:room).merge(Room.directs).order(rooms: { updated_at: :desc }) }
|
||||
scope :without_direct_rooms, -> { joins(:room).merge(Room.without_directs) }
|
||||
|
||||
scope :visible, -> { where.not(involvement: :invisible) }
|
||||
scope :unread, -> { where.not(unread_at: nil) }
|
||||
|
||||
+21
-9
@@ -9,23 +9,29 @@ class Message < ApplicationRecord
|
||||
has_rich_text :body
|
||||
|
||||
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
|
||||
after_create_commit -> { room.receive(self) }
|
||||
# Run after Action Text and Active Storage autosave, while creation is still atomic.
|
||||
after_save :record_creation, if: :previously_new_record?
|
||||
after_create_commit -> { room.push_later(self) }
|
||||
|
||||
scope :ordered, -> { order(:created_at) }
|
||||
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
||||
scope :with_attachment_details, -> {
|
||||
with_rich_text_body_and_embeds
|
||||
with_attached_attachment
|
||||
with_rich_text_body_and_embeds.with_attached_attachment
|
||||
.includes(attachment_blob: :variant_records)
|
||||
}
|
||||
scope :with_boosts, -> { includes(boosts: :booster) }
|
||||
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
|
||||
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
|
||||
|
||||
def plain_text_body
|
||||
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
||||
end
|
||||
|
||||
def to_key
|
||||
[ client_message_id ]
|
||||
content = body.body
|
||||
text = if content && content.fragment.find_all(ActionText::Attachment.tag_name).empty?
|
||||
# ActionText::Content#to_plain_text duplicates the fragment to replace
|
||||
# attachments. Without them, reuse its native Fragment conversion instead.
|
||||
content.fragment.to_plain_text.dup
|
||||
else
|
||||
body.to_plain_text
|
||||
end
|
||||
text.presence || attachment&.filename&.to_s || ""
|
||||
end
|
||||
|
||||
def content_type
|
||||
@@ -41,4 +47,10 @@ class Message < ApplicationRecord
|
||||
Sound.find_by_name match[:name]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def record_creation
|
||||
create_in_index
|
||||
room.unread_memberships(self)
|
||||
end
|
||||
end
|
||||
@@ -4,9 +4,14 @@ module Message::Attachment
|
||||
THUMBNAIL_MAX_WIDTH = 1200
|
||||
THUMBNAIL_MAX_HEIGHT = 800
|
||||
|
||||
# Decoding a picture, or a video's frame, costs in proportion to its pixels, however small the file. The largest
|
||||
# phone photos have 200 million.
|
||||
THUMBNAIL_MAX_PIXELS = 250_000_000
|
||||
|
||||
included do
|
||||
has_one_attached :attachment do |attachable|
|
||||
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
||||
attachable.variant :poster, format: :webp, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,12 +35,25 @@ module Message::Attachment
|
||||
attachment&.analyze
|
||||
end
|
||||
|
||||
# A file that ffmpeg or libvips can't decode is still the message: post it without a preview.
|
||||
def process_attachment_thumbnail
|
||||
return if too_many_pixels_to_preview?
|
||||
|
||||
case
|
||||
when attachment.video?
|
||||
attachment.preview(format: :webp).processed
|
||||
attachment.preview(:poster).processed
|
||||
when attachment.representable?
|
||||
attachment.representation(:thumb).processed
|
||||
end
|
||||
rescue ActiveStorage::PreviewError, Vips::Error => error
|
||||
Rails.logger.warn "Posted #{attachment.filename} without a preview: #{error.class}: #{error.message.lines.first&.chomp}"
|
||||
end
|
||||
|
||||
# Without a size, the analyzer couldn't read the file's header, and the previewer wouldn't either.
|
||||
def too_many_pixels_to_preview?
|
||||
if attachment.image? || attachment.video?
|
||||
width, height = attachment.metadata.values_at(:width, :height)
|
||||
width.nil? || height.nil? || width * height > THUMBNAIL_MAX_PIXELS
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,19 +1,29 @@
|
||||
module Message::Broadcasts
|
||||
def broadcast_create
|
||||
broadcast_append_to room, :messages, target: [ room, :messages ]
|
||||
broadcast_unread_room
|
||||
def broadcast_create(html: nil)
|
||||
broadcast_append_to room, :messages, target: [ room, :messages ], **(html ? { html: html } : {})
|
||||
broadcast_unread_room_later
|
||||
end
|
||||
|
||||
def broadcast_remove
|
||||
broadcast_remove_to room, :messages
|
||||
end
|
||||
|
||||
# Fanned out to the room's members rather than published on one global stream, so
|
||||
# that the timing of activity in a room only reaches people who are in it. Of those,
|
||||
# only the ones who still have it unread or are in it now: by the time this runs,
|
||||
# someone may have opened the room and moved on, and would see it marked unread again.
|
||||
def broadcast_unread_room
|
||||
payload = ActiveSupport::JSON.encode(roomId: room.id, at: created_at.to_f)
|
||||
|
||||
room.memberships.unread.or(room.memberships.connected).pluck(:user_id).each do |user_id|
|
||||
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Fanned out to the room's members rather than published on one global stream, so
|
||||
# that the timing of activity in a room only reaches people who are in it.
|
||||
def broadcast_unread_room
|
||||
room.memberships.pluck(:user_id).each do |user_id|
|
||||
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
|
||||
end
|
||||
# The fanout is one publish per member, which in a big room takes far longer than the
|
||||
# rest of posting a message, so it runs in a job rather than while the poster waits.
|
||||
def broadcast_unread_room_later
|
||||
Message::BroadcastUnreadRoomJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,8 @@ module Message::Mentionee
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def mentionees
|
||||
room.users.where(id: mentioned_users.map(&:id))
|
||||
ids = mentioned_users.map(&:id)
|
||||
ids.empty? ? User.none : room.users.where(id: ids)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -3,9 +3,31 @@ module Message::Pagination
|
||||
|
||||
PAGE_SIZE = 40
|
||||
|
||||
# Expose Rails' collection-preloading hook while preserving Array pagination and
|
||||
# validators. The renderer passes only cache misses to preload_associations.
|
||||
class Page < Array
|
||||
def self.load(relation, direction, size)
|
||||
new(relation.skip_preloading!.public_send(direction, size), relation)
|
||||
end
|
||||
|
||||
def initialize(records, relation)
|
||||
super(records)
|
||||
@relation = relation
|
||||
end
|
||||
|
||||
def loaded?
|
||||
true
|
||||
end
|
||||
|
||||
def preload_associations(records)
|
||||
@relation.preload_associations(records)
|
||||
end
|
||||
end
|
||||
|
||||
included do
|
||||
scope :last_page, -> { ordered.last(PAGE_SIZE) }
|
||||
scope :first_page, -> { ordered.first(PAGE_SIZE) }
|
||||
# Keep presentation data lazy until the collection cache knows which messages missed.
|
||||
scope :last_page, -> { last_page_of(PAGE_SIZE) }
|
||||
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
|
||||
|
||||
scope :before, ->(message) { where("created_at < ?", message.created_at) }
|
||||
scope :after, ->(message) { where("created_at > ?", message.created_at) }
|
||||
@@ -14,16 +36,23 @@ module Message::Pagination
|
||||
scope :page_after, ->(message) { after(message).first_page }
|
||||
|
||||
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
|
||||
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
|
||||
# Sorting on +created_at, which no index covers, has SQLite find the few messages updated since
|
||||
# through (room_id, updated_at) and sort just those, instead of walking the whole room by
|
||||
# (room_id, created_at) looking for them.
|
||||
scope :page_updated_since, ->(time) { Page.load(where("updated_at > ?", time).reorder(Arel.sql("+messages.created_at")), :last, PAGE_SIZE) }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def last_page_of(size)
|
||||
Page.load(ordered, :last, size)
|
||||
end
|
||||
|
||||
def page_around(message)
|
||||
page_before(message) + [ message ] + page_after(message)
|
||||
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
|
||||
end
|
||||
|
||||
def paged?
|
||||
count > PAGE_SIZE
|
||||
offset(PAGE_SIZE).exists?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,11 +2,45 @@ module Message::Searchable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_create_commit :create_in_index
|
||||
after_update_commit :update_in_index
|
||||
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
|
||||
after_update_commit :update_in_index, if: :indexed_text_changed?
|
||||
after_destroy_commit :remove_from_index
|
||||
|
||||
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
|
||||
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
# Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than
|
||||
# parsing them as operators, which fails on a query like "AND" or "salt AND".
|
||||
def match_terms(query)
|
||||
query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ")
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def search_reachable(user, query, size: 100)
|
||||
relation = user.reachable_messages.search(query).with_presentation
|
||||
probe = connection.select_rows(sanitize_sql([ <<~SQL, user.id, match_terms(query) ]))
|
||||
SELECT m.id, mm.user_id IS NOT NULL FROM message_search_index idx
|
||||
JOIN messages m ON m.id = idx.rowid
|
||||
LEFT JOIN memberships mm ON mm.room_id = m.room_id AND mm.user_id = ?
|
||||
WHERE idx.body MATCH ? ORDER BY idx.rowid DESC LIMIT 1000
|
||||
SQL
|
||||
ids = probe.filter_map { |id, reachable| id if reachable == 1 }.first(size)
|
||||
|
||||
# A scoped fallback preserves older reachable hits behind a large private history.
|
||||
if ids.size < size && probe.size == 1000
|
||||
relation.last_page_of_matches(size)
|
||||
else
|
||||
Message::Pagination::Page.load(relation.where(id: ids).reorder(:id), :last, size)
|
||||
end
|
||||
end
|
||||
|
||||
# Orders by the index's rowid, which is the message id, so SQLite walks the full-text index
|
||||
# newest first and stops at the page. Ordering by created_at sorted every match before paging.
|
||||
def last_page_of_matches(size)
|
||||
Message::Pagination::Page.load(reorder("idx.rowid"), :last, size)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -14,6 +48,12 @@ module Message::Searchable
|
||||
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
|
||||
end
|
||||
|
||||
# Boosts touch their message too: rewrite the index only when the body was saved or the attachment replaced.
|
||||
# Active Storage clears attachment_changes in its own after_commit, before this one runs.
|
||||
def indexed_text_changed?
|
||||
association(:rich_text_body).target&.saved_change_to_body? || @attachment_replaced
|
||||
end
|
||||
|
||||
def update_in_index
|
||||
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@ class Opengraph::Fetch
|
||||
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
|
||||
MAX_BODY_SIZE = 5.megabytes
|
||||
MAX_REDIRECTS = 10
|
||||
TIMEOUT = 7.seconds # to connect (TLS included), and for each read and write, as Webhook does
|
||||
|
||||
class TooManyRedirectsError < StandardError; end
|
||||
class RedirectDeniedError < StandardError; end
|
||||
@@ -22,9 +23,14 @@ class Opengraph::Fetch
|
||||
end
|
||||
|
||||
private
|
||||
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
|
||||
# would no longer go to the checked IP.
|
||||
#
|
||||
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
|
||||
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
|
||||
def request(url, request_class, ip:)
|
||||
MAX_REDIRECTS.times do
|
||||
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") do |http|
|
||||
Net::HTTP.start(url.host, url.port, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
|
||||
http.request request_class.new(url) do |response|
|
||||
if response.is_a?(Net::HTTPRedirection)
|
||||
url, ip = resolve_redirect(response["location"])
|
||||
@@ -38,6 +44,11 @@ class Opengraph::Fetch
|
||||
raise TooManyRedirectsError
|
||||
end
|
||||
|
||||
# Without max_retries: 0, Net::HTTP sends a GET or HEAD that timed out a second time.
|
||||
def timeouts
|
||||
{ open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT, max_retries: 0 }
|
||||
end
|
||||
|
||||
def resolve_redirect(location)
|
||||
url = URI.parse(location)
|
||||
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
|
||||
|
||||
@@ -14,9 +14,9 @@ class Push::Subscription < ApplicationRecord
|
||||
validates :endpoint, presence: true
|
||||
validate :validate_endpoint_url
|
||||
|
||||
def notification(**params)
|
||||
def notification(badge: user.memberships.unread.count, **params)
|
||||
# Defer DNS lookup to the delivery worker to prevent rebinding
|
||||
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
|
||||
WebPush::Notification.new(**params, badge: badge, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
|
||||
end
|
||||
|
||||
# Validate at point of use, not just when saved.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Rebuild request-local models from bounded, immutable database snapshots.
|
||||
# The observer must still see the captured epoch after lookup and admission.
|
||||
class RecordCache
|
||||
def self.fetch(key, version)
|
||||
return yield unless version && ResponseCache.instance.budget.positive? && !FragmentCache.transaction_open?
|
||||
|
||||
key = ActiveSupport::Cache.expand_cache_key([ "record-snapshot-v1", version, key ])
|
||||
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
|
||||
|
||||
if snapshot = FragmentCache.store.read(key)
|
||||
if ResponseCache.instance.version == version
|
||||
return ActiveSupport::JSON.decode(snapshot).map { |name, attributes| name.constantize.instantiate(attributes) }
|
||||
end
|
||||
end
|
||||
|
||||
records = yield
|
||||
if records.all? { |record| record&.persisted? && !record.changed? } && ResponseCache.instance.version == version
|
||||
# Raw database values retain timestamp precision and native enum binding.
|
||||
snapshot = ActiveSupport::JSON.encode(records.map { |record| [ record.class.name, record.attributes_before_type_cast ] })
|
||||
FragmentCache.store.write(key, snapshot) if snapshot.bytesize <= ResponseCache::MAX_ENTRY_BYTES
|
||||
end
|
||||
records
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
require "sqlite3"
|
||||
|
||||
# A read-only observer sees commits from every writer, including other runtimes.
|
||||
# PRAGMA data_version can only be compared on the same persistent connection.
|
||||
class ResponseCache
|
||||
MAX_ENTRY_BYTES = 1.megabyte
|
||||
MAX_KEY_BYTES = 2.kilobytes
|
||||
|
||||
def self.instance
|
||||
INSTANCE
|
||||
end
|
||||
|
||||
def initialize
|
||||
@mutex = Mutex.new
|
||||
@entries = {}
|
||||
@bytes = 0
|
||||
@render_locks = Array.new(16) { Mutex.new }
|
||||
end
|
||||
|
||||
def budget
|
||||
[ ENV.fetch("CAMPFIRE_RESPONSE_CACHE_MB", "64").to_i, 0 ].max.megabytes
|
||||
end
|
||||
|
||||
def version
|
||||
@mutex.synchronize { current_version }
|
||||
rescue SQLite3::Exception
|
||||
clear
|
||||
nil
|
||||
end
|
||||
|
||||
def read(key, version)
|
||||
@mutex.synchronize do
|
||||
@entries[key]&.first if current_version == version
|
||||
end
|
||||
rescue SQLite3::Exception
|
||||
clear
|
||||
nil
|
||||
end
|
||||
|
||||
# Collapse cold renders without retaining a mutex for every viewer or URL.
|
||||
# Rendering must never hold the observer/entry mutex: controllers read it too.
|
||||
def synchronize_render(key, version, &block)
|
||||
@render_locks[[ key, version ].hash % @render_locks.length].synchronize(&block)
|
||||
end
|
||||
|
||||
def write(key, version, entry)
|
||||
size = key.bytesize + entry[:body].bytesize + entry[:headers].sum { |name, value| name.bytesize + value.bytesize } + 256
|
||||
return if key.bytesize > MAX_KEY_BYTES || size > [ budget, MAX_ENTRY_BYTES ].min
|
||||
|
||||
@mutex.synchronize do
|
||||
return unless current_version == version
|
||||
return if @entries.key?(key)
|
||||
|
||||
while @entries.any? && @bytes + size > budget
|
||||
_, (_, removed_size) = @entries.shift
|
||||
@bytes -= removed_size
|
||||
end
|
||||
@entries[key] = [ entry, size ]
|
||||
@bytes += size
|
||||
end
|
||||
rescue SQLite3::Exception
|
||||
clear
|
||||
end
|
||||
|
||||
def clear
|
||||
@mutex.synchronize do
|
||||
@entries.clear
|
||||
@bytes = 0
|
||||
@observer&.close
|
||||
@observer = @database = @version = nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def current_version
|
||||
database = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||
if @database != database || !@observer
|
||||
@entries.clear
|
||||
@bytes = 0
|
||||
@observer&.close
|
||||
@observer = nil
|
||||
@observer = SQLite3::Database.new(database, readonly: true)
|
||||
@database = database
|
||||
@namespace = SecureRandom.hex(16)
|
||||
@version = nil
|
||||
end
|
||||
|
||||
version = @observer.get_first_value("PRAGMA data_version")
|
||||
if @version != version
|
||||
@entries.clear
|
||||
@bytes = 0
|
||||
@version = version
|
||||
end
|
||||
[ @database, @namespace, @version ]
|
||||
end
|
||||
|
||||
INSTANCE = new
|
||||
end
|
||||
+41
-8
@@ -45,11 +45,52 @@ 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)
|
||||
end
|
||||
|
||||
# Rewriting every member on every message is most of what posting to a large room writes,
|
||||
# so members who are unread already stay as they are. Directs keep touching them all: a
|
||||
# direct's sidebar row is cached by membership and shows the room's recency.
|
||||
def unread_memberships(message)
|
||||
recipients = memberships.visible.disconnected.where.not(user: message.creator)
|
||||
recipients = recipients.where(unread_at: nil) unless direct?
|
||||
recipients.update_all(unread_at: message.created_at, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def push_later(message)
|
||||
Room::PushMessageJob.perform_later(self, message)
|
||||
end
|
||||
|
||||
def open?
|
||||
is_a?(Rooms::Open)
|
||||
end
|
||||
@@ -75,12 +116,4 @@ class Room < ApplicationRecord
|
||||
errors.add :type, "can't be changed for a direct room"
|
||||
end
|
||||
end
|
||||
|
||||
def unread_memberships(message)
|
||||
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def push_later(message)
|
||||
Room::PushMessageJob.perform_later(self, message)
|
||||
end
|
||||
end
|
||||
@@ -6,9 +6,9 @@ class Room::MessagePusher
|
||||
end
|
||||
|
||||
def push
|
||||
build_payload.tap do |payload|
|
||||
push_to_users_involved_in_everything(payload)
|
||||
push_to_users_involved_in_mentions(payload)
|
||||
subscriptions = push_subscriptions_for_users_involved_in_everything.or(push_subscriptions_for_mentionable_users(message.mentionees))
|
||||
if subscriptions.exists?
|
||||
Rails.configuration.x.web_push_pool.queue(build_payload, subscriptions)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,14 +37,6 @@ class Room::MessagePusher
|
||||
}
|
||||
end
|
||||
|
||||
def push_to_users_involved_in_everything(payload)
|
||||
enqueue_payload_for_delivery payload, push_subscriptions_for_users_involved_in_everything
|
||||
end
|
||||
|
||||
def push_to_users_involved_in_mentions(payload)
|
||||
enqueue_payload_for_delivery payload, push_subscriptions_for_mentionable_users(message.mentionees)
|
||||
end
|
||||
|
||||
def push_subscriptions_for_users_involved_in_everything
|
||||
relevant_subscriptions.merge(Membership.involved_in_everything)
|
||||
end
|
||||
@@ -53,13 +45,12 @@ class Room::MessagePusher
|
||||
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
|
||||
end
|
||||
|
||||
# Banning keeps the user's subscriptions, so that unbanning brings their notifications back,
|
||||
# but nobody who can't sign in should be sent what's said in their rooms meanwhile.
|
||||
def relevant_subscriptions
|
||||
Push::Subscription
|
||||
.joins(user: :memberships)
|
||||
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
|
||||
end
|
||||
|
||||
def enqueue_payload_for_delivery(payload, subscriptions)
|
||||
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
|
||||
.merge(User.active)
|
||||
.merge(Membership.visible.disconnected.where(room: room).where.not(user_id: message.creator_id))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
# Keeps rooms.messages_count correct for ordinary SQLite writes — Rails, bulk SQL,
|
||||
# and foreign connections — without ActiveRecord counter_cache callbacks that
|
||||
# those paths skip (and that would double-count if combined with triggers).
|
||||
# Foreign REPLACE writes require PRAGMA recursive_triggers=ON so SQLite also
|
||||
# fires the delete trigger. Migrations rebuilding messages must call ensure!
|
||||
# before reading counters inside that migration; database tasks repair afterwards.
|
||||
class Room::MessagesCount
|
||||
INSERT_TRIGGER = "messages_ai_rooms_messages_count"
|
||||
DELETE_TRIGGER = "messages_ad_rooms_messages_count"
|
||||
UPDATE_TRIGGER = "messages_au_rooms_messages_count"
|
||||
TRIGGERS = [ INSERT_TRIGGER, DELETE_TRIGGER, UPDATE_TRIGGER ].freeze
|
||||
|
||||
class << self
|
||||
def install!(connection = ActiveRecord::Base.connection)
|
||||
with_immediate_write(connection) do
|
||||
replace_triggers!(connection)
|
||||
end
|
||||
end
|
||||
|
||||
def uninstall!(connection = ActiveRecord::Base.connection)
|
||||
TRIGGERS.each do |name|
|
||||
connection.execute("DROP TRIGGER IF EXISTS #{name}")
|
||||
end
|
||||
end
|
||||
|
||||
def backfill!(connection = ActiveRecord::Base.connection)
|
||||
connection.execute <<~SQL
|
||||
UPDATE rooms SET messages_count = (
|
||||
SELECT COUNT(*) FROM messages WHERE messages.room_id = rooms.id
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
# schema.rb does not dump SQLite triggers; reinstall after schema:load.
|
||||
# Repair rechecks, rebuilds counts, and installs triggers in one write txn
|
||||
# so concurrent writers and concurrent boot repairs cannot observe a gap.
|
||||
def ensure!(connection = ActiveRecord::Base.connection)
|
||||
return unless connection.adapter_name.match?(/sqlite/i)
|
||||
return unless connection.data_source_exists?(:rooms)
|
||||
return unless connection.column_exists?(:rooms, :messages_count)
|
||||
return if triggers_installed?(connection)
|
||||
|
||||
with_immediate_write(connection) do
|
||||
next if triggers_installed?(connection)
|
||||
|
||||
uninstall!(connection)
|
||||
backfill!(connection)
|
||||
create_triggers!(connection)
|
||||
end
|
||||
end
|
||||
|
||||
def trigger_installed?(connection, name)
|
||||
connection.select_value(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = #{connection.quote(name)}"
|
||||
).present?
|
||||
end
|
||||
|
||||
def triggers_installed?(connection = ActiveRecord::Base.connection)
|
||||
TRIGGERS.all? { |name| trigger_installed?(connection, name) }
|
||||
end
|
||||
|
||||
private
|
||||
def with_immediate_write(connection)
|
||||
if connection.transaction_open?
|
||||
yield
|
||||
else
|
||||
connection.raw_connection.transaction(:immediate) { yield }
|
||||
end
|
||||
end
|
||||
|
||||
def replace_triggers!(connection)
|
||||
uninstall!(connection)
|
||||
create_triggers!(connection)
|
||||
end
|
||||
|
||||
def create_triggers!(connection)
|
||||
connection.execute <<~SQL
|
||||
CREATE TRIGGER #{INSERT_TRIGGER} AFTER INSERT ON messages
|
||||
BEGIN
|
||||
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
|
||||
END
|
||||
SQL
|
||||
connection.execute <<~SQL
|
||||
CREATE TRIGGER #{DELETE_TRIGGER} AFTER DELETE ON messages
|
||||
BEGIN
|
||||
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
|
||||
END
|
||||
SQL
|
||||
connection.execute <<~SQL
|
||||
CREATE TRIGGER #{UPDATE_TRIGGER} AFTER UPDATE OF room_id ON messages
|
||||
WHEN OLD.room_id IS NOT NEW.room_id
|
||||
BEGIN
|
||||
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
|
||||
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
|
||||
END
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,12 +7,15 @@ class Rooms::Direct < Room
|
||||
end
|
||||
|
||||
private
|
||||
# FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
|
||||
# which could be to store the membership id list as a hash on the room, and use that for lookup.
|
||||
# Among the first user's rooms, the one whose members are exactly these users: as many
|
||||
# memberships as users, and all of them theirs.
|
||||
def find_for(users)
|
||||
all.joins(:users).detect do |room|
|
||||
Set.new(room.user_ids) == Set.new(users.pluck(:id))
|
||||
end
|
||||
user_ids = users.pluck(:id).uniq
|
||||
|
||||
where(id: Membership.where(user_id: user_ids.first).select(:room_id))
|
||||
.joins(:memberships).group(:id)
|
||||
.having("COUNT(*) = :size AND COUNT(CASE WHEN memberships.user_id IN (:user_ids) THEN 1 END) = :size", size: user_ids.size, user_ids: user_ids)
|
||||
.first
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+11
-1
@@ -50,8 +50,18 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
|
||||
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
|
||||
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
|
||||
# as insert_all did.
|
||||
def grant_membership_to_open_rooms
|
||||
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
|
||||
now = Time.current
|
||||
|
||||
Membership.connection.execute Membership.sanitize_sql([ <<~SQL, id, now, now ])
|
||||
insert into memberships (room_id, user_id, created_at, updated_at)
|
||||
select id, ?, ?, ? from rooms where type = 'Rooms::Open'
|
||||
on conflict do nothing
|
||||
SQL
|
||||
end
|
||||
|
||||
def deactived_email_address
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
module User::Bannable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# Administrators still see banned users, so they can lift the ban.
|
||||
scope :visible_to, ->(user) { user.can_administer? ? where(status: %i[ active banned ]) : active }
|
||||
end
|
||||
|
||||
def ban
|
||||
transaction do
|
||||
create_bans_from_sessions
|
||||
|
||||
@@ -11,6 +11,11 @@ module User::Mentionable
|
||||
"users/mention"
|
||||
end
|
||||
|
||||
# How a mention appears inside the editor, matching the prompt's editor template.
|
||||
def to_editor_content_attachment_partial_path
|
||||
"users/mention"
|
||||
end
|
||||
|
||||
def attachable_plain_text_representation(caption)
|
||||
"@#{name}"
|
||||
end
|
||||
|
||||
@@ -63,7 +63,7 @@ class Webhook < ApplicationRecord
|
||||
end
|
||||
|
||||
def extract_attachment_from(response)
|
||||
if response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
||||
if response.code == "200" && response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
||||
ActiveStorage::Blob.create_and_upload! \
|
||||
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
|
||||
end
|
||||
|
||||
@@ -76,10 +76,9 @@
|
||||
<% end %>
|
||||
|
||||
<label class="switch">
|
||||
<input type="checkbox"
|
||||
class="switch__input"
|
||||
<%= "checked" if Current.account.settings.restrict_room_creation_to_administrators? %>
|
||||
data-action="change->form#submit">
|
||||
<%= tag.input type: "checkbox", class: "switch__input",
|
||||
checked: Current.account.settings.restrict_room_creation_to_administrators?,
|
||||
data: { action: "change->form#submit" } %>
|
||||
<span class="switch__btn round"></span>
|
||||
<span class="for-screen-reader">
|
||||
Must be admin to create new rooms
|
||||
@@ -102,11 +101,11 @@
|
||||
<turbo-frame id="account_users">
|
||||
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
|
||||
|
||||
<% if @administrators.any? && @members.any? %>
|
||||
<% if @administrators.any? && @page.records.any? %>
|
||||
<hr class="separator full-width" style="--border-style: solid">
|
||||
<% end %>
|
||||
|
||||
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
|
||||
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
|
||||
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
|
||||
</turbo-frame>
|
||||
</menu>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<%# Files are posted as a message's own attachment, which gets its preview when it's posted; the composer adds none to
|
||||
the rich text. One that's there anyway is shown by its name, with no preview: Action Text's partial links to a URL
|
||||
that makes one on view, and nothing else in the app would make it. %>
|
||||
<figure class="attachment attachment--file attachment--<%= blob.filename.extension %>">
|
||||
<figcaption class="attachment__caption">
|
||||
<% if caption = blob.try(:caption) %>
|
||||
<%= caption %>
|
||||
<% else %>
|
||||
<span class="attachment__name"><%= blob.filename %></span>
|
||||
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
|
||||
<% end %>
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -9,7 +9,6 @@
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
<%= current_user_meta_tags %>
|
||||
<%= script_aware_action_cable_meta_tag %>
|
||||
@@ -40,9 +39,9 @@
|
||||
<div class="flash" data-controller="element-removal" data-action="animationend->element-removal#remove">
|
||||
<div class="flash__inner shadow" style="<%= "--flash-background: var(--color-negative)" if flash[:alert] %>">
|
||||
<% if flash[:alert] %>
|
||||
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
|
||||
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
|
||||
<% else %>
|
||||
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
|
||||
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="for-screen-reader" role="alert" aria-atomic="true"><%= notice %></span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
||||
|
||||
<%= cache_message_actions(message) do %>
|
||||
<div class="message__actions" data-controller="soft-keyboard">
|
||||
<%= tag.details class: "position-relative",
|
||||
data: { controller: "popup", action: "keydown.esc->popup#close toggle->popup#toggle click@document->popup#closeOnClickOutside", popup_orientation_top_class: "popup-orientation-top" } do %>
|
||||
@@ -10,14 +11,16 @@
|
||||
|
||||
<div class="message__actions-menu border shadow" data-popup-target="menu">
|
||||
<div class="quick-boosts">
|
||||
<% boost_path = message_boosts_path(message) %>
|
||||
<% boost_frame = dom_id(message, :boosting) %>
|
||||
<% EmojiHelper::REACTIONS.each do |character, title| %>
|
||||
<%= form_with model: [ message, Boost.new ], data: { turbo_frame: dom_id(message, :boosting), action: "popup#close"} do |form| %>
|
||||
<form data-turbo-frame="<%= boost_frame %>" data-action="popup#close" action="<%= boost_path %>" accept-charset="UTF-8" method="post">
|
||||
<%= hidden_field_tag "boost[content]", character %>
|
||||
<%= form.button type: "submit", title: title, class: "btn message__action-btn", data: { emoji: character } do %>
|
||||
<button name="button" type="submit" title="<%= title %>" class="btn message__action-btn" data-emoji="<%= character %>">
|
||||
<figure class="margin-none boost-character"><%= character %></figure>
|
||||
<span class="for-screen-reader"><%= title %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</button>
|
||||
</form>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_message_boost_path(message),
|
||||
@@ -29,7 +32,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap border-top margin-block-start-half pad-block-start-half message__actions-grid">
|
||||
<% if message.content_type.attachment? %>
|
||||
<% if message.attachment? %>
|
||||
<%= link_to rails_blob_path(message.attachment, disposition: "attachment", only_path: true), class: "btn message__action-btn center full-width hide-in-ios-pwa", title: "Download", aria: { label: "Download" } do %>
|
||||
<%= image_tag "download.svg", class: "colorize--black", size: 20, aria: { hidden: "true" } %>
|
||||
<% end %>
|
||||
@@ -56,3 +59,4 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
||||
|
||||
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
|
||||
<% cache [ message, "presentation-v3" ] do %>
|
||||
<% cache [ message_fragment_cache_key(message), "presentation-v7" ] do %>
|
||||
<%= message_tag message do %>
|
||||
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<div class="message message--me $messageClasses$"
|
||||
id="message_$clientMessageId$"
|
||||
data-format-message-target="message"
|
||||
data-pending-message="true"
|
||||
data-client-message-id="$clientMessageId$"
|
||||
data-user-id="<%= Current.user.id %>"
|
||||
data-message-timestamp="$messageTimestamp$"
|
||||
data-messages-target="message">
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<% cache boost do %>
|
||||
<div id="<%= dom_id(boost) %>"
|
||||
class="boost boost-item flex-inline postion--relative max-width align-center fill-white gap"
|
||||
data-controller="boost-delete" data-boost-delete-perform-class="boost--deleting" data-boost-delete-reveal-class="expanded" data-boost-delete-booster-id-value="<%= boost.booster.id %>">
|
||||
<figure class="avatar boost__avatar flex-item-no-shrink">
|
||||
<%= avatar_tag boost.booster, aria: { label: "#{boost.booster.name} boosted #{boost.content}" } %>
|
||||
</figure>
|
||||
<div id="<%= dom_id(boost) %>"
|
||||
class="boost boost-item flex-inline postion--relative max-width align-center fill-white gap"
|
||||
data-controller="boost-delete" data-boost-delete-perform-class="boost--deleting" data-boost-delete-reveal-class="expanded" data-boost-delete-booster-id-value="<%= boost.booster.id %>">
|
||||
<figure class="avatar boost__avatar flex-item-no-shrink">
|
||||
<%= avatar_tag boost.booster, aria: { label: "#{boost.booster.name} boosted #{boost.content}" } %>
|
||||
</figure>
|
||||
|
||||
<%= tag.span boost.content, role: "button",
|
||||
class: [ "txt-small", { "txt-medium": boost.content.all_emoji? } ],
|
||||
data: { action: "click->boost-delete#reveal keydown.enter->boost-delete#reveal:prevent", boost_delete_target: "content" } %>
|
||||
<%= tag.span boost.content, role: "button",
|
||||
class: [ "txt-small", { "txt-medium": boost.content.all_emoji? } ],
|
||||
data: { action: "click->boost-delete#reveal keydown.enter->boost-delete#reveal:prevent", boost_delete_target: "content" } %>
|
||||
|
||||
<%= button_to message_boost_path(boost.message, boost), method: :delete, data: { action: "boost-delete#perform", boost_delete_target: "button" },
|
||||
class: "btn btn--negative flex-item-justify-end boost__delete" do %>
|
||||
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
|
||||
<span class="for-screen-reader">Delete this boost</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<span id="delete_boost_accessible_label" class="for-screen-reader">Press enter to delete this boost</span>
|
||||
<% end %>
|
||||
<%= button_to message_boost_path(boost.message, boost), method: :delete, data: { action: "boost-delete#perform", boost_delete_target: "button" },
|
||||
class: "btn btn--negative flex-item-justify-end boost__delete" do %>
|
||||
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
|
||||
<span class="for-screen-reader">Delete this boost</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<span id="delete_boost_accessible_label" class="for-screen-reader">Press enter to delete this boost</span>
|
||||
@@ -2,12 +2,12 @@
|
||||
<div class="boosts flex flex-wrap align-center gap full-width" style="--column-gap: 0.4ch; --row-gap: 0"
|
||||
data-controller="turbo-streaming" data-action="turbo:submit-start->turbo-streaming#unsubscribe">
|
||||
<div class="flex-inline flex-wrap gap" id="<%= dom_id(message, :boosts) %>" data-turbo-streaming-target="container">
|
||||
<%= render partial: "messages/boosts/boost", collection: message.boosts.ordered, cached: true %>
|
||||
<%= render partial: "messages/boosts/boost", collection: message.boosts.loaded? ? message.boosts.sort_by(&:created_at) : message.boosts.ordered.includes(:booster) %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag message, :new_boost do %>
|
||||
<div class="flex-inline message__boost-inline" data-controller="soft-keyboard">
|
||||
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", action: "soft-keyboard#open" do %>
|
||||
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", data: { action: "soft-keyboard#open" } do %>
|
||||
<%= image_tag "boost.svg", size: 20, aria: { hidden: "true" } %>
|
||||
<span class="for-screen-reader">Add a boost</span>
|
||||
<% end %>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<%= turbo_stream.append dom_id(@message.room, :messages), @message %>
|
||||
<%= turbo_stream.append dom_id(@message.room, :messages), @message_html %>
|
||||
@@ -1 +1 @@
|
||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
|
||||
@@ -6,8 +6,8 @@
|
||||
<%= image_tag "disclosure.svg", aria: { hidden: "true" }, size: 10, class: "disclosure" %>
|
||||
</summary>
|
||||
|
||||
<% case
|
||||
when platform.firefox? && platform.android? %>
|
||||
<% case %>
|
||||
<% when platform.firefox? && platform.android? %>
|
||||
<ol>
|
||||
<li>Tap <em><%= image_tag "lock.svg", alt: "the View site information button", size: 20 %></em> in the address bar.</li>
|
||||
<li>Tap <em>Notification</em> to change to <em>Allowed</em>.</li>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<%= image_tag "disclosure.svg", aria: { hidden: "true" }, size: 10, class: "disclosure" %>
|
||||
</summary>
|
||||
|
||||
<% case
|
||||
when platform.edge? %>
|
||||
<% case %>
|
||||
<% when platform.edge? %>
|
||||
<ol>
|
||||
<li>Click <em><%= image_tag "install-edge.svg", alt: "the app available - install Campfire chat button", size: 16 %></em>in the address bar.</li>
|
||||
<li>Click <em><%= image_tag "external/install-edge.svg", alt: "the app available - install Campfire chat button", size: 16 %></em>in the address bar.</li>
|
||||
<li>Click <em>Install</em>.</li>
|
||||
</ol>
|
||||
<% when platform.chrome? && platform.android? %>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<%= image_tag "disclosure.svg", aria: { hidden: "true" }, size: 10, class: "disclosure" %>
|
||||
</summary>
|
||||
|
||||
<% case
|
||||
when platform.firefox? && platform.android? %>
|
||||
<% case %>
|
||||
<% when platform.firefox? && platform.android? %>
|
||||
<ol>
|
||||
<li>Tap the <em><%= image_tag "menu-dots-vertical.svg", alt: "More options", size: 16 %></em> menu button.</li>
|
||||
<li>Tap <em>Settings</em>.</li>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= turbo_stream.append dom_id(@room, :messages) do %>
|
||||
<%= render partial: "messages/message", collection: @new_messages, cached: true %>
|
||||
<%= render partial: "messages/message", collection: @new_messages, cached: ->(message) { message_fragment_cache_key(message) } %>
|
||||
<% end if @new_messages.any? %>
|
||||
|
||||
<% @updated_messages.each do |message| %>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<%= messages_tag(@room) do %>
|
||||
<%= render "rooms/show/invitation", room: @room %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
|
||||
<%= search_results_tag do %>
|
||||
<%= render @messages %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<%= auto_submit_form_with method: :put %>
|
||||
<%= auto_submit_form_with method: :put do %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,27 @@
|
||||
# Message benchmarks
|
||||
|
||||
The drivers use Ruby's standard library and Docker; no separate load generator is required.
|
||||
Provide a frozen baseline source directory, an isolated seed containing `db/`, `storage/`
|
||||
and `labels.json`, and a matching application image with compiled assets.
|
||||
|
||||
```sh
|
||||
ruby bench/compare_message_hot_paths.rb --baseline PATH --baseline-ref SHA --seed SEED
|
||||
ruby bench/compare_http.rb --baseline PATH --seed SEED
|
||||
ruby bench/compare_http.rb --baseline PATH --seed SEED --paths sidebar,search --concurrencies 16 --duration 10
|
||||
```
|
||||
|
||||
Both drivers alternate before/after order and reset fixture storage for each run.
|
||||
Results default to ignored `tmp/rails-optimization/results/`; override with `--output PATH`.
|
||||
Use `--image` to override `campfire-reference:app`, and `--cpus` to override server CPUs
|
||||
`8-11`. HTTP clients use CPUs `12-15` by default (`--client-cpus`). `--help` lists options.
|
||||
|
||||
The rendering probe checks exact response bodies, selected headers and unread payloads,
|
||||
and records timing, queries and allocations with MemoryStore, frozen time and fixture-only
|
||||
CSRF disabling. Unread fanout excludes adapter I/O.
|
||||
|
||||
The HTTP driver uses production Puma/Redis with one worker and five threads. Ruby threads
|
||||
each maintain a keep-alive connection, request uncompressed responses, and consume the
|
||||
whole body. Login uses normal CSRF protection; all warmup and measured responses must be
|
||||
HTTP 200 without transport errors, complete HTML, exact independently seeded message windows
|
||||
and the visible sidebar rooms; this applies to every warmup and measured response. Measurements exclude Thruster, TLS and gzip. Client
|
||||
CPU, JIT warmup and GC can affect throughput; repeat runs and check client saturation.
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Compare warm production Puma/Redis requests using isolated seeded containers.
|
||||
require "socket"
|
||||
require_relative "support"
|
||||
require_relative "http_client"
|
||||
require_relative "response_contract"
|
||||
|
||||
include BenchmarkSupport
|
||||
options = parse_options("Compare HTTP throughput with Ruby keep-alive clients; every response must be HTTP 200.",
|
||||
duration: 3.0, paths: "room,messages,sidebar,search", concurrencies: "1,16", client_cpus: "12-15",
|
||||
output: File.join(WORK, "results/http"))
|
||||
labels = JSON.parse(File.read(File.join(options[:seed], "labels.json")))
|
||||
paths = {
|
||||
"room" => "/rooms/#{labels.fetch('rooms.watercooler')}",
|
||||
"messages" => "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
|
||||
"sidebar" => "/users/me/sidebar", "search" => "/searches?q=coffee"
|
||||
}.slice(*options[:paths].split(","))
|
||||
abort "--paths must select room,messages,sidebar,search" unless paths.size == options[:paths].split(",").size && !paths.empty?
|
||||
concurrencies = options[:concurrencies].split(",").map { |value| Integer(value) }
|
||||
abort "--duration and --concurrencies must be positive" unless options[:duration].positive? && !concurrencies.empty? && concurrencies.all?(&:positive?)
|
||||
run("taskset", "-pc", options[:client_cpus], Process.pid.to_s)
|
||||
work = File.join(WORK, "http")
|
||||
assets = prepare_assets(options[:image])
|
||||
network = "cf-ruby-bench-#{Process.pid}"
|
||||
redis = "#{network}-redis"
|
||||
app = "#{network}-app"
|
||||
|
||||
begin
|
||||
run("docker", "network", "create", network)
|
||||
run("docker", "run", "-d", "--name", redis, "--network", network, "redis:7-alpine")
|
||||
options[:rounds].times do |iteration|
|
||||
sides = iteration.even? ? %w[before after] : %w[after before]
|
||||
sides.each do |side|
|
||||
remove_container(app)
|
||||
data = File.join(work, "data")
|
||||
prepare_storage(options[:seed], File.join(data, "storage"))
|
||||
FileUtils.mkdir_p([ File.join(data, "tmp/pids"), File.join(data, "log") ])
|
||||
run("docker", "exec", redis, "redis-cli", "FLUSHALL")
|
||||
port = TCPServer.open("127.0.0.1", 0) { |socket| socket.addr[1] }
|
||||
client = BenchmarkHTTPClient.new("http://127.0.0.1:#{port}")
|
||||
source = side == "before" ? options[:baseline] : ROOT
|
||||
command = [ "docker", "run", "-d", "--name", app, "--entrypoint", "", "--network", network,
|
||||
"--cpuset-cpus", options[:cpus], "-p", "127.0.0.1:#{port}:3000" ]
|
||||
command.concat mounts(source => "/rails", File.join(data, "storage") => "/rails/storage",
|
||||
File.join(data, "tmp") => "/rails/tmp", File.join(data, "log") => "/rails/log", assets => "/rails/public/assets")
|
||||
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
|
||||
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", WEB_CONCURRENCY: 1, JOB_CONCURRENCY: 1, RAILS_MAX_THREADS: 5,
|
||||
REDIS_URL: "redis://#{redis}:6379/0")
|
||||
command.concat [ options[:image], "bundle", "exec", "puma", "-C", "config/puma.rb" ]
|
||||
run(*command)
|
||||
deadline = clock + 45
|
||||
until client.ready?
|
||||
if clock > deadline
|
||||
File.write(File.join(work, "server.log"), run("docker", "logs", app))
|
||||
raise "server did not become ready; see #{work}/server.log"
|
||||
end
|
||||
sleep 0.1
|
||||
end
|
||||
cookie = client.login(labels)
|
||||
results = {}
|
||||
paths.each do |name, path|
|
||||
contract = BenchmarkResponseContract.new(name, File.join(data, "storage/db/production.sqlite3"), labels)
|
||||
client.measure(path, cookie, concurrency: 1, duration: 3, contract: contract)
|
||||
concurrencies.each do |concurrency|
|
||||
results["#{name}_#{concurrency}"] = client.measure(path, cookie, concurrency: concurrency, duration: options[:duration], contract: contract)
|
||||
end
|
||||
end
|
||||
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), results)
|
||||
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
|
||||
end
|
||||
end
|
||||
ensure
|
||||
remove_container(app)
|
||||
remove_container(redis)
|
||||
Open3.capture3("docker", "network", "rm", network)
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Compare frozen Rails sources with isolated fixtures and an existing Ruby image.
|
||||
require "digest"
|
||||
require_relative "support"
|
||||
|
||||
include BenchmarkSupport
|
||||
options = parse_options("Compare rendering queries, allocations, timing and response parity.",
|
||||
baseline_ref: nil, rounds: 4, output: File.join(WORK, "results/requests"))
|
||||
work = File.join(WORK, "runtime")
|
||||
FileUtils.mkdir_p([ File.join(work, "tmp"), File.join(work, "log") ])
|
||||
assets = prepare_assets(options[:image])
|
||||
runs = { "before" => [], "after" => [] }
|
||||
order = []
|
||||
options[:rounds].times do |iteration|
|
||||
sides = iteration.even? ? %w[before after] : %w[after before]
|
||||
order << sides
|
||||
sides.each do |side|
|
||||
storage = File.join(work, "storage")
|
||||
prepare_storage(options[:seed], storage)
|
||||
source = side == "before" ? options[:baseline] : ROOT
|
||||
command = [ "docker", "run", "--rm", "--entrypoint", "", "--cpuset-cpus", options[:cpus] ]
|
||||
command.concat mounts(source => "/rails", storage => "/rails/storage", File.join(work, "tmp") => "/rails/tmp",
|
||||
File.join(work, "log") => "/rails/log", assets => "/rails/public/assets", File.join(ROOT, "bench") => "/bench",
|
||||
File.join(options[:seed], "labels.json") => "/bench-labels.json")
|
||||
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
|
||||
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", BENCH_LABELS: "/bench-labels.json")
|
||||
command.concat [ options[:image], "bundle", "exec", "ruby", "-r", "/rails/config/environment.rb", "/bench/message_hot_paths.rb" ]
|
||||
data = JSON.parse(run(*command))
|
||||
runs[side] << data
|
||||
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), data)
|
||||
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
|
||||
end
|
||||
end
|
||||
|
||||
summary = {}
|
||||
runs["before"].first.fetch("results").each_key do |name|
|
||||
values = runs.transform_values { |rows| rows.map { |row| row.fetch("results").fetch(name) } }
|
||||
%w[body_sha256 headers payload_sha256].each do |field|
|
||||
expected = values["before"].first[field]
|
||||
raise "#{name}: #{field} differs; no winning summary written" unless values.values.flatten.all? { |row| row[field] == expected }
|
||||
end
|
||||
summary[name] = values.transform_values do |rows|
|
||||
fields = %w[milliseconds allocations queries].select { |field| rows.first.key?(field) }
|
||||
fields.to_h { |field| [ field, median(rows.map { |row| median(row.fetch(field)) }) ] }
|
||||
.merge("round_medians_ms" => rows.map { |row| median(row.fetch("milliseconds")) })
|
||||
end
|
||||
summary[name]["speedup"] = summary[name]["before"]["milliseconds"] / summary[name]["after"]["milliseconds"]
|
||||
end
|
||||
metadata = {
|
||||
baseline_sha: options[:baseline_ref], candidate_head: run("git", "-C", ROOT, "rev-parse", "HEAD").strip,
|
||||
candidate_app_diff_sha256: Digest::SHA256.hexdigest(run("git", "-C", ROOT, "diff", "--", "app")),
|
||||
platform: RUBY_PLATFORM, cpus: options[:cpus], image: options[:image],
|
||||
image_id: run("docker", "image", "inspect", "-f", "{{.Id}}", options[:image]).strip,
|
||||
body_and_selected_header_parity: "exact across all measured baseline/candidate requests",
|
||||
order: order, ruby: runs["before"].first.fetch("ruby"), rails: runs["before"].first.fetch("rails"),
|
||||
limits: "In-process Rails requests, MemoryStore, frozen clock and fixture-controller CSRF disabled. Fanout excludes adapter I/O. Not network throughput or connection capacity."
|
||||
}
|
||||
write_json(File.join(options[:output], "summary.json"), metadata: metadata, results: summary)
|
||||
summary.each do |name, value|
|
||||
puts "%s: %.2f -> %.2f ms (%.2fx)" % [ name, value["before"]["milliseconds"], value["after"]["milliseconds"], value["speedup"] ]
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
require "cgi"
|
||||
require "net/http"
|
||||
|
||||
class BenchmarkHTTPClient
|
||||
def initialize(base)
|
||||
@base = URI(base)
|
||||
end
|
||||
|
||||
def ready?
|
||||
connection.start { |http| http.get("/up").code == "200" }
|
||||
rescue IOError, SystemCallError, Timeout::Error, SocketError
|
||||
false
|
||||
end
|
||||
|
||||
def login(labels)
|
||||
cookies = {}
|
||||
connection.start do |http|
|
||||
response = http.get("/session/new", "Accept-Encoding" => "identity")
|
||||
raise "sign-in page: HTTP #{response.code}" unless response.code == "200"
|
||||
merge_cookies(cookies, response)
|
||||
token = response.body[/<meta name="csrf-token" content="([^"]*)"/, 1]
|
||||
raise "sign-in page has no CSRF token" unless token
|
||||
request = Net::HTTP::Post.new("/session")
|
||||
request["Cookie"] = cookie_header(cookies)
|
||||
request["Origin"] = @base.to_s
|
||||
request["Sec-Fetch-Site"] = "same-origin"
|
||||
request.set_form_data(email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all"),
|
||||
authenticity_token: CGI.unescapeHTML(token))
|
||||
response = http.request(request)
|
||||
merge_cookies(cookies, response)
|
||||
raise "login failed: HTTP #{response.code}" unless response.code == "302" && cookies.key?("session_token")
|
||||
end
|
||||
cookie_header(cookies)
|
||||
end
|
||||
|
||||
def measure(path, cookie, concurrency:, duration:, contract:)
|
||||
start = clock
|
||||
deadline = start + duration
|
||||
workers = Array.new(concurrency) do
|
||||
Thread.new do
|
||||
result = { latencies: [], statuses: Hash.new(0), bytes: 0, errors: 0, invalid_responses: 0 }
|
||||
while clock < deadline
|
||||
begin
|
||||
connection.start do |http|
|
||||
while clock < deadline
|
||||
requested = clock
|
||||
response = http.get(path, "Cookie" => cookie, "Accept-Encoding" => "identity")
|
||||
result[:latencies] << (clock - requested) * 1000
|
||||
result[:statuses][response.code] += 1
|
||||
result[:bytes] += response.body.bytesize
|
||||
result[:invalid_responses] += 1 unless contract.valid?(response)
|
||||
end
|
||||
end
|
||||
rescue IOError, SystemCallError, Timeout::Error, SocketError, Net::HTTPBadResponse
|
||||
result[:errors] += 1
|
||||
sleep 0.01
|
||||
end
|
||||
end
|
||||
result
|
||||
end
|
||||
end
|
||||
samples = workers.map(&:value)
|
||||
elapsed = clock - start
|
||||
latencies = samples.flat_map { |sample| sample[:latencies] }.sort
|
||||
statuses = Hash.new(0)
|
||||
samples.each { |sample| sample[:statuses].each { |status, count| statuses[status] += count } }
|
||||
errors = samples.sum { |sample| sample[:errors] }
|
||||
invalid = samples.sum { |sample| sample[:invalid_responses] }
|
||||
raise "#{path}: HTTP statuses #{statuses}, #{errors} transport errors, #{invalid} invalid bodies" unless errors.zero? && invalid.zero? && statuses.keys == [ "200" ]
|
||||
{ path: path, conc: concurrency, gzip: false, secs: elapsed, rps: latencies.size / elapsed,
|
||||
ok: latencies.size, statuses: statuses, errors: errors, invalid_responses: invalid, validation: "route-contract-v1",
|
||||
avg_bytes: samples.sum { |sample| sample[:bytes] } / latencies.size,
|
||||
latency_ms: { p50: percentile(latencies, 0.50), p95: percentile(latencies, 0.95), p99: percentile(latencies, 0.99) } }
|
||||
end
|
||||
|
||||
private
|
||||
def connection
|
||||
Net::HTTP.new(@base.host, @base.port, nil).tap do |http|
|
||||
http.open_timeout = 1
|
||||
http.read_timeout = 5
|
||||
http.write_timeout = 5
|
||||
http.max_retries = 0
|
||||
end
|
||||
end
|
||||
|
||||
def merge_cookies(cookies, response)
|
||||
response.get_fields("set-cookie").to_a.each do |header|
|
||||
name, value = header.split(";", 2).first.split("=", 2)
|
||||
cookies[name] = value
|
||||
end
|
||||
end
|
||||
|
||||
def cookie_header(cookies)
|
||||
cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
|
||||
end
|
||||
|
||||
def percentile(values, fraction)
|
||||
values[(values.size * fraction).ceil - 1]
|
||||
end
|
||||
|
||||
def clock
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
# Run through compare_message_hot_paths.rb against isolated production fixtures.
|
||||
require "json"
|
||||
require "digest"
|
||||
require "active_support/testing/time_helpers"
|
||||
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
travel_to Time.utc(2026, 10, 4, 12)
|
||||
|
||||
# Keep framework/rendering measurements independent of Redis/network variance.
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
ActionView::PartialRenderer.collection_cache = Rails.cache
|
||||
ApplicationController.cache_store = Rails.cache
|
||||
ApplicationController.descendants.each do |controller|
|
||||
controller.cache_store = Rails.cache
|
||||
controller.allow_forgery_protection = false
|
||||
end
|
||||
ApplicationController.allow_forgery_protection = false
|
||||
|
||||
labels = JSON.parse(File.read(ENV.fetch("BENCH_LABELS")))
|
||||
client = ActionDispatch::Integration::Session.new(Rails.application)
|
||||
client.host! "campfire-benchmark.test"
|
||||
client.post "/session", params: { email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all") }
|
||||
raise "login failed" unless client.response.redirect?
|
||||
|
||||
paths = {
|
||||
room: "/rooms/#{labels.fetch('rooms.watercooler')}",
|
||||
messages: "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
|
||||
sidebar: Rails.application.routes.url_helpers.user_sidebar_path,
|
||||
search: "/searches?q=coffee"
|
||||
}
|
||||
queries = []
|
||||
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, event|
|
||||
queries << event[:sql] unless event[:cached] || event[:name] == "SCHEMA"
|
||||
end
|
||||
results = {}
|
||||
paths.each do |name, path|
|
||||
%w[cold warm].each do |cache|
|
||||
2.times { client.get path }
|
||||
times, counts, allocations, hashes, headers = [], [], [], [], []
|
||||
Integer(ENV.fetch("BENCH_ITERATIONS", "20")).times do
|
||||
Rails.cache.clear if cache == "cold"
|
||||
queries.clear
|
||||
start_allocations = GC.stat(:total_allocated_objects)
|
||||
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
client.get path
|
||||
times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
|
||||
allocations << GC.stat(:total_allocated_objects) - start_allocations
|
||||
raise "#{name}: HTTP #{client.response.status}" unless client.response.status == 200
|
||||
counts << queries.length
|
||||
hashes << Digest::SHA256.hexdigest(client.response.body)
|
||||
headers << client.response.headers.slice("content-type", "etag", "cache-control", "vary")
|
||||
end
|
||||
results["#{name}_#{cache}"] = { milliseconds: times, queries: counts, allocations: allocations,
|
||||
body_sha256: hashes.uniq, headers: headers.uniq, body_bytes: client.response.body.bytesize }
|
||||
end
|
||||
end
|
||||
ActiveSupport::Notifications.unsubscribe(subscriber)
|
||||
|
||||
# Real Action Cable encoding/instrumentation, with adapter I/O removed. Keep fanout
|
||||
# separate from network delivery capacity, which this probe does not measure.
|
||||
room = Room.find(labels.fetch("rooms.watercooler"))
|
||||
class BenchmarkPubsub
|
||||
attr_reader :messages
|
||||
def initialize
|
||||
@messages = []
|
||||
end
|
||||
def broadcast(stream, payload)
|
||||
@messages << [ stream, payload ]
|
||||
end
|
||||
end
|
||||
adapter = BenchmarkPubsub.new
|
||||
ActionCable.server.instance_variable_set(:@pubsub, adapter)
|
||||
message = Message.new(room: room)
|
||||
recipient_ids = (1..1000).to_a
|
||||
memberships = Object.new
|
||||
memberships.define_singleton_method(:pluck) { |_| recipient_ids }
|
||||
room.define_singleton_method(:memberships) { memberships }
|
||||
fanout_times, fanout_allocations = [], []
|
||||
20.times do
|
||||
adapter.messages.clear
|
||||
allocated = GC.stat(:total_allocated_objects)
|
||||
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
message.send(:broadcast_unread_room)
|
||||
fanout_times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
|
||||
fanout_allocations << GC.stat(:total_allocated_objects) - allocated
|
||||
raise "wrong fanout count" unless adapter.messages.size == recipient_ids.size
|
||||
raise "wrong payload" unless adapter.messages.all? { |_, body| JSON.parse(body) == { "roomId" => room.id } }
|
||||
end
|
||||
results["unread_fanout_1000"] = { milliseconds: fanout_times, allocations: fanout_allocations,
|
||||
payload_sha256: Digest::SHA256.hexdigest(adapter.messages.first.last) }
|
||||
puts JSON.pretty_generate({ ruby: RUBY_VERSION, rails: Rails.version, iterations: ENV.fetch("BENCH_ITERATIONS", "20").to_i,
|
||||
cache: "MemoryStore", results: results })
|
||||
@@ -0,0 +1,43 @@
|
||||
require "json"
|
||||
require "open3"
|
||||
require "cgi"
|
||||
|
||||
# Independent seed SQL determines the required result window before any response is sampled.
|
||||
class BenchmarkResponseContract
|
||||
def initialize(kind, database, labels)
|
||||
@kind = kind
|
||||
room = Integer(labels.fetch("rooms.watercooler"))
|
||||
user = labels.fetch("emails.david").gsub("'", "''")
|
||||
@ids = case kind
|
||||
when "room"
|
||||
query(database, "SELECT id FROM messages WHERE room_id=#{room} ORDER BY created_at DESC LIMIT 40").reverse.map { |row| row.fetch("id") }
|
||||
when "messages"
|
||||
before = Integer(labels.fetch("messages.busy_060"))
|
||||
query(database, "SELECT id FROM messages WHERE room_id=#{room} AND created_at<(SELECT created_at FROM messages WHERE id=#{before}) ORDER BY created_at DESC LIMIT 40").reverse.map { |row| row.fetch("id") }
|
||||
when "search"
|
||||
query(database, "SELECT m.id FROM message_search_index idx JOIN messages m ON m.id=idx.rowid JOIN memberships mm ON mm.room_id=m.room_id JOIN users u ON u.id=mm.user_id WHERE u.email_address='#{user}' AND idx.body MATCH 'coffee' ORDER BY m.id DESC LIMIT 100").reverse.map { |row| row.fetch("id") }
|
||||
when "sidebar"
|
||||
@names = query(database, "SELECT r.name FROM rooms r JOIN memberships mm ON mm.room_id=r.id JOIN users u ON u.id=mm.user_id WHERE u.email_address='#{user}' AND r.type='Rooms::Open' AND mm.involvement<>'invisible'").map { |row| CGI.escapeHTML(row.fetch("name")) }
|
||||
nil
|
||||
else
|
||||
raise "Unknown route contract #{kind}"
|
||||
end
|
||||
end
|
||||
|
||||
def valid?(response)
|
||||
body = response.body.to_s
|
||||
return false unless response.code == "200" && response["content-type"].to_s.split(";").first == "text/html" &&
|
||||
[ nil, "identity" ].include?(response["content-encoding"]) && !body.empty? && body.dup.force_encoding("UTF-8").valid_encoding?
|
||||
return false if @kind != "messages" && !(body.lstrip.start_with?("<!DOCTYPE html>") && body.rstrip.end_with?("</html>"))
|
||||
return false if @ids && body.scan(/data-message-id="(\d+)"/).flatten.map(&:to_i) != @ids
|
||||
return false if @names && (!body.include?("shared_rooms") || @names.any? { |name| !body.include?(name) })
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
def query(database, sql)
|
||||
output, errors, status = Open3.capture3("sqlite3", "-readonly", "-json", database, sql)
|
||||
raise "Cannot read benchmark seed: #{errors}" unless status.success?
|
||||
JSON.parse(output)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require "open3"
|
||||
require "optparse"
|
||||
|
||||
module BenchmarkSupport
|
||||
ROOT = File.expand_path("..", __dir__)
|
||||
WORK = File.join(ROOT, "tmp/rails-optimization")
|
||||
|
||||
def parse_options(description, defaults)
|
||||
options = { baseline: nil, seed: nil, image: "campfire-reference:app", cpus: "8-11", rounds: 2 }.merge(defaults)
|
||||
parser = OptionParser.new do |parser|
|
||||
parser.banner = "Usage: ruby #{$PROGRAM_NAME} --baseline PATH --seed PATH [options]\n#{description}"
|
||||
options.each do |name, default|
|
||||
type = case default
|
||||
when Integer then Integer
|
||||
when Float then Float
|
||||
else String
|
||||
end
|
||||
parser.on("--#{name.to_s.tr('_', '-')} VALUE", type) { |value| options[name] = value }
|
||||
end
|
||||
parser.on("-h", "--help") { puts parser; exit }
|
||||
end
|
||||
parser.parse!
|
||||
raise OptionParser::InvalidArgument, "unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
|
||||
raise OptionParser::MissingArgument, "--baseline and --seed are required" unless options[:baseline] && options[:seed]
|
||||
raise OptionParser::InvalidArgument, "--rounds must be even and at least 2" unless options[:rounds] >= 2 && options[:rounds].even?
|
||||
%i[baseline seed output].each { |name| options[name] = File.expand_path(options.fetch(name)) }
|
||||
options
|
||||
rescue OptionParser::ParseError => error
|
||||
abort "#{error.message}\n#{parser}"
|
||||
end
|
||||
|
||||
def run(*command, input: nil)
|
||||
output, errors, status = Open3.capture3(*command, stdin_data: input, binmode: true)
|
||||
raise "#{command.first} failed (#{status.exitstatus}): #{errors}" unless status.success?
|
||||
output
|
||||
end
|
||||
|
||||
def remove_container(name)
|
||||
Open3.capture3("docker", "rm", "-f", name)
|
||||
end
|
||||
|
||||
def prepare_storage(seed, destination)
|
||||
FileUtils.rm_rf(destination)
|
||||
FileUtils.mkdir_p(destination)
|
||||
FileUtils.cp_r(File.join(seed, "db"), File.join(destination, "db"))
|
||||
FileUtils.cp_r(File.join(seed, "storage"), File.join(destination, "files"))
|
||||
end
|
||||
|
||||
def prepare_assets(image)
|
||||
assets = File.join(WORK, "runtime/assets")
|
||||
FileUtils.mkdir_p(assets)
|
||||
if Dir.empty?(assets)
|
||||
archive = run("docker", "run", "--rm", "--entrypoint", "", image,
|
||||
"tar", "-C", "/rails/public/assets", "-cf", "-", ".")
|
||||
run("tar", "-xf", "-", "-C", assets, input: archive)
|
||||
end
|
||||
assets
|
||||
end
|
||||
|
||||
def mounts(paths)
|
||||
paths.flat_map { |host, target| [ "-v", "#{host}:#{target}" ] }
|
||||
end
|
||||
|
||||
def environment(values)
|
||||
values.flat_map { |name, value| [ "-e", "#{name}=#{value}" ] }
|
||||
end
|
||||
|
||||
def write_json(path, data)
|
||||
FileUtils.mkdir_p(File.dirname(path))
|
||||
File.write(path, JSON.pretty_generate(data) + "\n")
|
||||
end
|
||||
|
||||
def median(values)
|
||||
values = values.sort
|
||||
middle = values.length / 2
|
||||
values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
|
||||
end
|
||||
|
||||
def clock
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
require "minitest/autorun"
|
||||
require "fileutils"
|
||||
require "socket"
|
||||
require "sqlite3"
|
||||
require "tmpdir"
|
||||
require_relative "http_client"
|
||||
require_relative "response_contract"
|
||||
|
||||
class BenchmarkHTTPClientTest < Minitest::Test
|
||||
def setup
|
||||
@directory = Dir.mktmpdir("campfire-benchmark-test")
|
||||
database = File.join(@directory, "seed.sqlite3")
|
||||
SQLite3::Database.new(database).tap do |db|
|
||||
db.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY, room_id INTEGER, created_at TEXT)")
|
||||
db.execute("INSERT INTO messages VALUES (42, 1, '2026-10-01'), (43, 1, '2026-10-02')")
|
||||
db.close
|
||||
end
|
||||
@contract = BenchmarkResponseContract.new("room", database, "rooms.watercooler" => 1, "emails.david" => "david@example.com")
|
||||
@body = '<!DOCTYPE html><html><article data-message-id="42">coffee</article><article data-message-id="43">meeting</article></html>'
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@directory)
|
||||
end
|
||||
|
||||
def test_seeded_contract_rejects_error_pages_truncation_wrong_windows_and_encoding
|
||||
assert @contract.valid?(response(@body))
|
||||
refute @contract.valid?(response("Something went wrong"))
|
||||
refute @contract.valid?(response(@body.delete_suffix("</html>")))
|
||||
refute @contract.valid?(response(@body.sub('data-message-id="42"', 'data-message-id="99"')))
|
||||
refute @contract.valid?(response(@body, "text/plain"))
|
||||
refute @contract.valid?(response(@body + "\xFF".b))
|
||||
refute @contract.valid?(response(@body, "text/html", "500"))
|
||||
end
|
||||
|
||||
def test_intermittent_200_error_page_rejects_the_loaded_run
|
||||
server = TCPServer.new("127.0.0.1", 0)
|
||||
worker = Thread.new do
|
||||
loop do
|
||||
socket = server.accept
|
||||
count = 0
|
||||
while socket.gets
|
||||
while (line = socket.gets) && line != "\r\n"
|
||||
end
|
||||
body = count % 8 == 0 ? "Something went wrong" : @body
|
||||
count += 1
|
||||
socket.write("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
|
||||
end
|
||||
socket.close
|
||||
end
|
||||
rescue IOError, SystemCallError
|
||||
nil
|
||||
end
|
||||
client = BenchmarkHTTPClient.new("http://127.0.0.1:#{server.addr[1]}")
|
||||
error = assert_raises(RuntimeError) { client.measure("/rooms/1", "", concurrency: 1, duration: 0.1, contract: @contract) }
|
||||
assert_match(/HTTP statuses \{"200"\s*=>\s*\d+\}, 0 transport errors, [1-9]\d* invalid bodies/, error.message)
|
||||
ensure
|
||||
server&.close
|
||||
worker&.kill
|
||||
worker&.join
|
||||
end
|
||||
|
||||
private
|
||||
def response(body, type = "text/html", status = "200")
|
||||
Net::HTTPResponse.new("1.1", status, "OK").tap do |result|
|
||||
result["content-type"] = type
|
||||
result.instance_variable_set(:@body, body)
|
||||
result.instance_variable_set(:@read, true)
|
||||
end
|
||||
end
|
||||
end
|
||||
+14
-1
@@ -18,7 +18,8 @@ GIT_SHA = `git rev-parse HEAD`.strip
|
||||
MANUAL_NOTES = ARGV.delete("--notes")
|
||||
VERSION = ARGV[0]
|
||||
|
||||
DOCKER_IMAGE_NAME = "registry.once.com/campfire"
|
||||
DOCKER_REGISTRY = "registry.once.com"
|
||||
DOCKER_IMAGE_NAME = "#{DOCKER_REGISTRY}/campfire"
|
||||
DOCKER_LATEST_IMAGE_NAME = [ DOCKER_IMAGE_NAME, :latest ].join(":")
|
||||
DOCKER_TAGGED_IMAGE_NAME = [ DOCKER_IMAGE_NAME, GIT_SHA ].join(":")
|
||||
|
||||
@@ -47,6 +48,18 @@ def announcing(message)
|
||||
yield
|
||||
end
|
||||
|
||||
announcing "Checking #{DOCKER_REGISTRY} login..." do
|
||||
unless system "docker", "manifest", "inspect", DOCKER_LATEST_IMAGE_NAME, out: File::NULL, err: File::NULL
|
||||
abort <<~MESSAGE
|
||||
Not logged in to #{DOCKER_REGISTRY}. Run:
|
||||
|
||||
docker login #{DOCKER_REGISTRY}
|
||||
|
||||
The credentials are in the "chatinabox" entry in 1Password.
|
||||
MESSAGE
|
||||
end
|
||||
end
|
||||
|
||||
on(:local) do
|
||||
builder_found = test :docker, :buildx, :inspect, BUILDER_NAME
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ default: &default
|
||||
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 10 } %>
|
||||
timeout: 5000
|
||||
default_transaction_mode: immediate
|
||||
# Checkpoint on a background connection instead (SqliteWalCheckpoint).
|
||||
# Non-test processes start a contender; forking servers stop before fork and
|
||||
# start again in the child so the flock is never inherited.
|
||||
pragmas:
|
||||
wal_autocheckpoint: 0
|
||||
|
||||
development:
|
||||
primary:
|
||||
|
||||
@@ -80,5 +80,5 @@ Rails.application.configure do
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
# Visit /rails/locks to see the locks
|
||||
config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks
|
||||
config.middleware.insert_before ActionDispatch::Executor, ActionDispatch::DebugLocks
|
||||
end
|
||||
@@ -1,5 +1,20 @@
|
||||
require "rails_ext/time_limited_video_previewer"
|
||||
|
||||
ActiveSupport.on_load(:active_storage_blob) do
|
||||
ActiveStorage::DiskController.after_action only: :show do
|
||||
response.set_header("Cache-Control", "max-age=3600, public")
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.configure do
|
||||
# Rails' filter takes the second of the frames it selects (the first one, keyframes, scene changes), so a video
|
||||
# with a single keyframe and no scene change is decoded to its end. Selecting any frame from 5 seconds on stops
|
||||
# it there.
|
||||
config.active_storage.video_preview_arguments =
|
||||
"-vf 'select=eq(n\\,0)+eq(key\\,1)+gt(scene\\,0.015)+gte(t\\,5),loop=loop=-1:size=2,trim=start_frame=1'" \
|
||||
" -frames:v 1 -f image2"
|
||||
|
||||
config.active_storage.previewers = config.active_storage.previewers.map do |previewer|
|
||||
previewer == ActiveStorage::Previewer::VideoPreviewer ? TimeLimitedVideoPreviewer : previewer
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@
|
||||
# can read the public login page. Require a valid Campfire session before an
|
||||
# anonymous caller can allocate a Blob or persist bytes to disk.
|
||||
Rails.application.config.to_prepare do
|
||||
ActiveStorage::BaseController.forgery_protection_verification_strategy = :header_only
|
||||
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
|
||||
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
require "jbuilder/jbuilder_template"
|
||||
|
||||
# Jbuilder 2.14.1 uses Rails.cache directly; use the same bounded store as ERB
|
||||
# while retaining its native keys and controller instrumentation.
|
||||
module BoundedJbuilderFragments
|
||||
private
|
||||
def _read_fragment_cache(key, options = nil)
|
||||
@context.controller.instrument_fragment_cache :read_fragment, key do
|
||||
@context.controller.cache_store.read(key, options)
|
||||
end
|
||||
end
|
||||
|
||||
def _write_fragment_cache(key, options = nil)
|
||||
@context.controller.instrument_fragment_cache :write_fragment, key do
|
||||
yield.tap { |value| @context.controller.cache_store.write(key, value, options) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
JbuilderTemplate.prepend(BoundedJbuilderFragments)
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
ActionView::PartialRenderer.collection_cache = FragmentCache.store
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
# schema.rb cannot dump SQLite triggers. Cover existing DBs that lost them.
|
||||
Rails.application.config.after_initialize do
|
||||
Room::MessagesCount.ensure!
|
||||
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
|
||||
end
|
||||
@@ -6,3 +6,11 @@ if Rails.env.production? && ENV["SKIP_TELEMETRY"].blank?
|
||||
config.release = ENV["GIT_REVISION"]
|
||||
end
|
||||
end
|
||||
|
||||
# sentry-rails declares its Action Cable handle_open and handle_close wrappers private,
|
||||
# but Rails 8.2 calls them from outside the connection, so every /cable connection fails.
|
||||
# Remove once sentry-rails ships getsentry/sentry-ruby#2972 (issue #2975).
|
||||
ActiveSupport.on_load(:action_cable_connection) do
|
||||
require "sentry/rails/action_cable"
|
||||
Sentry::Rails::ActionCableExtensions::Connection.send(:public, :handle_open, :handle_close)
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
Rails.application.config.session_store :cookie_store,
|
||||
key: "_campfire_session",
|
||||
# Persist session cookie as permament so re-opened browser windows maintain a CSRF token
|
||||
# Preserve the existing installation's persistent browser-session cookie.
|
||||
expire_after: 20.years
|
||||
@@ -0,0 +1,8 @@
|
||||
# Loaded from lib/rails_ext (autoload_lib ignore list) so reloads do not orphan
|
||||
# the contender thread. Non-test processes start here; Puma/Resque stop before
|
||||
# fork and start again in the child.
|
||||
require Rails.root.join("lib/rails_ext/sqlite_wal_checkpoint")
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
SqliteWalCheckpoint.start unless Rails.env.test?
|
||||
end
|
||||
@@ -1,2 +1,2 @@
|
||||
# Used to match JavaScripts (new Date).getTime() for sorting
|
||||
Time::DATE_FORMATS[:epoch] = ->(time) { (time.to_f * 1000).to_i }
|
||||
ActiveSupport::TimeFormats.register(:epoch, ->(time) { (time.to_f * 1000).to_i })
|
||||
@@ -1,4 +1,5 @@
|
||||
require "web-push"
|
||||
require "web_push/connections"
|
||||
require "web_push/pool"
|
||||
require "web_push/notification"
|
||||
|
||||
@@ -17,43 +18,31 @@ end
|
||||
|
||||
module WebPush::PersistentRequest
|
||||
def perform
|
||||
if endpoint_ip = @options[:endpoint_ip]
|
||||
# Pin the connection to the public IP resolved (and guarded) by
|
||||
# Push::Subscription so delivery can't be rebound to a private address
|
||||
# between resolution and connect. Bypasses the shared persistent pool,
|
||||
# which would re-resolve the host itself.
|
||||
#
|
||||
# The explicit nil proxy address disables proxy discovery from
|
||||
# http_proxy/https_proxy. An egress proxy would open the TCP connection
|
||||
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
|
||||
# pin the destination and the DNS-rebinding guarantee would be lost. This
|
||||
# path is already committed to a direct connection (it bypasses the pool);
|
||||
# push delivery to public vendor endpoints goes direct.
|
||||
http = Net::HTTP.new(uri.host, uri.port, nil)
|
||||
http.ipaddr = endpoint_ip
|
||||
http.use_ssl = true
|
||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
||||
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
||||
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
|
||||
elsif @options[:connection]
|
||||
http = @options[:connection]
|
||||
else
|
||||
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
|
||||
http.use_ssl = true
|
||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
||||
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
||||
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
|
||||
end
|
||||
# Pin the connection to the public IP resolved (and guarded) by
|
||||
# Push::Subscription so delivery can't be rebound to a private address
|
||||
# between resolution and connect. There is no unpinned path: a delivery
|
||||
# without a resolved IP is not sent.
|
||||
endpoint_ip = @options[:endpoint_ip] or raise ArgumentError, "Push deliveries must be pinned to a resolved endpoint IP"
|
||||
|
||||
# The explicit nil proxy address disables proxy discovery from
|
||||
# http_proxy/https_proxy. An egress proxy would open the TCP connection
|
||||
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
|
||||
# pin the destination and the DNS-rebinding guarantee would be lost.
|
||||
# Push delivery to public vendor endpoints goes direct.
|
||||
http = Net::HTTP.new(uri.host, uri.port, nil)
|
||||
http.ipaddr = endpoint_ip
|
||||
http.use_ssl = true
|
||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
||||
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
||||
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
|
||||
|
||||
req = Net::HTTP::Post.new(uri.request_uri, headers)
|
||||
req.body = body
|
||||
|
||||
if http.is_a?(Net::HTTP::Persistent)
|
||||
response = http.request uri, req
|
||||
else
|
||||
resp = http.request(req)
|
||||
verify_response(resp)
|
||||
end
|
||||
# WebPush::Connections reuses an open connection only for this same host
|
||||
# and pinned IP, so the guarantee holds for every request it sends.
|
||||
resp = @options[:connection] ? @options[:connection].request(http, req) : http.request(req)
|
||||
verify_response(resp)
|
||||
|
||||
resp
|
||||
end
|
||||
|
||||
@@ -50,6 +50,12 @@ plugin :tmp_restart
|
||||
# Reset all membership connections
|
||||
Membership.disconnect_all
|
||||
|
||||
# Initializer starts a contender in this process. Stop before fork so workers
|
||||
# do not inherit the flock; each worker starts its own contender. Single-process
|
||||
# mode never forks, so the initializer's contender keeps running.
|
||||
before_fork { SqliteWalCheckpoint.stop }
|
||||
before_worker_boot { SqliteWalCheckpoint.start }
|
||||
|
||||
Signal.trap :SIGPROF do
|
||||
Thread.list.each do |t|
|
||||
puts t
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_index :messages, %i[ room_id created_at ], if_not_exists: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddRoomIdAndUpdatedAtIndexToMessages < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_index :messages, %i[ room_id updated_at ], if_not_exists: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class AddMessagesCountToRooms < ActiveRecord::Migration[8.2]
|
||||
def up
|
||||
add_column :rooms, :messages_count, :integer, null: false, default: 0
|
||||
|
||||
# Backfill before installing triggers so the COUNT rewrite does not race with
|
||||
# concurrent inserts, and so we never rely on Rails callbacks for the tally.
|
||||
Room::MessagesCount.backfill!(connection)
|
||||
Room::MessagesCount.install!(connection)
|
||||
end
|
||||
|
||||
def down
|
||||
Room::MessagesCount.uninstall!(connection)
|
||||
remove_column :rooms, :messages_count
|
||||
end
|
||||
end
|
||||
Generated
+5
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_10_07_190000) do
|
||||
create_table "accounts", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.text "custom_styles"
|
||||
@@ -101,6 +101,8 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
|
||||
t.integer "room_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["creator_id"], name: "index_messages_on_creator_id"
|
||||
t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at"
|
||||
t.index ["room_id", "updated_at"], name: "index_messages_on_room_id_and_updated_at"
|
||||
t.index ["room_id"], name: "index_messages_on_room_id"
|
||||
end
|
||||
|
||||
@@ -122,6 +124,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
|
||||
t.string "name"
|
||||
t.string "type", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "messages_count", default: 0, null: false
|
||||
end
|
||||
|
||||
create_table "searches", force: :cascade do |t|
|
||||
@@ -181,3 +184,4 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
|
||||
# Note that virtual tables may not work with other database engines. Be careful if changing database.
|
||||
create_virtual_table "message_search_index", "fts5", ["body", "tokenize=porter"]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# SQLite's default WAL auto-checkpoint (~1,000 pages) runs inside the committing
|
||||
# writer and fsyncs during the request. database.yml sets wal_autocheckpoint=0;
|
||||
# this module copies pages off the request thread with PASSIVE checkpoints.
|
||||
#
|
||||
# Every non-test process starts a contender. Callers that fork (Puma, Resque pool)
|
||||
# must stop before fork and start again in the child so the flock is never shared
|
||||
# across an inherited file descriptor.
|
||||
module SqliteWalCheckpoint
|
||||
INTERVAL = 0.25
|
||||
MAX_BACKOFF = 30.0
|
||||
STOP_TIMEOUT = 5.0
|
||||
LOCK_PATH = Rails.root.join("tmp/pids/sqlite_wal_checkpoint.lock")
|
||||
LIFECYCLE_MUTEX = Mutex.new
|
||||
private_constant :LIFECYCLE_MUTEX
|
||||
|
||||
class StopTimeout < StandardError; end
|
||||
|
||||
class << self
|
||||
attr_writer :lock_path, :database_path_override
|
||||
|
||||
def start(interval: INTERVAL, enabled: !Rails.env.test?)
|
||||
return unless enabled
|
||||
|
||||
LIFECYCLE_MUTEX.synchronize do
|
||||
if @thread&.alive?
|
||||
raise StopTimeout, "SQLite WAL checkpointer is still stopping" if @wakeup.closed?
|
||||
|
||||
return @thread
|
||||
end
|
||||
|
||||
@wakeup = Thread::Queue.new
|
||||
install_exit_checkpoint
|
||||
@thread = Thread.new(@wakeup) { |wakeup| run(interval, wakeup) }
|
||||
@thread.report_on_exception = false
|
||||
@thread
|
||||
end
|
||||
end
|
||||
|
||||
# Signal the contender to exit and wait until it has released the flock and
|
||||
# closed its SQLite connection. before_fork must not return while those are open.
|
||||
def stop(timeout: STOP_TIMEOUT)
|
||||
LIFECYCLE_MUTEX.synchronize do
|
||||
return unless @thread
|
||||
|
||||
@wakeup.close
|
||||
unless @thread.join(timeout)
|
||||
# Never let a caller fork with live SQLite or flock ownership.
|
||||
raise StopTimeout, "SQLite WAL checkpointer did not stop within #{timeout}s"
|
||||
end
|
||||
@thread = @wakeup = nil
|
||||
end
|
||||
end
|
||||
|
||||
def checkpoint
|
||||
with_database { |database| checkpoint_on(database) }
|
||||
end
|
||||
|
||||
# One leadership attempt for tests: acquire the lock, checkpoint once, release.
|
||||
def tick
|
||||
return :no_database unless database_path
|
||||
lock = acquire_lock
|
||||
return :busy unless lock
|
||||
|
||||
begin
|
||||
result = checkpoint
|
||||
result.nil? ? :no_database : :checkpointed
|
||||
ensure
|
||||
release_lock(lock)
|
||||
end
|
||||
end
|
||||
|
||||
def lock_path
|
||||
@lock_path || LOCK_PATH
|
||||
end
|
||||
|
||||
def reset!
|
||||
stop
|
||||
@lock_path = nil
|
||||
@database_path_override = nil
|
||||
end
|
||||
|
||||
private
|
||||
def run(interval, wakeup)
|
||||
Thread.current.name = "sqlite-wal-checkpoint"
|
||||
backoff = interval
|
||||
|
||||
until wakeup.closed?
|
||||
begin
|
||||
path = database_path
|
||||
unless path && File.exist?(path)
|
||||
wait(wakeup, interval)
|
||||
next
|
||||
end
|
||||
|
||||
lock = acquire_lock
|
||||
unless lock
|
||||
wait(wakeup, interval)
|
||||
next
|
||||
end
|
||||
|
||||
begin
|
||||
ran = false
|
||||
with_database do |database|
|
||||
ran = true
|
||||
until wakeup.closed?
|
||||
checkpoint_on(database)
|
||||
backoff = interval
|
||||
wait(wakeup, interval)
|
||||
end
|
||||
end
|
||||
ensure
|
||||
release_lock(lock)
|
||||
end
|
||||
|
||||
# with_database no-ops if the file vanished between the exist? check
|
||||
# and open; sleep so we do not spin on the lock file.
|
||||
wait(wakeup, interval) unless ran || wakeup.closed?
|
||||
rescue => error
|
||||
Rails.logger.warn "SQLite WAL checkpoint failed: #{error.class}: #{error.message}"
|
||||
wait(wakeup, backoff)
|
||||
backoff = [ backoff * 2, MAX_BACKOFF ].min
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def wait(wakeup, timeout)
|
||||
wakeup.pop(timeout: timeout)
|
||||
end
|
||||
|
||||
def checkpoint_on(database)
|
||||
database.execute("PRAGMA wal_checkpoint(PASSIVE)").first
|
||||
end
|
||||
|
||||
def with_database
|
||||
path = database_path
|
||||
return unless path && File.exist?(path)
|
||||
|
||||
result = nil
|
||||
SQLite3::Database.new(path) do |database|
|
||||
# Bounds busy-handler retries, not PASSIVE checkpoint I/O.
|
||||
database.busy_handler_timeout = 1_000
|
||||
result = yield database
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def database_path
|
||||
return @database_path_override if @database_path_override
|
||||
|
||||
config = ActiveRecord::Base.connection_db_config
|
||||
return unless config.adapter.to_s == "sqlite3"
|
||||
|
||||
ActiveRecord::ConnectionAdapters::SQLite3Adapter.resolve_path(config.database)
|
||||
end
|
||||
|
||||
def acquire_lock
|
||||
FileUtils.mkdir_p(File.dirname(lock_path))
|
||||
file = File.open(lock_path, File::RDWR | File::CREAT, 0644)
|
||||
if file.flock(File::LOCK_EX | File::LOCK_NB)
|
||||
file
|
||||
else
|
||||
file.close
|
||||
nil
|
||||
end
|
||||
rescue
|
||||
file&.close
|
||||
raise
|
||||
end
|
||||
|
||||
def release_lock(file)
|
||||
return unless file
|
||||
|
||||
file.flock(File::LOCK_UN)
|
||||
ensure
|
||||
file&.close
|
||||
end
|
||||
|
||||
# Best-effort PASSIVE for short-lived console/rake writers that exit before
|
||||
# the contender acquires the flock. Does not touch lock ownership.
|
||||
def install_exit_checkpoint
|
||||
return if @exit_checkpoint_installed
|
||||
|
||||
@exit_checkpoint_installed = true
|
||||
at_exit { checkpoint rescue nil }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
# Rails waits for ffmpeg however long it takes, and a video's preview is drawn inside the request that posts it.
|
||||
# This previewer gives ffmpeg a wall-clock limit, kills it past that, and reports a failed preview, so the message
|
||||
# is posted without one.
|
||||
class TimeLimitedVideoPreviewer < ActiveStorage::Previewer::VideoPreviewer
|
||||
TIME_LIMIT = 10 # seconds
|
||||
|
||||
private
|
||||
def capture(*argv, to:)
|
||||
to.binmode
|
||||
|
||||
open_tempfile do |err|
|
||||
IO.popen(argv, in: IO::NULL, err: err) do |out|
|
||||
Timeout.timeout(TIME_LIMIT) { IO.copy_stream(out, to) }
|
||||
rescue Timeout::Error
|
||||
Process.kill :KILL, out.pid
|
||||
raise ActiveStorage::PreviewError, "#{argv.first} took longer than #{TIME_LIMIT} seconds"
|
||||
end
|
||||
err.rewind
|
||||
|
||||
unless $?.success?
|
||||
raise ActiveStorage::PreviewError, "#{argv.first} failed (status #{$?.exitstatus}): #{err.read.to_s.chomp}"
|
||||
end
|
||||
end
|
||||
|
||||
to.rewind
|
||||
end
|
||||
end
|
||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user