mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3857b36b2a | ||
|
|
ca9b963961 | ||
|
|
59d2164b11 | ||
|
|
eccb2d6c91 | ||
|
|
d7589951cf | ||
|
|
c8f74a0b0e | ||
|
|
8d4a5264cf | ||
|
|
ce7b5d4ea0 | ||
|
|
a10211fb8a | ||
|
|
a476cbfa24 | ||
|
|
c3faccb0f4 | ||
|
|
19319a1cff | ||
|
|
ac073010c2 | ||
|
|
4a580302a5 | ||
|
|
af4dd9fd59 | ||
|
|
aa93269ceb | ||
|
|
3786ee5244 | ||
|
|
1af62a5bff | ||
|
|
6cab924097 | ||
|
|
44a92ff6c6 | ||
|
|
160ba295b5 | ||
|
|
e158388f3d | ||
|
|
b01bb7a0a7 | ||
|
|
c6e0105491 | ||
|
|
c78c3fd7ef | ||
|
|
11d3fb9ce0 | ||
|
|
2ba940c683 | ||
|
|
0a494a10cb | ||
|
|
2be2227f40 | ||
|
|
2a449401ec | ||
|
|
9c37f41ff6 | ||
|
|
d713f2631d | ||
|
|
562888251e | ||
|
|
cad2d7211d | ||
|
|
821ff92dda | ||
|
|
9b271d0e19 | ||
|
|
4e29c6e32b | ||
|
|
6052b123d5 | ||
|
|
7ea05e18f8 | ||
|
|
1ac6dfde84 | ||
|
|
b2140cf2aa | ||
|
|
edbc779f4d | ||
|
|
2daa01e26b | ||
|
|
2d4b05a74a | ||
|
|
a7e9ea53dd | ||
|
|
b52b244f38 | ||
|
|
05c5a2c0d7 | ||
|
|
81973c564c | ||
|
|
5796e5c837 | ||
|
|
19c9e6b6f9 | ||
|
|
0aa339d81e | ||
|
|
b042182791 | ||
|
|
6944a4a796 | ||
|
|
4bcc745f7a | ||
|
|
2e89e08003 | ||
|
|
7331d3a946 | ||
|
|
e9f8b7117e | ||
|
|
59eb690f23 | ||
|
|
9f1b73a75f | ||
|
|
da44351c5f | ||
|
|
24042e6b4c | ||
|
|
66883b6fb1 | ||
|
|
bb338a8c74 | ||
|
|
9b8a1683a7 | ||
|
|
a0d72938f3 | ||
|
|
a9007010c1 | ||
|
|
bbee420097 | ||
|
|
74381e8b58 | ||
|
|
9ad45892ed | ||
|
|
59e1c6d3d7 | ||
|
|
2c53c46767 | ||
|
|
5940ea068f | ||
|
|
e55b6dc3e5 | ||
|
|
f36b5a2fae | ||
|
|
bec1577529 | ||
|
|
ec31092919 | ||
|
|
0f5d0b2b6e | ||
|
|
008ea1ab2a | ||
|
|
8de3e22ab4 | ||
|
|
f792e16e64 | ||
|
|
fd49099729 | ||
|
|
d9c3207886 | ||
|
|
cd0414ef8f | ||
|
|
b4ab2df20f | ||
|
|
32748e7fd6 | ||
|
|
7271eb59f4 | ||
|
|
9fd1563c9b | ||
|
|
105f4b8fd1 | ||
|
|
a95361dd15 | ||
|
|
8d02540e31 | ||
|
|
1f5858098b | ||
|
|
85ad590632 | ||
|
|
8704baa860 | ||
|
|
61631ea5f9 | ||
|
|
b220486c16 | ||
|
|
88acc4e026 | ||
|
|
ac73267b07 | ||
|
|
af4f94c4bd | ||
|
|
1e61cfc8f3 | ||
|
|
244e77241b |
No files matched your search
@@ -21,7 +21,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
|
||||||
env:
|
env:
|
||||||
REDIS_URL: redis://localhost:6379/0
|
REDIS_URL: redis://localhost:6379/0
|
||||||
with:
|
with:
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
|
||||||
env:
|
env:
|
||||||
REDIS_URL: redis://localhost:6379/0
|
REDIS_URL: redis://localhost:6379/0
|
||||||
with:
|
with:
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.4.10
|
4.0.9
|
||||||
+13
-1
@@ -1,7 +1,7 @@
|
|||||||
# syntax = docker/dockerfile:1
|
# syntax = docker/dockerfile:1
|
||||||
|
|
||||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||||
ARG RUBY_VERSION=3.4.10
|
ARG RUBY_VERSION=4.0.9
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
# Rails app lives here
|
# Rails app lives here
|
||||||
@@ -13,6 +13,18 @@ RUN apt-get update -qq && \
|
|||||||
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
||||||
rm -rf /var/lib/apt/lists /var/cache/apt/archive
|
rm -rf /var/lib/apt/lists /var/cache/apt/archive
|
||||||
|
|
||||||
|
# Fail the build if either of these withdrawn roots is still where Ruby's OpenSSL looks for
|
||||||
|
# trusted certificates
|
||||||
|
RUN ruby -ropenssl -e ' \
|
||||||
|
file = ENV[OpenSSL::X509::DEFAULT_CERT_FILE_ENV] || OpenSSL::X509::DEFAULT_CERT_FILE; \
|
||||||
|
dir = ENV[OpenSSL::X509::DEFAULT_CERT_DIR_ENV] || OpenSSL::X509::DEFAULT_CERT_DIR; \
|
||||||
|
pems = [file, *Dir.glob(File.join(dir, "*"))].select { |path| File.file?(path) } \
|
||||||
|
.flat_map { |path| File.read(path).scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m) }; \
|
||||||
|
subjects = pems.map { |pem| OpenSSL::X509::Certificate.new(pem).subject.to_s }.uniq; \
|
||||||
|
abort "Only #{subjects.size} roots in #{file} and #{dir}" if subjects.size < 100; \
|
||||||
|
withdrawn = subjects.grep(%r{/CN=(emSign ECC Root CA - G3|AAA Certificate Services)\z}); \
|
||||||
|
abort "Withdrawn roots still trusted: #{withdrawn}" if withdrawn.any?'
|
||||||
|
|
||||||
# Set production environment
|
# Set production environment
|
||||||
ENV RAILS_ENV="production" \
|
ENV RAILS_ENV="production" \
|
||||||
BUNDLE_DEPLOYMENT="1" \
|
BUNDLE_DEPLOYMENT="1" \
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# syntax = docker/dockerfile:1
|
# syntax = docker/dockerfile:1
|
||||||
|
|
||||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||||
ARG RUBY_VERSION=3.4.10
|
ARG RUBY_VERSION=4.0.9
|
||||||
FROM ruby:$RUBY_VERSION-slim as base
|
FROM ruby:$RUBY_VERSION-slim as base
|
||||||
|
|
||||||
# Install the tools we need
|
# Install the tools we need
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ gem "sqlite3"
|
|||||||
gem "redis", "~> 5.4"
|
gem "redis", "~> 5.4"
|
||||||
|
|
||||||
# Deployment
|
# Deployment
|
||||||
gem "puma", "~> 7.2", ">= 7.2.1"
|
gem "puma", "~> 8.0"
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
gem "resque", "~> 2.7.0"
|
gem "resque", "~> 2.7.0"
|
||||||
|
|||||||
+21
-21
@@ -146,7 +146,7 @@ GEM
|
|||||||
bcrypt (3.1.22)
|
bcrypt (3.1.22)
|
||||||
benchmark (0.5.0)
|
benchmark (0.5.0)
|
||||||
bigdecimal (4.1.3)
|
bigdecimal (4.1.3)
|
||||||
brakeman (8.0.6)
|
brakeman (8.1.0)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -167,9 +167,9 @@ GEM
|
|||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.7)
|
crass (1.0.8)
|
||||||
date (3.5.1)
|
date (3.5.1)
|
||||||
debug (1.11.0)
|
debug (1.11.1)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
@@ -270,13 +270,13 @@ GEM
|
|||||||
useragent (~> 0.16.3)
|
useragent (~> 0.16.3)
|
||||||
pp (0.6.4)
|
pp (0.6.4)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.1)
|
||||||
prism (1.9.0)
|
prism (1.9.0)
|
||||||
psych (5.5.0)
|
psych (5.5.0)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.2.1)
|
puma (8.0.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.7)
|
rack (3.2.7)
|
||||||
@@ -336,29 +336,29 @@ GEM
|
|||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.1.0)
|
rqrcode_core (2.1.0)
|
||||||
rubocop (1.80.2)
|
rubocop (1.91.0)
|
||||||
json (~> 2.3)
|
json (>= 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
parallel (~> 1.10)
|
parallel (>= 1.10)
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.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)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.46.0)
|
rubocop-ast (1.50.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.7)
|
||||||
rubocop-performance (1.25.0)
|
rubocop-performance (1.27.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.89.0, < 2.0)
|
||||||
rubocop-ast (>= 1.38.0, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
rubocop-rails (2.33.3)
|
rubocop-rails (2.38.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 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-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-rails-omakase (1.1.0)
|
rubocop-rails-omakase (1.1.0)
|
||||||
rubocop (>= 1.72)
|
rubocop (>= 1.72)
|
||||||
@@ -390,10 +390,10 @@ GEM
|
|||||||
rack-protection (= 4.2.1)
|
rack-protection (= 4.2.1)
|
||||||
rack-session (>= 2.0.0, < 3)
|
rack-session (>= 2.0.0, < 3)
|
||||||
tilt (~> 2.0)
|
tilt (~> 2.0)
|
||||||
sqlite3 (2.9.6-aarch64-linux-gnu)
|
sqlite3 (2.10.2-aarch64-linux-gnu)
|
||||||
sqlite3 (2.9.6-arm64-darwin)
|
sqlite3 (2.10.2-arm64-darwin)
|
||||||
sqlite3 (2.9.6-x86_64-darwin)
|
sqlite3 (2.10.2-x86_64-darwin)
|
||||||
sqlite3 (2.9.6-x86_64-linux-gnu)
|
sqlite3 (2.10.2-x86_64-linux-gnu)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.2.0)
|
stringio (3.2.0)
|
||||||
@@ -452,7 +452,7 @@ DEPENDENCIES
|
|||||||
ostruct
|
ostruct
|
||||||
platform_agent
|
platform_agent
|
||||||
propshaft!
|
propshaft!
|
||||||
puma (~> 7.2, >= 7.2.1)
|
puma (~> 8.0)
|
||||||
rails!
|
rails!
|
||||||
rails_autolink
|
rails_autolink
|
||||||
redis (~> 5.4)
|
redis (~> 5.4)
|
||||||
|
|||||||
@@ -49,17 +49,26 @@ 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
|
> 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.
|
> 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
|
## Other implementations
|
||||||
|
|
||||||
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
|
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, 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) |
|
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|
||||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||||
| Room page | 230 | 62 | 760 | 2,622 | 942 | 31,673 | 35,484 | 141,834 |
|
| Room page | 4,101 | 1,507 | 3,872 | 42,636 | 5,350 | 53,060 | 106,494 | 137,524 |
|
||||||
| Messages page | 402 | 70 | 924 | 3,245 | 1,267 | 30,746 | 40,674 | 151,564 |
|
| Messages page | 4,115 | 1,596 | 3,995 | 74,362 | 5,712 | 54,800 | 102,697 | 144,642 |
|
||||||
| Sidebar | 468 | 229 | 1,383 | 34,938 | 2,515 | 18,586 | 34,479 | 159,850 |
|
| Sidebar | 4,333 | 1,873 | 4,493 | 94,329 | 5,949 | 59,144 | 120,294 | 152,002 |
|
||||||
| Search | 399 | 118 | 1,135 | 6,613 | 1,814 | 29,765 | 34,432 | 155,456 |
|
| Search | 4,282 | 1,862 | 4,172 | 84,665 | 5,848 | 60,509 | 121,378 | 149,487 |
|
||||||
| Post a message | 248 | 112 | 498 | 2,088 | 1,400 | 9,073 | 8,998 | 7,460 |
|
| 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).
|
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
|
||||||
|
|
||||||
@@ -74,3 +83,7 @@ Please see our [development guide](docs/development.md) for how to get Campfire
|
|||||||
## Security
|
## Security
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
|
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.
|
||||||
@@ -2,19 +2,42 @@ module ApplicationCable
|
|||||||
class Connection < ActionCable::Connection::Base
|
class Connection < ActionCable::Connection::Base
|
||||||
include Authentication::SessionLookup
|
include Authentication::SessionLookup
|
||||||
|
|
||||||
|
SESSION_CHECK_INTERVAL = 1.minute
|
||||||
|
|
||||||
identified_by :current_user
|
identified_by :current_user
|
||||||
|
|
||||||
def connect
|
def connect
|
||||||
self.current_user = find_verified_user
|
self.current_user = find_verified_user
|
||||||
|
@session_checked_at = Time.now
|
||||||
|
end
|
||||||
|
|
||||||
|
# Signing out or changing the password ends sessions and then disconnects their connections, but a connection can
|
||||||
|
# miss that: one that found its session just before isn't listening yet. So on the server's heartbeat, which
|
||||||
|
# reaches every open connection, each one looks its session up again now and then, and closes once it's gone. A
|
||||||
|
# rejected connection has no session, and stays registered until its socket closes.
|
||||||
|
def beat
|
||||||
|
super
|
||||||
|
check_session if @session_checked_at&.before?(SESSION_CHECK_INTERVAL.ago)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def find_verified_user
|
def find_verified_user
|
||||||
if verified_session = find_session_by_cookie
|
if @verified_session = find_session_by_cookie
|
||||||
verified_session.user
|
@verified_session.user
|
||||||
else
|
else
|
||||||
reject_unauthorized_connection
|
reject_unauthorized_connection
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_session
|
||||||
|
@session_checked_at = Time.now
|
||||||
|
perform_work -> { close_unless_session_remains }, :call
|
||||||
|
end
|
||||||
|
|
||||||
|
def close_unless_session_remains
|
||||||
|
unless Session.exists?(@verified_session.id)
|
||||||
|
close reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized]
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
|
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses, InlineScriptHashes
|
||||||
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
||||||
end
|
end
|
||||||
@@ -7,7 +7,7 @@ module Authentication
|
|||||||
before_action :deny_bots
|
before_action :deny_bots
|
||||||
helper_method :signed_in?
|
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
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
@@ -57,7 +57,7 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def start_new_session_for(user)
|
def start_new_session_for(user)
|
||||||
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||||
authenticated_as session
|
authenticated_as session
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
module Authentication::SessionLookup
|
module Authentication::SessionLookup
|
||||||
def find_session_by_cookie
|
def find_session_by_cookie
|
||||||
if token = cookies.signed[:session_token]
|
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
|
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
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Allows the scripts the importmap helper emits and nothing else: the inline
|
||||||
|
# importmap and its entry-point module by content hash, and the asset
|
||||||
|
# directory its URLs point into. Not the whole origin, because uploads are
|
||||||
|
# served from this origin too. The hashes change only when the importmap does
|
||||||
|
# (a deploy), so cached HTML stays valid, unlike a per-request nonce.
|
||||||
|
module InlineScriptHashes
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
ENTRY_POINT_SCRIPT = %(import "application")
|
||||||
|
|
||||||
|
included do
|
||||||
|
# With the policy turned off there's nothing to add to, so leave the
|
||||||
|
# request without one rather than starting an empty policy
|
||||||
|
content_security_policy if: -> { request.content_security_policy } do |policy|
|
||||||
|
if sources = policy.directives["script-src"]
|
||||||
|
importmap = Rails.application.importmap.to_json(resolver: helpers)
|
||||||
|
policy.directives["script-src"] = (sources - [ "'self'" ]) | script_sources(importmap)
|
||||||
|
policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mattr_accessor :last_script_sources
|
||||||
|
|
||||||
|
private
|
||||||
|
# Parsing the importmap and resolving each of its URLs costs about a
|
||||||
|
# millisecond a page, yet the answer changes only with the importmap and
|
||||||
|
# the host the page is served from. Keep the last one: a single entry, so
|
||||||
|
# a request with a made-up Host header can't grow it.
|
||||||
|
def script_sources(importmap)
|
||||||
|
key = [ importmap, request.base_url, request.host_with_port, config.relative_url_root ]
|
||||||
|
cached_key, sources = InlineScriptHashes.last_script_sources
|
||||||
|
|
||||||
|
if cached_key == key
|
||||||
|
sources
|
||||||
|
else
|
||||||
|
(script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)).freeze.tap do |sources|
|
||||||
|
InlineScriptHashes.last_script_sources = [ key, sources ].freeze
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The importmap is resolved by Rails' asset helpers, so its URLs already
|
||||||
|
# carry any relative root and asset host, however the host is written:
|
||||||
|
# with or without a scheme, protocol-relative, a %d set, or a proc. Each
|
||||||
|
# is allowed up to its asset directory, the last one in its path, since an
|
||||||
|
# asset host's own path may contain one too; a URL outside it is allowed
|
||||||
|
# exactly.
|
||||||
|
#
|
||||||
|
# This origin's own sources leave out the scheme, so they match the page's
|
||||||
|
# scheme even behind a proxy that terminates TLS without saying so.
|
||||||
|
#
|
||||||
|
# A path only limits the first request: browsers check just the origin
|
||||||
|
# after a redirect. Nothing under the asset directory redirects.
|
||||||
|
def script_locations(importmap)
|
||||||
|
assets = File.join(config.relative_url_root.to_s, Rails.application.config.assets.prefix, "")
|
||||||
|
|
||||||
|
JSON.parse(importmap)["imports"].values.map do |url|
|
||||||
|
url = URI.join(request.base_url, url)
|
||||||
|
origin = url.origin == request.base_url ? request.host_with_port : url.origin
|
||||||
|
directory = url.path[0, url.path.rindex(assets) + assets.length] if url.path.include?(assets)
|
||||||
|
"#{origin}#{directory || url.path}"
|
||||||
|
end.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def script_hashes(*scripts)
|
||||||
|
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,7 +7,10 @@ module RoomScoped
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
def set_room
|
||||||
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
|
@membership, @room = RecordCache.fetch([ "membership", Current.user.id, params[:room_id] ], read_record_cache_version) do
|
||||||
@room = @membership.room
|
membership = Current.user.memberships.find_by!(room_id: params[:room_id])
|
||||||
|
[ membership, membership.room ]
|
||||||
|
end
|
||||||
|
@membership.room = @room
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -37,7 +37,7 @@ class Messages::ByBotsController < MessagesController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_pagination_headers
|
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
|
if next_page = next_page_params
|
||||||
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
|
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_room, except: :create
|
||||||
before_action :set_message, only: %i[ show edit update destroy ]
|
before_action :set_message, only: %i[ show edit update destroy ]
|
||||||
before_action :ensure_can_administer, only: %i[ edit update destroy ]
|
before_action :ensure_can_administer, only: %i[ edit update destroy ]
|
||||||
|
around_action :cache_read_response, only: :index
|
||||||
|
|
||||||
layout false, only: :index
|
layout false, only: :index
|
||||||
|
|
||||||
@@ -11,7 +12,13 @@ class MessagesController < ApplicationController
|
|||||||
@messages = find_paged_messages
|
@messages = find_paged_messages
|
||||||
|
|
||||||
if @messages.any?
|
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
|
else
|
||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
@@ -21,7 +28,9 @@ class MessagesController < ApplicationController
|
|||||||
set_room
|
set_room
|
||||||
@message = @room.messages.create_with_attachment!(message_params)
|
@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
|
deliver_webhooks_to_bots
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render action: :room_not_found
|
render action: :room_not_found
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ class QrCodeController < ApplicationController
|
|||||||
|
|
||||||
expires_in 1.year, public: true
|
expires_in 1.year, public: true
|
||||||
render plain: qr_code, content_type: "image/svg+xml"
|
render plain: qr_code, content_type: "image/svg+xml"
|
||||||
|
rescue ArgumentError, RQRCodeCore::QRCodeRunTimeError
|
||||||
|
head :bad_request
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2,6 +2,7 @@ class RoomsController < ApplicationController
|
|||||||
before_action :set_room, only: %i[ show destroy ]
|
before_action :set_room, only: %i[ show destroy ]
|
||||||
before_action :ensure_can_administer, only: %i[ destroy ]
|
before_action :ensure_can_administer, only: %i[ destroy ]
|
||||||
before_action :remember_last_room_visited, only: :show
|
before_action :remember_last_room_visited, only: :show
|
||||||
|
around_action :cache_read_response, only: :show
|
||||||
|
|
||||||
def index
|
def index
|
||||||
redirect_to room_url(Current.user.rooms.last)
|
redirect_to room_url(Current.user.rooms.last)
|
||||||
@@ -20,7 +21,10 @@ class RoomsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
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
|
@room = room
|
||||||
else
|
else
|
||||||
redirect_to root_url, alert: "Room not found or inaccessible"
|
redirect_to root_url, alert: "Room not found or inaccessible"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
class SearchesController < ApplicationController
|
class SearchesController < ApplicationController
|
||||||
|
around_action :cache_read_response, only: :index
|
||||||
before_action :set_messages
|
before_action :set_messages
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ class Sessions::TransfersController < ApplicationController
|
|||||||
else
|
else
|
||||||
head :bad_request
|
head :bad_request
|
||||||
end
|
end
|
||||||
|
rescue ActiveRecord::RecordInvalid
|
||||||
|
head :bad_request
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -14,6 +14,8 @@ class SessionsController < ApplicationController
|
|||||||
else
|
else
|
||||||
render_rejection :unauthorized
|
render_rejection :unauthorized
|
||||||
end
|
end
|
||||||
|
rescue ActiveRecord::RecordInvalid
|
||||||
|
render_rejection :unauthorized
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
class Users::ProfilesController < ApplicationController
|
class Users::ProfilesController < ApplicationController
|
||||||
before_action :set_user
|
before_action :set_user
|
||||||
|
|
||||||
|
# Limited like signing in, so the current password can't be guessed here instead.
|
||||||
|
rate_limit to: 10, within: 3.minutes, only: :update, if: :answering_password_challenge?,
|
||||||
|
with: -> { redirect_to user_profile_url, alert: "Too many attempts. Try again in a few minutes." }
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@direct_memberships, @shared_memberships =
|
@direct_memberships, @shared_memberships =
|
||||||
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
|
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@user.update user_params
|
if @user.update user_params
|
||||||
redirect_to user_profile_url, notice: update_notice
|
start_new_session_for @user if @user.saved_change_to_password_digest?
|
||||||
|
|
||||||
|
redirect_to user_profile_url, notice: update_notice
|
||||||
|
else
|
||||||
|
redirect_to user_profile_url, alert: update_alert
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::RecordInvalid
|
||||||
|
# The password changed again before this browser's new session could start, and that change signed it out.
|
||||||
|
redirect_to new_session_url
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -16,11 +28,35 @@ class Users::ProfilesController < ApplicationController
|
|||||||
@user = Current.user
|
@user = Current.user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Always with the challenge, so the model checks it whenever the save would change the password, however the
|
||||||
|
# request spelled the new one.
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
|
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
|
||||||
|
.merge(password_challenge: password_challenge)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only a string is a challenge, so the rate limit counts exactly what the model checks.
|
||||||
|
def password_challenge
|
||||||
|
challenge = params.dig(:user, :password_challenge)
|
||||||
|
challenge.is_a?(String) ? challenge : ""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Guessing the current password means sending one, so this is what the rate limit counts.
|
||||||
|
def answering_password_challenge?
|
||||||
|
!password_challenge.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_notice
|
def update_notice
|
||||||
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
|
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_alert
|
||||||
|
if !@user.errors.include?(:password_challenge)
|
||||||
|
@user.errors.full_messages.to_sentence
|
||||||
|
elsif password_challenge.empty?
|
||||||
|
"Enter your current password to change it."
|
||||||
|
else
|
||||||
|
"That isn't your current password."
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -5,17 +5,14 @@ class Users::PushSubscriptionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if subscription = @push_subscriptions.find_by(push_subscription_params)
|
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params)
|
||||||
# Existing endpoints must pass current validations
|
subscription.user_agent = request.user_agent if subscription.new_record?
|
||||||
if subscription.valid?
|
|
||||||
subscription.touch
|
# Existing endpoints must pass current validations too
|
||||||
head :ok
|
if subscription.valid?
|
||||||
else
|
head register_while_signed_in(subscription) ? :ok : :unauthorized
|
||||||
head :unprocessable_entity
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
|
head :unprocessable_entity
|
||||||
head subscription.persisted? ? :ok : :unprocessable_entity
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -29,6 +26,24 @@ class Users::PushSubscriptionsController < ApplicationController
|
|||||||
@push_subscriptions = Current.user.push_subscriptions
|
@push_subscriptions = Current.user.push_subscriptions
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A password change ends every session and subscription. A request that authenticated just before it mustn't
|
||||||
|
# put one back, so the session is checked under the same write lock as the save, and the endpoint is looked up
|
||||||
|
# again there, past the query cache, so two tabs posting at once register it once. Validation, which resolves
|
||||||
|
# the endpoint's address, runs before taking the lock.
|
||||||
|
def register_while_signed_in(subscription)
|
||||||
|
Push::Subscription.uncached do
|
||||||
|
Push::Subscription.transaction do
|
||||||
|
if Session.exists?(Current.session.id)
|
||||||
|
if existing = @push_subscriptions.find_by(push_subscription_params)
|
||||||
|
existing.touch
|
||||||
|
else
|
||||||
|
subscription.save!(validate: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def push_subscription_params
|
def push_subscription_params
|
||||||
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
|
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class Users::SidebarsController < ApplicationController
|
class Users::SidebarsController < ApplicationController
|
||||||
DIRECT_PLACEHOLDERS = 20
|
DIRECT_PLACEHOLDERS = 20
|
||||||
|
around_action :cache_read_response, only: :show
|
||||||
|
|
||||||
def show
|
def show
|
||||||
visible_memberships = Current.user.memberships.visible
|
visible_memberships = Current.user.memberships.visible
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ module ApplicationHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# Header-only forgery protection needs no secret in forms or cached HTML.
|
||||||
|
def token_tag(*)
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
def admin_body_class
|
def admin_body_class
|
||||||
"admin" if Current.user&.can_administer?
|
"admin" if Current.user&.can_administer?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ module ContentFilters
|
|||||||
#
|
#
|
||||||
# * SanitizeTags strips disallowed markup from the message body
|
# * SanitizeTags strips disallowed markup from the message body
|
||||||
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
|
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
|
||||||
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
|
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
|
||||||
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
|
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
|
||||||
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
|
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
module MessagesHelper
|
module MessagesHelper
|
||||||
# auto_link re-sanitizes with Rails' default safe list, which lacks the
|
# Rendered text is re-sanitized before linking. Rails' default safe list
|
||||||
# formatting the rich text editor produces
|
# lacks the formatting the rich text editor produces
|
||||||
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
|
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
|
||||||
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
|
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
|
||||||
|
|
||||||
|
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
|
||||||
|
LINKABLE_TEXT = /:|@|www\./i
|
||||||
|
|
||||||
def message_area_tag(room, &)
|
def message_area_tag(room, &)
|
||||||
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
||||||
controller: "messages presence drop-target",
|
controller: "messages presence drop-target",
|
||||||
@@ -63,8 +66,7 @@ module MessagesHelper
|
|||||||
when "sound"
|
when "sound"
|
||||||
message_sound_presentation(message)
|
message_sound_presentation(message)
|
||||||
else
|
else
|
||||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
|
text_message_presentation(message.body.body)
|
||||||
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
|
|
||||||
end
|
end
|
||||||
rescue Exception => e
|
rescue Exception => e
|
||||||
Sentry.capture_exception(e, extra: { message: message })
|
Sentry.capture_exception(e, extra: { message: message })
|
||||||
@@ -73,7 +75,65 @@ module MessagesHelper
|
|||||||
""
|
""
|
||||||
end
|
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
|
private
|
||||||
|
def text_message_presentation(body)
|
||||||
|
render = -> { auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(body)) }
|
||||||
|
|
||||||
|
# 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-v2", I18n.locale, Digest::SHA256.hexdigest(html) ]) { render.call }
|
||||||
|
else
|
||||||
|
render.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# auto_link finds URLs and email addresses with regular expressions, which
|
||||||
|
# can't tell an attribute value from text. Hand it one text node at a time,
|
||||||
|
# escaped as the serializer writes text, so a link can only land between tags.
|
||||||
|
def auto_link_text(html)
|
||||||
|
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
|
||||||
|
|
||||||
|
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
|
||||||
|
if node.text.match?(LINKABLE_TEXT)
|
||||||
|
text = node.to_html
|
||||||
|
linked = auto_link(text, html: { target: "_blank" })
|
||||||
|
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A <pre> starting with a newline needs an extra one written out to survive the next parse
|
||||||
|
fragment.to_html(preserve_newline: true).html_safe
|
||||||
|
end
|
||||||
|
|
||||||
def messages_actions
|
def messages_actions
|
||||||
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ module TranslationsHelper
|
|||||||
TRANSLATIONS = {
|
TRANSLATIONS = {
|
||||||
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
|
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
|
||||||
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
|
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
|
||||||
|
current_password: { "🇺🇸": "Enter your current password", "🇪🇸": "Introduce tu contraseña actual", "🇫🇷": "Saisissez votre mot de passe actuel", "🇮🇳": "अपना वर्तमान पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr aktuelles Passwort ein", "🇧🇷": "Insira sua senha atual", "🇯🇵": "現在のパスワードを入力してください" },
|
||||||
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
|
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
|
||||||
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
|
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
|
||||||
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
|
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module Users::PushSubscriptionsHelper
|
||||||
|
# Names the session without revealing its token, so the browser can tell when it's in a new one. The creation
|
||||||
|
# time keeps an id reused by a new database from looking like the same session.
|
||||||
|
def push_subscription_session_marker
|
||||||
|
Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -106,7 +106,8 @@ export default class extends Controller {
|
|||||||
|
|
||||||
async #syncPushSubscription(subscription) {
|
async #syncPushSubscription(subscription) {
|
||||||
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
|
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
|
||||||
if (!response.ok) subscription.unsubscribe()
|
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
|
||||||
|
if (response.unprocessableEntity) subscription.unsubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
async #requestPermissionAndSubscribe(registration) {
|
async #requestPermissionAndSubscribe(registration) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { post } from "@rails/request.js"
|
||||||
|
|
||||||
|
const SYNCED_SESSION_KEY = "push-subscription-synced-session"
|
||||||
|
|
||||||
|
// A password change removes the account's push subscriptions, the one this browser holds included. Post it again
|
||||||
|
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
|
||||||
|
// browser's own subscription alone: the next session tries again.
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { url: String, session: String }
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (this.#allowed && this.sessionValue && localStorage.getItem(SYNCED_SESSION_KEY) !== this.sessionValue) {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration(window.location.origin)
|
||||||
|
const subscription = await registration?.pushManager?.getSubscription()
|
||||||
|
|
||||||
|
if (Notification.permission === "granted" && subscription) {
|
||||||
|
await post(this.urlValue, { body: this.#payload(subscription), responseKind: "turbo-stream" })
|
||||||
|
localStorage.setItem(SYNCED_SESSION_KEY, this.sessionValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get #allowed() {
|
||||||
|
return navigator.serviceWorker && window.Notification
|
||||||
|
}
|
||||||
|
|
||||||
|
#payload(subscription) {
|
||||||
|
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
|
||||||
|
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#stripMentionAttachments(node) {
|
#stripMentionAttachments(node) {
|
||||||
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
|
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export default class FileUploader {
|
|||||||
|
|
||||||
const req = new XMLHttpRequest()
|
const req = new XMLHttpRequest()
|
||||||
req.open("POST", this.url)
|
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))
|
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
|
||||||
|
|
||||||
const result = new Promise((resolve, reject) => {
|
const result = new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -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
|
||||||
+18
-2
@@ -9,7 +9,9 @@ class Message < ApplicationRecord
|
|||||||
has_rich_text :body
|
has_rich_text :body
|
||||||
|
|
||||||
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
|
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 :ordered, -> { order(:created_at) }
|
||||||
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
||||||
@@ -21,7 +23,15 @@ class Message < ApplicationRecord
|
|||||||
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
|
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
|
||||||
|
|
||||||
def plain_text_body
|
def plain_text_body
|
||||||
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
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
|
end
|
||||||
|
|
||||||
def content_type
|
def content_type
|
||||||
@@ -37,4 +47,10 @@ class Message < ApplicationRecord
|
|||||||
Sound.find_by_name match[:name]
|
Sound.find_by_name match[:name]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def record_creation
|
||||||
|
create_in_index
|
||||||
|
room.unread_memberships(self)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module Message::Broadcasts
|
module Message::Broadcasts
|
||||||
def broadcast_create
|
def broadcast_create(html: nil)
|
||||||
broadcast_append_to room, :messages, target: [ room, :messages ]
|
broadcast_append_to room, :messages, target: [ room, :messages ], **(html ? { html: html } : {})
|
||||||
broadcast_unread_room_later
|
broadcast_unread_room_later
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ module Message::Mentionee
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
def mentionees
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ module Message::Searchable
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
after_create_commit :create_in_index
|
|
||||||
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
|
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
|
||||||
after_update_commit :update_in_index, if: :indexed_text_changed?
|
after_update_commit :update_in_index, if: :indexed_text_changed?
|
||||||
after_destroy_commit :remove_from_index
|
after_destroy_commit :remove_from_index
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ class Opengraph::Fetch
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
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,
|
# 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.
|
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
|
||||||
def request(url, request_class, ip:)
|
def request(url, request_class, ip:)
|
||||||
MAX_REDIRECTS.times do
|
MAX_REDIRECTS.times do
|
||||||
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) 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|
|
http.request request_class.new(url) do |response|
|
||||||
if response.is_a?(Net::HTTPRedirection)
|
if response.is_a?(Net::HTTPRedirection)
|
||||||
url, ip = resolve_redirect(response["location"])
|
url, ip = resolve_redirect(response["location"])
|
||||||
|
|||||||
@@ -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
|
||||||
+13
-8
@@ -78,6 +78,19 @@ class Room < ApplicationRecord
|
|||||||
push_later(message)
|
push_later(message)
|
||||||
end
|
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?
|
def open?
|
||||||
is_a?(Rooms::Open)
|
is_a?(Rooms::Open)
|
||||||
end
|
end
|
||||||
@@ -103,12 +116,4 @@ class Room < ApplicationRecord
|
|||||||
errors.add :type, "can't be changed for a direct room"
|
errors.add :type, "can't be changed for a direct room"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
@@ -6,9 +6,9 @@ class Room::MessagePusher
|
|||||||
end
|
end
|
||||||
|
|
||||||
def push
|
def push
|
||||||
build_payload.tap do |payload|
|
subscriptions = push_subscriptions_for_users_involved_in_everything.or(push_subscriptions_for_mentionable_users(message.mentionees))
|
||||||
push_to_users_involved_in_everything(payload)
|
if subscriptions.exists?
|
||||||
push_to_users_involved_in_mentions(payload)
|
Rails.configuration.x.web_push_pool.queue(build_payload, subscriptions)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -37,14 +37,6 @@ class Room::MessagePusher
|
|||||||
}
|
}
|
||||||
end
|
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
|
def push_subscriptions_for_users_involved_in_everything
|
||||||
relevant_subscriptions.merge(Membership.involved_in_everything)
|
relevant_subscriptions.merge(Membership.involved_in_everything)
|
||||||
end
|
end
|
||||||
@@ -59,10 +51,6 @@ class Room::MessagePusher
|
|||||||
Push::Subscription
|
Push::Subscription
|
||||||
.joins(user: :memberships)
|
.joins(user: :memberships)
|
||||||
.merge(User.active)
|
.merge(User.active)
|
||||||
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
|
.merge(Membership.visible.disconnected.where(room: room).where.not(user_id: message.creator_id))
|
||||||
end
|
|
||||||
|
|
||||||
def enqueue_payload_for_delivery(payload, subscriptions)
|
|
||||||
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
|
|
||||||
end
|
end
|
||||||
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
|
||||||
+12
-3
@@ -7,13 +7,22 @@ class Session < ApplicationRecord
|
|||||||
|
|
||||||
before_create { self.last_active_at ||= Time.now }
|
before_create { self.last_active_at ||= Time.now }
|
||||||
|
|
||||||
def self.start!(user_agent:, ip_address:)
|
validate :user_password_unchanged, on: :create
|
||||||
create! user_agent: user_agent, ip_address: ip_address
|
|
||||||
end
|
|
||||||
|
|
||||||
def resume(user_agent:, ip_address:)
|
def resume(user_agent:, ip_address:)
|
||||||
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
|
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
|
||||||
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
|
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# A password change ends every session, so one can't start on a password checked before the change. Validated in
|
||||||
|
# the save's transaction, which holds the database's write lock from the start, so a change commits either before
|
||||||
|
# this check, and fails it, or after the insert, and ends this session with the others. Read past the query cache,
|
||||||
|
# and compared here rather than in the query, which logs its values.
|
||||||
|
def user_password_unchanged
|
||||||
|
unless User.uncached { User.where(id: user_id).pick(:password_digest) } == user.password_digest
|
||||||
|
errors.add :user, :invalid
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -18,8 +18,10 @@ class User < ApplicationRecord
|
|||||||
enum :status, %i[ active deactivated banned ], default: :active
|
enum :status, %i[ active deactivated banned ], default: :active
|
||||||
|
|
||||||
has_secure_password validations: false
|
has_secure_password validations: false
|
||||||
|
validate :password_challenge_matches, if: :will_save_change_to_password_digest?, unless: -> { password_challenge.nil? }
|
||||||
|
|
||||||
after_create_commit :grant_membership_to_open_rooms
|
after_create_commit :grant_membership_to_open_rooms
|
||||||
|
after_update :sign_out_all_sessions, if: :saved_change_to_password_digest?
|
||||||
|
|
||||||
scope :ordered, -> { order("LOWER(name)") }
|
scope :ordered, -> { order("LOWER(name)") }
|
||||||
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
|
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
|
||||||
@@ -50,6 +52,31 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# The profile always sets password_challenge, and it's checked whenever the save would change the password; scripts
|
||||||
|
# that reset one don't set it.
|
||||||
|
def password_challenge_matches
|
||||||
|
unless password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password_challenge)
|
||||||
|
errors.add :password_challenge, :invalid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A new password is how someone shuts out whoever else got into their account, so it ends every session, copies
|
||||||
|
# of the changing browser's cookie included, and every push subscription; browsers still signed in post theirs
|
||||||
|
# again. Connections close once that commits, registered on the transaction because a later save in it would
|
||||||
|
# replace the saved change an after_commit condition reads.
|
||||||
|
def sign_out_all_sessions
|
||||||
|
sessions.delete_all
|
||||||
|
push_subscriptions.delete_all
|
||||||
|
self.class.current_transaction.after_commit { disconnect_signed_out_sessions }
|
||||||
|
end
|
||||||
|
|
||||||
|
# The password is saved by now, so a realtime service that can't be reached mustn't fail the change.
|
||||||
|
def disconnect_signed_out_sessions
|
||||||
|
reset_remote_connections
|
||||||
|
rescue => error
|
||||||
|
Rails.logger.warn "Could not disconnect remote connections after a password change: #{error.class}"
|
||||||
|
end
|
||||||
|
|
||||||
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
|
# 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
|
# 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,
|
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
|
||||||
|
|||||||
@@ -3,13 +3,20 @@ module User::Transferable
|
|||||||
|
|
||||||
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
|
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
|
||||||
|
|
||||||
|
included do
|
||||||
|
# Bound to the password, so changing it also ends sign-in links made before.
|
||||||
|
generates_token_for :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION do
|
||||||
|
password_salt&.last(10)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
def find_by_transfer_id(id)
|
def find_by_transfer_id(id)
|
||||||
find_signed(id, purpose: :transfer)
|
find_by_token_for(:transfer, id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def transfer_id
|
def transfer_id
|
||||||
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
|
generate_token_for(:transfer)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<%= csrf_meta_tags %>
|
|
||||||
<%= csp_meta_tag %>
|
<%= csp_meta_tag %>
|
||||||
<%= current_user_meta_tags %>
|
<%= current_user_meta_tags %>
|
||||||
<%= script_aware_action_cable_meta_tag %>
|
<%= script_aware_action_cable_meta_tag %>
|
||||||
@@ -63,6 +62,11 @@
|
|||||||
|
|
||||||
<%= render "layouts/lightbox" %>
|
<%= render "layouts/lightbox" %>
|
||||||
|
|
||||||
|
<% if Current.session %>
|
||||||
|
<%= tag.div hidden: true, data: { controller: "push-subscription-sync",
|
||||||
|
push_subscription_sync_url_value: user_push_subscriptions_path, push_subscription_sync_session_value: push_subscription_session_marker } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<a href="https://once.com" id="app-logo" target="_blank" aria-label="Once software from 37signals home page">
|
<a href="https://once.com" id="app-logo" target="_blank" aria-label="Once software from 37signals home page">
|
||||||
<%= image_tag "campfire-icon.png", alt: "Campfire logo", width: 256, height: 216 %>
|
<%= image_tag "campfire-icon.png", alt: "Campfire logo", width: 256, height: 216 %>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
||||||
|
|
||||||
|
<%= cache_message_actions(message) do %>
|
||||||
<div class="message__actions" data-controller="soft-keyboard">
|
<div class="message__actions" data-controller="soft-keyboard">
|
||||||
<%= tag.details class: "position-relative",
|
<%= 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 %>
|
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="message__actions-menu border shadow" data-popup-target="menu">
|
||||||
<div class="quick-boosts">
|
<div class="quick-boosts">
|
||||||
|
<% boost_path = message_boosts_path(message) %>
|
||||||
|
<% boost_frame = dom_id(message, :boosting) %>
|
||||||
<% EmojiHelper::REACTIONS.each do |character, title| %>
|
<% 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 %>
|
<%= 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>
|
<figure class="margin-none boost-character"><%= character %></figure>
|
||||||
<span class="for-screen-reader"><%= title %></span>
|
<span class="for-screen-reader"><%= title %></span>
|
||||||
<% end %>
|
</button>
|
||||||
<% end %>
|
</form>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= link_to new_message_boost_path(message),
|
<%= link_to new_message_boost_path(message),
|
||||||
@@ -29,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap border-top margin-block-start-half pad-block-start-half message__actions-grid">
|
<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 %>
|
<%= 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" } %>
|
<%= image_tag "download.svg", class: "colorize--black", size: 20, aria: { hidden: "true" } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -56,3 +59,4 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
||||||
|
|
||||||
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
|
<%# 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-v6" ] do %>
|
<% cache [ message_fragment_cache_key(message), "presentation-v8" ] do %>
|
||||||
<%= message_tag message do %>
|
<%= message_tag message do %>
|
||||||
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
|
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
|
||||||
|
|
||||||
|
|||||||
@@ -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) } %>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<%= turbo_stream.append dom_id(@room, :messages) do %>
|
<%= 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? %>
|
<% end if @new_messages.any? %>
|
||||||
|
|
||||||
<% @updated_messages.each do |message| %>
|
<% @updated_messages.each do |message| %>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<%= messages_tag(@room) do %>
|
<%= messages_tag(@room) do %>
|
||||||
<%= render "rooms/show/invitation", room: @room %>
|
<%= 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 %>
|
<% end %>
|
||||||
|
|
||||||
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
|
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= search_results_tag do %>
|
<%= search_results_tag do %>
|
||||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,16 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-center gap">
|
||||||
|
<%= translation_button(:current_password) %>
|
||||||
|
|
||||||
|
<label class="flex align-center gap flex-item-grow input input--actor">
|
||||||
|
<%= form.password_field :password_challenge, class: "input txt-large", autocomplete: "current-password",
|
||||||
|
placeholder: "Current password", required: false, maxlength: 72 %>
|
||||||
|
<%= image_tag "password.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex align-center gap">
|
<div class="flex align-center gap">
|
||||||
<%= translation_button(:update_password) %>
|
<%= translation_button(:update_password) %>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ default: &default
|
|||||||
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 10 } %>
|
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 10 } %>
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
default_transaction_mode: immediate
|
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:
|
development:
|
||||||
primary:
|
primary:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
# can read the public login page. Require a valid Campfire session before an
|
# can read the public login page. Require a valid Campfire session before an
|
||||||
# anonymous caller can allocate a Blob or persist bytes to disk.
|
# anonymous caller can allocate a Blob or persist bytes to disk.
|
||||||
Rails.application.config.to_prepare do
|
Rails.application.config.to_prepare do
|
||||||
|
ActiveStorage::BaseController.forgery_protection_verification_strategy = :header_only
|
||||||
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
|
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
|
||||||
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
|
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,21 @@
|
|||||||
# Be sure to restart your server when you modify this file.
|
# Enforcing baseline policy. Application pages narrow script-src from this
|
||||||
|
# origin to its asset directory, since uploads are served from this origin
|
||||||
# Define an application-wide content security policy.
|
# too, and allow the two inline scripts the importmap helper emits by hash
|
||||||
# See the Securing Rails Applications Guide for more information:
|
# (InlineScriptHashes). There is no per-request nonce, so rendered HTML stays
|
||||||
# https://guides.rubyonrails.org/security.html#content-security-policy-header
|
# cacheable. 'self' remains for responses outside ApplicationController, none
|
||||||
|
# of which render user content.
|
||||||
# Rails.application.configure do
|
|
||||||
# config.content_security_policy do |policy|
|
|
||||||
# policy.default_src :self, :https
|
|
||||||
# policy.font_src :self, :https, :data
|
|
||||||
# policy.img_src :self, :https, :data
|
|
||||||
# policy.object_src :none
|
|
||||||
# policy.script_src :self, :https
|
|
||||||
# policy.style_src :self, :https
|
|
||||||
# # Specify URI for violation reports
|
|
||||||
# # policy.report_uri "/csp-violation-report-endpoint"
|
|
||||||
# end
|
|
||||||
#
|
#
|
||||||
# # Generate session nonces for permitted importmap and inline scripts
|
# Nothing else is restricted: images, media, fonts, styles (including an
|
||||||
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
# account's custom styles) and connections behave as before.
|
||||||
# config.content_security_policy_nonce_directives = %w(script-src)
|
|
||||||
#
|
#
|
||||||
# # Report violations without enforcing the policy.
|
# DISABLE_CONTENT_SECURITY_POLICY=true is the escape hatch for an install whose
|
||||||
# # config.content_security_policy_report_only = true
|
# proxy or customization injects scripts the policy would block.
|
||||||
# end
|
unless ENV["DISABLE_CONTENT_SECURITY_POLICY"] == "true"
|
||||||
|
Rails.application.configure do
|
||||||
|
config.content_security_policy do |policy|
|
||||||
|
policy.script_src :self
|
||||||
|
policy.object_src :none
|
||||||
|
policy.base_uri :self
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Rails.application.config.session_store :cookie_store,
|
Rails.application.config.session_store :cookie_store,
|
||||||
key: "_campfire_session",
|
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
|
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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Uploaded files are served from this origin. Application pages only allow
|
||||||
|
# scripts from the asset directory (InlineScriptHashes), so an upload can't
|
||||||
|
# load as a script whatever type it's served with. This guard is the second
|
||||||
|
# layer: browsers accept sixteen spellings of a JavaScript type, and Disk URLs
|
||||||
|
# sign the type they were minted with, so instead of refusing a list of types,
|
||||||
|
# anything Active Storage serves that isn't plainly media goes out as a
|
||||||
|
# download, whatever its URL says. A multi-range response keeps its
|
||||||
|
# multipart/byteranges type and boundary, which no browser runs as a script.
|
||||||
|
# Only reads are touched: uploading (direct upload JSON, Disk PUT) answers in
|
||||||
|
# its own types.
|
||||||
|
#
|
||||||
|
# A cache that stored an upload under a script type before this shipped can
|
||||||
|
# still serve it that way until the entry expires (an hour for Disk URLs, and
|
||||||
|
# never for blob proxy URLs, which Rails caches as immutable). The policy's
|
||||||
|
# path restriction is what keeps such a copy from running.
|
||||||
|
class UploadedScriptGuard
|
||||||
|
SERVABLE = %r{\A(image|video|audio)/|\A(application/pdf|application/octet-stream|text/plain|multipart/byteranges)\z}
|
||||||
|
|
||||||
|
def initialize(app)
|
||||||
|
@app = app
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
status, headers, body = @app.call(env)
|
||||||
|
|
||||||
|
if env["REQUEST_METHOD"].in?(%w[ GET HEAD ]) && env["PATH_INFO"].start_with?("#{ActiveStorage.routes_prefix}/") && (type = headers["content-type"])
|
||||||
|
headers["content-type"] = ActiveStorage.binary_content_type unless type.split(";").first.strip.downcase.match?(SERVABLE)
|
||||||
|
end
|
||||||
|
|
||||||
|
[ status, headers, body ]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.application.config.middleware.use UploadedScriptGuard
|
||||||
@@ -29,7 +29,7 @@ module WebPush::PersistentRequest
|
|||||||
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
|
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
|
||||||
# pin the destination and the DNS-rebinding guarantee would be lost.
|
# pin the destination and the DNS-rebinding guarantee would be lost.
|
||||||
# Push delivery to public vendor endpoints goes direct.
|
# Push delivery to public vendor endpoints goes direct.
|
||||||
http = Net::HTTP.new(uri.host, uri.port, nil)
|
http = (@options[:connection] ? WebPush::Connections::HTTP : Net::HTTP).new(uri.host, uri.port, nil)
|
||||||
http.ipaddr = endpoint_ip
|
http.ipaddr = endpoint_ip
|
||||||
http.use_ssl = true
|
http.use_ssl = true
|
||||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ plugin :tmp_restart
|
|||||||
# Reset all membership connections
|
# Reset all membership connections
|
||||||
Membership.disconnect_all
|
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
|
Signal.trap :SIGPROF do
|
||||||
Thread.list.each do |t|
|
Thread.list.each do |t|
|
||||||
puts t
|
puts t
|
||||||
|
|||||||
@@ -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
+3
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
|
ActiveRecord::Schema[8.2].define(version: 2026_10_07_190000) do
|
||||||
create_table "accounts", force: :cascade do |t|
|
create_table "accounts", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.text "custom_styles"
|
t.text "custom_styles"
|
||||||
@@ -124,6 +124,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
|
|||||||
t.string "name"
|
t.string "name"
|
||||||
t.string "type", null: false
|
t.string "type", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "messages_count", default: 0, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "searches", force: :cascade do |t|
|
create_table "searches", force: :cascade do |t|
|
||||||
@@ -183,3 +184,4 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
|
|||||||
# Note that virtual tables may not work with other database engines. Be careful if changing database.
|
# 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"]
|
create_virtual_table "message_search_index", "fts5", ["body", "tokenize=porter"]
|
||||||
end
|
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
|
||||||
@@ -4,9 +4,13 @@ end
|
|||||||
|
|
||||||
task "resque:pool:setup" do
|
task "resque:pool:setup" do
|
||||||
ActiveRecord::Base.connection.disconnect!
|
ActiveRecord::Base.connection.disconnect!
|
||||||
|
# Initializer may have started a contender in the pool master; drop it before
|
||||||
|
# workers fork so they do not inherit the flock.
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
|
||||||
Resque::Pool.after_prefork do |job|
|
Resque::Pool.after_prefork do |job|
|
||||||
ActiveRecord::Base.establish_connection
|
ActiveRecord::Base.establish_connection
|
||||||
Resque.redis.client.close
|
Resque.redis.client.close
|
||||||
|
SqliteWalCheckpoint.start
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Repair the database selected by each schema-load or migration operation.
|
||||||
|
# db:prepare calls DatabaseTasks directly, bypassing Rake enhancements;
|
||||||
|
# method hooks also run on every invocation.
|
||||||
|
module RoomMessagesCountDatabaseTasks
|
||||||
|
def load_schema(...)
|
||||||
|
super.tap { repair_room_messages_count }
|
||||||
|
end
|
||||||
|
|
||||||
|
def migrate(...)
|
||||||
|
super.tap { repair_room_messages_count }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def repair_room_messages_count
|
||||||
|
migration_connection_pool.with_connection do |connection|
|
||||||
|
Room::MessagesCount.ensure!(connection)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(RoomMessagesCountDatabaseTasks)
|
||||||
|
|
||||||
|
namespace :room_messages_count do
|
||||||
|
task ensure: :environment do
|
||||||
|
ActiveRecord::Base.connection_pool.with_connection do |connection|
|
||||||
|
Room::MessagesCount.ensure!(connection)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -26,6 +26,12 @@ class WebPush::Connections
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The Net::HTTP a pooled delivery is opened with: Stages is part of the class, so a connection carries its stage
|
||||||
|
# from the start instead of having the module mixed into each one as it's opened.
|
||||||
|
class HTTP < Net::HTTP
|
||||||
|
include Stages
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(keep_alive_timeout: 30, max_idle: 150)
|
def initialize(keep_alive_timeout: 30, max_idle: 150)
|
||||||
@keep_alive_timeout = keep_alive_timeout
|
@keep_alive_timeout = keep_alive_timeout
|
||||||
@max_idle = max_idle
|
@max_idle = max_idle
|
||||||
@@ -35,8 +41,8 @@ class WebPush::Connections
|
|||||||
end
|
end
|
||||||
|
|
||||||
def request(http, request)
|
def request(http, request)
|
||||||
unless http.ipaddr && !http.proxy? && http.use_ssl? && !http.started?
|
unless http.is_a?(HTTP) && http.ipaddr && !http.proxy? && http.use_ssl? && !http.started?
|
||||||
raise ArgumentError, "Only new, direct TLS connections pinned to an address are pooled"
|
raise ArgumentError, "Only new, direct TLS connections pinned to an address and opened as WebPush::Connections::HTTP are pooled"
|
||||||
end
|
end
|
||||||
address = [ http.address, http.port, http.ipaddr ]
|
address = [ http.address, http.port, http.ipaddr ]
|
||||||
|
|
||||||
@@ -48,7 +54,6 @@ class WebPush::Connections
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
http.extend Stages
|
|
||||||
http.keep_alive_timeout = @keep_alive_timeout
|
http.keep_alive_timeout = @keep_alive_timeout
|
||||||
http.start
|
http.start
|
||||||
send_over(http, request, reused: false).tap { checkin(address, http) }
|
send_over(http, request, reused: false).tap { checkin(address, http) }
|
||||||
|
|||||||
@@ -18,4 +18,53 @@ class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
|
|||||||
|
|
||||||
assert_reject_connection { connect }
|
assert_reject_connection { connect }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "closes once its session has ended without it hearing" do
|
||||||
|
cookies.signed[:session_token] = sessions(:david_safari).token
|
||||||
|
connect
|
||||||
|
sessions(:david_safari).destroy
|
||||||
|
|
||||||
|
travel 2.minutes
|
||||||
|
connection.beat
|
||||||
|
|
||||||
|
assert socket.closed
|
||||||
|
assert_equal "disconnect", socket.transmissions.last[:type]
|
||||||
|
assert socket.transmissions.last[:reconnect]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps checking its session while open" do
|
||||||
|
cookies.signed[:session_token] = sessions(:david_safari).token
|
||||||
|
connect
|
||||||
|
|
||||||
|
travel 2.minutes
|
||||||
|
connection.beat
|
||||||
|
assert_not socket.closed
|
||||||
|
|
||||||
|
sessions(:david_safari).destroy
|
||||||
|
travel 2.minutes
|
||||||
|
connection.beat
|
||||||
|
assert socket.closed
|
||||||
|
end
|
||||||
|
|
||||||
|
test "checks its session at most once a minute" do
|
||||||
|
cookies.signed[:session_token] = sessions(:david_safari).token
|
||||||
|
connect
|
||||||
|
sessions(:david_safari).destroy
|
||||||
|
|
||||||
|
travel 30.seconds
|
||||||
|
connection.beat
|
||||||
|
|
||||||
|
assert_not socket.closed
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a rejected connection's heartbeat doesn't fail" do
|
||||||
|
request = ActionCable::Connection::TestSocket.build_request(ActionCable.server.config.mount_path || "/cable", cookies: cookies)
|
||||||
|
rejected = ApplicationCable::Connection.new \
|
||||||
|
ActionCable::Connection::TestServer.new(ActionCable.server), ActionCable::Connection::TestSocket.new(request)
|
||||||
|
rejected.handle_open
|
||||||
|
|
||||||
|
travel 2.minutes
|
||||||
|
|
||||||
|
assert_nothing_raised { rejected.beat }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class CachedResponsesTest < ActionDispatch::IntegrationTest
|
||||||
|
self.use_transactional_tests = false
|
||||||
|
|
||||||
|
setup do
|
||||||
|
host! "once.campfire.test"
|
||||||
|
sign_in :david
|
||||||
|
@login_cookie = cookies["_campfire_session"]
|
||||||
|
@previous_forgery = ActionController::Base.allow_forgery_protection
|
||||||
|
ActionController::Base.allow_forgery_protection = true
|
||||||
|
@previous_cache = Rails.cache
|
||||||
|
@previous_caching = ActionController::Base.perform_caching
|
||||||
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||||
|
ActionController::Base.perform_caching = true
|
||||||
|
@room = rooms(:watercooler)
|
||||||
|
# Establish last-room cookies before checking reuse.
|
||||||
|
2.times { get room_url(@room) }
|
||||||
|
ResponseCache.instance.clear
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
ResponseCache.instance.clear
|
||||||
|
ActionController::Base.allow_forgery_protection = @previous_forgery
|
||||||
|
Rails.cache = @previous_cache
|
||||||
|
ActionController::Base.perform_caching = @previous_caching
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a reused page carries the same enforcing policy, with no nonce" do
|
||||||
|
get room_messages_url(@room)
|
||||||
|
first = response.headers["Content-Security-Policy"]
|
||||||
|
assert_match %r{script-src [^/ ]+/assets/ 'sha256-}, first
|
||||||
|
assert_no_match "nonce-", first
|
||||||
|
|
||||||
|
MessagesController.any_instance.expects(:find_paged_messages).never
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
assert_equal first, response.headers["Content-Security-Policy"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "all four read actions reuse completed pages" do
|
||||||
|
urls = [ room_url(@room), room_messages_url(@room), user_sidebar_url(:me), searches_url(q: "hello") ]
|
||||||
|
urls.each do |url|
|
||||||
|
get url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
ResponseCache.instance.expects(:write).never
|
||||||
|
urls.each do |url|
|
||||||
|
get url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pagination caches the completed HTML rather than silently bypassing admission" do
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
assert_equal "text/html", response.media_type
|
||||||
|
MessagesController.any_instance.expects(:find_paged_messages).never
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clients without token state reuse complete HTML and post without tokens" do
|
||||||
|
cookies["_campfire_session"] = @login_cookie
|
||||||
|
get room_url(@room)
|
||||||
|
first = response.body
|
||||||
|
assert_select 'meta[name="csrf-token"]', count: 0
|
||||||
|
assert_select 'input[name="authenticity_token"]', count: 0
|
||||||
|
cookies["_campfire_session"] = @login_cookie
|
||||||
|
ResponseCache.instance.expects(:write).never
|
||||||
|
get room_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
assert_equal first, response.body
|
||||||
|
post room_messages_url(@room, format: :turbo_stream), params: {
|
||||||
|
message: { body: "tokenless replay works", client_message_id: "cache-replay" } },
|
||||||
|
headers: { "Sec-Fetch-Site" => "same-origin", "Origin" => "http://once.campfire.test" }
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "literal token-like text survives complete page reuse" do
|
||||||
|
literal = "campfire-csrf-literal authenticity_token csrf-token"
|
||||||
|
@room.messages.create!(creator: users(:david), body: "literal #{literal}")
|
||||||
|
get room_url(@room)
|
||||||
|
first = response.body
|
||||||
|
get room_url(@room)
|
||||||
|
assert_equal first, response.body
|
||||||
|
assert_includes response.body, "literal #{literal}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "gzip bytes are reused and identity negotiation stays separate" do
|
||||||
|
require "stringio"
|
||||||
|
get room_url(@room), headers: { "Accept-Encoding" => "gzip" }
|
||||||
|
assert_equal "gzip", response.headers["Content-Encoding"]
|
||||||
|
encoded = response.body
|
||||||
|
decoded = Zlib::GzipReader.new(StringIO.new(encoded)).read
|
||||||
|
assert_includes decoded, "<html"
|
||||||
|
assert_includes response.headers["Vary"], "Accept-Encoding"
|
||||||
|
get room_url(@room)
|
||||||
|
assert_equal decoded, response.body
|
||||||
|
ResponseCache.instance.expects(:write).never
|
||||||
|
get room_url(@room), headers: { "Accept-Encoding" => "gzip" }
|
||||||
|
assert_equal encoded, response.body
|
||||||
|
get room_url(@room)
|
||||||
|
assert_nil response.headers["Content-Encoding"]
|
||||||
|
assert_equal decoded, response.body
|
||||||
|
get room_url(@room), headers: { "Accept-Encoding" => "gzip;q=0, identity;q=1" }
|
||||||
|
assert_nil response.headers["Content-Encoding"]
|
||||||
|
assert_equal decoded, response.body
|
||||||
|
get room_url(@room), headers: { "Accept-Encoding" => "*;q=0" }
|
||||||
|
assert_nil response.headers["Content-Encoding"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "local and foreign commits invalidate pages and nested fragments" do
|
||||||
|
message = @room.messages.ordered.last
|
||||||
|
get room_url(@room)
|
||||||
|
@room.messages.create!(creator: users(:david), body: "local commit")
|
||||||
|
get room_url(@room)
|
||||||
|
assert_includes response.body, "local commit"
|
||||||
|
|
||||||
|
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign creator", message.creator_id)
|
||||||
|
get room_url(@room)
|
||||||
|
assert_includes response.body, "Foreign creator"
|
||||||
|
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign message body", message.id)
|
||||||
|
get room_url(@room)
|
||||||
|
assert_includes response.body, "foreign message body"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cached room access and sessions are checked afresh" do
|
||||||
|
get room_url(@room)
|
||||||
|
foreign_write("DELETE FROM memberships WHERE room_id = ? AND user_id = ?", @room.id, users(:david).id)
|
||||||
|
get room_url(@room)
|
||||||
|
assert_redirected_to root_url
|
||||||
|
get user_sidebar_url(:me)
|
||||||
|
foreign_write("DELETE FROM sessions WHERE user_id = ?", users(:david).id)
|
||||||
|
get user_sidebar_url(:me)
|
||||||
|
assert_redirected_to new_session_url
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a session signed out by a password change isn't served from a cached snapshot or page" do
|
||||||
|
other_device = open_session { |session| session.host! "once.campfire.test" }
|
||||||
|
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
|
||||||
|
other_device.get user_sidebar_url(:me)
|
||||||
|
other_device.assert_response :success
|
||||||
|
|
||||||
|
Session.expects(:find_by).never
|
||||||
|
ResponseCache.instance.expects(:write).never
|
||||||
|
other_device.get user_sidebar_url(:me)
|
||||||
|
other_device.assert_response :success
|
||||||
|
Session.unstub(:find_by)
|
||||||
|
ResponseCache.instance.unstub(:write)
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } },
|
||||||
|
headers: { "Sec-Fetch-Site" => "same-origin", "Origin" => "http://once.campfire.test" }
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
|
||||||
|
other_device.get user_sidebar_url(:me)
|
||||||
|
other_device.assert_redirected_to new_session_url
|
||||||
|
get user_sidebar_url(:me)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "conditional requests keep native validators" do
|
||||||
|
get room_messages_url(@room)
|
||||||
|
etag = response.headers["ETag"]
|
||||||
|
assert etag.present?
|
||||||
|
get room_messages_url(@room), headers: { "If-None-Match" => etag }
|
||||||
|
assert_response :not_modified
|
||||||
|
end
|
||||||
|
|
||||||
|
test "foreign presentation changes cannot return a false not-modified response" do
|
||||||
|
get room_messages_url(@room)
|
||||||
|
etag = response.headers["ETag"]
|
||||||
|
message = @room.messages.ordered.last
|
||||||
|
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "changed presentation", message.id)
|
||||||
|
get room_messages_url(@room), headers: { "If-None-Match" => etag }
|
||||||
|
assert_response :success
|
||||||
|
assert_includes response.body, "changed presentation"
|
||||||
|
assert_not_equal etag, response.headers["ETag"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "origins and frame variants have separate entries" do
|
||||||
|
get room_url(@room)
|
||||||
|
ResponseCache.instance.expects(:write).twice
|
||||||
|
get room_url(@room), headers: { "Turbo-Frame" => "different-frame" }
|
||||||
|
host! "once.campfire.test:8081"
|
||||||
|
get room_path(@room)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cache can be disabled" do
|
||||||
|
ResponseCache.instance.stubs(:budget).returns(0)
|
||||||
|
ResponseCache.instance.expects(:read).never
|
||||||
|
2.times { get room_url(@room) }
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a commit after authentication cannot admit captured user fields" do
|
||||||
|
Users::SidebarsController.any_instance.stubs(:set_version_headers).with do
|
||||||
|
foreign_write("UPDATE users SET name = ? WHERE id = ?", "During authentication", users(:david).id)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
get user_sidebar_url(:me)
|
||||||
|
Users::SidebarsController.any_instance.unstub(:set_version_headers)
|
||||||
|
ResponseCache.instance.expects(:write).once
|
||||||
|
get user_sidebar_url(:me)
|
||||||
|
assert_includes response.body, "During authentication"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def foreign_write(sql, *bindings)
|
||||||
|
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
|
||||||
|
database.execute(sql, bindings)
|
||||||
|
end
|
||||||
|
# Fixtures share this thread's query cache outside the request executor.
|
||||||
|
ActiveRecord::Base.clear_query_caches_for_current_thread
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class FetchMetadataTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
host! "once.campfire.test"
|
||||||
|
@previous_exceptions = Rails.application.env_config["action_dispatch.show_exceptions"]
|
||||||
|
Rails.application.env_config["action_dispatch.show_exceptions"] = :rescuable
|
||||||
|
@previous_forgery = ActionController::Base.allow_forgery_protection
|
||||||
|
ActionController::Base.allow_forgery_protection = true
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
ActionController::Base.allow_forgery_protection = @previous_forgery
|
||||||
|
Rails.application.env_config["action_dispatch.show_exceptions"] = @previous_exceptions
|
||||||
|
end
|
||||||
|
|
||||||
|
test "login and authenticated forms have no token fields" do
|
||||||
|
get new_session_url
|
||||||
|
assert_response :success
|
||||||
|
assert_select 'meta[name="csrf-token"]', count: 0
|
||||||
|
assert_select 'input[name="authenticity_token"]', count: 0
|
||||||
|
sign_in :david
|
||||||
|
get room_url(rooms(:watercooler))
|
||||||
|
assert_response :success
|
||||||
|
assert_select 'meta[name="csrf-token"]', count: 0
|
||||||
|
assert_select 'input[name="authenticity_token"]', count: 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "HTTPS login accepts browser metadata without a token" do
|
||||||
|
https!
|
||||||
|
%w[ same-origin same-site ].each do |site|
|
||||||
|
reset!
|
||||||
|
host! "once.campfire.test"
|
||||||
|
https!
|
||||||
|
post session_url, params: credentials, headers: {
|
||||||
|
"Sec-Fetch-Site" => site, "Origin" => "https://once.campfire.test" }
|
||||||
|
assert_response :redirect
|
||||||
|
assert cookies["session_token"].present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "HTTPS writes reject missing metadata even with a legacy token parameter" do
|
||||||
|
https!
|
||||||
|
assert_no_difference "Session.count" do
|
||||||
|
post session_url, params: credentials.merge(authenticity_token: "old token"), headers: { "Origin" => "https://once.campfire.test" }
|
||||||
|
end
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cross-site none and malformed metadata cannot sign in over HTTP or HTTPS" do
|
||||||
|
[ false, true ].each do |tls|
|
||||||
|
https! tls
|
||||||
|
%w[ cross-site none garbage ].each do |site|
|
||||||
|
assert_no_difference "Session.count" do
|
||||||
|
post session_url, params: credentials, headers: { "Sec-Fetch-Site" => site }
|
||||||
|
end
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "provided foreign and null origins fail even with same-site metadata" do
|
||||||
|
%w[ https://foreign.example null ].each do |origin|
|
||||||
|
assert_no_difference "Session.count" do
|
||||||
|
post session_url, params: credentials, headers: { "Sec-Fetch-Site" => "same-site", "Origin" => origin }
|
||||||
|
end
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "plain HTTP retains the missing-header fallback" do
|
||||||
|
post session_url, params: credentials, headers: { "Origin" => "http://once.campfire.test" }
|
||||||
|
assert_response :redirect
|
||||||
|
assert cookies["session_token"].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cross-site reads remain available and method-overridden writes stay protected" do
|
||||||
|
get new_session_url, headers: { "Sec-Fetch-Site" => "cross-site" }
|
||||||
|
assert_response :success
|
||||||
|
sign_in :david
|
||||||
|
assert_no_difference "Room.count" do
|
||||||
|
post room_path(rooms(:watercooler)), params: { _method: "delete" }, headers: { "Sec-Fetch-Site" => "cross-site" }
|
||||||
|
end
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def credentials
|
||||||
|
{ email_address: users(:david).email_address, password: "secret123456" }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class FragmentRenderingTest < ActionDispatch::IntegrationTest
|
||||||
|
self.use_transactional_tests = false
|
||||||
|
|
||||||
|
setup do
|
||||||
|
host! "once.campfire.test"
|
||||||
|
sign_in :david
|
||||||
|
@previous_caching = ActionController::Base.perform_caching
|
||||||
|
@previous_message_caching = MessagesController.perform_caching
|
||||||
|
@class_store = ApplicationController.cache_store
|
||||||
|
@global_store = Rails.cache
|
||||||
|
ActionController::Base.perform_caching = true
|
||||||
|
MessagesController.perform_caching = true
|
||||||
|
FragmentCache.store.clear
|
||||||
|
ResponseCache.instance.clear
|
||||||
|
@room = rooms(:watercooler)
|
||||||
|
@message = @room.messages.ordered.last
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
ActionController::Base.perform_caching = @previous_caching
|
||||||
|
MessagesController.perform_caching = @previous_message_caching
|
||||||
|
FragmentCache.store.clear
|
||||||
|
ResponseCache.instance.clear
|
||||||
|
end
|
||||||
|
|
||||||
|
test "native collection caching uses the bounded store without replacing shared stores" do
|
||||||
|
assert_same FragmentCache.store, ApplicationController.new.cache_store
|
||||||
|
assert_same FragmentCache.store, ActionView::PartialRenderer.collection_cache
|
||||||
|
assert_same @class_store, ApplicationController.cache_store
|
||||||
|
assert_same @global_store, Rails.cache
|
||||||
|
ResponseCache.instance.stubs(:budget).returns(0)
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
hits = []
|
||||||
|
ActiveSupport::Notifications.subscribed(->(event) { hits.concat(event.payload[:hits]) }, "cache_read_multi.active_support") do
|
||||||
|
get room_messages_url(@room)
|
||||||
|
end
|
||||||
|
assert_response :success
|
||||||
|
assert_not_empty hits
|
||||||
|
token = users(:david).sessions.order(:id).last.token
|
||||||
|
assert_not_includes hits.join, token
|
||||||
|
end
|
||||||
|
|
||||||
|
test "disabled page caching retains fresh native fragments after foreign leaf writes" do
|
||||||
|
ResponseCache.instance.stubs(:budget).returns(0)
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign disabled body", @message.id)
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
assert_includes response.body, "foreign disabled body"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "conditional pagination invalidates warmed native fragments without timestamp changes" do
|
||||||
|
get room_messages_url(@room), headers: { "If-None-Match" => "unmatched" }
|
||||||
|
assert_response :success
|
||||||
|
etag = response.headers["ETag"]
|
||||||
|
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign conditional body", @message.id)
|
||||||
|
get room_messages_url(@room), headers: { "If-None-Match" => etag }
|
||||||
|
assert_response :success
|
||||||
|
assert_includes response.body, "foreign conditional body"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "message controls survive creator edits while the author is rendered fresh" do
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Fresh creator with cached controls", @message.creator_id)
|
||||||
|
ActionView::Base.any_instance.expects(:form_with).never
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
assert_includes response.body, "Fresh creator with cached controls"
|
||||||
|
assert_select "form[action=?]", message_boosts_path(@message)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a commit in another room retains content-validated message collection hits" do
|
||||||
|
ResponseCache.instance.stubs(:budget).returns(0)
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
foreign_write("UPDATE rooms SET name = ? WHERE id = ?", "Unrelated room rename", rooms(:pets).id)
|
||||||
|
collections = []
|
||||||
|
ActiveSupport::Notifications.subscribed(->(event) { collections << event.payload }, "render_collection.action_view") do
|
||||||
|
get room_messages_url(@room)
|
||||||
|
end
|
||||||
|
assert_response :success
|
||||||
|
messages = collections.find { |payload| payload[:identifier].end_with?("messages/_message.html.erb") }
|
||||||
|
assert messages
|
||||||
|
assert_equal @room.messages.count, messages[:cache_hits]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a foreign commit after capture bypasses old native fragment lookups" do
|
||||||
|
ResponseCache.instance.stubs(:budget).returns(0)
|
||||||
|
get room_messages_url(@room)
|
||||||
|
assert_response :success
|
||||||
|
MessagesController.any_instance.stubs(:set_version_headers).with do
|
||||||
|
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign before fragments", @message.id)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
get room_messages_url(@room), headers: { "If-None-Match" => "unmatched" }
|
||||||
|
assert_response :success
|
||||||
|
assert_includes response.body, "foreign before fragments"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh streams recheck native cached creator body and boost presentation" do
|
||||||
|
message = messages(:first)
|
||||||
|
room = message.room
|
||||||
|
get room_refresh_url(room, format: :turbo_stream), params: { since: 0 }
|
||||||
|
assert_response :success
|
||||||
|
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign refreshed body", message.id)
|
||||||
|
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign refreshed creator", message.creator_id)
|
||||||
|
foreign_write("UPDATE boosts SET content = ? WHERE message_id = ?", "Foreign refreshed boost", message.id)
|
||||||
|
get room_refresh_url(room, format: :turbo_stream), params: { since: 0 }
|
||||||
|
assert_response :success
|
||||||
|
assert_includes response.body, "foreign refreshed body"
|
||||||
|
assert_includes response.body, "Foreign refreshed creator"
|
||||||
|
assert_includes response.body, "Foreign refreshed boost"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Jbuilder fragments use the bounded store and do not create HTML token state" do
|
||||||
|
api = open_session { |session| session.host! "once.campfire.test" }
|
||||||
|
path = room_bot_messages_path(@room, users(:bender).bot_key)
|
||||||
|
api.get path
|
||||||
|
assert_equal 200, api.response.status
|
||||||
|
assert_not api.cookies["_campfire_session"]
|
||||||
|
hits = []
|
||||||
|
ActiveSupport::Notifications.subscribed(->(event) { hits << event.payload[:key] if event.payload[:hit] }, "cache_read.active_support") do
|
||||||
|
api.get path
|
||||||
|
end
|
||||||
|
assert_equal 200, api.response.status
|
||||||
|
assert_not_empty hits
|
||||||
|
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign JSON body", @message.id)
|
||||||
|
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign JSON creator", @message.creator_id)
|
||||||
|
api.get path
|
||||||
|
json = JSON.parse(api.response.body).find { |message| message["id"] == @message.id }
|
||||||
|
assert_includes json.dig("body", "html"), "foreign JSON body"
|
||||||
|
assert_equal "Foreign JSON creator", json.dig("creator", "name")
|
||||||
|
api.host! "another.campfire.test:8081"
|
||||||
|
api.get path
|
||||||
|
json = JSON.parse(api.response.body).find { |message| message["id"] == @message.id }
|
||||||
|
assert_includes json["url"], "another.campfire.test:8081"
|
||||||
|
assert_same @global_store, Rails.cache
|
||||||
|
end
|
||||||
|
|
||||||
|
test "detached broadcasts bypass fragments without a pre-render model snapshot" do
|
||||||
|
get room_messages_url(@room)
|
||||||
|
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign broadcast body", @message.id)
|
||||||
|
FragmentCache.store.expects(:read).never
|
||||||
|
FragmentCache.store.expects(:write).never
|
||||||
|
FragmentCache.store.expects(:read_multi).never
|
||||||
|
FragmentCache.store.expects(:write_multi).never
|
||||||
|
@message.reload.broadcast_create
|
||||||
|
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do |stream|
|
||||||
|
assert_includes stream.to_html, "foreign broadcast body"
|
||||||
|
end
|
||||||
|
body = ApplicationController.render(partial: "messages/message", locals: { message: @message.reload })
|
||||||
|
assert_includes body, "foreign broadcast body"
|
||||||
|
collection = ApplicationController.render(partial: "messages/message", collection: [ @message.reload ], cached: true)
|
||||||
|
assert_includes collection, "foreign broadcast body"
|
||||||
|
assert_not ApplicationController.new.perform_caching
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def foreign_write(sql, *bindings)
|
||||||
|
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
|
||||||
|
database.execute(sql, bindings)
|
||||||
|
end
|
||||||
|
ActiveRecord::Base.clear_query_caches_for_current_thread
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -31,6 +31,32 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "quick boost controls submit their displayed reaction to the message" do
|
||||||
|
get room_url(@message.room)
|
||||||
|
forms = css_select("##{dom_id(@message)} .quick-boosts form")
|
||||||
|
assert_equal EmojiHelper::REACTIONS.size, forms.size
|
||||||
|
forms.each do |form|
|
||||||
|
assert_equal "post", form["method"]
|
||||||
|
assert_equal message_boosts_path(@message), form["action"]
|
||||||
|
assert_equal dom_id(@message, :boosting), form["data-turbo-frame"]
|
||||||
|
assert_equal "popup#close", form["data-action"]
|
||||||
|
input = form.at_css('input[name="boost[content]"]')
|
||||||
|
button = form.at_css('button[type="submit"]')
|
||||||
|
assert_equal input["value"], button["data-emoji"]
|
||||||
|
assert_equal EmojiHelper::REACTIONS.fetch(input["value"]), button["title"]
|
||||||
|
assert_nil form.at_css('input[name="authenticity_token"]')
|
||||||
|
end
|
||||||
|
|
||||||
|
form = forms.first
|
||||||
|
reaction = form.at_css('input[name="boost[content]"]')["value"]
|
||||||
|
assert_difference -> { @message.boosts.count }, 1 do
|
||||||
|
post form["action"], params: { boost: { content: reaction } }, headers: {
|
||||||
|
"Sec-Fetch-Site" => "same-origin", "Origin" => "http://www.example.com" }
|
||||||
|
assert_redirected_to message_boosts_url(@message)
|
||||||
|
end
|
||||||
|
assert_equal reaction, @message.boosts.order(:id).last.content
|
||||||
|
end
|
||||||
|
|
||||||
test "destroy" do
|
test "destroy" do
|
||||||
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
|
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
|
||||||
assert_difference -> { @message.boosts.count }, -1 do
|
assert_difference -> { @message.boosts.count }, -1 do
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
require "active_record/testing/query_assertions"
|
||||||
|
|
||||||
class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
|
class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
include ActiveRecord::Assertions::QueryAssertions
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@room = rooms(:watercooler)
|
@room = rooms(:watercooler)
|
||||||
end
|
end
|
||||||
@@ -93,7 +96,9 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
@room.messages.create!(body: "Filler #{i}", creator: users(:jason), client_message_id: "filler-#{i}")
|
@room.messages.create!(body: "Filler #{i}", creator: users(:jason), client_message_id: "filler-#{i}")
|
||||||
end
|
end
|
||||||
|
|
||||||
get room_bot_messages_url(@room, users(:bender).bot_key)
|
assert_no_queries_match(/SELECT COUNT\(\*\) FROM "messages"/i) do
|
||||||
|
get room_bot_messages_url(@room, users(:bender).bot_key)
|
||||||
|
end
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
||||||
json = JSON.parse(response.body)
|
json = JSON.parse(response.body)
|
||||||
@@ -119,7 +124,10 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "index in a room with no messages" do
|
test "index in a room with no messages" do
|
||||||
get room_bot_messages_url(rooms(:bender_and_kevin), users(:bender).bot_key)
|
room = rooms(:bender_and_kevin)
|
||||||
|
assert_equal 0, room.messages_count
|
||||||
|
|
||||||
|
get room_bot_messages_url(room, users(:bender).bot_key)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
||||||
assert_equal [], JSON.parse(response.body)
|
assert_equal [], JSON.parse(response.body)
|
||||||
|
|||||||
@@ -49,8 +49,12 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "creating a message broadcasts the message to the room" do
|
test "creating a message broadcasts the message to the room" do
|
||||||
|
ContentFilters::TextMessagePresentationFilters.expects(:apply).once.with { |body| body.to_plain_text == "New one" }.returns(ActionText::Content.new("New one"))
|
||||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
|
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_select "turbo-stream[action=append] .message__body", text: /New one/
|
||||||
|
|
||||||
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
|
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
|
||||||
assert_select ".message__body", text: /New one/
|
assert_select ".message__body", text: /New one/
|
||||||
assert_copy_link_button room_at_message_url(@room, Message.last, host: "once.campfire.test")
|
assert_copy_link_button room_at_message_url(@room, Message.last, host: "once.campfire.test")
|
||||||
|
|||||||
@@ -12,4 +12,18 @@ class QrCodeControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal 1.year, response.cache_control[:max_age].to_i
|
assert_equal 1.year, response.cache_control[:max_age].to_i
|
||||||
assert response.cache_control[:public]
|
assert response.cache_control[:public]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "show rejects an id that isn't base64" do
|
||||||
|
get qr_code_path("not-base64!")
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
test "show rejects a URL too long for a QR code" do
|
||||||
|
id = Base64.urlsafe_encode64("http://example.com/" + "a" * 3000)
|
||||||
|
|
||||||
|
get qr_code_path(id)
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -19,4 +19,30 @@ class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_redirected_to root_url
|
assert_redirected_to root_url
|
||||||
assert parsed_cookies.signed[:session_token]
|
assert parsed_cookies.signed[:session_token]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "update rejects a link made before the password changed" do
|
||||||
|
user = users(:david)
|
||||||
|
transfer_id = user.transfer_id
|
||||||
|
user.update!(password: "a new secret123456")
|
||||||
|
|
||||||
|
put session_transfer_url(transfer_id)
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
assert_nil parsed_cookies.signed[:session_token]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update is refused when the password changes after the link was checked" do
|
||||||
|
user = users(:david)
|
||||||
|
transfer_id = user.transfer_id
|
||||||
|
stale = User.find(user.id)
|
||||||
|
User.find(user.id).update!(password: "a new secret123456")
|
||||||
|
User.stubs(:find_by_transfer_id).with(transfer_id).returns(stale)
|
||||||
|
|
||||||
|
assert_no_difference -> { Session.count } do
|
||||||
|
put session_transfer_url(transfer_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
assert_nil parsed_cookies.signed[:session_token]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -36,6 +36,19 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert parsed_cookies.signed[:session_token]
|
assert parsed_cookies.signed[:session_token]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "create is refused when the password changes after it was checked" do
|
||||||
|
user = User.find(users(:david).id)
|
||||||
|
User.find(user.id).update!(password: "a new secret123456")
|
||||||
|
User.stubs(:authenticate_by).returns(user)
|
||||||
|
|
||||||
|
assert_no_difference -> { Session.count } do
|
||||||
|
post session_url, params: { email_address: "david@37signals.com", password: "secret123456" }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
assert_nil parsed_cookies.signed[:session_token]
|
||||||
|
end
|
||||||
|
|
||||||
test "create with invalid credentials" do
|
test "create with invalid credentials" do
|
||||||
post session_url, params: { email_address: "david@37signals.com", password: "wrong" }
|
post session_url, params: { email_address: "david@37signals.com", password: "wrong" }
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,190 @@ class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal "david@37signals.com", users(:david).email_address
|
assert_equal "david@37signals.com", users(:david).email_address
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "changing the password signs out the account's other devices but not this browser" do
|
||||||
|
other_device = open_session
|
||||||
|
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
|
||||||
|
other_device.get user_profile_url
|
||||||
|
other_device.assert_response :success
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
|
||||||
|
other_device.get user_profile_url
|
||||||
|
other_device.assert_redirected_to new_session_url
|
||||||
|
get user_profile_url
|
||||||
|
assert_response :success
|
||||||
|
assert_equal 1, users(:david).sessions.count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the password signs this browser out when it changes again before its new session starts" do
|
||||||
|
User.stubs(:uncached).returns(BCrypt::Password.create("someone else's secret", cost: BCrypt::Engine::MIN_COST))
|
||||||
|
|
||||||
|
assert_changes -> { users(:david).sessions.count }, to: 0 do
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to new_session_url
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the password gives this browser a new session, so a copy of its cookie stops working" do
|
||||||
|
copied_cookie = cookies[:session_token]
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
|
||||||
|
assert_not_equal copied_cookie, cookies[:session_token]
|
||||||
|
|
||||||
|
copy = open_session
|
||||||
|
copy.cookies[:session_token] = copied_cookie
|
||||||
|
copy.get user_profile_url
|
||||||
|
copy.assert_redirected_to new_session_url
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the password along with the avatar still signs this browser back in and closes live connections" do
|
||||||
|
remote_connections = mock
|
||||||
|
remote_connections.expects(:disconnect).with(reconnect: true)
|
||||||
|
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456", avatar: fixture_file_upload("moon.jpg", "image/jpeg") } }
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
assert users(:david).reload.avatar.attached?
|
||||||
|
|
||||||
|
get user_profile_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the password closes the account's live connections" do
|
||||||
|
remote_connections = mock
|
||||||
|
remote_connections.expects(:disconnect).with(reconnect: true)
|
||||||
|
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
|
||||||
|
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the password still signs out other sessions when the realtime service is unreachable" do
|
||||||
|
other_session = users(:david).sessions.first
|
||||||
|
ActionCable.server.stubs(:remote_connections).raises(RuntimeError.new("cable down"))
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
|
||||||
|
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
assert_not Session.exists?(other_session.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updating the profile without a new password leaves other sessions signed in" do
|
||||||
|
ActionCable.server.expects(:remote_connections).never
|
||||||
|
|
||||||
|
assert_no_difference -> { users(:david).sessions.count } do
|
||||||
|
put user_profile_url, params: { user: { name: "David", password: "" } }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the password with the current one" do
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
|
||||||
|
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
assert_equal "✓", flash[:notice]
|
||||||
|
assert users(:david).reload.authenticate("a new secret123456")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the password without the current one changes nothing" do
|
||||||
|
assert_password_change_refused "Enter your current password to change it.",
|
||||||
|
user: { name: "Someone else", password: "a new secret123456" }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the password with a wrong current one changes nothing" do
|
||||||
|
assert_password_change_refused "That isn't your current password.",
|
||||||
|
user: { name: "Someone else", password: "a new secret123456", password_challenge: "not my password" }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a new password needs the current one however the form spells it" do
|
||||||
|
assert_password_change_refused "Enter your current password to change it.",
|
||||||
|
user: { name: "Someone else", "password(1)" => "a new secret123456" }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "editing the profile with an empty current password doesn't need it" do
|
||||||
|
put user_profile_url, params: { user: { name: "Dave", password: "", password_challenge: "" } }
|
||||||
|
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
assert_equal "Dave", users(:david).reload.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "editing the profile without changing the password doesn't need the current one" do
|
||||||
|
put user_profile_url, params: { user: { name: "Dave", bio: "Acrobat", password: "" } }
|
||||||
|
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
assert_equal "Dave", users(:david).reload.name
|
||||||
|
assert users(:david).authenticate("secret123456")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "attempts to change the password are rate limited like signing in" do
|
||||||
|
Users::ProfilesController.cache_store.stubs(:increment).returns(11)
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
assert_equal "Too many attempts. Try again in a few minutes.", flash[:alert]
|
||||||
|
assert users(:david).reload.authenticate("secret123456")
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456" } }
|
||||||
|
assert_equal "Enter your current password to change it.", flash[:alert]
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { name: "Dave" } }
|
||||||
|
assert_equal "Dave", users(:david).reload.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "any current password sent counts toward the rate limit, however blank it looks" do
|
||||||
|
Users::ProfilesController.cache_store.stubs(:increment).returns(11)
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: " " } }
|
||||||
|
assert_equal "Too many attempts. Try again in a few minutes.", flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a current password that isn't text is no current password" do
|
||||||
|
[ false, [], {}, 0 ].each do |challenge|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: challenge } }, as: :json
|
||||||
|
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
assert_equal "Enter your current password to change it.", flash[:alert]
|
||||||
|
assert users(:david).reload.authenticate("secret123456")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the password removes the other devices' push subscriptions, and this browser can register its own again" do
|
||||||
|
stub_web_push_dns_resolution
|
||||||
|
endpoint = push_subscriptions(:david_chrome).attributes.slice("endpoint", "p256dh_key", "auth_key")
|
||||||
|
|
||||||
|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
|
||||||
|
assert_empty users(:david).push_subscriptions
|
||||||
|
|
||||||
|
post user_push_subscriptions_url, params: { push_subscription: endpoint }
|
||||||
|
assert_response :ok
|
||||||
|
assert_equal [ endpoint["endpoint"] ], users(:david).push_subscriptions.pluck(:endpoint)
|
||||||
|
end
|
||||||
|
|
||||||
test "updates are limited to the current user" do
|
test "updates are limited to the current user" do
|
||||||
put user_profile_url(users(:jason)), params: { user: { name: "John Doe" } }
|
put user_profile_url(users(:jason)), params: { user: { name: "John Doe" } }
|
||||||
|
|
||||||
assert_equal "Jason", users(:jason).reload.name
|
assert_equal "Jason", users(:jason).reload.name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def assert_password_change_refused(alert, params)
|
||||||
|
other_device = open_session
|
||||||
|
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
|
||||||
|
ActionCable.server.expects(:remote_connections).never
|
||||||
|
|
||||||
|
assert_no_changes -> { [ Session.count, Push::Subscription.count ] } do
|
||||||
|
put user_profile_url, params: params
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to user_profile_url
|
||||||
|
assert_equal alert, flash[:alert]
|
||||||
|
assert users(:david).reload.authenticate("secret123456")
|
||||||
|
assert_equal "David", users(:david).name
|
||||||
|
other_device.get user_profile_url
|
||||||
|
other_device.assert_response :success
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -74,4 +74,98 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_redirected_to user_push_subscriptions_url
|
assert_redirected_to user_push_subscriptions_url
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "a signed-out session can't register a subscription" do
|
||||||
|
Session.delete_all
|
||||||
|
|
||||||
|
assert_no_difference -> { Push::Subscription.count } do
|
||||||
|
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to new_session_url
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a session signed out while its request is in flight doesn't register a subscription" do
|
||||||
|
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
|
||||||
|
|
||||||
|
assert_no_difference -> { Push::Subscription.count } do
|
||||||
|
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a session signed out while its request is in flight doesn't keep an existing subscription alive" do
|
||||||
|
subscription = push_subscriptions(:david_chrome)
|
||||||
|
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
|
||||||
|
|
||||||
|
assert_no_changes -> { subscription.reload.updated_at } do
|
||||||
|
post user_push_subscriptions_url, params: { push_subscription: subscription.attributes.slice("endpoint", "p256dh_key", "auth_key") }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pages name their session for the browser to post its subscription again in a new one" do
|
||||||
|
get user_profile_url
|
||||||
|
assert_equal user_push_subscriptions_path, css_select("[data-push-subscription-sync-url-value]").first["data-push-subscription-sync-url-value"]
|
||||||
|
|
||||||
|
get room_url(rooms(:watercooler))
|
||||||
|
first_marker = css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
|
||||||
|
|
||||||
|
assert_match(/\A\h{64}\z/, first_marker)
|
||||||
|
assert_not_includes response.body, users(:david).sessions.order(:id).last.token
|
||||||
|
|
||||||
|
sign_in :david
|
||||||
|
get room_url(rooms(:watercooler))
|
||||||
|
assert_not_equal first_marker, css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "two posts of the same new subscription at once register it once" do
|
||||||
|
Push::Subscription.any_instance.stubs(:valid?).with do
|
||||||
|
users(:david).push_subscriptions.build(new_subscription_params).save!(validate: false) unless Push::Subscription.exists?(endpoint: new_subscription_params["endpoint"])
|
||||||
|
true
|
||||||
|
end.returns(true)
|
||||||
|
|
||||||
|
assert_difference -> { Push::Subscription.where(endpoint: new_subscription_params["endpoint"]).count }, +1 do
|
||||||
|
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def new_subscription_params
|
||||||
|
{ "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Users::PushSubscriptionsControllerForeignWriteTest < ActionDispatch::IntegrationTest
|
||||||
|
self.use_transactional_tests = false
|
||||||
|
|
||||||
|
setup do
|
||||||
|
sign_in :david
|
||||||
|
stub_web_push_dns_resolution
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a subscription another connection registers while this one validates isn't registered twice" do
|
||||||
|
params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/foreign", "p256dh_key" => "123", "auth_key" => "456" }
|
||||||
|
Push::Subscription.any_instance.stubs(:valid?).with { foreign_insert(params); true }.returns(true)
|
||||||
|
|
||||||
|
post user_push_subscriptions_url, params: { push_subscription: params }
|
||||||
|
|
||||||
|
assert_response :ok
|
||||||
|
assert_equal 1, Push::Subscription.where(endpoint: params["endpoint"]).count
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def foreign_insert(params)
|
||||||
|
unless @inserted
|
||||||
|
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
|
||||||
|
database.execute "INSERT INTO push_subscriptions (user_id, endpoint, p256dh_key, auth_key, created_at, updated_at) " \
|
||||||
|
"VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))", [ users(:david).id, *params.values ]
|
||||||
|
end
|
||||||
|
@inserted = true
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -28,6 +28,23 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_select ".unread", count: users(:david).memberships.reject { |m| m.room.direct? || !m.unread? }.count
|
assert_select ".unread", count: users(:david).memberships.reject { |m| m.room.direct? || !m.unread? }.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "a cached direct that is already unread still sorts by its latest message" do
|
||||||
|
room = rooms(:david_and_jason)
|
||||||
|
|
||||||
|
with_memory_cache do
|
||||||
|
travel_to 1.hour.ago do
|
||||||
|
room.messages.create! client_message_id: 998, body: "First", creator: users(:jason)
|
||||||
|
end
|
||||||
|
get user_sidebar_url
|
||||||
|
assert memberships(:david_david_and_jason).reload.unread?
|
||||||
|
|
||||||
|
room.messages.create! client_message_id: 999, body: "Second", creator: users(:jason)
|
||||||
|
get user_sidebar_url
|
||||||
|
|
||||||
|
assert_select "##{dom_id(room, :list)}[data-sorted-list-number=?]", room.reload.updated_at.to_fs(:epoch)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "directs are ordered by room recency, not name" do
|
test "directs are ordered by room recency, not name" do
|
||||||
older = rooms(:david_and_jason)
|
older = rooms(:david_and_jason)
|
||||||
newer = rooms(:david_and_kevin)
|
newer = rooms(:david_and_kevin)
|
||||||
@@ -48,4 +65,23 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert positions.all?
|
assert positions.all?
|
||||||
assert_equal positions.sort, positions
|
assert_equal positions.sort, positions
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def with_memory_cache
|
||||||
|
old_cache = Rails.cache
|
||||||
|
old_collection_cache = ActionView::PartialRenderer.collection_cache
|
||||||
|
old_controller_cache = Users::SidebarsController.cache_store
|
||||||
|
old_caching = Users::SidebarsController.perform_caching
|
||||||
|
|
||||||
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||||
|
ActionView::PartialRenderer.collection_cache = Rails.cache
|
||||||
|
Users::SidebarsController.cache_store = Rails.cache
|
||||||
|
Users::SidebarsController.perform_caching = true
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
Rails.cache = old_cache
|
||||||
|
ActionView::PartialRenderer.collection_cache = old_collection_cache
|
||||||
|
Users::SidebarsController.cache_store = old_controller_cache
|
||||||
|
Users::SidebarsController.perform_caching = old_caching
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -172,7 +172,7 @@ class ContentFiltersTest < ActionView::TestCase
|
|||||||
|
|
||||||
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body).to_html
|
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body).to_html
|
||||||
|
|
||||||
assert_equal body, filtered
|
assert_dom_equal body, filtered
|
||||||
assert_match %r{<table>.*<th><p>Name</p></th>.*<td><p>Jason</p></td>}m, message_presentation(message)
|
assert_match %r{<table>.*<th><p>Name</p></th>.*<td><p>Jason</p></td>}m, message_presentation(message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class MessagesHelperTest < ActionView::TestCase
|
class MessagesHelperTest < ActionView::TestCase
|
||||||
|
test "plain text presentation is reused by content rather than database epoch" do
|
||||||
|
view.controller.stubs(:perform_caching).returns(true)
|
||||||
|
FragmentCache.store.clear
|
||||||
|
message = Message.create! room: rooms(:pets), body: "A reusable <strong>safe</strong> body", creator: users(:jason)
|
||||||
|
first = view.message_presentation(message)
|
||||||
|
assert_includes first, "A reusable <strong>safe</strong> body"
|
||||||
|
ContentFilters::TextMessagePresentationFilters.expects(:apply).never
|
||||||
|
assert_equal first, view.message_presentation(message)
|
||||||
|
ensure
|
||||||
|
FragmentCache.store.clear
|
||||||
|
end
|
||||||
|
|
||||||
test "message_presentation neutralizes unsafe URI schemes in links" do
|
test "message_presentation neutralizes unsafe URI schemes in links" do
|
||||||
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
|
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
|
||||||
|
|
||||||
@@ -25,6 +37,47 @@ class MessagesHelperTest < ActionView::TestCase
|
|||||||
assert_match /<strong>bold<\/strong>/, presentation
|
assert_match /<strong>bold<\/strong>/, presentation
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "message_presentation links URLs and email addresses in text" do
|
||||||
|
message = Message.create! room: rooms(:pets), body: "<div>See https://example.com/a?b=1&c=2, or write to me@example.com.</div>", client_message_id: "0015", creator: users(:jason)
|
||||||
|
|
||||||
|
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
|
||||||
|
assert_equal [ "https://example.com/a?b=1&c=2", "mailto:me@example.com" ], links.map { it["href"] }
|
||||||
|
assert_equal [ "_blank", "_blank" ], links.map { it["target"] }
|
||||||
|
assert_equal [ "https://example.com/a?b=1&c=2", "me@example.com" ], links.map(&:text)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "message_presentation links an email address with an apostrophe whole" do
|
||||||
|
message = Message.create! room: rooms(:pets), body: "<div>Write to o'brien@example.com</div>", client_message_id: "0015", creator: users(:jason)
|
||||||
|
|
||||||
|
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
|
||||||
|
assert_equal [ [ "mailto:o%27brien@example.com", "o'brien@example.com" ] ], links.map { [ it["href"], it.text ] }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "message_presentation leaves URLs and email addresses in attribute values as they are" do
|
||||||
|
[ "class", "title" ].product([ "https://example.com", "www.example.com", "me@example.com" ]).each do |attribute, linkable|
|
||||||
|
value = "a> #{linkable} <em>b</em> "
|
||||||
|
message = Message.create! room: rooms(:pets), body: %(<div><span #{attribute}="#{value}">c</span> https://example.org</div>), client_message_id: "0015", creator: users(:jason)
|
||||||
|
|
||||||
|
presentation = Nokogiri::HTML5.fragment(view.message_presentation(message))
|
||||||
|
assert_equal [ value ], presentation.css("span[#{attribute}]").map { it[attribute] }, "#{attribute} with #{linkable}"
|
||||||
|
assert_empty presentation.css("em"), "#{attribute} with #{linkable}"
|
||||||
|
assert_equal [ "https://example.org" ], presentation.css("a").map { it["href"] }, "#{attribute} with #{linkable}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "linking text doesn't eat a code block's leading blank line" do
|
||||||
|
linked = view.send(:auto_link_text, "<pre>\n\n\nfirst https://example.com</pre>".html_safe)
|
||||||
|
|
||||||
|
assert_equal "\nfirst https://example.com", Nokogiri::HTML5.fragment(linked).at_css("pre").text
|
||||||
|
end
|
||||||
|
|
||||||
|
test "message_presentation leaves an existing link's text alone" do
|
||||||
|
message = Message.create! room: rooms(:pets), body: '<div><a href="https://example.com/">https://example.org/</a></div>', client_message_id: "0015", creator: users(:jason)
|
||||||
|
|
||||||
|
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
|
||||||
|
assert_equal [ [ "https://example.com/", "https://example.org/" ] ], links.map { [ it["href"], it.text ] }
|
||||||
|
end
|
||||||
|
|
||||||
test "message_presentation shows an image's thumbnail made when it was posted" do
|
test "message_presentation shows an image's thumbnail made when it was posted" do
|
||||||
presentation = view.message_presentation(attachment_message("moon.jpg", "image/jpeg", processed: true))
|
presentation = view.message_presentation(attachment_message("moon.jpg", "image/jpeg", processed: true))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
|
||||||
|
PAGE_PATH = "/"
|
||||||
|
|
||||||
|
setup { sign_in :david }
|
||||||
|
|
||||||
|
test "enforcing policy allows exactly the inline scripts the page carries" do
|
||||||
|
get root_url
|
||||||
|
follow_redirect! while response.redirect?
|
||||||
|
|
||||||
|
policy = response.headers["Content-Security-Policy"]
|
||||||
|
assert policy, "expected an enforcing Content-Security-Policy header"
|
||||||
|
assert_nil response.headers["Content-Security-Policy-Report-Only"]
|
||||||
|
assert_match "object-src 'none'", policy
|
||||||
|
assert_match "base-uri 'self'", policy
|
||||||
|
assert_no_match "unsafe-inline", policy
|
||||||
|
assert_no_match "nonce-", policy
|
||||||
|
assert_equal "www.example.com/service-worker.js", policy[/worker-src ([^;]+)/, 1]
|
||||||
|
|
||||||
|
script_src = policy[/script-src ([^;]+)/, 1].split
|
||||||
|
assert_not_includes script_src, "'self'"
|
||||||
|
assert_includes script_src, "www.example.com/assets/"
|
||||||
|
inline = Nokogiri::HTML5(response.body).css("script").reject { |s| s["src"] || s["type"] == "text/template" }
|
||||||
|
assert_operator inline.size, :>=, 2
|
||||||
|
inline.each do |script|
|
||||||
|
assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(script.text)}'", "inline #{script["type"]} script not allowed by hash"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "scripts may load from a configured asset host, however it's written" do
|
||||||
|
{
|
||||||
|
"https://cdn.example.test" => %r{\Ahttps://cdn\.example\.test\z},
|
||||||
|
"cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z},
|
||||||
|
"//cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z},
|
||||||
|
"https://assets%d.example.test" => %r{\Ahttps://assets[0-3]\.example\.test\z},
|
||||||
|
->(source) { "https://one.example.test" } => %r{\Ahttps://one\.example\.test\z},
|
||||||
|
->(source, request) { "https://#{request.host}.example.test" } => %r{\Ahttps://www\.example\.com\.example\.test\z}
|
||||||
|
}.each do |asset_host, expected_origin|
|
||||||
|
with_asset_host(asset_host) do
|
||||||
|
get PAGE_PATH
|
||||||
|
follow_redirect! while response.redirect?
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
|
||||||
|
page = Nokogiri::HTML5(response.body)
|
||||||
|
urls = JSON.parse(page.at_css("script[type=importmap]").text)["imports"].values +
|
||||||
|
page.css("link[rel=modulepreload]").map { it["href"] } + page.css("script[src]").map { it["src"] }
|
||||||
|
origins = urls.map { URI.join(request.base_url, it).origin }.uniq - [ request.base_url ]
|
||||||
|
|
||||||
|
assert_not_empty origins, asset_host.inspect
|
||||||
|
origins.each { assert_match expected_origin, it, asset_host.inspect }
|
||||||
|
assert_scripts_allowed_by_path script_src, urls.map { URI.join(request.base_url, it).to_s }, asset_host.inspect
|
||||||
|
assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(page.at_css("script[type=importmap]").text)}'", asset_host.inspect
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an asset host whose path contains the asset directory is limited to the directory it serves" do
|
||||||
|
with_asset_host("https://cdn.example.test/assets") do
|
||||||
|
get PAGE_PATH
|
||||||
|
follow_redirect! while response.redirect?
|
||||||
|
|
||||||
|
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
|
||||||
|
assert_includes script_src, "https://cdn.example.test/assets/assets/"
|
||||||
|
assert_not allowed_by_path?(script_src, "https://cdn.example.test/assets/rails/active_storage/blobs/proxy/x/upload.js")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the sign-in page is limited to the asset directory too" do
|
||||||
|
cookies.delete(:session_token)
|
||||||
|
get new_session_url
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
|
||||||
|
assert_not_includes script_src, "'self'"
|
||||||
|
assert_includes script_src, "www.example.com/assets/"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "scripts are limited to the asset directory under a relative root" do
|
||||||
|
with_relative_url_root("/chat") do
|
||||||
|
get PAGE_PATH
|
||||||
|
follow_redirect! while response.redirect?
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
|
||||||
|
page = Nokogiri::HTML5(response.body)
|
||||||
|
urls = JSON.parse(page.at_css("script[type=importmap]").text)["imports"].values + page.css("link[rel=modulepreload]").map { it["href"] }
|
||||||
|
|
||||||
|
assert urls.all? { it.start_with?("/chat/assets/") }, urls.inspect
|
||||||
|
assert_includes script_src, "www.example.com/chat/assets/"
|
||||||
|
assert_scripts_allowed_by_path script_src, urls.map { URI.join(request.base_url, it).to_s }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "this origin's sources keep a non-default port and take the page's scheme" do
|
||||||
|
host! "www.example.com:8443"
|
||||||
|
get PAGE_PATH
|
||||||
|
follow_redirect! while response.redirect?
|
||||||
|
|
||||||
|
policy = response.headers["Content-Security-Policy"]
|
||||||
|
assert_includes policy[/script-src ([^;]+)/, 1].split, "www.example.com:8443/assets/"
|
||||||
|
assert_equal "www.example.com:8443/service-worker.js", policy[/worker-src ([^;]+)/, 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "each host's pages carry that host's sources, one after another" do
|
||||||
|
[ "www.example.com", "www.example.com:8443", "www.example.com" ].each do |host|
|
||||||
|
host! host
|
||||||
|
get PAGE_PATH
|
||||||
|
follow_redirect! while response.redirect?
|
||||||
|
|
||||||
|
assert_includes response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split, "#{host}/assets/"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uploads can't load as scripts" do
|
||||||
|
get PAGE_PATH
|
||||||
|
follow_redirect! while response.redirect?
|
||||||
|
|
||||||
|
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new("alert(1)"), filename: "upload.js", content_type: "text/javascript")
|
||||||
|
|
||||||
|
disk_url = ActiveStorage::Current.set(url_options: { protocol: "http", host: "www.example.com" }) { blob.url }
|
||||||
|
|
||||||
|
[ rails_blob_url(blob), rails_blob_url(blob, disposition: :inline), rails_storage_proxy_url(blob), disk_url ].each do |url|
|
||||||
|
assert_not allowed_by_path?(script_src, url), "#{url} allowed by #{script_src.inspect}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with the policy turned off, pages send none" do
|
||||||
|
without_policy do
|
||||||
|
get PAGE_PATH
|
||||||
|
follow_redirect! while response.redirect?
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_nil response.headers["Content-Security-Policy"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the escape hatch boots without a policy" do
|
||||||
|
output = IO.popen({ "DISABLE_CONTENT_SECURITY_POLICY" => "true" },
|
||||||
|
[ "bin/rails", "runner", "print Rails.application.config.content_security_policy.inspect" ], chdir: Rails.root, err: File::NULL, &:read)
|
||||||
|
|
||||||
|
assert_equal "nil", output
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uploaded files are guarded on their way out" do
|
||||||
|
assert_includes Rails.application.middleware.map(&:klass), UploadedScriptGuard
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# What DISABLE_CONTENT_SECURITY_POLICY=true leaves at boot: no application policy
|
||||||
|
def without_policy
|
||||||
|
previous = Rails.application.env_config.delete("action_dispatch.content_security_policy")
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
Rails.application.env_config["action_dispatch.content_security_policy"] = previous
|
||||||
|
end
|
||||||
|
|
||||||
|
# Every page script is allowed by a source with a path, never by a bare origin
|
||||||
|
def assert_scripts_allowed_by_path(script_src, urls, message = nil)
|
||||||
|
assert_empty script_src.grep(%r{\A([a-z]+://)?[^/']+/?\z}), message
|
||||||
|
urls.each { assert allowed_by_path?(script_src, it), "#{it} not allowed by #{script_src.inspect} #{message}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
# CSP source matching on a first request: 'self' and a bare origin match any
|
||||||
|
# path, a path ending in / matches as a prefix, any other path exactly. A
|
||||||
|
# source without a scheme takes the page's.
|
||||||
|
def allowed_by_path?(script_src, url)
|
||||||
|
url = URI(url)
|
||||||
|
script_src.any? do |source|
|
||||||
|
unless source.start_with?("'") && source != "'self'"
|
||||||
|
source = URI(source == "'self'" ? request.base_url : (source.include?("://") ? source : "#{request.scheme}://#{source}"))
|
||||||
|
end
|
||||||
|
source.is_a?(URI) && source.origin == url.origin &&
|
||||||
|
(source.path.empty? || (source.path.end_with?("/") ? url.path.start_with?(source.path) : url.path == source.path))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_relative_url_root(root)
|
||||||
|
previous, ApplicationController.config.relative_url_root = ApplicationController.config.relative_url_root, root
|
||||||
|
Rails.application.importmap.send(:clear_cache)
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
ApplicationController.config.relative_url_root = previous
|
||||||
|
Rails.application.importmap.send(:clear_cache)
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_asset_host(asset_host)
|
||||||
|
previous, ApplicationController.asset_host = ApplicationController.asset_host, asset_host
|
||||||
|
Rails.application.importmap.send(:clear_cache)
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
ApplicationController.asset_host = previous
|
||||||
|
Rails.application.importmap.send(:clear_cache)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class SqliteWalCheckpointTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@tmpdir = Dir.mktmpdir("sqlite-wal-checkpoint")
|
||||||
|
SqliteWalCheckpoint.reset!
|
||||||
|
SqliteWalCheckpoint.lock_path = File.join(@tmpdir, "checkpoint.lock")
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
SqliteWalCheckpoint.reset!
|
||||||
|
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.directory?(@tmpdir)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "connections disable WAL auto-checkpoint so commits do not fsync on the writer" do
|
||||||
|
assert_equal 0, ActiveRecord::Base.connection.select_value("PRAGMA wal_autocheckpoint").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a passive checkpoint against the primary database does not raise" do
|
||||||
|
assert_nothing_raised { SqliteWalCheckpoint.checkpoint }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not start a background thread in test by default" do
|
||||||
|
assert_nil SqliteWalCheckpoint.start
|
||||||
|
assert_empty checkpoint_threads
|
||||||
|
end
|
||||||
|
|
||||||
|
test "start with enabled: true runs a named contender thread that stop joins" do
|
||||||
|
thread = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
|
||||||
|
assert thread.alive?
|
||||||
|
wait_until { thread.name == "sqlite-wal-checkpoint" }
|
||||||
|
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
assert_not thread.alive?
|
||||||
|
assert_empty checkpoint_threads
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stop before start again mimics a fork-safe worker boot" do
|
||||||
|
db_path = build_wal_database(rows: 10)
|
||||||
|
SqliteWalCheckpoint.database_path_override = db_path
|
||||||
|
|
||||||
|
first = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
|
||||||
|
wait_until { first.name == "sqlite-wal-checkpoint" }
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
assert_not first.alive?
|
||||||
|
|
||||||
|
second = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
|
||||||
|
wait_until { second.name == "sqlite-wal-checkpoint" }
|
||||||
|
assert second.alive?
|
||||||
|
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
assert_not second.alive?
|
||||||
|
assert_equal :checkpointed, SqliteWalCheckpoint.tick
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stop interrupts a long checkpoint interval" do
|
||||||
|
waits = observe_waits
|
||||||
|
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
|
||||||
|
assert_equal 30, receive(waits)
|
||||||
|
|
||||||
|
SqliteWalCheckpoint.stop(timeout: 0.5)
|
||||||
|
assert_not contender.alive?
|
||||||
|
assert_empty checkpoint_threads
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stop interrupts checkpoint error backoff" do
|
||||||
|
SqliteWalCheckpoint.database_path_override = build_wal_database(rows: 10)
|
||||||
|
SqliteWalCheckpoint.stubs(:checkpoint_on).raises(SQLite3::Exception, "checkpoint failed")
|
||||||
|
waits = observe_waits
|
||||||
|
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
|
||||||
|
assert_equal 30, receive(waits)
|
||||||
|
|
||||||
|
SqliteWalCheckpoint.stop(timeout: 0.5)
|
||||||
|
assert_not contender.alive?
|
||||||
|
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "timed out stop prevents replacement until the checkpoint releases its resources" do
|
||||||
|
entered, release = block_checkpoint
|
||||||
|
contender = SqliteWalCheckpoint.start(enabled: true)
|
||||||
|
database = receive(entered)
|
||||||
|
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
|
||||||
|
|
||||||
|
assert_raises(SqliteWalCheckpoint::StopTimeout) { SqliteWalCheckpoint.stop(timeout: 0.01) }
|
||||||
|
assert contender.alive?
|
||||||
|
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
|
||||||
|
assert_raises(SqliteWalCheckpoint::StopTimeout) { SqliteWalCheckpoint.start(enabled: true) }
|
||||||
|
assert_equal [ contender ], checkpoint_threads
|
||||||
|
|
||||||
|
release << true
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
assert_not contender.alive?
|
||||||
|
assert database.closed?
|
||||||
|
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
|
||||||
|
ensure
|
||||||
|
release&.push(true)
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
end
|
||||||
|
|
||||||
|
test "start waits for a concurrent stop to release the previous contender" do
|
||||||
|
entered, release = block_checkpoint
|
||||||
|
contender = SqliteWalCheckpoint.start(enabled: true)
|
||||||
|
receive(entered)
|
||||||
|
stopping = Thread.new { SqliteWalCheckpoint.stop }
|
||||||
|
wait_until { stopping.status == "sleep" }
|
||||||
|
starting = Thread.new { SqliteWalCheckpoint.start(enabled: true) }
|
||||||
|
wait_until { starting.status == "sleep" }
|
||||||
|
assert contender.alive?
|
||||||
|
|
||||||
|
release << true
|
||||||
|
stopping.value
|
||||||
|
replacement = starting.value
|
||||||
|
assert_not contender.alive?
|
||||||
|
assert replacement.alive?
|
||||||
|
assert_not_equal contender, replacement
|
||||||
|
ensure
|
||||||
|
release&.push(true)
|
||||||
|
stopping&.join
|
||||||
|
starting&.join
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a contender cannot release a concurrent tick's lock" do
|
||||||
|
entered, release = block_checkpoint
|
||||||
|
ticking = Thread.new { SqliteWalCheckpoint.tick }
|
||||||
|
receive(entered)
|
||||||
|
waits = observe_waits
|
||||||
|
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
|
||||||
|
assert_equal 30, receive(waits)
|
||||||
|
|
||||||
|
SqliteWalCheckpoint.stop(timeout: 0.5)
|
||||||
|
assert_not contender.alive?
|
||||||
|
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
|
||||||
|
release << true
|
||||||
|
assert_equal :checkpointed, ticking.value
|
||||||
|
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
|
||||||
|
ensure
|
||||||
|
release&.push(true)
|
||||||
|
ticking&.join
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reset does not reinstall the process exit checkpoint" do
|
||||||
|
SqliteWalCheckpoint.start(enabled: true)
|
||||||
|
SqliteWalCheckpoint.reset!
|
||||||
|
SqliteWalCheckpoint.expects(:at_exit).never
|
||||||
|
SqliteWalCheckpoint.start(enabled: true)
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tick checkpoints through the elected lock holder" do
|
||||||
|
db_path = build_wal_database(rows: 50)
|
||||||
|
SqliteWalCheckpoint.database_path_override = db_path
|
||||||
|
|
||||||
|
assert_equal :checkpointed, SqliteWalCheckpoint.tick
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tick reports busy while another process holds the lock and succeeds after it exits" do
|
||||||
|
db_path = build_wal_database(rows: 20)
|
||||||
|
SqliteWalCheckpoint.database_path_override = db_path
|
||||||
|
lock = SqliteWalCheckpoint.lock_path
|
||||||
|
ready = File.join(@tmpdir, "holder-ready")
|
||||||
|
|
||||||
|
holder = spawn_lock_holder(lock, ready_path: ready, hold_for: 0.4)
|
||||||
|
|
||||||
|
begin
|
||||||
|
wait_until { File.exist?(ready) }
|
||||||
|
assert_equal :busy, SqliteWalCheckpoint.tick
|
||||||
|
ensure
|
||||||
|
Process.wait(holder)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal :checkpointed, SqliteWalCheckpoint.tick
|
||||||
|
end
|
||||||
|
|
||||||
|
test "passive checkpoint moves WAL pages after writes with autocheckpoint disabled" do
|
||||||
|
db_path = File.join(@tmpdir, "writer-#{SecureRandom.hex(4)}.sqlite3")
|
||||||
|
|
||||||
|
SQLite3::Database.new(db_path) do |writer|
|
||||||
|
writer.execute("PRAGMA journal_mode=WAL")
|
||||||
|
writer.execute("PRAGMA wal_autocheckpoint=0")
|
||||||
|
writer.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, body TEXT)")
|
||||||
|
200.times do |i|
|
||||||
|
writer.execute("INSERT INTO items (body) VALUES (?)", "row-#{i}-#{"x" * 200}")
|
||||||
|
end
|
||||||
|
|
||||||
|
wal_path = "#{db_path}-wal"
|
||||||
|
assert File.exist?(wal_path), "expected a WAL file after inserts"
|
||||||
|
assert File.size(wal_path) > 0
|
||||||
|
|
||||||
|
SqliteWalCheckpoint.database_path_override = db_path
|
||||||
|
lock = SqliteWalCheckpoint.send(:acquire_lock)
|
||||||
|
assert lock
|
||||||
|
begin
|
||||||
|
_busy, _log, checkpointed = SqliteWalCheckpoint.checkpoint
|
||||||
|
assert checkpointed.to_i > 0, "expected PASSIVE checkpoint to copy WAL pages, got #{checkpointed.inspect}"
|
||||||
|
ensure
|
||||||
|
SqliteWalCheckpoint.send(:release_lock, lock)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a contender thread takes over checkpointing after the lock holder stops" do
|
||||||
|
db_path = build_wal_database(rows: 30)
|
||||||
|
SqliteWalCheckpoint.database_path_override = db_path
|
||||||
|
lock = SqliteWalCheckpoint.lock_path
|
||||||
|
ready = File.join(@tmpdir, "holder-ready")
|
||||||
|
|
||||||
|
holder = spawn_lock_holder(lock, ready_path: ready, hold_for: 0.25)
|
||||||
|
wait_until { File.exist?(ready) }
|
||||||
|
|
||||||
|
contender = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
|
||||||
|
|
||||||
|
begin
|
||||||
|
assert_equal :busy, SqliteWalCheckpoint.tick
|
||||||
|
Process.wait(holder)
|
||||||
|
holder = nil
|
||||||
|
|
||||||
|
wait_until(timeout: 2) { lock_held_by_other_process?(lock) }
|
||||||
|
ensure
|
||||||
|
Process.wait(holder) if holder
|
||||||
|
SqliteWalCheckpoint.stop
|
||||||
|
assert_not contender.alive?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def observe_waits
|
||||||
|
waits = Thread::Queue.new
|
||||||
|
SqliteWalCheckpoint.stubs(:wait).with do |wakeup, timeout|
|
||||||
|
waits << timeout
|
||||||
|
wakeup.pop(timeout: timeout)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
waits
|
||||||
|
end
|
||||||
|
|
||||||
|
def block_checkpoint
|
||||||
|
SqliteWalCheckpoint.database_path_override = build_wal_database(rows: 10)
|
||||||
|
entered = Thread::Queue.new
|
||||||
|
release = Thread::Queue.new
|
||||||
|
blocked = false
|
||||||
|
SqliteWalCheckpoint.stubs(:checkpoint_on).returns([ 0, 0, 0 ]).with do |database|
|
||||||
|
unless blocked
|
||||||
|
blocked = true
|
||||||
|
entered << database
|
||||||
|
release.pop
|
||||||
|
end
|
||||||
|
true
|
||||||
|
end
|
||||||
|
[ entered, release ]
|
||||||
|
end
|
||||||
|
|
||||||
|
def receive(queue)
|
||||||
|
queue.pop(timeout: 2).tap { |value| assert_not_nil value, "expected checkpoint barrier" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def checkpoint_threads
|
||||||
|
Thread.list.select { |thread| thread.name == "sqlite-wal-checkpoint" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_wal_database(rows:)
|
||||||
|
path = File.join(@tmpdir, "writer-#{SecureRandom.hex(4)}.sqlite3")
|
||||||
|
|
||||||
|
SQLite3::Database.new(path) do |database|
|
||||||
|
database.execute("PRAGMA journal_mode=WAL")
|
||||||
|
database.execute("PRAGMA wal_autocheckpoint=0")
|
||||||
|
database.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, body TEXT)")
|
||||||
|
rows.times do |i|
|
||||||
|
database.execute("INSERT INTO items (body) VALUES (?)", "row-#{i}-#{"x" * 200}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path
|
||||||
|
end
|
||||||
|
|
||||||
|
def spawn_lock_holder(lock, ready_path:, hold_for:)
|
||||||
|
Process.spawn(
|
||||||
|
RbConfig.ruby, "-e", <<~RUBY
|
||||||
|
require "fileutils"
|
||||||
|
lock = #{lock.inspect}
|
||||||
|
ready = #{ready_path.inspect}
|
||||||
|
FileUtils.mkdir_p(File.dirname(lock))
|
||||||
|
file = File.open(lock, File::RDWR | File::CREAT, 0644)
|
||||||
|
abort "lock failed" unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
||||||
|
File.write(ready, "1")
|
||||||
|
sleep #{hold_for}
|
||||||
|
RUBY
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def lock_held_by_other_process?(lock)
|
||||||
|
return false unless File.exist?(lock)
|
||||||
|
|
||||||
|
probe = File.open(lock, File::RDWR | File::CREAT, 0644)
|
||||||
|
if probe.flock(File::LOCK_EX | File::LOCK_NB)
|
||||||
|
probe.flock(File::LOCK_UN)
|
||||||
|
probe.close
|
||||||
|
false
|
||||||
|
else
|
||||||
|
probe.close
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_until(timeout: 1)
|
||||||
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
||||||
|
until yield
|
||||||
|
raise "condition not met within #{timeout}s" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
||||||
|
sleep 0.02
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,11 +10,6 @@ class VipsLoaderPolicyTest < ActiveSupport::TestCase
|
|||||||
# ones are represented by their magic bytes.
|
# ones are represented by their magic bytes.
|
||||||
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
|
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
|
||||||
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
|
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
|
||||||
BMP = "BM" + [ 0, 0, 54 ].pack("V3") + "\x00" * 40
|
|
||||||
PSD = "8BPS" + [ 1 ].pack("n") + "\x00" * 26
|
|
||||||
ICO = "\x00\x00\x01\x00\x01\x00" + "\x00" * 16
|
|
||||||
SVG = %q(<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>)
|
|
||||||
|
|
||||||
test "loads PNG" do
|
test "loads PNG" do
|
||||||
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
|
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
|
||||||
end
|
end
|
||||||
@@ -43,20 +38,20 @@ class VipsLoaderPolicyTest < ActiveSupport::TestCase
|
|||||||
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
|
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "denies BMP through magickload" do
|
test "blocks BMP through magickload" do
|
||||||
assert_nil loader_for(BMP)
|
assert_loader_blocked :magickload, ".bmp"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "denies PSD through magickload" do
|
test "blocks PSD through magickload" do
|
||||||
assert_nil loader_for(PSD)
|
assert_loader_blocked :magickload, ".psd"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "denies ICO through magickload" do
|
test "blocks ICO through magickload" do
|
||||||
assert_nil loader_for(ICO)
|
assert_loader_blocked :magickload, ".ico"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "denies SVG through svgload" do
|
test "blocks SVG through svgload" do
|
||||||
assert_nil loader_for(SVG)
|
assert_loader_blocked :svgload, ".svg"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "denies OpenSlide files through openslideload" do
|
test "denies OpenSlide files through openslideload" do
|
||||||
|
|||||||
@@ -76,11 +76,13 @@ class WebPush::ConnectionsTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "a TLS failure on a new connection before the push is written is reported as it is" do
|
class FailingCheckHTTP < WebPush::Connections::HTTP
|
||||||
failing_check = Module.new { private def begin_transport(*) = raise(OpenSSL::SSL::SSLError, "alert right after the handshake") }
|
private def begin_transport(*) = raise(OpenSSL::SSL::SSLError, "alert right after the handshake")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a TLS failure on a new connection before the push is written is reported as it is" do
|
||||||
with_push_service do |server|
|
with_push_service do |server|
|
||||||
assert_raises(OpenSSL::SSL::SSLError) { @connections.request(pinned_connection(server).extend(failing_check), push_request) }
|
assert_raises(OpenSSL::SSL::SSLError) { @connections.request(pinned_connection(server, http_class: FailingCheckHTTP), push_request) }
|
||||||
assert_empty server.requests
|
assert_empty server.requests
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -97,14 +99,15 @@ class WebPush::ConnectionsTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "only new, direct TLS connections pinned to an address are pooled" do
|
test "only new, direct TLS connections pinned to an address and opened as WebPush::Connections::HTTP are pooled" do
|
||||||
with_push_service do |server|
|
with_push_service do |server|
|
||||||
unpinned = Net::HTTP.new(HOST, server.port, nil).tap { it.use_ssl = true }
|
unpinned = WebPush::Connections::HTTP.new(HOST, server.port, nil).tap { it.use_ssl = true }
|
||||||
proxied = Net::HTTP.new(HOST, server.port, "127.0.0.1", 3128).tap { it.ipaddr = IP; it.use_ssl = true }
|
proxied = WebPush::Connections::HTTP.new(HOST, server.port, "127.0.0.1", 3128).tap { it.ipaddr = IP; it.use_ssl = true }
|
||||||
plain = pinned_connection(server).tap { it.use_ssl = false }
|
plain = pinned_connection(server).tap { it.use_ssl = false }
|
||||||
started = pinned_connection(server).tap(&:start)
|
started = pinned_connection(server).tap(&:start)
|
||||||
|
untracked = Net::HTTP.new(HOST, server.port, nil).tap { it.ipaddr = IP; it.use_ssl = true }
|
||||||
|
|
||||||
[ unpinned, proxied, plain, started ].each do |http|
|
[ unpinned, proxied, plain, started, untracked ].each do |http|
|
||||||
assert_raises(ArgumentError) { @connections.request(http, push_request) }
|
assert_raises(ArgumentError) { @connections.request(http, push_request) }
|
||||||
end
|
end
|
||||||
assert_empty server.requests
|
assert_empty server.requests
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class UploadedScriptGuardTest < ActiveSupport::TestCase
|
||||||
|
# Every JavaScript MIME type essence in the MIME Sniffing Standard, plus
|
||||||
|
# parameters and casing a signed Disk URL might carry.
|
||||||
|
SCRIPT_TYPES = %w[
|
||||||
|
application/ecmascript application/javascript application/x-ecmascript application/x-javascript
|
||||||
|
text/ecmascript text/javascript text/javascript1.0 text/javascript1.1 text/javascript1.2 text/javascript1.3
|
||||||
|
text/javascript1.4 text/javascript1.5 text/jscript text/livescript text/x-ecmascript text/x-javascript
|
||||||
|
] + [ "Text/JavaScript; charset=utf-8" ]
|
||||||
|
|
||||||
|
test "uploads never leave with a script type, whatever the URL says" do
|
||||||
|
SCRIPT_TYPES.each do |type|
|
||||||
|
assert_equal ActiveStorage.binary_content_type, served_type(type, "#{ActiveStorage.routes_prefix}/disk/key/probe.js"), type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "media and downloads keep their type" do
|
||||||
|
%w[ image/png video/mp4 audio/mpeg application/pdf text/plain ].each do |type|
|
||||||
|
assert_equal type, served_type(type, "#{ActiveStorage.routes_prefix}/disk/key/file"), type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multi-range responses keep their boundary" do
|
||||||
|
type = "multipart/byteranges; boundary=AaB03x"
|
||||||
|
assert_equal type, served_type(type, "#{ActiveStorage.routes_prefix}/blobs/proxy/key/movie.mp4")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "direct upload responses keep their JSON type" do
|
||||||
|
assert_equal "application/json", served_type("application/json", "#{ActiveStorage.routes_prefix}/direct_uploads", method: "POST")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "application responses outside uploads are untouched" do
|
||||||
|
assert_equal "text/javascript", served_type("text/javascript", "/assets/application-abc123.js")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def served_type(type, path, method: "GET")
|
||||||
|
app = ->(env) { [ 200, { "content-type" => type }, [ "" ] ] }
|
||||||
|
UploadedScriptGuard.new(app).call(Rack::MockRequest.env_for(path, method: method))[1]["content-type"]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Message::CreationTest < ActiveSupport::TestCase
|
||||||
|
include ActionDispatch::TestProcess
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@room = rooms(:designers)
|
||||||
|
@creator = users(:david)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creation indexes persisted native HTML and mentions before notifying" do
|
||||||
|
html = "<p>Fish & chips 🌍 #{mention_attachment_for(:jason)}</p>"
|
||||||
|
message = @room.messages.new(creator: @creator, body: html)
|
||||||
|
original = message.method(:create_in_index)
|
||||||
|
message.define_singleton_method(:create_in_index) do
|
||||||
|
raise "rich text not autosaved" unless ActionText::RichText.exists?(record: self)
|
||||||
|
original.call
|
||||||
|
end
|
||||||
|
|
||||||
|
message.save!
|
||||||
|
|
||||||
|
assert_equal message.body.to_plain_text, indexed_body(message)
|
||||||
|
assert_equal message.reload.body.to_plain_text, indexed_body(message)
|
||||||
|
assert_includes message.mentionees, users(:jason)
|
||||||
|
assert_equal [ message ], @room.messages.search("Fish chips")
|
||||||
|
assert_no_match(/<p>|&/, indexed_body(message))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an attachment-only message is attached before its filename is indexed" do
|
||||||
|
message = @room.messages.new(creator: @creator, attachment: fixture_file_upload("moon.jpg", "image/jpeg"))
|
||||||
|
original = message.method(:create_in_index)
|
||||||
|
message.define_singleton_method(:create_in_index) do
|
||||||
|
raise "attachment not autosaved" unless ActiveStorage::Attachment.exists?(record: self, name: "attachment")
|
||||||
|
original.call
|
||||||
|
end
|
||||||
|
|
||||||
|
message.save!
|
||||||
|
|
||||||
|
assert_equal "moon.jpg", indexed_body(message)
|
||||||
|
assert_equal [ message ], @room.messages.search("moon")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a failed index insert rolls back the message and its associations without notifications" do
|
||||||
|
message = @room.messages.new(creator: @creator, body: "<p>Failed & indexed</p>",
|
||||||
|
attachment: fixture_file_upload("moon.jpg", "image/jpeg"))
|
||||||
|
message.define_singleton_method(:create_in_index) { raise "index unavailable" }
|
||||||
|
|
||||||
|
assert_rolled_back_creation(message) do
|
||||||
|
assert_raises(RuntimeError) { message.save! }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a failed unread update rolls back the already inserted index and associations" do
|
||||||
|
message = @room.messages.new(creator: @creator, body: "<p>Unread & rollback</p>",
|
||||||
|
attachment: fixture_file_upload("moon.jpg", "image/jpeg"))
|
||||||
|
original = @room.method(:unread_memberships)
|
||||||
|
@room.define_singleton_method(:unread_memberships) do |created|
|
||||||
|
original.call(created)
|
||||||
|
raise "unread unavailable"
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_rolled_back_creation(message) do
|
||||||
|
assert_raises(RuntimeError) { message.save! }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an outer rollback restores the index unread state and counter and queues no jobs" do
|
||||||
|
message = @room.messages.new(creator: @creator, body: "<p>Outer & rollback</p>")
|
||||||
|
|
||||||
|
assert_rolled_back_creation(message) do
|
||||||
|
Message.transaction(requires_new: true) do
|
||||||
|
message.save!
|
||||||
|
assert_equal "Outer & rollback", indexed_body(message)
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the existing public receive operation still marks unread and queues a push" do
|
||||||
|
message = messages(:first)
|
||||||
|
room = message.room
|
||||||
|
room.expects(:unread_memberships).with(message)
|
||||||
|
assert_enqueued_with(job: Room::PushMessageJob, args: [ room, message ]) { room.receive(message) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def indexed_body(message)
|
||||||
|
Message.connection.select_value(Message.sanitize_sql([ "SELECT body FROM message_search_index WHERE rowid = ?", message.id ]))
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_rolled_back_creation(message)
|
||||||
|
models = [ Message, ActionText::RichText, ActiveStorage::Attachment, ActiveStorage::Blob ]
|
||||||
|
counts = models.map(&:count)
|
||||||
|
memberships = @room.memberships.order(:id).map(&:attributes)
|
||||||
|
room_state = @room.reload.attributes.slice("messages_count", "updated_at")
|
||||||
|
indexed = Message.connection.select_value("SELECT COUNT(*) FROM message_search_index")
|
||||||
|
clear_enqueued_jobs
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
assert_equal counts, models.map(&:count)
|
||||||
|
assert_equal indexed, Message.connection.select_value("SELECT COUNT(*) FROM message_search_index")
|
||||||
|
assert_equal memberships, @room.memberships.order(:id).map(&:attributes)
|
||||||
|
assert_equal room_state, @room.reload.attributes.slice("messages_count", "updated_at")
|
||||||
|
assert_enqueued_jobs 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class MessageCreationCommitTest < ActiveSupport::TestCase
|
||||||
|
self.use_transactional_tests = false
|
||||||
|
|
||||||
|
test "creation commits its index and unread writes once before enqueuing push" do
|
||||||
|
room = rooms(:designers)
|
||||||
|
sql = []
|
||||||
|
collect = ->(*, payload) { sql << payload[:sql] }
|
||||||
|
Room::PushMessageJob.expects(:perform_later).with do |queued_room, message|
|
||||||
|
assert_not Message.connection.transaction_open?
|
||||||
|
assert_equal room, queued_room
|
||||||
|
assert_equal "One native commit", Message.connection.select_value("SELECT body FROM message_search_index WHERE rowid = #{message.id}")
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
ActiveSupport::Notifications.subscribed(collect, "sql.active_record") do
|
||||||
|
room.messages.create!(creator: users(:david), body: "<p>One native commit</p>")
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal 1, sql.count { |statement| statement.match?(/\ACOMMIT\b/i) }
|
||||||
|
commit = sql.index { |statement| statement.match?(/\ACOMMIT\b/i) }
|
||||||
|
assert_operator sql.index { |statement| statement.match?(/insert into message_search_index/i) }, :<, commit
|
||||||
|
assert_operator sql.index { |statement| statement.match?(/UPDATE "memberships"/i) }, :<, commit
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Message::MentioneeTest < ActiveSupport::TestCase
|
||||||
|
test "ordinary messages return an empty user relation without loading their room" do
|
||||||
|
message = rooms(:pets).messages.create!(creator: users(:jason), body: "<p>Fish & chips 🌍</p>").reload
|
||||||
|
message.body
|
||||||
|
assert_not message.association(:room).loaded?
|
||||||
|
queries = []
|
||||||
|
subscriber = ->(*, payload) { queries << payload[:sql] unless payload[:name] == "SCHEMA" || payload[:cached] }
|
||||||
|
|
||||||
|
ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do
|
||||||
|
mentionees = message.mentionees
|
||||||
|
assert_equal User, mentionees.klass
|
||||||
|
assert_empty mentionees.to_a
|
||||||
|
assert_empty mentionees.ids
|
||||||
|
assert_empty mentionees.active_bots.excluding(users(:jason)).to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_empty queries
|
||||||
|
assert_not message.association(:room).loaded?
|
||||||
|
assert_not message.changed?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "signed mentions follow a moved message and fresh membership changes" do
|
||||||
|
message = rooms(:designers).messages.create!(creator: users(:david), body: "<p>Hey #{mention_attachment_for(:kevin)}</p>")
|
||||||
|
assert_equal [ users(:kevin).id ], message.reload.mentionees.ids
|
||||||
|
Message.where(id: message.id).update_all(room_id: rooms(:pets).id)
|
||||||
|
message = Message.find(message.id)
|
||||||
|
assert_empty message.mentionees.ids
|
||||||
|
|
||||||
|
membership = Membership.create!(room: rooms(:pets), user: users(:kevin))
|
||||||
|
assert_equal [ users(:kevin).id ], message.mentionees.ids
|
||||||
|
membership.delete
|
||||||
|
assert_empty message.mentionees.ids
|
||||||
|
assert_equal rooms(:pets).id, message.room_id
|
||||||
|
assert_not message.changed?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -17,6 +17,28 @@ class MessageTest < ActiveSupport::TestCase
|
|||||||
assert_not Message.new(body: "🔥 💯").plain_text_body.all_emoji?
|
assert_not Message.new(body: "🔥 💯").plain_text_body.all_emoji?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "plain text keeps Action Text formatting and attachment conversion" do
|
||||||
|
bodies = [ nil, "", "Hello & goodbye", "<div>First<br>second</div>", "<p>One</p><p>Two</p>",
|
||||||
|
"<ul><li>First</li><li><strong>Second</strong></li></ul>", "<blockquote>Quoted</blockquote>",
|
||||||
|
"<pre><code>one\n two</code></pre>", "<table><tr><td>one</td><td>two</td></tr></table>",
|
||||||
|
"<div><em>Unclosed", "😄🤘", "<div>Hi #{mention_attachment_for(:david)}</div>" ]
|
||||||
|
bodies.each do |html|
|
||||||
|
message = Message.new(body: html)
|
||||||
|
assert_equal message.body.to_plain_text, message.plain_text_body, html.inspect
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "plain text follows body replacement and does not expose mutable cached text" do
|
||||||
|
message = Message.new(body: "<p>First</p>")
|
||||||
|
message.plain_text_body.replace("Caller edit")
|
||||||
|
assert_equal "First", message.plain_text_body
|
||||||
|
message.body = "<p>Replacement</p>"
|
||||||
|
assert_equal "Replacement", message.plain_text_body
|
||||||
|
message = messages(:first)
|
||||||
|
message.update!(body: "<p>Reloaded body</p>")
|
||||||
|
assert_equal message.body.to_plain_text, message.reload.plain_text_body
|
||||||
|
end
|
||||||
|
|
||||||
test "mentionees" do
|
test "mentionees" do
|
||||||
message = Message.new room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason), client_message_id: "earth"
|
message = Message.new room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason), client_message_id: "earth"
|
||||||
assert_equal [ users(:david) ], message.mentionees
|
assert_equal [ users(:david) ], message.mentionees
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#fetch_document connects to the resolved IP even when a proxy is configured" do
|
||||||
|
url = URI.parse("http://www.example.com/")
|
||||||
|
saved = ENV.slice("http_proxy", "HTTP_PROXY", "no_proxy", "NO_PROXY")
|
||||||
|
%w[ no_proxy NO_PROXY ].each { |k| ENV.delete(k) }
|
||||||
|
%w[ http_proxy HTTP_PROXY ].each { |k| ENV[k] = "http://proxy.internal:3128" }
|
||||||
|
|
||||||
|
WebMock.disable_net_connect! allow: [ url.host ]
|
||||||
|
TCPSocket.expects(:open).with { |*args, **| args.first == "proxy.internal" }.never
|
||||||
|
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 80 }.throws(:not_proxied)
|
||||||
|
|
||||||
|
assert_throws :not_proxied do
|
||||||
|
@fetch.fetch_document(url, ip: "1.2.3.4")
|
||||||
|
end
|
||||||
|
ensure
|
||||||
|
%w[ http_proxy HTTP_PROXY no_proxy NO_PROXY ].each { |k| ENV.delete(k) }
|
||||||
|
saved.each { |k, v| ENV[k] = v }
|
||||||
|
end
|
||||||
|
|
||||||
test "#fetch_document is empty following redirects that never finish" do
|
test "#fetch_document is empty following redirects that never finish" do
|
||||||
WebMock.stub_request(:get, "https://www.example.com/")
|
WebMock.stub_request(:get, "https://www.example.com/")
|
||||||
.to_return(status: 302, headers: { location: "https://www.example.com/" })
|
.to_return(status: 302, headers: { location: "https://www.example.com/" })
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
require "test_helper"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
class RecordCacheTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@directory = Dir.mktmpdir
|
||||||
|
@database = File.join(@directory, "records.sqlite3")
|
||||||
|
SQLite3::Database.new(@database) do |database|
|
||||||
|
database.execute("PRAGMA journal_mode=WAL")
|
||||||
|
database.execute("CREATE TABLE changes_for_test (value TEXT)")
|
||||||
|
end
|
||||||
|
ActiveRecord::Base.stubs(:connection_db_config).returns(Struct.new(:database).new(@database))
|
||||||
|
ActiveRecord::Base.connection.stubs(:transaction_open?).returns(false)
|
||||||
|
@cache = ResponseCache.new
|
||||||
|
@cache.stubs(:budget).returns(4096)
|
||||||
|
ResponseCache.stubs(:instance).returns(@cache)
|
||||||
|
FragmentCache.stubs(:store).returns(ActiveSupport::Cache::MemoryStore.new(size: 64.kilobytes))
|
||||||
|
@record = User.instantiate(users(:david).attributes_before_type_cast.merge(
|
||||||
|
"created_at" => "2026-10-08 12:34:56.123456", "updated_at" => "2026-10-08 12:34:57.654321",
|
||||||
|
"status" => 2, "role" => 1))
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
@cache.clear
|
||||||
|
FileUtils.remove_entry(@directory)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "hits reconstruct independent persisted models without losing raw timestamps or enum values" do
|
||||||
|
version = @cache.version
|
||||||
|
assert_same @record, RecordCache.fetch("user", version) { [ @record ] }.first
|
||||||
|
first = RecordCache.fetch("user", version) { flunk "cache hit queried records" }.first
|
||||||
|
second = RecordCache.fetch("user", version) { flunk "cache hit queried records" }.first
|
||||||
|
|
||||||
|
assert_not_same @record, first
|
||||||
|
assert_not_same first, second
|
||||||
|
assert first.persisted?
|
||||||
|
assert_not first.changed?
|
||||||
|
assert_equal @record.attributes_before_type_cast, first.attributes_before_type_cast
|
||||||
|
assert_equal 123456, first.created_at.usec
|
||||||
|
assert_equal 654321, first.updated_at.usec
|
||||||
|
assert_equal "banned", first.status
|
||||||
|
assert_equal "administrator", first.role
|
||||||
|
first.name = "Request-local edit"
|
||||||
|
assert_equal @record.name, second.name
|
||||||
|
assert_equal @record.name, RecordCache.fetch("user", version) { flunk "cache hit queried records" }.first.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "foreign commit makes an old captured epoch query fresh records instead of reusing a snapshot" do
|
||||||
|
version = @cache.version
|
||||||
|
RecordCache.fetch("user", version) { [ @record ] }
|
||||||
|
foreign_commit
|
||||||
|
fresh = User.instantiate(@record.attributes_before_type_cast.merge("name" => "Changed elsewhere"))
|
||||||
|
assert_same fresh, RecordCache.fetch("user", version) { [ fresh ] }.first
|
||||||
|
assert_same fresh, RecordCache.fetch("user", @cache.version) { [ fresh ] }.first
|
||||||
|
assert_equal fresh.name, RecordCache.fetch("user", @cache.version) { flunk "fresh snapshot missing" }.first.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "observer is rechecked after a snapshot lookup" do
|
||||||
|
version = @cache.version
|
||||||
|
RecordCache.fetch("user", version) { [ @record ] }
|
||||||
|
store = FragmentCache.store
|
||||||
|
original_read = store.method(:read)
|
||||||
|
commit = method(:foreign_commit)
|
||||||
|
store.define_singleton_method(:read) do |*arguments|
|
||||||
|
original_read.call(*arguments).tap { |snapshot| commit.call if snapshot }
|
||||||
|
end
|
||||||
|
fresh = User.instantiate(@record.attributes_before_type_cast.merge("name" => "Committed during lookup"))
|
||||||
|
assert_same fresh, RecordCache.fetch("user", version) { [ fresh ] }.first
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a commit while loading records prevents admission under the old epoch" do
|
||||||
|
version = @cache.version
|
||||||
|
FragmentCache.store.expects(:write).never
|
||||||
|
assert_same @record, RecordCache.fetch("user", version) { foreign_commit; [ @record ] }.first
|
||||||
|
replacement = User.instantiate(@record.attributes_before_type_cast.merge("name" => "Fresh load"))
|
||||||
|
assert_same replacement, RecordCache.fetch("user", version) { [ replacement ] }.first
|
||||||
|
end
|
||||||
|
|
||||||
|
test "disabled budget missing epoch and open transactions always query and never admit" do
|
||||||
|
version = @cache.version
|
||||||
|
RecordCache.fetch("user", version) { [ @record ] }
|
||||||
|
FragmentCache.store.expects(:write).never
|
||||||
|
@cache.stubs(:budget).returns(0)
|
||||||
|
assert_same @record, RecordCache.fetch("user", version) { [ @record ] }.first
|
||||||
|
@cache.stubs(:budget).returns(4096)
|
||||||
|
assert_same @record, RecordCache.fetch("user", nil) { [ @record ] }.first
|
||||||
|
ActiveRecord::Base.connection.stubs(:transaction_open?).returns(true)
|
||||||
|
assert_same @record, RecordCache.fetch("user", version) { [ @record ] }.first
|
||||||
|
assert_same @record, RecordCache.fetch("transaction-only", version) { [ @record ] }.first
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def foreign_commit
|
||||||
|
SQLite3::Database.new(@database) { |database| database.execute("INSERT INTO changes_for_test VALUES ('committed')") }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
require "test_helper"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
class ResponseCacheTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@directory = Dir.mktmpdir
|
||||||
|
@database = File.join(@directory, "cache.sqlite3")
|
||||||
|
SQLite3::Database.new(@database) do |database|
|
||||||
|
database.execute("PRAGMA journal_mode=WAL")
|
||||||
|
database.execute("CREATE TABLE values_for_test (value TEXT)")
|
||||||
|
end
|
||||||
|
ActiveRecord::Base.stubs(:connection_db_config).returns(Struct.new(:database).new(@database))
|
||||||
|
@cache = ResponseCache.new
|
||||||
|
@cache.stubs(:budget).returns(1024)
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
@cache.clear
|
||||||
|
FileUtils.remove_entry(@directory)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "foreign commits expire entries and reject old render admission" do
|
||||||
|
version = @cache.version
|
||||||
|
@cache.write("page", version, entry("first"))
|
||||||
|
assert_equal "first", @cache.read("page", version)[:body]
|
||||||
|
SQLite3::Database.new(@database) { |database| database.execute("INSERT INTO values_for_test VALUES ('committed')") }
|
||||||
|
assert_nil @cache.read("page", version)
|
||||||
|
@cache.write("page", version, entry("old in-flight render"))
|
||||||
|
assert_nil @cache.read("page", @cache.version)
|
||||||
|
@cache.write("page", @cache.version, entry("fresh"))
|
||||||
|
assert_equal "fresh", @cache.read("page", @cache.version)[:body]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "byte budget evicts oldest and bypasses oversized entries and keys" do
|
||||||
|
version = @cache.version
|
||||||
|
@cache.stubs(:budget).returns(512)
|
||||||
|
@cache.write("first", version, entry("a" * 100))
|
||||||
|
@cache.write("second", version, entry("b" * 100))
|
||||||
|
assert_nil @cache.read("first", version)
|
||||||
|
assert_equal "b" * 100, @cache.read("second", version)[:body]
|
||||||
|
@cache.write("oversized", version, entry("c" * 1000))
|
||||||
|
assert_nil @cache.read("oversized", version)
|
||||||
|
@cache.write("k" * 2049, version, entry("small"))
|
||||||
|
assert_nil @cache.read("k" * 2049, version)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clearing the observer gives later pages a distinct namespace" do
|
||||||
|
version = @cache.version
|
||||||
|
@cache.clear
|
||||||
|
assert_not_equal version, @cache.version
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cold renders coalesce without holding the observer mutex" do
|
||||||
|
version = @cache.version
|
||||||
|
entered = Queue.new
|
||||||
|
release = Queue.new
|
||||||
|
first = Thread.new do
|
||||||
|
@cache.synchronize_render("page", version) do
|
||||||
|
entered << true
|
||||||
|
release.pop
|
||||||
|
@cache.write("page", version, entry("completed render"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
entered.pop
|
||||||
|
second_started = Queue.new
|
||||||
|
second = Thread.new do
|
||||||
|
second_started << true
|
||||||
|
@cache.synchronize_render("page", version) { @cache.read("page", version) }
|
||||||
|
end
|
||||||
|
second_started.pop
|
||||||
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
|
||||||
|
Thread.pass until second.status == "sleep" || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
||||||
|
assert_equal "sleep", second.status
|
||||||
|
assert_nil @cache.read("page", version)
|
||||||
|
assert_equal version, @cache.version
|
||||||
|
release << true
|
||||||
|
first.value
|
||||||
|
assert_equal "completed render", second.value[:body]
|
||||||
|
ensure
|
||||||
|
release << true if release
|
||||||
|
[ first, second ].compact.each { |thread| thread.join(2) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def entry(body)
|
||||||
|
{ body: body, marker: "unexposed-marker", headers: {} }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
require "test_helper"
|
||||||
|
require "sqlite3"
|
||||||
|
|
||||||
|
# Destructive trigger DDL and foreign connections need their own file so parallel
|
||||||
|
# workers (and transactional tests in messages_count_test) keep a stable trigger set.
|
||||||
|
class Room::MessagesCountLifecycleTest < ActiveSupport::TestCase
|
||||||
|
self.use_transactional_tests = false
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Room::MessagesCount.ensure!
|
||||||
|
@room = rooms(:designers)
|
||||||
|
@other_room = rooms(:pets)
|
||||||
|
Room::MessagesCount.backfill!
|
||||||
|
@room.reload
|
||||||
|
@other_room.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
Message.where("client_message_id LIKE ?", "count-%").delete_all
|
||||||
|
Room::MessagesCount.ensure!
|
||||||
|
Room::MessagesCount.backfill!
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ensure! installs missing triggers and keeps accurate counts" do
|
||||||
|
before = @room.reload.messages_count
|
||||||
|
Room::MessagesCount.uninstall!
|
||||||
|
|
||||||
|
assert_not Room::MessagesCount.triggers_installed?
|
||||||
|
|
||||||
|
Room::MessagesCount.ensure!
|
||||||
|
|
||||||
|
assert Room::MessagesCount.triggers_installed?
|
||||||
|
assert_equal before, @room.reload.messages_count
|
||||||
|
assert_equal @room.messages.count, @room.messages_count
|
||||||
|
|
||||||
|
assert_difference -> { @room.reload.messages_count }, +1 do
|
||||||
|
@room.messages.create!(creator: users(:jason), body: "After ensure", client_message_id: "count-ensure")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ensure! repairs a partial trigger install and backfills drifted counts" do
|
||||||
|
Room::MessagesCount.uninstall!
|
||||||
|
ActiveRecord::Base.connection.execute <<~SQL
|
||||||
|
CREATE TRIGGER #{Room::MessagesCount::INSERT_TRIGGER} AFTER INSERT ON messages
|
||||||
|
BEGIN
|
||||||
|
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
assert Room::MessagesCount.trigger_installed?(ActiveRecord::Base.connection, Room::MessagesCount::INSERT_TRIGGER)
|
||||||
|
assert_not Room::MessagesCount.trigger_installed?(ActiveRecord::Base.connection, Room::MessagesCount::DELETE_TRIGGER)
|
||||||
|
|
||||||
|
# Delete fires with no delete trigger → counter drifts high.
|
||||||
|
drifted = @room.messages.create!(creator: users(:jason), body: "Drift", client_message_id: "count-partial-drift")
|
||||||
|
ActiveRecord::Base.connection.execute("DELETE FROM messages WHERE id = #{drifted.id}")
|
||||||
|
assert_operator @room.reload.messages_count, :>, @room.messages.count
|
||||||
|
|
||||||
|
Room::MessagesCount.ensure!
|
||||||
|
|
||||||
|
assert Room::MessagesCount.triggers_installed?
|
||||||
|
assert_equal @room.messages.count, @room.reload.messages_count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ensure! backfills writes that landed while the insert trigger was missing" do
|
||||||
|
Room::MessagesCount.uninstall!
|
||||||
|
before_count = @room.messages.count
|
||||||
|
before_cached = @room.reload.messages_count
|
||||||
|
assert_equal before_count, before_cached
|
||||||
|
|
||||||
|
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||||
|
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
|
||||||
|
ActiveRecord::Base.connection_pool.release_connection
|
||||||
|
|
||||||
|
SQLite3::Database.new(path) do |db|
|
||||||
|
db.busy_timeout = 5_000
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[ @room.id, users(:david).id, "count-missing-insert", now, now ]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal before_count + 1, @room.messages.count
|
||||||
|
assert_equal before_cached, @room.reload.messages_count
|
||||||
|
|
||||||
|
Room::MessagesCount.ensure!
|
||||||
|
|
||||||
|
assert Room::MessagesCount.triggers_installed?
|
||||||
|
assert_equal @room.messages.count, @room.reload.messages_count
|
||||||
|
assert_equal before_cached + 1, @room.messages_count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "concurrent ensure! repairs leave triggers installed and counts accurate" do
|
||||||
|
Room::MessagesCount.uninstall!
|
||||||
|
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||||
|
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
|
||||||
|
ActiveRecord::Base.connection_pool.release_connection
|
||||||
|
|
||||||
|
SQLite3::Database.new(path) do |db|
|
||||||
|
db.busy_timeout = 5_000
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[ @room.id, users(:david).id, "count-concurrent-drift", now, now ]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_not_equal @room.messages.count, @room.reload.messages_count
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
errors_mutex = Mutex.new
|
||||||
|
|
||||||
|
workers = 2.times.map do
|
||||||
|
Thread.new do
|
||||||
|
ActiveRecord::Base.connection_pool.with_connection do |connection|
|
||||||
|
Room::MessagesCount.ensure!(connection)
|
||||||
|
end
|
||||||
|
rescue => error
|
||||||
|
errors_mutex.synchronize { errors << error }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
workers.each(&:join)
|
||||||
|
|
||||||
|
assert_empty errors, -> { errors.map(&:full_message).join("\n") }
|
||||||
|
assert Room::MessagesCount.triggers_installed?
|
||||||
|
assert_equal @room.messages.count, @room.reload.messages_count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "writes concurrent with ensure! repair leave accurate counts" do
|
||||||
|
Room::MessagesCount.uninstall!
|
||||||
|
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||||
|
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
|
||||||
|
ActiveRecord::Base.connection_pool.release_connection
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
errors_mutex = Mutex.new
|
||||||
|
|
||||||
|
ensure_thread = Thread.new do
|
||||||
|
ActiveRecord::Base.connection_pool.with_connection do |connection|
|
||||||
|
Room::MessagesCount.ensure!(connection)
|
||||||
|
end
|
||||||
|
rescue => error
|
||||||
|
errors_mutex.synchronize { errors << error }
|
||||||
|
end
|
||||||
|
|
||||||
|
insert_thread = Thread.new do
|
||||||
|
SQLite3::Database.new(path) do |db|
|
||||||
|
db.busy_timeout = 10_000
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[ @room.id, users(:david).id, "count-during-repair", now, now ]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
rescue => error
|
||||||
|
errors_mutex.synchronize { errors << error }
|
||||||
|
end
|
||||||
|
|
||||||
|
[ ensure_thread, insert_thread ].each(&:join)
|
||||||
|
|
||||||
|
assert_empty errors, -> { errors.map(&:full_message).join("\n") }
|
||||||
|
assert Room::MessagesCount.triggers_installed?
|
||||||
|
assert_equal @room.messages.count, @room.reload.messages_count
|
||||||
|
assert Message.exists?(client_message_id: "count-during-repair")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "install! never exposes a partial trigger set to other connections" do
|
||||||
|
Room::MessagesCount.uninstall!
|
||||||
|
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||||
|
ActiveRecord::Base.connection_pool.release_connection
|
||||||
|
|
||||||
|
stop = false
|
||||||
|
partial_snapshots = []
|
||||||
|
snapshots_mutex = Mutex.new
|
||||||
|
|
||||||
|
watcher = Thread.new do
|
||||||
|
SQLite3::Database.new(path) do |db|
|
||||||
|
db.busy_timeout = 5_000
|
||||||
|
until stop
|
||||||
|
names = db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (#{Room::MessagesCount::TRIGGERS.map { "'#{it}'" }.join(", ")})"
|
||||||
|
).flatten
|
||||||
|
if names.any? && names.sort != Room::MessagesCount::TRIGGERS.sort
|
||||||
|
snapshots_mutex.synchronize { partial_snapshots << names.sort }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
25.times { Room::MessagesCount.install! }
|
||||||
|
stop = true
|
||||||
|
watcher.join
|
||||||
|
|
||||||
|
assert_empty partial_snapshots
|
||||||
|
assert Room::MessagesCount.triggers_installed?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "foreign SQLite connections keep the counter in step" do
|
||||||
|
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||||
|
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
|
||||||
|
before = @room.reload.messages_count
|
||||||
|
other_before = @other_room.reload.messages_count
|
||||||
|
|
||||||
|
ActiveRecord::Base.connection_pool.release_connection
|
||||||
|
|
||||||
|
SQLite3::Database.new(path) do |db|
|
||||||
|
db.busy_timeout = 5_000
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[ @room.id, users(:david).id, "count-foreign-insert", now, now ]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal before + 1, @room.reload.messages_count
|
||||||
|
assert_equal @room.messages.count, @room.messages_count
|
||||||
|
|
||||||
|
foreign_id = Message.find_by!(client_message_id: "count-foreign-insert").id
|
||||||
|
|
||||||
|
ActiveRecord::Base.connection_pool.release_connection
|
||||||
|
|
||||||
|
SQLite3::Database.new(path) do |db|
|
||||||
|
db.busy_timeout = 5_000
|
||||||
|
db.execute("UPDATE messages SET room_id = ? WHERE id = ?", [ @other_room.id, foreign_id ])
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal before, @room.reload.messages_count
|
||||||
|
assert_equal other_before + 1, @other_room.reload.messages_count
|
||||||
|
|
||||||
|
ActiveRecord::Base.connection_pool.release_connection
|
||||||
|
|
||||||
|
SQLite3::Database.new(path) do |db|
|
||||||
|
db.busy_timeout = 5_000
|
||||||
|
db.execute("DELETE FROM messages WHERE id = ?", [ foreign_id ])
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal before, @room.reload.messages_count
|
||||||
|
assert_equal other_before, @other_room.reload.messages_count
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
require "test_helper"
|
||||||
|
require "rake"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
Rails.application.load_tasks unless Rake::Task.task_defined?("room_messages_count:ensure")
|
||||||
|
|
||||||
|
class Room::MessagesCountTasksTest < ActiveSupport::TestCase
|
||||||
|
self.use_transactional_tests = false
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@directory = Dir.mktmpdir("campfire-counter-tasks")
|
||||||
|
@schema = File.join(@directory, "schema.rb")
|
||||||
|
@migrations = File.join(@directory, "migrations")
|
||||||
|
Dir.mkdir(@migrations)
|
||||||
|
File.write(@schema, <<~RUBY)
|
||||||
|
ActiveRecord::Schema[8.2].define(version: 0) do
|
||||||
|
create_table :rooms, force: true do |t|
|
||||||
|
t.integer :messages_count, default: 0, null: false
|
||||||
|
end
|
||||||
|
create_table :messages, force: true do |t|
|
||||||
|
t.integer :room_id
|
||||||
|
t.string :client_message_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
RUBY
|
||||||
|
@tasks = ActiveRecord::Tasks::DatabaseTasks
|
||||||
|
@original_database_url = ENV.delete("DATABASE_URL")
|
||||||
|
@original_environment = @tasks.env
|
||||||
|
@original_configurations = ActiveRecord::Base.configurations
|
||||||
|
@original_migrations = @tasks.migrations_paths
|
||||||
|
@original_dump = ActiveRecord.dump_schema_after_migration
|
||||||
|
@original_seed_loader = @tasks.seed_loader
|
||||||
|
@tasks.migrations_paths = [ @migrations ]
|
||||||
|
ActiveRecord.dump_schema_after_migration = false
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
ActiveRecord::Base.configurations = @original_configurations
|
||||||
|
ENV["DATABASE_URL"] = @original_database_url if @original_database_url
|
||||||
|
@tasks.env = @original_environment
|
||||||
|
@tasks.migrations_paths = @original_migrations
|
||||||
|
@tasks.seed_loader = @original_seed_loader
|
||||||
|
ActiveRecord.dump_schema_after_migration = @original_dump
|
||||||
|
FileUtils.remove_entry(@directory)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "development schema task repairs both databases before restoring its original pool" do
|
||||||
|
development = configuration("development")
|
||||||
|
test = configuration("test")
|
||||||
|
ActiveRecord::Base.configurations = { "development" => { "primary" => development.configuration_hash }, "test" => { "primary" => test.configuration_hash } }
|
||||||
|
@tasks.env = "development"
|
||||||
|
|
||||||
|
@tasks.with_temporary_connection(development) do |connection|
|
||||||
|
@tasks.load_schema(development)
|
||||||
|
original_path = connection.pool.db_config.database
|
||||||
|
|
||||||
|
2.times do
|
||||||
|
task = Rake::Task["db:schema:load"]
|
||||||
|
task.all_prerequisite_tasks.each(&:reenable)
|
||||||
|
task.reenable
|
||||||
|
task.invoke
|
||||||
|
|
||||||
|
assert_equal original_path, ActiveRecord::Base.connection_db_config.database
|
||||||
|
assert Room::MessagesCount.triggers_installed?(connection)
|
||||||
|
@tasks.with_temporary_connection(test) do |test_connection|
|
||||||
|
assert Room::MessagesCount.triggers_installed?(test_connection)
|
||||||
|
assert_counter_tracks_insert(test_connection)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "test schema task repairs the test database selected by purge" do
|
||||||
|
config = configuration("test")
|
||||||
|
ActiveRecord::Base.configurations = { "test" => { "primary" => config.configuration_hash } }
|
||||||
|
|
||||||
|
@tasks.with_temporary_connection(config) do |connection|
|
||||||
|
task = Rake::Task["db:test:load_schema"]
|
||||||
|
task.all_prerequisite_tasks.each(&:reenable)
|
||||||
|
task.reenable
|
||||||
|
task.invoke
|
||||||
|
assert Room::MessagesCount.triggers_installed?(connection)
|
||||||
|
assert_counter_tracks_insert(connection)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prepare installs schema triggers before loading seeds" do
|
||||||
|
config = configuration("test")
|
||||||
|
ActiveRecord::Base.configurations = { "test" => { "primary" => config.configuration_hash } }
|
||||||
|
seeded = false
|
||||||
|
loader = Object.new
|
||||||
|
loader.define_singleton_method(:load_seed) do
|
||||||
|
connection = ActiveRecord::Base.connection
|
||||||
|
raise "seeds ran without counter triggers" unless Room::MessagesCount.triggers_installed?(connection)
|
||||||
|
connection.execute("INSERT INTO rooms (id) VALUES (1)")
|
||||||
|
connection.execute("INSERT INTO messages (room_id) VALUES (1), (1)")
|
||||||
|
raise "seed counter is stale" unless connection.select_value("SELECT messages_count FROM rooms WHERE id = 1") == 2
|
||||||
|
seeded = true
|
||||||
|
end
|
||||||
|
@tasks.seed_loader = loader
|
||||||
|
|
||||||
|
@tasks.with_temporary_connection(config) do |connection|
|
||||||
|
@tasks.prepare_all
|
||||||
|
assert seeded
|
||||||
|
assert Room::MessagesCount.triggers_installed?(connection)
|
||||||
|
assert_equal 2, connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "migrate repairs rebuilt message tables and repeats repair with no new migration" do
|
||||||
|
config = configuration("test")
|
||||||
|
File.write(File.join(@migrations, "20261008000100_change_counter_task_message_column.rb"), <<~RUBY)
|
||||||
|
class ChangeCounterTaskMessageColumn < ActiveRecord::Migration[8.2]
|
||||||
|
def change
|
||||||
|
change_column :messages, :client_message_id, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
RUBY
|
||||||
|
|
||||||
|
@tasks.with_temporary_connection(config) do |connection|
|
||||||
|
@tasks.load_schema(config)
|
||||||
|
Room::MessagesCount.ensure!(connection)
|
||||||
|
assert_counter_tracks_insert(connection)
|
||||||
|
@tasks.migrate
|
||||||
|
assert Room::MessagesCount.triggers_installed?(connection)
|
||||||
|
assert_counter_tracks_insert(connection)
|
||||||
|
|
||||||
|
Room::MessagesCount.uninstall!(connection)
|
||||||
|
connection.execute("INSERT INTO messages (room_id) VALUES (1)")
|
||||||
|
assert_not_equal connection.select_value("SELECT COUNT(*) FROM messages WHERE room_id = 1"), connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||||
|
@tasks.migrate
|
||||||
|
assert Room::MessagesCount.triggers_installed?(connection)
|
||||||
|
assert_equal connection.select_value("SELECT COUNT(*) FROM messages WHERE room_id = 1"), connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def configuration(environment)
|
||||||
|
ActiveRecord::DatabaseConfigurations::HashConfig.new(environment, "primary", {
|
||||||
|
adapter: "sqlite3", database: File.join(@directory, "#{environment}.sqlite3"),
|
||||||
|
default_transaction_mode: :immediate, schema_dump: @schema,
|
||||||
|
migrations_paths: [ @migrations ]
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_counter_tracks_insert(connection)
|
||||||
|
connection.execute("INSERT INTO rooms (id) VALUES (1) ON CONFLICT DO NOTHING")
|
||||||
|
before = connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||||
|
connection.execute("INSERT INTO messages (room_id) VALUES (1)")
|
||||||
|
assert_equal before + 1, connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||||
|
end
|
||||||
|
end
|
||||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user