mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b28b3106a | ||
|
|
9ea475a386 |
No files matched your search
@@ -41,7 +41,8 @@ jobs:
|
||||
arch: arm64
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
# Everything goes to <repo>-edge, with this repository's own token.
|
||||
IMAGE_NAME: ${{ github.repository }}-edge
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -87,9 +88,6 @@ jobs:
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short,prefix=sha-
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-${{ matrix.arch }}
|
||||
@@ -128,6 +126,8 @@ jobs:
|
||||
name: Create multi-arch manifest and sign
|
||||
needs: build
|
||||
if: github.event_name != 'pull_request'
|
||||
# Multi-platform tags, signed, in <repo>-edge only. basecamp/release-images
|
||||
# promotes v* tags from there into the release package after a reviewer approves.
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
IMAGE_NAME: ${{ github.repository }}-edge
|
||||
steps:
|
||||
- name: Set up Docker Buildx (for imagetools)
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
@@ -166,10 +166,6 @@ jobs:
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short,prefix=sha-
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
flavor: |
|
||||
latest=false
|
||||
labels: |
|
||||
@@ -188,10 +184,6 @@ jobs:
|
||||
[ -z "$tag" ] && continue
|
||||
echo "Creating manifest for $tag"
|
||||
src_tag="$tag"
|
||||
if [[ "$tag" == *:latest && "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
ref="${GITHUB_REF#refs/tags/}"
|
||||
src_tag="${tag%:latest}:$ref"
|
||||
fi
|
||||
if [ -n "${IMAGE_DESCRIPTION:-}" ]; then
|
||||
docker buildx imagetools create \
|
||||
--tag "$tag" \
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.0.7
|
||||
3.4.10
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=4.0.7
|
||||
ARG RUBY_VERSION=3.4.10
|
||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
# Rails app lives here
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=4.0.7
|
||||
ARG RUBY_VERSION=3.4.10
|
||||
FROM ruby:$RUBY_VERSION-slim as base
|
||||
|
||||
# Install the tools we need
|
||||
|
||||
@@ -12,7 +12,7 @@ gem "sqlite3"
|
||||
gem "redis", "~> 5.4"
|
||||
|
||||
# Deployment
|
||||
gem "puma", "~> 8.0"
|
||||
gem "puma", "~> 7.2", ">= 7.2.1"
|
||||
|
||||
# Jobs
|
||||
gem "resque", "~> 2.7.0"
|
||||
|
||||
+17
-18
@@ -276,7 +276,7 @@ GEM
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (8.0.2)
|
||||
puma (7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.7)
|
||||
@@ -336,29 +336,29 @@ GEM
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.1.0)
|
||||
rubocop (1.91.0)
|
||||
json (>= 2.3)
|
||||
rubocop (1.80.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (>= 1.10)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
rubocop-ast (>= 1.46.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.50.0)
|
||||
rubocop-ast (1.46.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.27.0)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.25.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.89.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.38.0)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.33.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.89.0, < 2.0)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rails-omakase (1.1.0)
|
||||
rubocop (>= 1.72)
|
||||
@@ -377,13 +377,12 @@ GEM
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (7.1.0)
|
||||
railties (>= 5.2.0)
|
||||
sentry-ruby (~> 7.1.0)
|
||||
sentry-ruby (7.1.0)
|
||||
sentry-rails (5.26.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.26.0)
|
||||
sentry-ruby (5.26.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
logger
|
||||
sinatra (4.2.1)
|
||||
logger (>= 1.6.0)
|
||||
mustermann (~> 3.0)
|
||||
@@ -453,7 +452,7 @@ DEPENDENCIES
|
||||
ostruct
|
||||
platform_agent
|
||||
propshaft!
|
||||
puma (~> 8.0)
|
||||
puma (~> 7.2, >= 7.2.1)
|
||||
rails!
|
||||
rails_autolink
|
||||
redis (~> 5.4)
|
||||
|
||||
@@ -51,7 +51,7 @@ If you'd rather run the Docker image yourself, you can read more about that in t
|
||||
|
||||
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.
|
||||
and room access; commits from any SQLite writer invalidate pages, and CSRF masks stay fresh.
|
||||
Native HTML, JSON and stream fragments have a separate 64 MiB memory limit per worker;
|
||||
shared rate limits retain their existing store.
|
||||
|
||||
@@ -61,14 +61,11 @@ Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust
|
||||
|
||||
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| Room page | 4,101 | 1,507 | 3,872 | 42,636 | 5,350 | 53,060 | 106,494 | 137,524 |
|
||||
| Messages page | 4,115 | 1,596 | 3,995 | 74,362 | 5,712 | 54,800 | 102,697 | 144,642 |
|
||||
| Sidebar | 4,333 | 1,873 | 4,493 | 94,329 | 5,949 | 59,144 | 120,294 | 152,002 |
|
||||
| Search | 4,282 | 1,862 | 4,172 | 84,665 | 5,848 | 60,509 | 121,378 | 149,487 |
|
||||
| Post a message | 330 | 262 | 794 | 2,155 | 1,278 | 9,021 | 8,037 | 7,530 |
|
||||
| Backend KLOC | 5.5 | 5.3 | 4.1 | 6.8 | 11.6 | 23.2 | 30.7 | 94.8 |
|
||||
|
||||
KLOC counts backend code plus executable code in templates; excludes plain HTML, frontend, tests, dependencies and generated files.
|
||||
| Room page | 710 | 414 | 1,696 | 42,481 | 1,126 | 52,512 | 105,909 | 137,505 |
|
||||
| Messages page | 1,113 | 454 | 1,890 | 74,779 | 1,407 | 54,100 | 103,301 | 142,669 |
|
||||
| Sidebar | 1,901 | 576 | 3,364 | 94,460 | 3,621 | 58,714 | 120,930 | 151,001 |
|
||||
| Search | 1,332 | 549 | 2,615 | 83,493 | 2,127 | 60,444 | 121,502 | 148,766 |
|
||||
| Post a message | 226 | 113 | 567 | 2,121 | 1,392 | 9,000 | 8,004 | 7,486 |
|
||||
|
||||
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
|
||||
|
||||
@@ -83,7 +80,3 @@ Please see our [development guide](docs/development.md) for how to get Campfire
|
||||
## Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
|
||||
|
||||
Browser writes use Rails’ `Sec-Fetch-Site` header-only protection and `Origin` check.
|
||||
HTTPS requires browser metadata; plain HTTP retains the missing-header fallback.
|
||||
Forms contain no CSRF tokens. Existing cookies, bot APIs and signed uploads remain compatible.
|
||||
@@ -7,7 +7,7 @@ module Authentication
|
||||
before_action :deny_bots
|
||||
helper_method :signed_in?
|
||||
|
||||
protect_from_forgery using: :header_only, with: :exception, unless: -> { authenticated_by.bot_key? }
|
||||
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
module Authentication::SessionLookup
|
||||
def find_session_by_cookie
|
||||
if token = cookies.signed[:session_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 }
|
||||
Session.find_by(token: token)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,16 +1,15 @@
|
||||
require "zlib"
|
||||
|
||||
module CachedResponses
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
CACHE_HEADERS = %w[ content-type content-encoding cache-control etag last-modified vary ].freeze
|
||||
CACHE_HEADERS = %w[ content-type cache-control etag last-modified vary ].freeze
|
||||
CSRF_TAG = /<meta\b[^>]*\bname="csrf-token"[^>]*>|<input\b[^>]*\bname="authenticity_token"[^>]*>/
|
||||
|
||||
included do
|
||||
prepend_before_action :capture_response_cache_version
|
||||
end
|
||||
|
||||
def perform_caching
|
||||
return false unless super && @response_cache_version.present? && !FragmentCache.transaction_open?
|
||||
return false unless super && @response_cache_version.present? && !ActiveRecord::Base.connection.transaction_open?
|
||||
|
||||
# Check again after authentication, before the first native fragment lookup.
|
||||
# Later renders retain this immutable namespace and cannot poison a new epoch.
|
||||
@@ -26,19 +25,15 @@ module CachedResponses
|
||||
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)
|
||||
@fragment_cache_namespace ||= [
|
||||
@response_cache_version, request.base_url, request.script_name, request.format.to_s, I18n.locale,
|
||||
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session),
|
||||
(Digest::SHA256.hexdigest(real_csrf_token) if request.format.html? || request.format.turbo_stream?)
|
||||
].freeze
|
||||
version = @response_cache_version unless Array(key).flatten.any? { |part| part.is_a?(FragmentCache::ContentKey) }
|
||||
super([ version, @fragment_cache_context, key ])
|
||||
super([ @fragment_cache_namespace, 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.
|
||||
@@ -48,10 +43,10 @@ module CachedResponses
|
||||
# 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
|
||||
token = form_authenticity_token
|
||||
key = response_cache_key
|
||||
original_session = session.to_hash.deep_dup
|
||||
|
||||
key = response_cache_key(encoding)
|
||||
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
|
||||
|
||||
entry = ResponseCache.instance.read(key, @response_cache_version)
|
||||
@@ -60,18 +55,16 @@ module CachedResponses
|
||||
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)
|
||||
cache_completed_response(key, original_session)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if entry
|
||||
response.headers.merge!(entry[:headers])
|
||||
response.headers.delete("Content-Length")
|
||||
self.response_body = entry[:body]
|
||||
self.response_body = entry[:body].gsub(entry[:marker], token)
|
||||
elsif !rendered
|
||||
# A queued request retains its pre-auth snapshot. If it has expired,
|
||||
# render outside the stripe instead of blocking the next generation.
|
||||
@@ -82,19 +75,14 @@ module CachedResponses
|
||||
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(", ")
|
||||
def cache_completed_response(key, original_session)
|
||||
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session
|
||||
marker = "campfire-csrf-#{SecureRandom.hex(32)}"
|
||||
# Replace only framework token attributes, never a matching token in
|
||||
# message text. Postprocessing also leaves fragment caches untouched.
|
||||
body = csrf_neutral_body(response.body, marker)
|
||||
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
|
||||
ResponseCache.instance.write(key, @response_cache_version, { body: body.freeze, marker: marker.freeze, headers: headers }.freeze)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,16 +91,24 @@ module CachedResponses
|
||||
!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?
|
||||
!ActiveRecord::Base.connection.transaction_open?
|
||||
end
|
||||
|
||||
def response_cache_key(encoding)
|
||||
def response_cache_key
|
||||
ActiveSupport::JSON.encode([
|
||||
controller_path, request.fullpath, request.base_url, request.user_agent, encoding,
|
||||
controller_path, request.fullpath, request.base_url, request.user_agent,
|
||||
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
|
||||
# Old token-bearing installation cookies remain valid without affecting HTML.
|
||||
# Tokens are hydrated per request, including clients that replay an old
|
||||
# cookie. Their raw CSRF secret does not select a presentation variant.
|
||||
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
|
||||
cookies.to_h.except("_campfire_session", "session_token")
|
||||
])
|
||||
end
|
||||
|
||||
def csrf_neutral_body(body, marker)
|
||||
body.gsub(CSRF_TAG) do |tag|
|
||||
attribute = tag.start_with?("<meta") ? "content" : "value"
|
||||
tag.sub(/\b#{attribute}="[^"]*"/, %(#{attribute}="#{marker}"))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,10 +7,7 @@ module RoomScoped
|
||||
|
||||
private
|
||||
def set_room
|
||||
@membership, @room = RecordCache.fetch([ "membership", Current.user.id, params[:room_id] ], read_record_cache_version) do
|
||||
membership = Current.user.memberships.find_by!(room_id: params[:room_id])
|
||||
[ membership, membership.room ]
|
||||
end
|
||||
@membership.room = @room
|
||||
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
|
||||
@room = @membership.room
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,8 @@ class MessagesController < ApplicationController
|
||||
if @messages.any?
|
||||
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
|
||||
# Masked CSRF tokens remain fresh while the presentation validator stays stable.
|
||||
fresh_when etag: Digest::SHA256.hexdigest(csrf_neutral_body(body, "")), template: false
|
||||
unless performed?
|
||||
response.content_type = "text/html"
|
||||
self.response_body = body
|
||||
@@ -28,9 +29,7 @@ class MessagesController < ApplicationController
|
||||
set_room
|
||||
@message = @room.messages.create_with_attachment!(message_params)
|
||||
|
||||
# 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)
|
||||
@message.broadcast_create
|
||||
deliver_webhooks_to_bots
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render action: :room_not_found
|
||||
|
||||
@@ -7,7 +7,5 @@ class QrCodeController < ApplicationController
|
||||
|
||||
expires_in 1.year, public: true
|
||||
render plain: qr_code, content_type: "image/svg+xml"
|
||||
rescue ArgumentError, RQRCodeCore::QRCodeRunTimeError
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
@@ -21,10 +21,7 @@ class RoomsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_room
|
||||
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
|
||||
if room = room_scope.find_by(id: params[:room_id] || params[:id])
|
||||
@room = room
|
||||
else
|
||||
redirect_to root_url, alert: "Room not found or inaccessible"
|
||||
|
||||
@@ -36,11 +36,6 @@ module ApplicationHelper
|
||||
end
|
||||
|
||||
private
|
||||
# Header-only forgery protection needs no secret in forms or cached HTML.
|
||||
def token_tag(*)
|
||||
""
|
||||
end
|
||||
|
||||
def admin_body_class
|
||||
"admin" if Current.user&.can_administer?
|
||||
end
|
||||
|
||||
@@ -63,7 +63,8 @@ module MessagesHelper
|
||||
when "sound"
|
||||
message_sound_presentation(message)
|
||||
else
|
||||
text_message_presentation(message.body.body)
|
||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
|
||||
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
|
||||
end
|
||||
rescue Exception => e
|
||||
Sentry.capture_exception(e, extra: { message: message })
|
||||
@@ -72,50 +73,7 @@ module MessagesHelper
|
||||
""
|
||||
end
|
||||
|
||||
# These controls contain URLs and static markup, but no viewer or token state.
|
||||
# Keep them across database commits; attachment controls still render afresh.
|
||||
def cache_message_actions(message, &block)
|
||||
return capture(&block) unless controller.perform_caching && !message.attachment?
|
||||
|
||||
key = fragment_name_with_digest([
|
||||
"message-actions-v1", request.base_url, request.script_name, I18n.locale, message.id, message.room_id
|
||||
], nil)
|
||||
FragmentCache.store.fetch(key) { capture(&block) }
|
||||
end
|
||||
|
||||
def message_fragment_cache_key(message)
|
||||
return message unless controller.perform_caching &&
|
||||
%i[room creator rich_text_body boosts attachment_attachment].all? { |name| message.association(name).loaded? }
|
||||
|
||||
room = message.room
|
||||
body = message.body.body
|
||||
return message if room.direct? || message.attachment? || !body || body.to_html.include?("<action-text-attachment") ||
|
||||
message.boosts.any? { |boost| !boost.association(:booster).loaded? }
|
||||
|
||||
# Collection caching preloads these rows before deriving keys. Read their
|
||||
# actual content: foreign SQL writers need not touch Rails timestamps.
|
||||
dependencies = [ message.attributes, room.attributes.slice("id", "type", "name"), message.creator.attributes,
|
||||
body.to_html, message.boosts.sort_by(&:created_at).map { |boost| [ boost.attributes, boost.booster.attributes ] } ]
|
||||
FragmentCache::ContentKey.new(Digest::SHA256.hexdigest(ActiveSupport::JSON.encode(dependencies)))
|
||||
end
|
||||
|
||||
private
|
||||
def text_message_presentation(body)
|
||||
render = -> do
|
||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(body)),
|
||||
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
|
||||
end
|
||||
|
||||
# Embedded attachments render database-backed metadata and signed URLs.
|
||||
# Plain text HTML depends only on its content, even after a foreign edit.
|
||||
html = body.to_html
|
||||
if controller.perform_caching && !html.include?("<action-text-attachment") && html.bytesize <= ResponseCache::MAX_ENTRY_BYTES
|
||||
FragmentCache.store.fetch([ "text-presentation-v1", I18n.locale, Digest::SHA256.hexdigest(html) ]) { render.call }
|
||||
else
|
||||
render.call
|
||||
end
|
||||
end
|
||||
|
||||
def messages_actions
|
||||
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ export default class FileUploader {
|
||||
|
||||
const req = new XMLHttpRequest()
|
||||
req.open("POST", this.url)
|
||||
req.setRequestHeader("X-CSRF-Token", document.querySelector("meta[name=csrf-token]").content)
|
||||
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
|
||||
|
||||
const result = new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
# 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
|
||||
+2
-18
@@ -9,9 +9,7 @@ class Message < ApplicationRecord
|
||||
has_rich_text :body
|
||||
|
||||
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
|
||||
# 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) }
|
||||
after_create_commit -> { room.receive(self) }
|
||||
|
||||
scope :ordered, -> { order(:created_at) }
|
||||
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
||||
@@ -23,15 +21,7 @@ class Message < ApplicationRecord
|
||||
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
|
||||
|
||||
def plain_text_body
|
||||
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 || ""
|
||||
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
||||
end
|
||||
|
||||
def content_type
|
||||
@@ -47,10 +37,4 @@ class Message < ApplicationRecord
|
||||
Sound.find_by_name match[:name]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def record_creation
|
||||
create_in_index
|
||||
room.unread_memberships(self)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
module Message::Broadcasts
|
||||
def broadcast_create(html: nil)
|
||||
broadcast_append_to room, :messages, target: [ room, :messages ], **(html ? { html: html } : {})
|
||||
def broadcast_create
|
||||
broadcast_append_to room, :messages, target: [ room, :messages ]
|
||||
broadcast_unread_room_later
|
||||
end
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ module Message::Mentionee
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def mentionees
|
||||
ids = mentioned_users.map(&:id)
|
||||
ids.empty? ? User.none : room.users.where(id: ids)
|
||||
room.users.where(id: mentioned_users.map(&:id))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,6 +2,7 @@ module Message::Searchable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_create_commit :create_in_index
|
||||
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
|
||||
after_update_commit :update_in_index, if: :indexed_text_changed?
|
||||
after_destroy_commit :remove_from_index
|
||||
|
||||
@@ -23,14 +23,11 @@ class Opengraph::Fetch
|
||||
end
|
||||
|
||||
private
|
||||
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
|
||||
# would no longer go to the checked IP.
|
||||
#
|
||||
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
|
||||
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
|
||||
def request(url, request_class, ip:)
|
||||
MAX_REDIRECTS.times do
|
||||
Net::HTTP.start(url.host, url.port, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
|
||||
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
|
||||
http.request request_class.new(url) do |response|
|
||||
if response.is_a?(Net::HTTPRedirection)
|
||||
url, ip = resolve_redirect(response["location"])
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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
|
||||
+8
-13
@@ -78,19 +78,6 @@ class Room < ApplicationRecord
|
||||
push_later(message)
|
||||
end
|
||||
|
||||
# Rewriting every member on every message is most of what posting to a large room writes,
|
||||
# so members who are unread already stay as they are. Directs keep touching them all: a
|
||||
# direct's sidebar row is cached by membership and shows the room's recency.
|
||||
def unread_memberships(message)
|
||||
recipients = memberships.visible.disconnected.where.not(user: message.creator)
|
||||
recipients = recipients.where(unread_at: nil) unless direct?
|
||||
recipients.update_all(unread_at: message.created_at, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def push_later(message)
|
||||
Room::PushMessageJob.perform_later(self, message)
|
||||
end
|
||||
|
||||
def open?
|
||||
is_a?(Rooms::Open)
|
||||
end
|
||||
@@ -116,4 +103,12 @@ class Room < ApplicationRecord
|
||||
errors.add :type, "can't be changed for a direct room"
|
||||
end
|
||||
end
|
||||
|
||||
def unread_memberships(message)
|
||||
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def push_later(message)
|
||||
Room::PushMessageJob.perform_later(self, message)
|
||||
end
|
||||
end
|
||||
@@ -6,9 +6,9 @@ class Room::MessagePusher
|
||||
end
|
||||
|
||||
def push
|
||||
subscriptions = push_subscriptions_for_users_involved_in_everything.or(push_subscriptions_for_mentionable_users(message.mentionees))
|
||||
if subscriptions.exists?
|
||||
Rails.configuration.x.web_push_pool.queue(build_payload, subscriptions)
|
||||
build_payload.tap do |payload|
|
||||
push_to_users_involved_in_everything(payload)
|
||||
push_to_users_involved_in_mentions(payload)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,6 +37,14 @@ class Room::MessagePusher
|
||||
}
|
||||
end
|
||||
|
||||
def push_to_users_involved_in_everything(payload)
|
||||
enqueue_payload_for_delivery payload, push_subscriptions_for_users_involved_in_everything
|
||||
end
|
||||
|
||||
def push_to_users_involved_in_mentions(payload)
|
||||
enqueue_payload_for_delivery payload, push_subscriptions_for_mentionable_users(message.mentionees)
|
||||
end
|
||||
|
||||
def push_subscriptions_for_users_involved_in_everything
|
||||
relevant_subscriptions.merge(Membership.involved_in_everything)
|
||||
end
|
||||
@@ -51,6 +59,10 @@ class Room::MessagePusher
|
||||
Push::Subscription
|
||||
.joins(user: :memberships)
|
||||
.merge(User.active)
|
||||
.merge(Membership.visible.disconnected.where(room: room).where.not(user_id: message.creator_id))
|
||||
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
|
||||
end
|
||||
|
||||
def enqueue_payload_for_delivery(payload, subscriptions)
|
||||
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
<%= current_user_meta_tags %>
|
||||
<%= script_aware_action_cable_meta_tag %>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
||||
|
||||
<%= cache_message_actions(message) do %>
|
||||
<div class="message__actions" data-controller="soft-keyboard">
|
||||
<%= tag.details class: "position-relative",
|
||||
data: { controller: "popup", action: "keydown.esc->popup#close toggle->popup#toggle click@document->popup#closeOnClickOutside", popup_orientation_top_class: "popup-orientation-top" } do %>
|
||||
@@ -11,16 +10,14 @@
|
||||
|
||||
<div class="message__actions-menu border shadow" data-popup-target="menu">
|
||||
<div class="quick-boosts">
|
||||
<% boost_path = message_boosts_path(message) %>
|
||||
<% boost_frame = dom_id(message, :boosting) %>
|
||||
<% EmojiHelper::REACTIONS.each do |character, title| %>
|
||||
<form data-turbo-frame="<%= boost_frame %>" data-action="popup#close" action="<%= boost_path %>" accept-charset="UTF-8" method="post">
|
||||
<%= form_with model: [ message, Boost.new ], data: { turbo_frame: dom_id(message, :boosting), action: "popup#close"} do |form| %>
|
||||
<%= hidden_field_tag "boost[content]", character %>
|
||||
<button name="button" type="submit" title="<%= title %>" class="btn message__action-btn" data-emoji="<%= character %>">
|
||||
<%= form.button type: "submit", title: title, class: "btn message__action-btn", data: { emoji: character } do %>
|
||||
<figure class="margin-none boost-character"><%= character %></figure>
|
||||
<span class="for-screen-reader"><%= title %></span>
|
||||
</button>
|
||||
</form>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_message_boost_path(message),
|
||||
@@ -32,7 +29,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap border-top margin-block-start-half pad-block-start-half message__actions-grid">
|
||||
<% if message.attachment? %>
|
||||
<% if message.content_type.attachment? %>
|
||||
<%= link_to rails_blob_path(message.attachment, disposition: "attachment", only_path: true), class: "btn message__action-btn center full-width hide-in-ios-pwa", title: "Download", aria: { label: "Download" } do %>
|
||||
<%= image_tag "download.svg", class: "colorize--black", size: 20, aria: { hidden: "true" } %>
|
||||
<% end %>
|
||||
@@ -59,4 +56,3 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
||||
|
||||
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
|
||||
<% cache [ message_fragment_cache_key(message), "presentation-v7" ] do %>
|
||||
<% cache [ message, "presentation-v6" ] do %>
|
||||
<%= message_tag message do %>
|
||||
<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_html %>
|
||||
<%= turbo_stream.append dom_id(@message.room, :messages), @message %>
|
||||
@@ -1 +1 @@
|
||||
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= turbo_stream.append dom_id(@room, :messages) do %>
|
||||
<%= render partial: "messages/message", collection: @new_messages, cached: ->(message) { message_fragment_cache_key(message) } %>
|
||||
<%= render partial: "messages/message", collection: @new_messages, cached: true %>
|
||||
<% end if @new_messages.any? %>
|
||||
|
||||
<% @updated_messages.each do |message| %>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<%= messages_tag(@room) do %>
|
||||
<%= render "rooms/show/invitation", room: @room %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
|
||||
<%= search_results_tag do %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
|
||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,11 +9,6 @@ default: &default
|
||||
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 10 } %>
|
||||
timeout: 5000
|
||||
default_transaction_mode: immediate
|
||||
# Checkpoint on a background connection instead (SqliteWalCheckpoint).
|
||||
# Non-test processes start a contender; forking servers stop before fork and
|
||||
# start again in the child so the flock is never inherited.
|
||||
pragmas:
|
||||
wal_autocheckpoint: 0
|
||||
|
||||
development:
|
||||
primary:
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
# can read the public login page. Require a valid Campfire session before an
|
||||
# anonymous caller can allocate a Blob or persist bytes to disk.
|
||||
Rails.application.config.to_prepare do
|
||||
ActiveStorage::BaseController.forgery_protection_verification_strategy = :header_only
|
||||
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
|
||||
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Rails.application.config.session_store :cookie_store,
|
||||
key: "_campfire_session",
|
||||
# Preserve the existing installation's persistent browser-session cookie.
|
||||
# Persist session cookie as permament so re-opened browser windows maintain a CSRF token
|
||||
expire_after: 20.years
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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
|
||||
@@ -50,12 +50,6 @@ plugin :tmp_restart
|
||||
# Reset all membership connections
|
||||
Membership.disconnect_all
|
||||
|
||||
# Initializer starts a contender in this process. Stop before fork so workers
|
||||
# do not inherit the flock; each worker starts its own contender. Single-process
|
||||
# mode never forks, so the initializer's contender keeps running.
|
||||
before_fork { SqliteWalCheckpoint.stop }
|
||||
before_worker_boot { SqliteWalCheckpoint.start }
|
||||
|
||||
Signal.trap :SIGPROF do
|
||||
Thread.list.each do |t|
|
||||
puts t
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# 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,13 +4,9 @@ end
|
||||
|
||||
task "resque:pool:setup" do
|
||||
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|
|
||||
ActiveRecord::Base.establish_connection
|
||||
Resque.redis.client.close
|
||||
SqliteWalCheckpoint.start
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,7 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
ActionController::Base.perform_caching = true
|
||||
@room = rooms(:watercooler)
|
||||
# Establish last-room cookies before checking reuse.
|
||||
# Establish last-room and CSRF cookies before checking reuse.
|
||||
2.times { get room_url(@room) }
|
||||
ResponseCache.instance.clear
|
||||
end
|
||||
@@ -48,54 +48,37 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "clients without token state reuse complete HTML and post without tokens" do
|
||||
test "clients without a persisted CSRF session still reuse token-neutral HTML" 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
|
||||
first = css_select('meta[name="csrf-token"]').first["content"]
|
||||
cookies["_campfire_session"] = @login_cookie
|
||||
ResponseCache.instance.expects(:write).never
|
||||
get room_url(@room)
|
||||
second = css_select('meta[name="csrf-token"]').first["content"]
|
||||
assert_response :success
|
||||
assert_equal first, response.body
|
||||
assert_not_equal first, second
|
||||
assert_no_match /campfire-csrf-/, 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" }
|
||||
authenticity_token: second, message: { body: "fresh replay token works", client_message_id: "cache-replay-token" } }
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "literal token-like text survives complete page reuse" do
|
||||
literal = "campfire-csrf-literal authenticity_token csrf-token"
|
||||
test "cached tokens stay fresh and literal token-like text survives" do
|
||||
get room_url(@room)
|
||||
literal = css_select('meta[name="csrf-token"]').first["content"]
|
||||
@room.messages.create!(creator: users(:david), body: "literal #{literal}")
|
||||
get room_url(@room)
|
||||
first = response.body
|
||||
first = css_select('meta[name="csrf-token"]').first["content"]
|
||||
get room_url(@room)
|
||||
assert_equal first, response.body
|
||||
second = css_select('meta[name="csrf-token"]').first["content"]
|
||||
assert_not_equal first, second
|
||||
assert_includes response.body, "literal #{literal}"
|
||||
end
|
||||
assert_no_match /campfire-csrf-/, response.body
|
||||
|
||||
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"]
|
||||
post room_messages_url(@room, format: :turbo_stream), params: {
|
||||
authenticity_token: second, message: { body: "cached token works", client_message_id: "cache-token" } }
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "local and foreign commits invalidate pages and nested fragments" do
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
@@ -63,32 +63,6 @@ class FragmentRenderingTest < ActionDispatch::IntegrationTest
|
||||
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)
|
||||
|
||||
@@ -31,32 +31,6 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
|
||||
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
|
||||
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
|
||||
assert_difference -> { @message.boosts.count }, -1 do
|
||||
|
||||
@@ -49,12 +49,8 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
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 } }
|
||||
|
||||
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_select ".message__body", text: /New one/
|
||||
assert_copy_link_button room_at_message_url(@room, Message.last, host: "once.campfire.test")
|
||||
|
||||
@@ -12,18 +12,4 @@ class QrCodeControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal 1.year, response.cache_control[:max_age].to_i
|
||||
assert response.cache_control[:public]
|
||||
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
|
||||
@@ -28,23 +28,6 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_select ".unread", count: users(:david).memberships.reject { |m| m.room.direct? || !m.unread? }.count
|
||||
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
|
||||
older = rooms(:david_and_jason)
|
||||
newer = rooms(:david_and_kevin)
|
||||
@@ -65,23 +48,4 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert positions.all?
|
||||
assert_equal positions.sort, positions
|
||||
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
|
||||
@@ -172,7 +172,7 @@ class ContentFiltersTest < ActionView::TestCase
|
||||
|
||||
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body).to_html
|
||||
|
||||
assert_dom_equal body, filtered
|
||||
assert_equal body, filtered
|
||||
assert_match %r{<table>.*<th><p>Name</p></th>.*<td><p>Jason</p></td>}m, message_presentation(message)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
require "test_helper"
|
||||
|
||||
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
|
||||
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
|
||||
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
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,6 +10,11 @@ class VipsLoaderPolicyTest < ActiveSupport::TestCase
|
||||
# ones are represented by their magic bytes.
|
||||
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
|
||||
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
|
||||
BMP = "BM" + [ 0, 0, 54 ].pack("V3") + "\x00" * 40
|
||||
PSD = "8BPS" + [ 1 ].pack("n") + "\x00" * 26
|
||||
ICO = "\x00\x00\x01\x00\x01\x00" + "\x00" * 16
|
||||
SVG = %q(<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>)
|
||||
|
||||
test "loads PNG" do
|
||||
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
|
||||
end
|
||||
@@ -38,20 +43,20 @@ class VipsLoaderPolicyTest < ActiveSupport::TestCase
|
||||
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
|
||||
end
|
||||
|
||||
test "blocks BMP through magickload" do
|
||||
assert_loader_blocked :magickload, ".bmp"
|
||||
test "denies BMP through magickload" do
|
||||
assert_nil loader_for(BMP)
|
||||
end
|
||||
|
||||
test "blocks PSD through magickload" do
|
||||
assert_loader_blocked :magickload, ".psd"
|
||||
test "denies PSD through magickload" do
|
||||
assert_nil loader_for(PSD)
|
||||
end
|
||||
|
||||
test "blocks ICO through magickload" do
|
||||
assert_loader_blocked :magickload, ".ico"
|
||||
test "denies ICO through magickload" do
|
||||
assert_nil loader_for(ICO)
|
||||
end
|
||||
|
||||
test "blocks SVG through svgload" do
|
||||
assert_loader_blocked :svgload, ".svg"
|
||||
test "denies SVG through svgload" do
|
||||
assert_nil loader_for(SVG)
|
||||
end
|
||||
|
||||
test "denies OpenSlide files through openslideload" do
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
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
|
||||
@@ -1,38 +0,0 @@
|
||||
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,28 +17,6 @@ class MessageTest < ActiveSupport::TestCase
|
||||
assert_not Message.new(body: "🔥 💯").plain_text_body.all_emoji?
|
||||
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
|
||||
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
|
||||
|
||||
@@ -74,24 +74,6 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
|
||||
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
|
||||
WebMock.stub_request(:get, "https://www.example.com/")
|
||||
.to_return(status: 302, headers: { location: "https://www.example.com/" })
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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
|
||||
@@ -89,16 +89,6 @@ class Room::PushTest < ActiveSupport::TestCase
|
||||
assert_includes pushed_users { post_to_designers_mentioning_kevin }, users(:kevin)
|
||||
end
|
||||
|
||||
test "does not build notification payloads without recipients" do
|
||||
Push::Subscription.delete_all
|
||||
message = messages(:first)
|
||||
message.expects(:plain_text_body).never
|
||||
message.expects(:creator).never
|
||||
Rails.configuration.x.web_push_pool.expects(:queue).never
|
||||
|
||||
Room::MessagePusher.new(room: message.room, message: message).push
|
||||
end
|
||||
|
||||
private
|
||||
def post_to_designers_mentioning_kevin
|
||||
rooms(:designers).messages.create! body: "Hey #{mention_attachment_for(:kevin)}", client_message_id: "earth", creator: users(:david)
|
||||
|
||||
@@ -25,20 +25,6 @@ class RoomTest < ActiveSupport::TestCase
|
||||
assert room.users.include?(users(:david))
|
||||
end
|
||||
|
||||
test "a new message marks unread the members who had read the room and leaves the unread ones as they were" do
|
||||
room = rooms(:watercooler)
|
||||
reader = memberships(:jason_watercooler)
|
||||
behind = memberships(:bender_watercooler)
|
||||
behind.update_columns unread_at: 1.hour.ago, updated_at: 1.hour.ago
|
||||
behind_before = behind.reload.attributes.slice("unread_at", "updated_at")
|
||||
|
||||
message = room.messages.create! creator: users(:david), body: "Hello", client_message_id: "unread-once"
|
||||
|
||||
assert_equal message.created_at, reader.reload.unread_at
|
||||
assert_equal behind_before, behind.reload.attributes.slice("unread_at", "updated_at")
|
||||
assert_not memberships(:david_watercooler).reload.unread?
|
||||
end
|
||||
|
||||
test "type" do
|
||||
assert Rooms::Open.new.open?
|
||||
assert_not Rooms::Open.new.direct?
|
||||
|
||||
@@ -87,16 +87,18 @@ export function sockets() {
|
||||
export function messages() {
|
||||
const cookie = `session_token=${dummyCookies[0][0]}`;
|
||||
|
||||
const response = http.get(`http://${host}${port}/rooms/1`, { headers: { "Cookie": cookie }, responseType: "text" });
|
||||
const csrfToken = response.body.match(/<meta name="csrf-token" content="([^"]*)"/i)[1];
|
||||
|
||||
const postHeaders = {
|
||||
"Cookie": cookie,
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Origin": `http://${host}${port}`,
|
||||
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
|
||||
}
|
||||
|
||||
const payload = {
|
||||
"message[body]": "Hello from k6",
|
||||
"message[client_message_id]": Math.random().toString(36)
|
||||
"message[client_message_id]": Math.random().toString(36),
|
||||
"authenticity_token": csrfToken
|
||||
};
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
|
||||
Reference in new issue
Block a user