Compare commits

..
Author SHA1 Message Date
Jeremy Daer 0141eae018 Address review: assert uploaded bytes in disk update test
direct_upload_url_for now returns [blob, url] so the authenticated update
test can assert blob.download matches the uploaded payload, not just the
204 status — guarding against a write landing at the wrong key or with the
wrong content. Both update call sites destructure the tuple.
2026-06-15 13:24:38 -07:00
Jeremy Daer c49c41a0d7 Address review: cover DiskController#update in tests
Add integration coverage for the disk service PUT now that the initializer
relies on it being both session-gated and CSRF-exempt:

- unauthenticated PUT -> redirected to login (write blocked)
- authenticated PUT with forgery protection enabled and no authenticity
  token -> 204 (proves the signed-token service PUT stays CSRF-exempt and
  guards against the concern's protect_from_forgery re-arming it)
2026-06-15 13:18:40 -07:00
Jeremy Daer 49f06d0b22 Address review: keep disk PUT CSRF-exempt, use intent helpers
- Including Authentication re-arms protect_from_forgery on DiskController.
  Active Storage's direct-upload service PUT (#update) sends only signed
  service headers and no CSRF token, so a real authenticated upload would
  422 storing bytes. Re-exempt #update from forgery protection; the signed
  URL token and session check still gate the write.
- Swap the raw skip_before_action for the Authentication concern's
  intent-revealing allow_unauthenticated_access / allow_bot_access helpers
  on #show, matching the rest of the app.
- Scope the test's ActiveStorage::Current.url_options override to a
  set { } block so it can't leak thread-local state into later tests.
2026-06-15 13:10:59 -07:00
Jeremy Daer ed5a172871 Require authentication for ActiveStorage direct-upload write endpoints
ActiveStorage's direct-upload endpoints ship unauthenticated by Rails
default: ActiveStorage::DirectUploadsController and DiskController inherit
from ActionController::Base, so they bypass the app's Authentication
concern. That leaves the write path open — anyone could mint blob records
and PUT bytes to local disk storage.

Campfire never uses direct upload for legitimate attachments. Those flow
through MessagesController#create (already authenticated), and Trix file
drops are disabled in the composer. Gating the write path is therefore
pure defense-in-depth with no functional cost.

Require an authenticated session on the two write actions
(DirectUploadsController#create and DiskController#update) by including the
existing Authentication concern. Blob serving stays public —
DiskController#show keeps its auth skip, and the Blobs/Representations
controllers are untouched — so message attachments and the account logo
keep loading. Because these controllers live in ActiveStorage::Engine and
only see the engine's url helpers, also include the application route
helpers so the concern can redirect to new_session_url on failure (302,
write blocked).
2026-06-15 13:00:29 -07:00
245 changed files with 1080 additions and 7643 deletions

No files matched your search

-3
View File
@@ -50,6 +50,3 @@
# Ignore test files # Ignore test files
/test/ /test/
# Agent session state — worktrees under .claude/ are whole extra checkouts of this repo
.claude
+9 -8
View File
@@ -20,12 +20,13 @@ updates:
semver-minor-days: 3 semver-minor-days: 3
semver-patch-days: 2 semver-patch-days: 2
default-days: 7 default-days: 7
exclude:
- "brakeman"
# No docker ecosystem. Our base image tag is a build arg - package-ecosystem: docker
# (`FROM ruby:$RUBY_VERSION-slim`), which the updater cannot read, so the directory: "/"
# entry only ever matched nothing — worse than no entry, because it looked schedule:
# like coverage. Ruby base image bumps are a manual, reviewed step here, as interval: weekly
# they are across the rest of the fleet. Keep Dockerfile, Dockerfile-export cooldown:
# and .ruby-version on the same version by hand. semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
+14 -40
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: [ main ] branches: [ main ]
pull_request: pull_request:
branches: [ main, "release-*" ] branches: [ main ]
permissions: {} permissions: {}
@@ -16,12 +16,12 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with: with:
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@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with: with:
ruby-version: .ruby-version ruby-version: .ruby-version
bundler-cache: true bundler-cache: true
@@ -35,12 +35,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with: with:
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@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with: with:
ruby-version: .ruby-version ruby-version: .ruby-version
bundler-cache: true bundler-cache: true
@@ -48,29 +48,6 @@ jobs:
- name: Lint code for consistent style - name: Lint code for consistent style
run: bin/rubocop run: bin/rubocop
herb:
name: Herb check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Check that every HTML template compiles through Herb
run: bin/rails herb:check
lint-actions: lint-actions:
name: GitHub Actions audit name: GitHub Actions audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -78,15 +55,15 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with: with:
persist-credentials: false persist-credentials: false
- name: Run actionlint - name: Run actionlint
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12 uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
with: with:
advanced-security: false advanced-security: false
@@ -102,15 +79,15 @@ jobs:
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps: steps:
- name: Install packages - name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with: with:
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@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env: env:
REDIS_URL: redis://localhost:6379/0 REDIS_URL: redis://localhost:6379/0
with: with:
@@ -120,9 +97,6 @@ jobs:
- name: Run tests - name: Run tests
run: bin/rails db:setup test run: bin/rails db:setup test
- name: Test benchmark response validation
run: bundle exec ruby bench/test_http_client.rb
test_system: test_system:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -135,15 +109,15 @@ jobs:
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps: steps:
- name: Install packages - name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with: with:
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@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env: env:
REDIS_URL: redis://localhost:6379/0 REDIS_URL: redis://localhost:6379/0
with: with:
+10 -23
View File
@@ -44,15 +44,15 @@ jobs:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -67,20 +67,9 @@ jobs:
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}" CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT" echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
- name: Compute app version
id: version
shell: bash
run: |
set -eu
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- name: Extract Docker metadata (tags, labels) with arch suffix - name: Extract Docker metadata (tags, labels) with arch suffix
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with: with:
images: ${{ steps.vars.outputs.canonical }} images: ${{ steps.vars.outputs.canonical }}
tags: | tags: |
@@ -98,15 +87,13 @@ jobs:
- name: Build and push (${{ matrix.platform }}) - name: Build and push (${{ matrix.platform }})
id: build id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with: with:
context: . context: .
file: Dockerfile file: Dockerfile
build-args: | build-args: |
OCI_SOURCE=${{ env.SOURCE_URL }} OCI_SOURCE=${{ env.SOURCE_URL }}
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }} OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
APP_VERSION=${{ steps.version.outputs.version }}
GIT_REVISION=${{ github.sha }}
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
@@ -118,7 +105,7 @@ jobs:
- name: Attest image provenance (per-arch) - name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with: with:
subject-name: ${{ steps.vars.outputs.canonical }} subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }} subject-digest: ${{ steps.build.outputs.digest }}
@@ -139,10 +126,10 @@ jobs:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
steps: steps:
- name: Set up Docker Buildx (for imagetools) - name: Set up Docker Buildx (for imagetools)
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -159,7 +146,7 @@ jobs:
- name: Compute base tags (no suffix) - name: Compute base tags (no suffix)
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with: with:
images: ${{ steps.vars.outputs.canonical }} images: ${{ steps.vars.outputs.canonical }}
tags: | tags: |
@@ -207,7 +194,7 @@ jobs:
done <<< "$tags" done <<< "$tags"
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
- name: Cosign sign all tags (keyless OIDC) - name: Cosign sign all tags (keyless OIDC)
shell: bash shell: bash
+1 -1
View File
@@ -1 +1 @@
3.4.10 3.4.5
+2 -18
View File
@@ -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=3.4.5
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
@@ -9,23 +9,10 @@ WORKDIR /rails
# Install base packages # Install base packages
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install --no-install-recommends -y ca-certificates curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \ apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ 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
# Naming ca-certificates above takes the distribution's current trusted roots rather than the
# ones frozen into the base image. Fail the build if either of these withdrawn roots is still
# where Ruby's OpenSSL looks for trusted certificates.
RUN ruby -ropenssl -e ' \
file = ENV[OpenSSL::X509::DEFAULT_CERT_FILE_ENV] || OpenSSL::X509::DEFAULT_CERT_FILE; \
dir = ENV[OpenSSL::X509::DEFAULT_CERT_DIR_ENV] || OpenSSL::X509::DEFAULT_CERT_DIR; \
pems = [file, *Dir.glob(File.join(dir, "*"))].select { |path| File.file?(path) } \
.flat_map { |path| File.read(path).scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m) }; \
subjects = pems.map { |pem| OpenSSL::X509::Certificate.new(pem).subject.to_s }.uniq; \
abort "Only #{subjects.size} roots in #{file} and #{dir}" if subjects.size < 100; \
withdrawn = subjects.grep(%r{/CN=(emSign ECC Root CA - G3|AAA Certificate Services)\z}); \
abort "Withdrawn roots still trusted: #{withdrawn}" if withdrawn.any?'
# Set production environment # Set production environment
ENV RAILS_ENV="production" \ ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \ BUNDLE_DEPLOYMENT="1" \
@@ -79,9 +66,6 @@ ENV HTTP_WRITE_TIMEOUT=300
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
COPY --from=build --chown=rails:rails /rails /rails COPY --from=build --chown=rails:rails /rails /rails
# Install ONCE backup/restore hooks
COPY --chmod=755 hooks /hooks
# Set version and revision # Set version and revision
ARG APP_VERSION ARG APP_VERSION
ENV APP_VERSION=$APP_VERSION ENV APP_VERSION=$APP_VERSION
+1 -1
View File
@@ -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=3.4.7
FROM ruby:$RUBY_VERSION-slim as base FROM ruby:$RUBY_VERSION-slim as base
# Install the tools we need # Install the tools we need
+1 -4
View File
@@ -26,9 +26,6 @@ gem "importmap-rails", github: "rails/importmap-rails"
gem "turbo-rails", github: "hotwired/turbo-rails" gem "turbo-rails", github: "hotwired/turbo-rails"
gem "stimulus-rails" gem "stimulus-rails"
# Rich text
gem "lexxy", "~> 0.9.24"
# Media handling # Media handling
gem "image_processing", ">= 1.2" gem "image_processing", ">= 1.2"
@@ -43,7 +40,7 @@ gem "rqrcode"
gem "rails_autolink" gem "rails_autolink"
gem "geared_pagination" gem "geared_pagination"
gem "jbuilder" gem "jbuilder"
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP gem "net-http-persistent"
gem "kredis" gem "kredis"
gem "platform_agent" gem "platform_agent"
gem "thruster" gem "thruster"
+65 -89
View File
@@ -1,9 +1,3 @@
GIT
remote: https://github.com/basecamp/surfguard.git
revision: 59e278c01a537755f22791429051891949231ead
specs:
surfguard (0.2.0)
GIT GIT
remote: https://github.com/hotwired/turbo-rails.git remote: https://github.com/hotwired/turbo-rails.git
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3 revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
@@ -23,16 +17,16 @@ GIT
GIT GIT
remote: https://github.com/rails/propshaft.git remote: https://github.com/rails/propshaft.git
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623 revision: e49a9de659ff27462015e54dd832e86e762a6ddc
specs: specs:
propshaft (1.3.2) propshaft (1.2.1)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
GIT GIT
remote: https://github.com/rails/rails.git remote: https://github.com/rails/rails.git
revision: e3d5c569d217c56c022b04acb127a8c24dbe9685 revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
branch: main branch: main
specs: specs:
actioncable (8.2.0.alpha) actioncable (8.2.0.alpha)
@@ -63,10 +57,10 @@ GIT
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.7) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.2.0.alpha) actiontext (8.2.0.alpha)
action_text-trix (~> 2.1.16) action_text-trix (~> 2.1.15)
actionpack (= 8.2.0.alpha) actionpack (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha) activerecord (= 8.2.0.alpha)
activestorage (= 8.2.0.alpha) activestorage (= 8.2.0.alpha)
@@ -77,12 +71,11 @@ GIT
activesupport (= 8.2.0.alpha) activesupport (= 8.2.0.alpha)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
herb (>= 0.10)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.7) rails-html-sanitizer (~> 1.6)
activejob (8.2.0.alpha) activejob (8.2.0.alpha)
activesupport (= 8.2.0.alpha) activesupport (= 8.2.0.alpha)
globalid (>= 1.4.0) globalid (>= 0.3.6)
activemodel (8.2.0.alpha) activemodel (8.2.0.alpha)
activesupport (= 8.2.0.alpha) activesupport (= 8.2.0.alpha)
activerecord (8.2.0.alpha) activerecord (8.2.0.alpha)
@@ -94,7 +87,7 @@ GIT
activejob (= 8.2.0.alpha) activejob (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha) activerecord (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha) activesupport (= 8.2.0.alpha)
marcel (~> 2.0) marcel (~> 1.0)
activesupport (8.2.0.alpha) activesupport (8.2.0.alpha)
base64 base64
bigdecimal bigdecimal
@@ -105,8 +98,6 @@ GIT
json json
logger (>= 1.4.2) logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
psych (>= 4)
ractor-dispatch (>= 0.3.0)
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) uri (>= 0.13.1)
@@ -145,8 +136,8 @@ GEM
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.22) bcrypt (3.1.22)
benchmark (0.5.0) benchmark (0.5.0)
bigdecimal (4.1.3) bigdecimal (3.3.1)
brakeman (8.1.0) brakeman (8.0.4)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.3) bundler-audit (0.9.3)
@@ -162,22 +153,21 @@ GEM
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
chunky_png (1.4.0) chunky_png (1.4.0)
concurrent-ruby (1.3.8) concurrent-ruby (1.3.5)
connection_pool (2.5.5) connection_pool (2.5.5)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
crass (1.0.7) crass (1.0.6)
date (3.5.1) date (3.5.0)
debug (1.11.0) debug (1.11.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
drb (2.2.3) drb (2.2.3)
erb (6.0.7) erb (6.0.4)
erubi (1.13.1) erubi (1.13.1)
faker (3.8.0) faker (3.5.2)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin) ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-darwin)
@@ -185,28 +175,23 @@ GEM
geared_pagination (1.2.0) geared_pagination (1.2.0)
activesupport (>= 5.0) activesupport (>= 5.0)
addressable (>= 2.5.0) addressable (>= 2.5.0)
globalid (1.4.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
hashdiff (1.2.0) hashdiff (1.2.0)
herb (0.11.0-aarch64-linux-gnu) i18n (1.14.7)
herb (0.11.0-arm64-darwin)
herb (0.11.0-x86_64-darwin)
herb (0.11.0-x86_64-linux-gnu)
i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0) image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6) mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
io-console (0.9.4) io-console (0.8.1)
irb (1.18.0) irb (1.15.3)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1) jbuilder (2.14.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.21.2) json (2.13.2)
jwt (3.2.0) jwt (3.2.0)
base64 base64
kredis (1.8.0) kredis (1.8.0)
@@ -214,52 +199,50 @@ GEM
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
redis (>= 4.2, < 6) redis (>= 4.2, < 6)
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
lexxy (0.9.24)
rails (>= 8.0.2)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.25.2) loofah (2.24.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.1) mail (2.9.0)
logger logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (2.1.0) marcel (1.1.0)
matrix (0.4.3) matrix (0.4.3)
mini_magick (5.3.1) mini_magick (5.3.1)
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (6.0.6) minitest (5.26.2)
drb (~> 2.0) mocha (2.7.1)
prism (~> 1.5)
mocha (3.1.0)
ruby2_keywords (>= 0.0.5) ruby2_keywords (>= 0.0.5)
mono_logger (1.1.2) mono_logger (1.1.2)
multi_json (1.17.0) multi_json (1.17.0)
mustermann (3.0.4) mustermann (3.0.4)
ruby2_keywords (~> 0.0.1) ruby2_keywords (~> 0.0.1)
net-imap (0.6.7) net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.6.4)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.4.0) net-protocol (0.2.2)
timeout timeout
net-smtp (0.5.2) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu) nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin) nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin) nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu) nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
openssl (4.0.2) openssl (3.3.0)
ostruct (0.6.3) ostruct (0.6.3)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.9.0) parser (3.3.9.0)
@@ -268,18 +251,18 @@ GEM
platform_agent (1.0.1) platform_agent (1.0.1)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
useragent (~> 0.16.3) useragent (~> 0.16.3)
pp (0.6.4) pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.9.0) prism (1.4.0)
psych (5.5.0) psych (5.2.6)
date date
stringio stringio
public_suffix (6.0.2) public_suffix (6.0.2)
puma (7.2.1) puma (7.2.1)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.7) rack (3.2.6)
rack-protection (4.2.1) rack-protection (4.2.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
@@ -289,39 +272,33 @@ GEM
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.2.1)
rack (>= 3) rack (>= 3)
ractor-dispatch (0.3.0)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1) rails-html-sanitizer (1.6.2)
loofah (~> 2.25, >= 2.25.2) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails_autolink (1.1.8) rails_autolink (1.1.8)
actionview (> 3.1) actionview (> 3.1)
activesupport (> 3.1) activesupport (> 3.1)
railties (> 3.1) railties (> 3.1)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.4.2) rake (13.3.1)
rbs (4.2.0) rdoc (6.16.1)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
erb erb
prism (>= 1.6.0) psych (>= 4.0.0)
rbs (>= 4.0.0)
tsort tsort
redis (5.4.1) redis (5.4.1)
redis-client (>= 0.22.0) redis-client (>= 0.22.0)
redis-client (0.30.1) redis-client (0.25.2)
connection_pool connection_pool
redis-namespace (1.11.0) redis-namespace (1.11.0)
redis (>= 4) redis (>= 4)
regexp_parser (2.11.2) regexp_parser (2.11.2)
reline (0.7.0) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
resque (2.7.0) resque (2.7.0)
mono_logger (~> 1) mono_logger (~> 1)
@@ -369,9 +346,9 @@ GEM
ffi (~> 1.12) ffi (~> 1.12)
logger logger
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (3.7.0) rubyzip (3.0.2)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.49.0) selenium-webdriver (4.35.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@@ -390,20 +367,20 @@ 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.7.3-aarch64-linux-gnu)
sqlite3 (2.9.6-arm64-darwin) sqlite3 (2.7.3-arm64-darwin)
sqlite3 (2.9.6-x86_64-darwin) sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.9.6-x86_64-linux-gnu) sqlite3 (2.7.3-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.1.8)
thor (1.5.0) thor (1.4.0)
thruster (0.1.23-aarch64-linux) thruster (0.1.15-aarch64-linux)
thruster (0.1.23-arm64-darwin) thruster (0.1.15-arm64-darwin)
thruster (0.1.23-x86_64-darwin) thruster (0.1.15-x86_64-darwin)
thruster (0.1.23-x86_64-linux) thruster (0.1.15-x86_64-linux)
tilt (2.6.1) tilt (2.6.1)
timeout (0.6.1) timeout (0.4.4)
tsort (0.2.0) tsort (0.2.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@@ -412,21 +389,21 @@ GEM
unicode-emoji (4.2.0) unicode-emoji (4.2.0)
uri (1.1.1) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
web-push (3.1.0) web-push (3.0.2)
jwt (~> 3.0) jwt (~> 3.0)
openssl (>= 3.0) openssl (~> 3.0)
webmock (3.25.1) webmock (3.25.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.2) websocket-driver (0.8.0)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.8.3) zeitwerk (2.7.3)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -447,8 +424,8 @@ DEPENDENCIES
importmap-rails! importmap-rails!
jbuilder jbuilder
kredis kredis
lexxy (~> 0.9.24)
mocha mocha
net-http-persistent
ostruct ostruct
platform_agent platform_agent
propshaft! propshaft!
@@ -465,7 +442,6 @@ DEPENDENCIES
sentry-ruby sentry-ruby
sqlite3 sqlite3
stimulus-rails stimulus-rails
surfguard!
thruster thruster
turbo-rails! turbo-rails!
web-push web-push
+34 -49
View File
@@ -11,66 +11,51 @@ expect, including:
- @mentions - @mentions
- API, with support for bot integrations - API, with support for bot integrations
## Running your own Campfire instance ## Deploying with Docker
Campfire's Docker image contains everything needed for a fully-functional, Campfire's Docker image contains everything needed for a fully-functional,
single-machine deployment. This includes the web app, background jobs, caching, single-machine deployment. This includes the web app, background jobs, caching,
file serving, and SSL. You can use our pre-built image at file serving, and SSL.
`ghcr.io/basecamp/once-campfire:latest`, or build your own from this repo.
### Deploying with ONCE To persist storage of the database and file attachments, map a volume to `/rails/storage`.
The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once). To configure additional features, you can set the following environment variables:
It will guide you through the initial set up and then keep your instance up to date automatically.
If you don't already have `once` installed, run this on the machine you want to run Campfire on: - `SSL_DOMAIN` - enable automatic SSL via Let's Encrypt for the given domain name
- `DISABLE_SSL` - alternatively, set `DISABLE_SSL` to serve over plain HTTP
- `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` - set these to a valid keypair to
allow sending Web Push notifications. You can generate a new keypair by running
`/script/admin/create-vapid-key`
- `SENTRY_DSN` - to enable error reporting to sentry in production, supply your
DSN here
```sh For example:
curl https://get.once.com | sh
```
`once` will launch as soon as the install is finished. docker build -t campfire .
Choose Campfire from the list of applications, follow the instructions, and ONCE will take care of the rest. docker run \
--publish 80:80 --publish 443:443 \
--restart unless-stopped \
--volume campfire:/rails/storage \
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
--env TLS_DOMAIN=chat.example.com \
campfire
If you prefer the command line to the dashboard, you can deploy directly: ## Running in development
```sh bin/setup
once deploy ghcr.io/basecamp/once-campfire --host chat.example.com bin/rails server
```
### Deploying with Docker ## Worth Noting
If you'd rather run the Docker image yourself, you can read more about that in the [self-hosting guide](docs/self-hosting.md). When you start Campfire for the first time, you’ll be guided through
creating an admin account.
The email address of this admin account will be shown on the login page
so that people who forget their password know who to contact for help.
(You can change this email later in the settings)
> [!TIP] Campfire is single-tenant: any rooms designated "public" will be accessible by
> When you start Campfire for the first time, you'll be guided through a wizard to create an admin account. all users in the system. To support entirely distinct groups of customers, you
> The email address that you enter for the admin account will be visible on the sign-in page, it's there so would deploy multiple instances of the application.
> that people have someone to contact if they need help with their account. If that bothers you, put in any
> email address you want and create yourself a new admin account.
## Other implementations
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| Room page | 230 | 62 | 760 | 2,622 | 942 | 31,673 | 35,484 | 141,834 |
| Messages page | 402 | 70 | 924 | 3,245 | 1,267 | 30,746 | 40,674 | 151,564 |
| Sidebar | 468 | 229 | 1,383 | 34,938 | 2,515 | 18,586 | 34,479 | 159,850 |
| Search | 399 | 118 | 1,135 | 6,613 | 1,814 | 29,765 | 34,432 | 155,456 |
| Post a message | 248 | 112 | 498 | 2,088 | 1,400 | 9,073 | 8,998 | 7,460 |
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395 with 32 GB RAM,
with four hardware cores allocated to each app.
## Development
You are welcome - and encouraged - to modify Campfire to your liking.
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
## Security
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
-22
View File
@@ -1,22 +0,0 @@
# Security
Report vulnerabilities through our [HackerOne program](https://hackerone.com/basecamp) or at
https://github.com/basecamp/once-campfire/security. Note that Campfire is in scope for our security
program, but is not bounty eligible.
## Trust model
Campfire is self-hosted and single-tenant, so the administrator is the server operator,
with shell, network, and database access already. Anything requiring the administrator
role grants nothing they do not already have, and is not a vulnerability.
We do want reports of anything a **non-administrator** can reach, and of any credential or
network path held by the Campfire process but not by the operator's own shell.
## Intentional behavior
Bot webhook URLs are unrestricted: an administrator can point one at any address,
including internal ones, because operators legitimately wire bots to their own services.
Link unfurling is different because any member can trigger it by pasting a URL, so it
validates destinations through `RestrictedHTTP::PrivateNetworkGuard`. The difference is who
picks the destination.
+149 -147
View File
@@ -1,160 +1,165 @@
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages, trix-toolbar {
so everything follows Campfire's light/dark color scheme */ display: none;
.lexxy-content { inset: auto 0 0 0;
--lexxy-color-canvas: transparent; min-width: 0;
--lexxy-color-text: var(--color-text); position: sticky;
--lexxy-color-text-subtle: var(--color-text); z-index: 3;
--lexxy-color-link: var(--color-link);
--lexxy-color-ink: var(--color-text);
--lexxy-color-ink-inverted: var(--color-text-reversed);
--lexxy-color-ink-medium: var(--color-text);
--lexxy-color-ink-lighter: var(--color-border-darker);
--lexxy-color-ink-lightest: var(--color-bg);
--lexxy-color-selected: var(--color-selected, var(--color-bg));
--lexxy-color-code-bg: var(--color-bg);
/* Campfire's message lines stack without extra block margins */ .trix-dialog {
--lexxy-content-margin: 0; bottom: 3rem !important;
top: auto !important;
}
.trix-button--icon {
height: auto !important;
}
.trix-dialog {
background-color: var(--color-bg) !important;
border-radius: 1rem !important;
border: 2px solid var(--color-text) !important;
box-shadow: none !important;
color: var(--color-text) !important;
gap: var(--inline-space);
padding: 0 !important;
overflow: clip;
.trix-input {
background: transparent !important;
border: 0 !important;
color: var(--color-text) !important;
font-size: max(16px, 1em) !important;
padding: var(--input-padding, 0.5em 0.8em) !important;
}
.trix-button {
color: var(--color-text) !important;
}
}
.trix-dialog--link {
.trix-dialog__link-fields {
align-items: center;
padding-right: 0.3em;
}
[value="Link"],
[value="Unlink"] {
width: 2.6em;
aspect-ratio: 1 / 1;
background-position: center center;
background-repeat: no-repeat;
text-indent: -9999%;
padding: 0;
}
[value="Link"] { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.948 5.258a4.337 4.337 0 0 0-6.108 0L11.217 6.87a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l1.623-1.613a2.323 2.323 0 0 1 3.271 0 2.29 2.29 0 0 1 0 3.251l-2.393 2.38a3.021 3.021 0 0 1-4.255 0l-.05-.049a1.007 1.007 0 0 0-1.418 0 .993.993 0 0 0 0 1.41l.05.049a5.036 5.036 0 0 0 7.091 0l2.394-2.38a4.275 4.275 0 0 0 0-6.072Zm-13.683 13.6a4.337 4.337 0 0 0 6.108 0l1.262-1.255a.993.993 0 0 0 0-1.41 1.007 1.007 0 0 0-1.418 0L9.954 17.45a2.323 2.323 0 0 1-3.27 0 2.29 2.29 0 0 1 0-3.251l2.344-2.331a2.579 2.579 0 0 1 3.631 0c.392.39 1.027.39 1.419 0a.993.993 0 0 0 0-1.41 4.593 4.593 0 0 0-6.468 0l-2.345 2.33a4.275 4.275 0 0 0 0 6.072Z' clip-rule='evenodd'/%3E%3C/svg%3E"); }
[value="Unlink"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.954 5.264a3.58 3.58 0 0 0-5.04 0l-2.157 2.144a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l2.157-2.144a1.565 1.565 0 0 1 2.204 0c.607.603.609 1.58.005 2.186l-2.348 2.355a.993.993 0 0 0 .006 1.41 1.007 1.007 0 0 0 1.419-.007l2.347-2.354a3.528 3.528 0 0 0-.01-5ZM5.257 18.852a3.59 3.59 0 0 0 5.041.015l2.6-2.554a.993.993 0 0 0 .01-1.41 1.007 1.007 0 0 0-1.419-.009l-2.6 2.554c-.615.603-1.603.6-2.214-.006a1.554 1.554 0 0 1-.012-2.195l2.539-2.58a.993.993 0 0 0-.016-1.41 1.007 1.007 0 0 0-1.418.015l-2.539 2.58a3.54 3.54 0 0 0 .028 5Z' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-width='2' d='M10.233 5.78V3.769M5.968 9.802H4.21M7.343 7.107 5.968 5.781M14.348 17.523v2.013M18.613 13.502h1.758M17.238 16.196l1.375 1.327'/%3E%3C/svg%3E%0A");
border-top-right-radius: 1em !important;
border-bottom-right-radius: 1em !important;
}
}
} }
lexxy-editor { trix-editor {
&.input { &.input {
--lexxy-editor-padding: 0;
/* One line high when empty. Lexxy derives this from attr(rows lh, 8lh),
whose resolution differs between Chrome versions — pin it instead of
risking the 8-line fallback */
--lexxy-editor-rows: 1lh;
background: transparent;
border: 0;
border-radius: 0;
min-height: 24px; min-height: 24px;
} }
.attachment--content { .attachment--content {
margin: 0; margin: 0;
display: inline-flex; display: inline-flex;
}
}
/* The mentions menu needs a solid surface — the editor's transparent canvas .attachment__toolbar {
would let messages show through it */ top: 0;
.lexxy-prompt-menu { z-index: 2;
--lexxy-color-canvas: var(--color-bg);
border: 1px solid var(--color-border-dark);
}
/* Same for the code block language picker: with a transparent canvas the
native dropdown renders white-on-white in dark mode */
lexxy-editor lexxy-code-language-picker select {
background-color: var(--color-bg);
color: var(--color-text);
option {
background-color: var(--color-bg);
color: var(--color-text);
}
}
/* The toolbar only shows when rich text mode is toggled on (see composer.css) */
lexxy-toolbar {
/* Wide, airy button spacing like the Trix toolbar */
--lexxy-toolbar-gap: 1.3em;
/* Match the compact size of the Trix toolbar buttons */
--lexxy-toolbar-button-size: 1.7em;
--lexxy-toolbar-icon-size: 1.125em;
display: none;
border-block-end: 0;
min-width: 0;
z-index: 3;
/* Tools Campfire doesn't offer, matching the Trix toolbar: underline,
color highlights, tables, dividers and undo/redo (history tools were
hidden under Trix as well) */
button[name="underline"],
button[name="table"],
button[name="divider"],
button[name="undo"],
button[name="redo"],
lexxy-highlight-dropdown {
display: none !important;
}
/* No group separator pipes, like the Trix toolbar */
.lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after {
display: none;
}
/* Dropdown panels: a solid surface with a uniform radius. Lexxy derives
the panel's radius and gap from --lexxy-toolbar-gap, which Campfire
repurposes for button spacing */
.lexxy-editor__toolbar-dropdown [data-dropdown-panel] {
--lexxy-color-canvas: var(--color-bg);
--lexxy-color-ink-lightest: var(--color-message-bg);
--lexxy-color-selected: var(--color-selected);
--lexxy-color-selected-hover: var(--color-message-bg);
border: 1px solid var(--color-border-dark);
border-radius: 0.5em;
gap: 0.25em;
padding: 0.25em;
}
.lexxy-editor__toolbar-dropdown > .lexxy-editor__toolbar-button[aria-expanded="true"] {
border-radius: var(--lexxy-radius);
}
}
/* Attachment markup contains formatting whitespace that must not turn into
line breaks under the editor's white-space: pre-wrap */
lexxy-editor action-text-attachment {
white-space: normal;
}
action-text-attachment[content-type~="application/vnd.campfire.mention"] {
display: inline-flex;
margin: 0;
padding: 0;
position: relative;
}
action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed"] {
lexxy-editor & {
display: block;
inline-size: fit-content;
max-inline-size: 100%;
}
/* No selection background — it peeks through the card's rounded corners.
The lexxy-editor prefix out-specifies Lexxy's own selection rule. */
lexxy-editor &.node--selected {
--lexxy-attachment-bg-color: transparent;
}
/* A prominent remove button on the card's top edge, horizontally
centered, like the Trix one. The lexxy-editor prefix out-specifies
Lexxy's own placement rules. */
lexxy-editor & lexxy-node-delete-button {
inset-block-start: 0;
inset-inline-start: 50%;
inset-inline-end: auto;
transform: translate(-50%, -50%);
.lexxy-floating-controls__group {
--button-size: 2.2em;
background-color: var(--color-negative);
border-radius: 50%;
color: white;
} }
} }
} }
.lexxy-content { .trix-button-group--file-tools,
.trix-button-group--history-tools {
display: none !important;
}
.trix-button-group,
.trix-button {
border: 0 !important;
margin: 0 !important;
border-radius: 0.3em !important;
box-shadow: none;
&::before {
opacity: 1 !important;
}
@media (prefers-color-scheme: dark) {
&::before {
filter: invert(100);
}
}
&.trix-active {
background-color: var(--color-text) !important;
&::before {
filter: invert(100);
}
@media (prefers-color-scheme: dark) {
&::before {
filter: invert(0);
}
}
}
&.trix-button--remove {
background-color: var(--color-negative);
border-radius: 1.8em !important;
&::before {
filter: invert(100);
background-size: 70%;
background-image: url("minus.svg");
}
}
}
[data-trix-content-type~='application/vnd.campfire.mention'] {
.trix-button-group {
display: inline;
}
}
[data-trix-content-type~="application/vnd.actiontext.opengraph-embed"] {
* {
text-decoration: none;
}
trix-editor & {
display: block;
inline-size: fit-content;
max-inline-size: 100%;
}
}
.trix-button:not(.trix-button--remove) {
@media (any-hover: hover) {
&:where(:not(:active):hover) {
filter: brightness(0.9);
background-color: var(--color-bg);
}
}
&:disabled {
opacity: 0.33;
}
}
trix-editor,
.trix-content {
pre { pre {
background-color: var(--color-bg) !important; background-color: var(--color-bg) !important;
border-radius: 0.5em; border-radius: 0.5em;
@@ -174,13 +179,11 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
blockquote { blockquote {
border: 0 solid var(--color-border-darker) !important; border: 0 solid var(--color-border-darker) !important;
border-inline-start-width: var(--blockquote-border) !important; border-inline-start-width: var(--blockquote-border) !important;
font-style: normal;
margin: 0 var(--blockquote-margin) !important; margin: 0 var(--blockquote-margin) !important;
padding-inline-start: var(--blockquote-padding) !important; padding-inline-start: var(--blockquote-padding) !important;
} }
cite { cite {
display: block;
margin-inline-start: calc(var(--blockquote-padding)); margin-inline-start: calc(var(--blockquote-padding));
font-style: normal; font-style: normal;
font-size: 0.8rem; font-size: 0.8rem;
@@ -196,14 +199,13 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
color: var(--color-link); color: var(--color-link);
} }
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */ ul,
ul:not(.lexxy-prompt-menu),
ol { ol {
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
} }
li:not(.lexxy-prompt-menu__item) { li {
margin: 0 0 0 var(--inline-space-double) !important; margin: 0 0 0 var(--inline-space-double) !important;
padding: 0 !important; padding: 0 !important;
} }
+1 -1
View File
@@ -33,7 +33,7 @@
} }
} }
action-text-attachment[content-type~='application/vnd.campfire.mention'] { [data-trix-content-type~='application/vnd.campfire.mention'] {
display: inline-flex; display: inline-flex;
margin: 0; margin: 0;
padding: 0; padding: 0;
+4 -40
View File
@@ -100,42 +100,16 @@
} }
.composer--rich-text { .composer--rich-text {
lexxy-toolbar { trix-toolbar {
display: flex; display: block;
} }
lexxy-editor.input { trix-editor.input {
margin-block-start: var(--block-space-half); margin-block-start: var(--block-space-half);
} }
} }
/* In the message composer the toolbar goes below the text, on the same .composer--edit trix-toolbar {
row as the surrounding action buttons, like the Trix toolbar did.
The edit composer keeps it on top. */
#composer.composer--rich-text lexxy-editor.input {
display: flex;
flex-direction: column;
/* Sit the toolbar row on the same baseline as the composer's round
buttons, which pull themselves down with negative margins */
margin-block-end: -0.25em;
.lexxy-editor__content {
order: -1;
}
lexxy-toolbar {
margin-block-start: 0.4em;
/* Dropdowns open upward — the toolbar sits at the bottom of the screen */
[data-dropdown-panel] {
inset-block-start: auto;
inset-block-end: 100%;
}
}
}
.composer--edit lexxy-toolbar {
border-block-end: 1px solid var(--color-border); border-block-end: 1px solid var(--color-border);
margin-inline: -0.8em; margin-inline: -0.8em;
padding-inline: 0.3em; padding-inline: 0.3em;
@@ -143,16 +117,6 @@
} }
} }
/* The editor's container-type prevents it from sizing the message bubble by
its content, so give the bubble the full available width while editing */
.message__body:has(.composer--edit) {
inline-size: 100%;
.message__body-content--editing {
flex-grow: 1;
}
}
/* Typing indicator */ /* Typing indicator */
.typing-indicator { .typing-indicator {
inset-block-start: var(--indicator-position, 0.15rem); inset-block-start: var(--indicator-position, 0.15rem);
+5 -10
View File
@@ -1,5 +1,5 @@
trix-editor,
.lexxy-content { .trix-content {
.og-embed { .og-embed {
background: var(--color-bg); background: var(--color-bg);
border: 1px solid var(--color-border-dark); border: 1px solid var(--color-border-dark);
@@ -15,7 +15,7 @@
flex-direction: row; flex-direction: row;
} }
&.og-embed--twitter-avatar { .cf-twitter-avatar & {
flex-direction: row; flex-direction: row;
} }
} }
@@ -35,11 +35,10 @@
.og-embed__title { .og-embed__title {
font-weight: 600; font-weight: 600;
text-decoration: underline;
/* Underline the link itself so the line is link-colored */
a { a {
color: var(--color-link); color: var(--color-link);
text-decoration: underline;
} }
} }
@@ -54,11 +53,7 @@
inline-size: 15dvw; inline-size: 15dvw;
} }
/* block-size/border-radius guard against Lexxy's 1em round avatar
styling for custom attachment images */
> * { > * {
block-size: auto;
border-radius: 0;
object-position: top right; object-position: top right;
inline-size: 100%; inline-size: 100%;
object-fit: contain; object-fit: contain;
@@ -68,7 +63,7 @@
} }
} }
.og-embed--twitter-avatar & { .cf-twitter-avatar & {
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 50%; border-radius: 50%;
inline-size: 2lh; inline-size: 2lh;
+1 -1
View File
@@ -537,7 +537,7 @@ img.message__attachment {
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline); padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
text-shadow: 0 0 3px var(--color-text-reversed); text-shadow: 0 0 3px var(--color-text-reversed);
.lexxy-content { .trix-content {
font-size: 2.4em; font-size: 2.4em;
line-height: 1.4; line-height: 1.4;
} }
+2 -25
View File
@@ -2,42 +2,19 @@ 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,13 +0,0 @@
# Prepended onto Turbo::StreamsChannel. The subscriber names the channel it wants, so
# authorizing room messages only in RoomMessagesChannel would leave the stock channel as
# a way around it: same signed stream name, no membership check. Turn those names away
# here and RoomMessagesChannel becomes the only door.
module RoomStreamsAreAuthorized
def subscribed
if RoomMessagesChannel.guarded_stream?(verified_stream_name_from_params)
reject
else
super
end
end
end
+1 -1
View File
@@ -22,6 +22,6 @@ class PresenceChannel < RoomChannel
end end
def broadcast_read_room def broadcast_read_room
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id, at: Time.current.to_f } ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
end end
end end
-55
View File
@@ -1,55 +0,0 @@
# Authorizes the room message stream when the subscription is made, so that revoking a
# membership actually stops delivery.
#
# Turbo's stock channel verifies only the signature on the stream name. That name carries
# no expiry and no binding to a user, so one harvested while a member keeps working after
# the membership is gone. Reconnecting makes it worse rather than better: revoking a
# membership disconnects the user with reconnect: true, and the client then replays its
# subscriptions on the fresh socket.
#
# The room is derived from the verified stream name rather than taken as a parameter, so
# there's nothing for a subscriber to point somewhere else. The subscriber also doesn't
# get to choose the channel: Turbo::StreamsChannel turns these stream names away, so this
# is the only way onto them. See config/initializers/turbo_streams_authorization.rb.
class RoomMessagesChannel < ApplicationCable::Channel
extend Turbo::Streams::StreamName
include Turbo::Streams::StreamName::ClassMethods
STREAM_SUFFIX = "messages"
class << self
# True for the stream names this channel exists to guard, whoever is asking.
def guarded_stream?(stream_name)
stream_name.to_s.split(":", 2).second == STREAM_SUFFIX
end
def subscribable_room(user, stream_name)
gid_param, suffix = stream_name.to_s.split(":", 2)
if suffix == STREAM_SUFFIX && room = room_from(gid_param)
user.rooms.find_by(id: room.id)
end
end
private
def room_from(gid_param)
GlobalID::Locator.locate gid_param, only: Room
rescue ActiveRecord::RecordNotFound
nil
end
end
def subscribed
if stream_name = authorized_stream_name
stream_from stream_name
else
reject
end
end
private
def authorized_stream_name
stream_name = verified_stream_name_from_params
stream_name if stream_name.present? && self.class.subscribable_room(current_user, stream_name)
end
end
+1 -8
View File
@@ -1,12 +1,5 @@
class UnreadRoomsChannel < ApplicationCable::Channel class UnreadRoomsChannel < ApplicationCable::Channel
# Scoped per user, like ReadRoomsChannel. A single global stream would tell every
# authenticated connection when any room on the account is active, including closed
# rooms and direct conversations they're not part of.
def self.stream_name_for(user_id)
"user_#{user_id}_unreads"
end
def subscribed def subscribed
stream_from self.class.stream_name_for(current_user.id) stream_from "unread_rooms"
end end
end end
+8 -4
View File
@@ -8,8 +8,9 @@ class Accounts::LogosController < ApplicationController
if stale?(etag: Current.account) if stale?(etag: Current.account)
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
if (logo_variant = Current.account&.logo_variant(logo_size)) if Current.account&.logo&.attached?
send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key) logo = Current.account.logo.variant(logo_variant).processed
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
else else
send_stock_icon send_stock_icon
end end
@@ -22,6 +23,9 @@ class Accounts::LogosController < ApplicationController
end end
private private
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
def send_png_file(path) def send_png_file(path)
send_file path, content_type: "image/png", disposition: :inline send_file path, content_type: "image/png", disposition: :inline
end end
@@ -34,8 +38,8 @@ class Accounts::LogosController < ApplicationController
end end
end end
def logo_size def logo_variant
small_logo? ? :small : :large small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
end end
def small_logo? def small_logo?
+1 -1
View File
@@ -2,7 +2,7 @@ class Accounts::UsersController < ApplicationController
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ] before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
def index def index
set_page_and_extract_portion_from User.visible_to(Current.user).ordered.member, per_page: 500 set_page_and_extract_portion_from User.active.ordered.without_bots, per_page: 500
end end
def update def update
+11 -3
View File
@@ -3,9 +3,9 @@ class AccountsController < ApplicationController
before_action :set_account before_action :set_account
def edit def edit
users = User.visible_to(Current.user).ordered users = account_users.ordered.without_bots
@administrators = users.administrator @administrators, @members = users.partition(&:administrator?)
set_page_and_extract_portion_from users.member, per_page: 500 set_page_and_extract_portion_from users, per_page: 500
end end
def update def update
@@ -21,4 +21,12 @@ class AccountsController < ApplicationController
def account_params def account_params
params.require(:account).permit(:name, :logo, settings: {}) params.require(:account).permit(:name, :logo, settings: {})
end end
def account_users
if Current.user.can_administer?
User.where(status: [ :active, :banned ])
else
User.active
end
end
end end
+1 -1
View File
@@ -1,4 +1,4 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
end end
@@ -1,26 +1,11 @@
class Autocompletable::UsersController < ApplicationController class Autocompletable::UsersController < ApplicationController
def index def index
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20 set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
respond_to do |format|
format.html { render layout: false } # <lexxy-prompt-item> elements for the mentions prompt
format.json
end
end end
private private
def find_autocompletable_users def find_autocompletable_users
if query.present? params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
users_scope.active.filtered_by(query)
else
users_scope.active
end
end
# The rich text editor's mentions prompt filters with `filter`, the
# autocomplete inputs with `query`
def query
params[:filter].presence || params[:query].presence
end end
def users_scope def users_scope
@@ -1,9 +0,0 @@
module ActiveStorageAuthentication
extend ActiveSupport::Concern
include Authentication::SessionLookup
private
def require_active_storage_authentication
head :unauthorized unless find_session_by_cookie
end
end
+1 -8
View File
@@ -57,7 +57,7 @@ module Authentication
end end
def start_new_session_for(user) def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
authenticated_as session authenticated_as session
end end
end end
@@ -71,13 +71,6 @@ module Authentication
Current.session&.destroy! Current.session&.destroy!
reset_session reset_session
remove_authentication_cookie remove_authentication_cookie
disconnect_remote_connections
end
def disconnect_remote_connections
Current.user&.reset_remote_connections
rescue => error
Rails.logger.warn "Could not disconnect remote connections on sign out: #{error.class}"
end end
def authenticated_as(session) def authenticated_as(session)
@@ -1,50 +0,0 @@
# Allows the scripts the importmap helper emits and nothing else: the inline
# importmap and its entry-point module by content hash, and the asset
# directory its URLs point into. Not the whole origin, because uploads are
# served from this origin too. The hashes change only when the importmap does
# (a deploy), so cached HTML stays valid, unlike a per-request nonce.
module InlineScriptHashes
extend ActiveSupport::Concern
ENTRY_POINT_SCRIPT = %(import "application")
included do
# With the policy turned off there's nothing to add to, so leave the
# request without one rather than starting an empty policy
content_security_policy if: -> { request.content_security_policy } do |policy|
if sources = policy.directives["script-src"]
importmap = Rails.application.importmap.to_json(resolver: helpers)
policy.directives["script-src"] = (sources - [ "'self'" ]) | script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)
policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}"
end
end
end
private
# The importmap is resolved by Rails' asset helpers, so its URLs already
# carry any relative root and asset host, however the host is written:
# with or without a scheme, protocol-relative, a %d set, or a proc. Each
# is allowed up to its asset directory, the last one in its path, since an
# asset host's own path may contain one too; a URL outside it is allowed
# exactly.
#
# This origin's own sources leave out the scheme, so they match the page's
# scheme even behind a proxy that terminates TLS without saying so.
#
# A path only limits the first request: browsers check just the origin
# after a redirect. Nothing under the asset directory redirects.
def script_locations(importmap)
assets = File.join(config.relative_url_root.to_s, Rails.application.config.assets.prefix, "")
JSON.parse(importmap)["imports"].values.map do |url|
url = URI.join(request.base_url, url)
origin = url.origin == request.base_url ? request.host_with_port : url.origin
directory = url.path[0, url.path.rindex(assets) + assets.length] if url.path.include?(assets)
"#{origin}#{directory || url.path}"
end.uniq
end
def script_hashes(*scripts)
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
end
end
@@ -1,12 +0,0 @@
module RawRequestBody
extend ActiveSupport::Concern
private
def raw_request_body
request.body.rewind
request.body.read.force_encoding("UTF-8")
ensure
request.body.rewind
end
end
@@ -8,6 +8,6 @@ module SetCurrentRequest
end end
def default_url_options def default_url_options
{ host: Current.request_host, port: Current.request&.optional_port, protocol: Current.request_protocol }.compact_blank { host: Current.request_host, protocol: Current.request_protocol }.compact_blank
end end
end end
@@ -1,39 +0,0 @@
class Messages::Boosts::ByBotsController < Messages::BoostsController
include RawRequestBody
allow_bot_access only: %i[ create destroy ]
before_action :ensure_content_present, only: :create
def create
@boost = @message.boosts.create!(boost_params)
broadcast_create
render :show, status: :created
end
private
def set_message
if room = Current.user.rooms.find_by(id: params[:room_id])
@message = room.messages.find_by(id: params[:message_id])
end
head :not_found unless @message
end
def set_boost
super
rescue ActiveRecord::RecordNotFound
head :not_found
end
def ensure_content_present
if raw_request_body.blank?
head :unprocessable_content
end
end
def boost_params
{ content: raw_request_body }
end
end
@@ -1,6 +1,5 @@
class Messages::BoostsController < ApplicationController class Messages::BoostsController < ApplicationController
before_action :set_message before_action :set_message
before_action :set_boost, only: :destroy
def index def index
end end
@@ -16,6 +15,7 @@ class Messages::BoostsController < ApplicationController
end end
def destroy def destroy
@boost = Current.user.boosts.find(params[:id])
@boost.destroy! @boost.destroy!
broadcast_remove broadcast_remove
@@ -26,17 +26,13 @@ class Messages::BoostsController < ApplicationController
@message = Current.user.reachable_messages.find(params[:message_id]) @message = Current.user.reachable_messages.find(params[:message_id])
end end
def set_boost
@boost = @message.boosts.find_by!(id: params[:id], booster: Current.user)
end
def boost_params def boost_params
params.require(:boost).permit(:content) params.require(:boost).permit(:content)
end end
def broadcast_create def broadcast_create
@boost.broadcast_append_to @boost.message.room, :messages, @boost.broadcast_append_to @boost.message.room, :messages,
target: helpers.dom_id(@boost.message, :boosts), partial: "messages/boosts/boost", attributes: { maintain_scroll: true } target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
end end
def broadcast_remove def broadcast_remove
+9 -49
View File
@@ -1,64 +1,24 @@
class Messages::ByBotsController < MessagesController class Messages::ByBotsController < MessagesController
include RawRequestBody allow_bot_access only: :create
allow_bot_access only: %i[ index create update destroy ]
before_action :set_room
before_action :set_message, only: %i[ update destroy ]
before_action :ensure_can_administer, only: %i[ update destroy ]
before_action :ensure_body_or_attachment_present, only: :create
def index
@messages = find_paged_messages
set_pagination_headers
end
def create def create
super super
head :created, location: message_url(@message) head :created, location: message_url(@message)
end end
def destroy
super
head :no_content
end
private private
def set_room
@room = Current.user.rooms.find_by(id: params[:room_id])
head :not_found unless @room
end
def ensure_body_or_attachment_present
if params[:attachment].blank? && raw_request_body.blank?
head :unprocessable_content
end
end
def set_pagination_headers
headers["X-Total-Count"] = @room.messages.count.to_s
if next_page = next_page_params
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
end
end
def next_page_params
if @messages.any?
if params[:after].present?
{ after: @messages.last.id } if @room.messages.after(@messages.last).exists?
else
{ before: @messages.first.id } if @room.messages.before(@messages.first).exists?
end
end
end
def message_params def message_params
if params[:attachment] if params[:attachment]
params.permit(:attachment) params.permit(:attachment)
else else
{ body: raw_request_body } reading(request.body) { |body| { body: body } }
end end
end end
def reading(io)
io.rewind
yield io.read.force_encoding("UTF-8")
ensure
io.rewind
end
end end
+4 -8
View File
@@ -37,11 +37,7 @@ class MessagesController < ApplicationController
@message.update!(message_params) @message.update!(message_params)
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true } @message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
redirect_to room_message_url(@room, @message)
respond_to do |format|
format.html { redirect_to room_message_url(@room, @message) }
format.json { render :show }
end
end end
def destroy def destroy
@@ -62,11 +58,11 @@ class MessagesController < ApplicationController
def find_paged_messages def find_paged_messages
case case
when params[:before].present? when params[:before].present?
@room.messages.with_presentation.page_before(@room.messages.find(params[:before])) @room.messages.with_creator.page_before(@room.messages.find(params[:before]))
when params[:after].present? when params[:after].present?
@room.messages.with_presentation.page_after(@room.messages.find(params[:after])) @room.messages.with_creator.page_after(@room.messages.find(params[:after]))
else else
@room.messages.with_presentation.last_page @room.messages.with_creator.last_page
end end
end end
@@ -42,12 +42,6 @@ class Rooms::ClosedsController < RoomsController
@room = @room.becomes!(Rooms::Closed) @room = @room.becomes!(Rooms::Closed)
end end
# Open and closed rooms convert into each other, so both are in reach here. Direct
# rooms never are: converting one would let its creator revise who's in it.
def room_scope
Current.user.rooms.without_directs
end
def grantees def grantees
User.where(id: grantee_ids) User.where(id: grantee_ids)
end end
+1 -6
View File
@@ -29,13 +29,8 @@ class Rooms::DirectsController < RoomsController
end end
end end
# All users in a direct room can administer it. Only direct rooms, though: this # All users in a direct room can administer it
# relaxation is why room_scope below has to keep every other type out of reach.
def ensure_can_administer def ensure_can_administer
true true
end end
def room_scope
Current.user.rooms.directs
end
end end
@@ -40,12 +40,6 @@ class Rooms::OpensController < RoomsController
@room = @room.becomes!(Rooms::Open) @room = @room.becomes!(Rooms::Open)
end end
# Open and closed rooms convert into each other, so both are in reach here. Direct
# rooms never are: promoting one would republish its history to the whole account.
def room_scope
Current.user.rooms.without_directs
end
def broadcast_create_room(room) def broadcast_create_room(room)
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room } broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
end end
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
before_action :set_last_updated_at before_action :set_last_updated_at
def show def show
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at) @new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at) @updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
end end
private private
+4 -10
View File
@@ -12,7 +12,7 @@ class RoomsController < ApplicationController
end end
def destroy def destroy
@room.destroy_later @room.destroy
broadcast_remove_room broadcast_remove_room
redirect_to root_url redirect_to root_url
@@ -20,19 +20,13 @@ class RoomsController < ApplicationController
private private
def set_room def set_room
if room = room_scope.find_by(id: params[:room_id] || params[:id]) if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
@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"
end end
end end
# Subclasses narrow this to the room types they're allowed to act on, so that one
# room namespace can't be used to reach another's rooms.
def room_scope
Current.user.rooms
end
def ensure_can_administer def ensure_can_administer
head :forbidden unless Current.user.can_administer?(@room) head :forbidden unless Current.user.can_administer?(@room)
end end
@@ -44,9 +38,9 @@ class RoomsController < ApplicationController
end end
def find_messages def find_messages
messages = @room.messages.with_presentation messages = @room.messages.with_creator.with_attachment_details.with_boosts
if params[:message_id].present? && (show_first_message = messages.find_by(id: params[:message_id])) if show_first_message = messages.find_by(id: params[:message_id])
@messages = messages.page_around(show_first_message) @messages = messages.page_around(show_first_message)
else else
@messages = messages.last_page @messages = messages.last_page
+1 -1
View File
@@ -20,7 +20,7 @@ class SearchesController < ApplicationController
private private
def set_messages def set_messages
if query.present? if query.present?
@messages = Message.search_reachable(Current.user, query) @messages = Current.user.reachable_messages.search(query).last(100)
else else
@messages = Message.none @messages = Message.none
end end
@@ -11,7 +11,5 @@ class Sessions::TransfersController < ApplicationController
else else
head :bad_request head :bad_request
end end
rescue ActiveRecord::RecordInvalid
head :bad_request
end end
end end
-2
View File
@@ -14,8 +14,6 @@ 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
+3 -14
View File
@@ -1,9 +1,8 @@
class UnfurlLinksController < ApplicationController class UnfurlLinksController < ApplicationController
# The pasted link decides which hosts an unfurl looks up and fetches from, and how quickly they answer.
DEADLINE = 10.seconds
def create def create
if opengraph = unfurl(url_param) opengraph = Opengraph::Metadata.from_url(url_param)
if opengraph.valid?
render json: opengraph render json: opengraph
else else
head :no_content head :no_content
@@ -14,14 +13,4 @@ class UnfurlLinksController < ApplicationController
def url_param def url_param
params.require(:url) params.require(:url)
end end
# One deadline for everything the link leads to: DNS lookups, redirects, the page, the image check.
def unfurl(url)
Timeout.timeout(DEADLINE) do
Opengraph::Metadata.from_url(url).then { |opengraph| opengraph if opengraph.valid? }
end
rescue Timeout::Error
Rails.logger.warn "Gave up unfurling #{url} after #{DEADLINE.inspect}"
nil
end
end end
+4 -1
View File
@@ -9,7 +9,8 @@ class Users::AvatarsController < ApplicationController
if stale?(etag: @user) if stale?(etag: @user)
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
if (avatar_variant = @user.avatar_variant) if @user.avatar.attached?
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
send_webp_blob_file avatar_variant.key send_webp_blob_file avatar_variant.key
elsif @user.bot? elsif @user.bot?
render_default_bot render_default_bot
@@ -25,6 +26,8 @@ class Users::AvatarsController < ApplicationController
end end
private private
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
def send_webp_blob_file(key) def send_webp_blob_file(key)
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
end end
+2 -38
View File
@@ -1,26 +1,14 @@
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
if @user.update user_params @user.update user_params
start_new_session_for @user if @user.saved_change_to_password_digest? redirect_to user_profile_url, notice: update_notice
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
@@ -28,35 +16,11 @@ 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,15 +5,13 @@ class Users::PushSubscriptionsController < ApplicationController
end end
def create def create
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params) if subscription = @push_subscriptions.find_by(push_subscription_params)
subscription.user_agent = request.user_agent if subscription.new_record? subscription.touch
# Existing endpoints must pass current validations too
if subscription.valid?
head register_while_signed_in(subscription) ? :ok : :unauthorized
else else
head :unprocessable_entity @push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
end end
head :ok
end end
def destroy def destroy
@@ -26,24 +24,6 @@ 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
+7 -3
View File
@@ -2,14 +2,18 @@ class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20 DIRECT_PLACEHOLDERS = 20
def show def show
visible_memberships = Current.user.memberships.visible all_memberships = Current.user.memberships.visible.with_ordered_room
@direct_memberships = visible_memberships.with_direct_rooms @direct_memberships = extract_direct_memberships(all_memberships)
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms @other_memberships = all_memberships.without(@direct_memberships)
@direct_placeholder_users = find_direct_placeholder_users @direct_placeholder_users = find_direct_placeholder_users
end end
private private
def extract_direct_memberships(all_memberships)
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
end
def find_direct_placeholder_users def find_direct_placeholder_users
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id) exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max) User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
+1 -11
View File
@@ -1,13 +1,3 @@
module ContentFilters module ContentFilters
# Formatting the rich text editor produces that Rails' sanitizers don't TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
# allow by default. A message passes through three sanitization layers on
# its way to the screen, and each builds on this list:
#
# * SanitizeTags strips disallowed markup from the message body
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, SanitizeTags, SanitizeAttributes)
end end
@@ -1,22 +1,18 @@
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
TWITTER_DOMAINS = %w[ x.com twitter.com ]
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
def applicable? def applicable?
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text) normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
end end
def apply def apply
if trix_body? fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
remove_link_text_from_wrapping_div
else
remove_link_paragraphs
end
end end
private private
TWITTER_DOMAINS = %w[ x.com twitter.com ]
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
def solo_unfurled_url def solo_unfurled_url
ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1 unfurled_links.first["href"] if unfurled_links.size == 1
end end
def unfurled_links def unfurled_links
@@ -39,20 +35,4 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
def twitter_url?(url) def twitter_url?(url)
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) } url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
end end
def trix_body?
fragment.find_all("div").any?
end
def remove_link_text_from_wrapping_div
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
end
def remove_link_paragraphs
fragment.replace("p") do |node|
if node.at_css("action-text-attachment")
node
end
end
end
end end
@@ -1,47 +0,0 @@
class ContentFilters::SanitizeAttributes < ActionText::Content::Filter
def applicable?
true
end
# Scrub attributes on the tags that SanitizeTags allows, using Rails' safe-list
# sanitizer so unsafe URI schemes and event-handler attributes are stripped.
# Runs after SanitizeTags, so passing the same allowed tags makes the tag pass
# a no-op and only attributes are scrubbed.
def apply
sanitizer.sanitize fragment.to_html, tags: allowed_tags, attributes: allowed_attributes
end
private
# Presentation styling relies on class attributes (e.g. unfurled link embeds),
# which the standard ActionText set doesn't include.
EXTRA_ALLOWED_ATTRIBUTES = %w[ class ]
# ActionText::ContentHelper.sanitizer is a single process-wide instance, and
# rails-html-sanitizer reuses one mutable permit scrubber that it reconfigures
# from the tags/attributes on every #sanitize call. Sharing it here would race
# our stricter tag set against ActionText's default rendering on concurrent
# requests, so use a dedicated instance of the same sanitizer class to keep the
# scrubber isolated.
def sanitizer
sanitizer_class.new
end
def sanitizer_class
ActionText::ContentHelper.sanitizer.class
end
def allowed_tags
ContentFilters::SanitizeTags::ALLOWED_TAGS
end
def allowed_attributes
standard_allowed_attributes + EXTRA_ALLOWED_ATTRIBUTES
end
# Mirrors ActionText::ContentHelper#sanitizer_allowed_attributes, which isn't
# exposed at the module level.
def standard_allowed_attributes
ActionText::ContentHelper.allowed_attributes ||
(sanitizer_class.allowed_attributes + ActionText::Attachment::ATTRIBUTES).to_a
end
end
+3 -4
View File
@@ -1,8 +1,4 @@
class ContentFilters::SanitizeTags < ActionText::Content::Filter class ContentFilters::SanitizeTags < ActionText::Content::Filter
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
p pre samp small span strong sub sup time tt ul var ] + ContentFilters::EDITOR_FORMATTING_TAGS +
[ ActionText::Attachment.tag_name, "figure", "figcaption" ]
def applicable? def applicable?
true true
end end
@@ -12,6 +8,9 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter
end end
private private
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
p pre samp small span strong sub sup time tt ul var ] + [ ActionText::Attachment.tag_name, "figure", "figcaption" ]
def not_allowed_tags_css_selector def not_allowed_tags_css_selector
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("") ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
end end
@@ -0,0 +1,24 @@
class ContentFilters::StyleUnfurledTwitterAvatars < ActionText::Content::Filter
def applicable?
unfurled_twitter_avatars.present?
end
def apply
fragment.update do |source|
div = source.at_css("div")
div["class"] = UNFURLED_TWITTER_AVATAR_CSS_CLASS
end
end
private
UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
def unfurled_twitter_avatars
fragment.find_all("#{opengraph_css_selector}[url*='#{TWITTER_AVATAR_URL_PREFIX}']")
end
def opengraph_css_selector
"action-text-attachment[@content-type='#{ActionText::Attachment::OpengraphEmbed::OPENGRAPH_EMBED_CONTENT_TYPE}']"
end
end
@@ -17,16 +17,11 @@ class Messages::AttachmentPresentation
attr_reader :message, :context attr_reader :message, :context
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
# Previews are made when the message is posted, within limits. One that wasn't, because the file was too large or
# the preview failed, isn't attempted again from here: its URL would make it on every view.
def render_preview def render_preview
case if message.attachment.video?
when message.attachment.video?
video_preview_tag video_preview_tag
when message.attachment.representation(:thumb).processed?
lightboxed_image_preview_tag
else else
render_link lightboxed_image_preview_tag
end end
end end
@@ -35,17 +30,11 @@ class Messages::AttachmentPresentation
inline_media_dimension_constraints(width, height) do inline_media_dimension_constraints(width, height) do
tag.video \ tag.video \
src: rails_blob_path(message.attachment), poster: video_poster_url, src: rails_blob_path(message.attachment), poster: url_for(message.attachment.preview(format: :webp, resize_to_limit: [ Message::THUMBNAIL_MAX_WIDTH, Message::THUMBNAIL_MAX_HEIGHT ])),
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment" controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
end end
end end
# A preview is processed once the frame is drawn, but the poster is a variant of that frame, which can fail on its own.
def video_poster_url
poster = message.attachment.preview(:poster)
url_for(poster) if poster.processed? && poster.image.variant(poster.variation).processed?
end
def lightboxed_image_preview_tag def lightboxed_image_preview_tag
width, height = preview_dimensions width, height = preview_dimensions
+1 -28
View File
@@ -1,12 +1,4 @@
module MessagesHelper module MessagesHelper
# Rendered text is re-sanitized before linking. Rails' default safe list
# lacks the formatting the rich text editor produces
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
LINKABLE_TEXT = /:|@|www\./i
def message_area_tag(room, &) 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",
@@ -39,7 +31,6 @@ module MessagesHelper
controller: "reply", controller: "reply",
user_id: message.creator_id, user_id: message.creator_id,
message_id: message.id, message_id: message.id,
client_message_id: message.client_message_id,
message_timestamp: message_timestamp_milliseconds, message_timestamp: message_timestamp_milliseconds,
message_updated_at: message.updated_at.to_fs(:epoch), message_updated_at: message.updated_at.to_fs(:epoch),
sort_value: message_timestamp_milliseconds, sort_value: message_timestamp_milliseconds,
@@ -66,7 +57,7 @@ module MessagesHelper
when "sound" when "sound"
message_sound_presentation(message) message_sound_presentation(message)
else else
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)) auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
end end
rescue Exception => e rescue Exception => e
Sentry.capture_exception(e, extra: { message: message }) Sentry.capture_exception(e, extra: { message: message })
@@ -76,24 +67,6 @@ module MessagesHelper
end end
private private
# auto_link finds URLs and email addresses with regular expressions, which
# can't tell an attribute value from text. Hand it one text node at a time,
# escaped as the serializer writes text, so a link can only land between tags.
def auto_link_text(html)
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
if node.text.match?(LINKABLE_TEXT)
text = node.to_html
linked = auto_link(text, html: { target: "_blank" })
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
end
end
# A <pre> starting with a newline needs an extra one written out to survive the next parse
fragment.to_html(preserve_newline: true).html_safe
end
def messages_actions 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
+5 -27
View File
@@ -1,33 +1,11 @@
module RichTextHelper module RichTextHelper
def rich_text_data_actions def rich_text_data_actions
# submitByKeyboard runs in the capture phase so it can submit on Enter default_actions =
# before the editor turns the keystroke into a newline "trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
end
def mention_prompt_tag(room) autocomplete_actions =
tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id), "trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
"remote-filtering": true, "empty-results": "No matches"
end
# The editor keeps an attachment's content as it finds it, so every [ default_actions, autocomplete_actions ].join(" ")
# attachment is rebuilt from its attachable before editing: a Trix-era
# embed carries its details as node attributes the editor doesn't
# round-trip, a mention edited under Trix carries the generic content type
# the editor doesn't permit, and a hand-written embed carries whatever
# markup the author put there.
def editable_body(message)
fragment = ActionText::Fragment.wrap(message.body.body_before_type_cast)
transformed = fragment.replace(ActionText::Attachment.tag_name) do |node|
attachment = ActionText::Attachment.from_node(node)
node.tap do |n|
n["content-type"] = attachment.attachable.attachable_content_type
n["content"] = render_action_text_attachment(attachment)
end
end
ActionText::RichText.new(body: transformed.to_html)
end end
end end
+3 -3
View File
@@ -73,12 +73,12 @@ module RoomsHelper
def composer_data_actions def composer_data_actions
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles" drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
attachment_actions = trix_attachment_actions =
"lexxy:file-accept->composer#preventAttachment refresh-room:online@window->composer#online" "trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
remaining_actions = remaining_actions =
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline" "typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
[ drop_target_actions, drag_and_drop_actions, attachment_actions, remaining_actions ].join(" ") [ drop_target_actions, drag_and_drop_actions, trix_attachment_actions, remaining_actions ].join(" ")
end end
end end
-1
View File
@@ -2,7 +2,6 @@ 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", "🇯🇵": "アカウントに名前を付ける" },
@@ -1,7 +0,0 @@
module Users::PushSubscriptionsHelper
# Names the session without revealing its token, so the browser can tell when it's in a new one. The creation
# time keeps an id reused by a new database from looking like the same session.
def push_subscription_session_marker
Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session
end
end
+2
View File
@@ -1,4 +1,6 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails" import "@hotwired/turbo-rails"
import "trix"
import "@rails/actiontext"
import "initializers" import "initializers"
import "controllers" import "controllers"
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
import FileUploader from "models/file_uploader" import FileUploader from "models/file_uploader"
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers" import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
import { escapeHTML } from "helpers/string_helpers" import { escapeHTML } from "helpers/dom_helpers"
export default class extends Controller { export default class extends Controller {
static classes = ["toolbar"] static classes = ["toolbar"]
@@ -12,21 +12,11 @@ export default class extends Controller {
#files = [] #files = []
connect() { connect() {
this.#restoreDraft()
if (!this.#usingTouchDevice) { if (!this.#usingTouchDevice) {
onNextEventLoopTick(() => this.textTarget.focus()) onNextEventLoopTick(() => this.textTarget.focus())
} }
} }
saveDraft() {
if (this.textTarget.isBlank) {
localStorage.removeItem(this.#draftKey)
} else {
localStorage.setItem(this.#draftKey, this.textTarget.value)
}
}
submit(event) { submit(event) {
event.preventDefault() event.preventDefault()
@@ -54,20 +44,21 @@ export default class extends Controller {
} }
replaceMessageContent(content) { replaceMessageContent(content) {
this.textTarget.value = content const editor = this.textTarget.editor
this.textTarget.focus()
this.textTarget.selection.placeCursorAtTheEnd() editor.recordUndoEntry("Format reply")
editor.setSelectedRange([0, editor.getDocument().toString().length])
editor.deleteInDirection("forward")
editor.insertHTML(content)
editor.setSelectedRange([editor.getDocument().toString().length - 1])
} }
submitByKeyboard(event) { submitByKeyboard(event) {
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
const toolbarVisible = this.element.classList.contains(this.toolbarClass) const toolbarVisible = this.element.classList.contains(this.toolbarClass)
const metaEnter = event.metaKey || event.ctrlKey const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
const plainEnter = !event.shiftKey && !event.isComposing const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) { if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
event.stopPropagation()
this.submit(event) this.submit(event)
} }
} }
@@ -117,19 +108,6 @@ export default class extends Controller {
this.fieldsTarget.disabled = true this.fieldsTarget.disabled = true
} }
#restoreDraft() {
const draft = localStorage.getItem(this.#draftKey)
if (draft) {
this.textTarget.value = draft
this.textTarget.selection.placeCursorAtTheEnd()
}
}
get #draftKey() {
return `composer-draft-${this.roomIdValue}`
}
get #usingTouchDevice() { get #usingTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
} }
@@ -148,7 +126,7 @@ export default class extends Controller {
} }
#validInput() { #validInput() {
return !this.textTarget.isBlank return this.textTarget.textContent.trim().length > 0
} }
async #submitFiles() { async #submitFiles() {
@@ -181,7 +159,6 @@ export default class extends Controller {
#reset() { #reset() {
this.textTarget.value = "" this.textTarget.value = ""
localStorage.removeItem(this.#draftKey)
} }
#updateFileList() { #updateFileList() {
@@ -46,7 +46,6 @@ export default class extends Controller {
} }
messageTargetConnected(target) { messageTargetConnected(target) {
this.#reconcilePendingMessage(target)
this.#formatter.format(target, ThreadStyle.thread) this.#formatter.format(target, ThreadStyle.thread)
} }
@@ -90,7 +89,7 @@ export default class extends Controller {
} }
async editMyLastMessage() { async editMyLastMessage() {
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
if (editorEmpty && this.#paginator.upToDate) { if (editorEmpty && this.#paginator.upToDate) {
this.#myLastMessage?.querySelector(".message__edit-btn")?.click() this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
@@ -126,25 +125,6 @@ export default class extends Controller {
// Internal // Internal
// The composer inserts a pending message client-side using the client_message_id
// as its DOM id. The server broadcast now renders the real message with a DOM id
// derived from the record's primary key, so it no longer replaces the pending
// element by id. Reconcile by matching the server message's client_message_id and
// dropping our own pending placeholder. Only elements flagged data-pending-message
// (client-side placeholders, never broadcast) are removed, so a message another
// member posted reusing this client_message_id can never displace a real one.
#reconcilePendingMessage(target) {
const clientMessageId = target.dataset.clientMessageId
if (target.dataset.messageId && clientMessageId) {
const selector = `[data-pending-message][data-client-message-id="${CSS.escape(clientMessageId)}"]`
this.messagesTarget.querySelectorAll(selector).forEach((pending) => {
if (pending !== target) pending.remove()
})
}
}
async #ensureUpToDate() { async #ensureUpToDate() {
if (!this.#paginator.upToDate) { if (!this.#paginator.upToDate) {
await this.#paginator.resetToLastPage() await this.#paginator.resetToLastPage()
@@ -106,8 +106,7 @@ 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" })
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in. if (!response.ok) subscription.unsubscribe()
if (response.unprocessableEntity) subscription.unsubscribe()
} }
async #requestPermissionAndSubscribe(registration) { async #requestPermissionAndSubscribe(registration) {
@@ -1,32 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
const SYNCED_SESSION_KEY = "push-subscription-synced-session"
// A password change removes the account's push subscriptions, the one this browser holds included. Post it again
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
// browser's own subscription alone: the next session tries again.
export default class extends Controller {
static values = { url: String, session: String }
async connect() {
if (this.#allowed && this.sessionValue && localStorage.getItem(SYNCED_SESSION_KEY) !== this.sessionValue) {
const registration = await navigator.serviceWorker.getRegistration(window.location.origin)
const subscription = await registration?.pushManager?.getSubscription()
if (Notification.permission === "granted" && subscription) {
await post(this.urlValue, { body: this.#payload(subscription), responseKind: "turbo-stream" })
localStorage.setItem(SYNCED_SESSION_KEY, this.sessionValue)
}
}
}
get #allowed() {
return navigator.serviceWorker && window.Notification
}
#payload(subscription) {
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
}
}
@@ -16,7 +16,7 @@ export default class extends Controller {
}) })
} }
#read = ({ room_id, at }) => { #read = ({ room_id }) => {
this.dispatch("read", { detail: { roomId: room_id, at } }) this.dispatch("read", { detail: { roomId: room_id } })
} }
} }
@@ -11,7 +11,7 @@ export default class extends Controller {
} }
reply() { reply() {
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><p><br></p>` const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><br>`
this.composerOutlet.replaceMessageContent(content) this.composerOutlet.replaceMessageContent(content)
} }
@@ -23,12 +23,12 @@ export default class extends Controller {
} }
get #bodyContent() { get #bodyContent() {
const body = this.bodyTarget.querySelector(".lexxy-content").cloneNode(true) const body = this.bodyTarget.querySelector(".trix-content").cloneNode(true)
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
} }
#stripMentionAttachments(node) { #stripMentionAttachments(node) {
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim())) node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
return node return node
} }
@@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus"
import MentionsAutocompleteHandler from "lib/autocomplete/mentions_autocomplete_handler"
import { debounce } from "helpers/timing_helpers"
export default class extends Controller {
static values = { url: String }
initialize() {
this.handlers = []
this.search = debounce(this.search.bind(this), 300)
}
connect() {
if (this.element == document.activeElement) {
this.#installHandlers()
}
}
focus(event) {
this.#installHandlers()
}
search(event) {
const content = this.editor.getDocument().toString()
const position = this.editor.getPosition()
this.handlers.forEach(handler => handler.updateWithContentAndPosition(content, position))
}
blur(event) {
this.#uninstallHandlers()
}
#installHandlers() {
this.#uninstallHandlers()
this.handlers = [ new MentionsAutocompleteHandler(this.element, this.urlValue) ]
}
#uninstallHandlers() {
this.handlers.forEach(handler => handler.destroy())
this.handlers = []
}
get editor() {
return this.element.editor
}
}
@@ -7,13 +7,8 @@ export default class extends Controller {
static classes = [ "unread" ] static classes = [ "unread" ]
#disconnected = true #disconnected = true
#connection = 0
#discardDirectEditor = false
#readAt = new Map()
async connect() { async connect() {
this.element.addEventListener("turbo:click", this.#directNavigation)
this.element.addEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, { this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
connected: this.#channelConnected.bind(this), connected: this.#channelConnected.bind(this),
disconnected: this.#channelDisconnected.bind(this), disconnected: this.#channelDisconnected.bind(this),
@@ -22,11 +17,7 @@ export default class extends Controller {
} }
disconnect() { disconnect() {
this.element.removeEventListener("turbo:click", this.#directNavigation)
this.element.removeEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
this.#discardDirectEditor = false
ignoringBriefDisconnects(this.element, () => { ignoringBriefDisconnects(this.element, () => {
this.#channelDisconnected()
this.channel?.unsubscribe() this.channel?.unsubscribe()
this.channel = null this.channel = null
}) })
@@ -36,58 +27,19 @@ export default class extends Controller {
this.read({ detail: { roomId: Current.room.id } }) this.read({ detail: { roomId: Current.room.id } })
} }
read({ detail: { roomId, at } }) { read({ detail: { roomId } }) {
const room = this.#findRoomTarget(roomId) const room = this.#findRoomTarget(roomId)
if (at) {
this.#readAt.set(Number(roomId), Math.max(Number(at), this.#readAt.get(Number(roomId)) ?? 0))
}
if (room) { if (room) {
room.classList.remove(this.unreadClass) room.classList.remove(this.unreadClass)
this.dispatch("read", { detail: { targetId: roomId } }) this.dispatch("read", { detail: { targetId: roomId } })
} }
} }
// Preserve the live picker through background refreshes, but allow deliberate navigation. #channelConnected() {
#directNavigation = event => {
if (event.target.closest("a")?.dataset.turboFrame === this.element.id) {
this.#discardDirectEditor = true
}
}
#preserveDirectEditor = event => {
if (event.target !== this.element) return
const discard = this.#discardDirectEditor
this.#discardDirectEditor = false
if (discard) return
const current = this.element.querySelector("#direct_rooms_control")
const incoming = event.detail.newFrame.querySelector("#direct_rooms_control")
if (!current?.querySelector('[data-autocomplete-target="input"]') || !incoming) return
// Turbo's Bardo moves the actual element, keeping input, selections and controllers.
const controls = [ current, incoming ].map(element => [ element, element.hasAttribute("data-turbo-permanent") ])
for (const [ element ] of controls) element.setAttribute("data-turbo-permanent", "")
const render = event.detail.render
event.detail.render = (...args) => {
try {
return render(...args)
} finally {
for (const [ element, permanent ] of controls) element.toggleAttribute("data-turbo-permanent", permanent)
}
}
}
async #channelConnected() {
if (this.#disconnected) { if (this.#disconnected) {
this.#disconnected = false this.#disconnected = false
const connection = ++this.#connection this.element.reload()
// Reloading an unfinished frame aborts its response body reader.
await this.element.loaded
if (this.element.isConnected && !this.#disconnected && connection === this.#connection) {
this.element.reload()
}
} }
} }
@@ -95,11 +47,11 @@ export default class extends Controller {
this.#disconnected = true this.#disconnected = true
} }
#unread({ roomId, at }) { #unread({ roomId }) {
const unreadRoom = this.#findRoomTarget(roomId) const unreadRoom = this.#findRoomTarget(roomId)
if (unreadRoom) { if (unreadRoom) {
if (Current.room.id != roomId && !this.#readSince(roomId, at)) { if (Current.room.id != roomId) {
unreadRoom.classList.add(this.unreadClass) unreadRoom.classList.add(this.unreadClass)
} }
@@ -107,12 +59,6 @@ export default class extends Controller {
} }
} }
// Notices fan out one member at a time, so one can arrive after the member has already
// read the room in another tab. It still reorders the room, but doesn't mark it unread.
#readSince(roomId, at) {
return Number(at) <= this.#readAt.get(Number(roomId))
}
#findRoomTarget(roomId) { #findRoomTarget(roomId) {
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId) return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
} }
@@ -1,84 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
import { escapeHTML, truncateString } from "helpers/string_helpers"
const OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "og-embed--twitter-avatar"
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
export default class extends Controller {
#abortController
disconnect() {
this.#abortController?.abort()
}
async unfurl(event) {
if (!this.element.permitsAttachmentContentType(OPENGRAPH_EMBED_CONTENT_TYPE)) return
const { url, insertBelowLink } = event.detail
const metadata = await this.#fetchOpengraphMetadata(url)
if (metadata) {
insertBelowLink(this.#opengraphEmbedHTML(metadata), { attachment: { contentType: OPENGRAPH_EMBED_CONTENT_TYPE } })
}
}
async #fetchOpengraphMetadata(url) {
this.#abortController?.abort()
this.#abortController = new AbortController()
try {
const response = await post("/unfurl_link", {
body: { url },
contentType: "application/json",
signal: this.#abortController.signal
})
if (response.ok && response.statusCode !== 204) {
const { title, url: href, image, description } = await response.json
if (title && href) return { title, href, image, description }
}
} catch {
// Ignore aborted and failed requests
}
return null
}
#opengraphEmbedHTML({ title, href, image, description }) {
return `<actiontext-opengraph-embed>
<div class="og-embed gap ${this.#embedClass(image)}">
<div class="og-embed__content">
<div class="og-embed__title">
<a href="${escapeHTML(href)}" rel="noreferrer" target="_blank">${escapeHTML(truncateString(title, 280))}</a>
</div>
<div class="og-embed__description">${escapeHTML(truncateString(description, 560))}</div>
</div>
${this.#imageHTML(image)}
</div>
</actiontext-opengraph-embed>`
}
#embedClass(image) {
if (this.#isTwitterAvatar(image)) {
return UNFURLED_TWITTER_AVATAR_CSS_CLASS
} else {
return ""
}
}
#isTwitterAvatar(image) {
return Boolean(image) && image.startsWith(TWITTER_AVATAR_URL_PREFIX)
}
#imageHTML(image) {
if (image) {
return `<div class="og-embed__image"><img src="${escapeHTML(image)}" class="image center" alt="" /></div>`
} else {
return ""
}
}
}
+6
View File
@@ -4,6 +4,12 @@ export function scrollToBottom(container) {
container.scrollTop = container.scrollHeight container.scrollTop = container.scrollHeight
} }
export function escapeHTML(html) {
const div = document.createElement("div")
div.textContent = html
return div.innerHTML
}
export function parseHTMLFragment(html) { export function parseHTMLFragment(html) {
const template = document.createElement("template") const template = document.createElement("template")
template.innerHTML = html template.innerHTML = html
-6
View File
@@ -1,5 +1,3 @@
const HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }
export function truncateString(string, length, omission = "…") { export function truncateString(string, length, omission = "…") {
if (string.length <= length) { if (string.length <= length) {
return string return string
@@ -7,7 +5,3 @@ export function truncateString(string, length, omission = "…") {
return string.slice(0, length - omission.length) + omission return string.slice(0, length - omission.length) + omission
} }
} }
export function escapeHTML(string) {
return String(string ?? "").replace(/[&<>"']/g, character => HTML_ESCAPES[character])
}
+8 -17
View File
@@ -1,19 +1,10 @@
import * as Lexxy from "lexxy" import Unfurler from "lib/rich_text/unfurl/unfurler"
import CampfireRichTextExtension from "lib/rich_text/campfire_extension"
Lexxy.configure({ // Support a `cite` block for attribution links
global: { Trix.config.blockAttributes.cite = {
// Keep the content type of mention attachments (application/vnd.campfire.mention) tagName: "cite",
// that Campfire has used since its Trix days inheritable: false,
attachmentContentTypeNamespace: "campfire", }
extensions: [ CampfireRichTextExtension ]
},
default: {
// Campfire sends files as separate messages via the composer's own
// attach button, never through the editor
toolbar: { attachments: false },
// Trix offered a single heading level, rendered as h1 const unfurler = new Unfurler()
headings: [ "h1" ] unfurler.install()
}
})
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
#fetchAutocompletables(url) { #fetchAutocompletables(url) {
if (url) { if (url) {
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json()) return fetch(url, { as: "json" }).then(response => response.json())
} else { } else {
return Promise.resolve() return Promise.resolve()
} }
@@ -0,0 +1,48 @@
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
import { PUNCTUATION_PATTERN } from "lib/autocomplete/constants"
export default class extends BaseAutocompleteHandler {
get pattern() {
return new RegExp(`^@(.*?)(${PUNCTUATION_PATTERN.source}*)$`)
}
insertAutocompletable(autocompletable, range, terminator, options = {}) {
const attachment = this.#createAttachmentForAutocompletable(autocompletable)
this.#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range, options)
}
// Override to set selector's position relative to the cursor in the editor
getOffsetsAtPosition(position) {
return this.#getOffsetsFromEditorAtPosition(this.#editor, position)
}
#createAttachmentForAutocompletable(mentionable) {
const mention = `
<span class="mention" sgid=${mentionable.sgid}>
<img src="${mentionable.avatar_url}" class="avatar" alt="${mentionable.name}">
${mentionable.name}
</span>
`
return new Trix.Attachment({
content: mention,
contentType: "application/vnd.campfire.mention",
sgid: mentionable.sgid
})
}
#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range) {
if (range) { this.#editor.setSelectedRange(range) }
this.#editor.insertAttachment(attachment)
this.#editor.insertString(terminator)
}
get #editor() {
return this.element.editor
}
#getOffsetsFromEditorAtPosition(editor, position) {
const rect = this.#editor.getClientRectAtPosition(position)
return rect ? rect : {}
}
}
@@ -1,24 +0,0 @@
import * as Lexxy from "lexxy"
import CiteNode from "lib/rich_text/cite_node"
export default class CampfireRichTextExtension extends Lexxy.Extension {
get allowedElements() {
return [
"cite",
"figure",
"figcaption",
"actiontext-opengraph-embed",
{ tag: "div", attributes: [ "sgid" ] },
{ tag: "span", attributes: [ "sgid" ] },
{ tag: "img", attributes: [ "alt" ] },
{ tag: "a", attributes: [ "rel", "target" ] }
]
}
get lexicalExtension() {
return this.defineExtension({
name: "campfire/rich-text",
nodes: [ CiteNode ]
})
}
}
-39
View File
@@ -1,39 +0,0 @@
import * as Lexxy from "lexxy"
const { ElementNode } = Lexxy.Lexical
export default class CiteNode extends ElementNode {
static getType() {
return "cite"
}
static clone(node) {
return new CiteNode(node.__key)
}
static importJSON(serializedNode) {
return new CiteNode().updateFromJSON(serializedNode)
}
static importDOM() {
return {
cite: () => ({ conversion: () => ({ node: new CiteNode() }), priority: 1 })
}
}
exportJSON() {
return { ...super.exportJSON(), type: "cite" }
}
createDOM() {
return document.createElement("cite")
}
updateDOM() {
return false
}
exportDOM() {
return { element: document.createElement("cite") }
}
}
@@ -0,0 +1,80 @@
import { post } from "@rails/request.js"
import { truncateString } from "helpers/string_helpers"
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
export default class OpengraphEmbedOperation {
constructor(paste) {
this.paste = paste
this.editor = this.paste.editor
this.url = this.paste.string
this.abortController = new AbortController()
}
perform() {
return this.#createOpenGraphMetadataRequest()
.then(response => response.json)
.then(this.#insertOpengraphAttachment.bind(this))
.catch(() => null)
}
abort() {
this.abortController.abort()
}
#createOpenGraphMetadataRequest() {
return post("/unfurl_link", {
body: { url: this.url },
contentType: "application/json",
signal: this.abortController.signal
})
}
#insertOpengraphAttachment(response) {
if (this.#shouldInsertOpengraphPreview) {
const currentRange = this.editor.getSelectedRange()
this.editor.setSelectedRange(this.editor.getSelectedRange())
this.editor.recordUndoEntry("Insert Opengraph preview for Pasted URL")
this.editor.insertAttachment(this.#createOpengraphAttachment(response))
this.editor.setSelectedRange(currentRange)
}
}
get #shouldInsertOpengraphPreview() {
return this.editor.getDocument().toString().includes(this.url)
}
#createOpengraphAttachment(response) {
const { title, url, image, description } = response
const html = this.#generateOpengraphEmbedHTML({ title, url, image, description })
return new Trix.Attachment({
contentType: "application/vnd.actiontext.opengraph-embed",
content: html,
filename: title,
href: url,
url: image,
caption: description
})
}
#generateOpengraphEmbedHTML(embed) {
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(embed) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ''}">
<div class="og-embed">
<div class="og-embed__content">
<div class="og-embed__title">${truncateString(embed.title, 560)}</div>
<div class="og-embed__description">${truncateString(embed.description, 560)}</div>
</div>
<div class="og-embed__image">
<img src="${embed.image}" class="image" alt="" />
</div>
</div>
</actiontext-opengraph-embed>`
}
#isTwitterAvatar(embed) {
return embed.image.startsWith(TWITTER_AVATAR_URL_PREFIX)
}
}
@@ -0,0 +1,39 @@
export default class Paste {
constructor(range, editor, document) {
this.range = range
this.editor = editor
this.document = document
if (this.document == null) { this.document = this.editor.getDocument() }
this.string = this.document.getStringAtRange(this.range)
}
isURL() {
return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/.test(this.string)
}
getPathname() {
const a = document.createElement("a")
a.href = this.string
return a.pathname
}
isLinked() {
const {href} = this.getCommonAttributes()
return (href != null) && (href !== this.string)
}
getCommonAttributes() {
return this.document.getCommonAttributesAtRange(this.range)
}
getSignificantPaste() {
return new this.constructor(this.getSignificantRange(), this.editor, this.document)
}
getSignificantRange() {
const significantString = this.string.trim()
const startOffset = this.range[0] + this.string.indexOf(significantString)
const endOffset = startOffset + significantString.length
return [startOffset, endOffset]
}
}
@@ -0,0 +1,59 @@
import OpengraphEmbedOperation from "lib/rich_text/unfurl/lib/opengraph_embed_operation"
import Paste from "lib/rich_text/unfurl/lib/paste"
const performOperation = (function() {
let operation = null
let requestId = null
return function(operationToPerform) {
operation?.abort()
cancelAnimationFrame(requestId)
requestId = requestAnimationFrame(function() {
operation = operationToPerform
operation.perform().then(() => operation = null)
})
}
})()
export default class Unfurler {
install() {
this.#addEventListeners()
}
#addEventListeners() {
addEventListener("trix-initialize", function(event) {
if (this.#editorElementPermitsAttribute(event.target, "href")) {
return event.target.addEventListener("trix-paste", this.#didPaste.bind(this))
}
}.bind(this))
}
#didPaste(event) {
const {range} = event.paste
const {editor} = event.target
if (range != null) {
const paste = new Paste(range, editor).getSignificantPaste()
if (paste.isURL()) {
if (this.#editorElementPermitsOpengraphAttachment(event.target)) {
performOperation(new OpengraphEmbedOperation(paste))
}
}
}
}
#editorElementPermitsAttribute(element, attributeName) {
if (element.hasAttribute("data-permitted-attributes")) {
return Array.from(element.getAttribute("data-permitted-attributes").split(" ")).includes(attributeName)
} else {
return true
}
}
#editorElementPermitsOpengraphAttachment(element) {
const permittedAttachmentTypes = element.getAttribute("data-permitted-attachment-types")
return permittedAttachmentTypes && permittedAttachmentTypes.includes("application/vnd.actiontext.opengraph-embed")
}
}
+6 -17
View File
@@ -18,7 +18,7 @@ export default class ClientMessage {
body, body,
messageTimestamp: Math.floor(now.getTime()), messageTimestamp: Math.floor(now.getTime()),
messageDatetime: now.toISOString(), messageDatetime: now.toISOString(),
messageClasses: this.#messageClassesFromNode(node), messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
}) })
} }
@@ -52,37 +52,26 @@ export default class ClientMessage {
} }
} }
#messageClassesFromNode(node) {
if (this.#containsOnlyEmoji(this.#plainTextFromNode(node))) {
return "message--emoji"
} else {
return ""
}
}
#isPlayCommand(node) { #isPlayCommand(node) {
return this.#matchPlayCommand(node) return this.#matchPlayCommand(node)
} }
#matchPlayCommand(node) { #matchPlayCommand(node) {
return this.#plainTextFromNode(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1] return this.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
} }
#plainTextFromNode(node) { #stripWrapperElement(node) {
if (this.#isRichText(node)) { return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
return node.toString().trim()
} else {
return node
}
} }
#isRichText(node) { #isRichText(node) {
return typeof(node) != "string" return typeof(node) != "string"
} }
#richTextContent(node) { #richTextContent(node) {
return `<div class="lexxy-content">${node.value}</div>` return `<div class="trix-content">${node.innerHTML}</div>`
} }
+1 -15
View File
@@ -76,21 +76,7 @@ export default class MessageFormatter {
} }
#highlightCodeBlock(block) { #highlightCodeBlock(block) {
this.#normalizeLineBreaks(block) if (this.#isPlainText(block)) window.hljs.highlightElement(block)
if (this.#isPlainText(block)) {
const language = block.dataset.language
if (language && window.hljs.getLanguage(language)) {
block.classList.add(`language-${language}`)
}
window.hljs.highlightElement(block)
}
}
// Lexxy breaks code block lines with <br>, Trix-era blocks used newlines
#normalizeLineBreaks(block) {
block.querySelectorAll("br").forEach(br => br.replaceWith("\n"))
} }
#isPlainText(element) { #isPlainText(element) {
@@ -1,5 +0,0 @@
class Message::BroadcastUnreadRoomJob < ApplicationJob
def perform(message)
message.broadcast_unread_room
end
end
-8
View File
@@ -1,8 +0,0 @@
class Room::DestroyJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(room, former_member_ids)
room.reset_remote_connections_of(former_member_ids)
room.destroy_one_message_at_a_time
end
end
+1 -9
View File
@@ -1,14 +1,6 @@
class Account < ApplicationRecord class Account < ApplicationRecord
include Joinable include Joinable
has_one_attached :logo do |attachable| has_one_attached :logo
attachable.variant :large, resize_to_limit: [ 512, 512 ], format: :png
attachable.variant :small, resize_to_limit: [ 192, 192 ], format: :png
end
has_json :settings, restrict_room_creation_to_administrators: false has_json :settings, restrict_room_creation_to_administrators: false
def logo_variant(size)
logo.variant(size).processed if logo.variable?
end
end end
+1 -2
View File
@@ -9,8 +9,7 @@ class Membership < ApplicationRecord
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") } scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
scope :with_direct_rooms, -> { includes(:room).joins(:room).merge(Room.directs).order(rooms: { updated_at: :desc }) } scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
scope :without_direct_rooms, -> { joins(:room).merge(Room.without_directs) }
scope :visible, -> { where.not(involvement: :invisible) } scope :visible, -> { where.not(involvement: :invisible) }
scope :unread, -> { where.not(unread_at: nil) } scope :unread, -> { where.not(unread_at: nil) }
+7 -3
View File
@@ -14,16 +14,20 @@ class Message < ApplicationRecord
scope :ordered, -> { order(:created_at) } scope :ordered, -> { order(:created_at) }
scope :with_creator, -> { preload(creator: :avatar_attachment) } scope :with_creator, -> { preload(creator: :avatar_attachment) }
scope :with_attachment_details, -> { scope :with_attachment_details, -> {
with_rich_text_body_and_embeds.with_attached_attachment with_rich_text_body_and_embeds
with_attached_attachment
.includes(attachment_blob: :variant_records) .includes(attachment_blob: :variant_records)
} }
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) } scope :with_boosts, -> { includes(boosts: :booster) }
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 || "" body.to_plain_text.presence || attachment&.filename&.to_s || ""
end end
def to_key
[ client_message_id ]
end
def content_type def content_type
case case
when attachment? then "attachment" when attachment? then "attachment"
+1 -19
View File
@@ -4,14 +4,9 @@ module Message::Attachment
THUMBNAIL_MAX_WIDTH = 1200 THUMBNAIL_MAX_WIDTH = 1200
THUMBNAIL_MAX_HEIGHT = 800 THUMBNAIL_MAX_HEIGHT = 800
# Decoding a picture, or a video's frame, costs in proportion to its pixels, however small the file. The largest
# phone photos have 200 million.
THUMBNAIL_MAX_PIXELS = 250_000_000
included do included do
has_one_attached :attachment do |attachable| has_one_attached :attachment do |attachable|
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ] attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
attachable.variant :poster, format: :webp, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
end end
end end
@@ -35,25 +30,12 @@ module Message::Attachment
attachment&.analyze attachment&.analyze
end end
# A file that ffmpeg or libvips can't decode is still the message: post it without a preview.
def process_attachment_thumbnail def process_attachment_thumbnail
return if too_many_pixels_to_preview?
case case
when attachment.video? when attachment.video?
attachment.preview(:poster).processed attachment.preview(format: :webp).processed
when attachment.representable? when attachment.representable?
attachment.representation(:thumb).processed attachment.representation(:thumb).processed
end end
rescue ActiveStorage::PreviewError, Vips::Error => error
Rails.logger.warn "Posted #{attachment.filename} without a preview: #{error.class}: #{error.message.lines.first&.chomp}"
end
# Without a size, the analyzer couldn't read the file's header, and the previewer wouldn't either.
def too_many_pixels_to_preview?
if attachment.image? || attachment.video?
width, height = attachment.metadata.values_at(:width, :height)
width.nil? || height.nil? || width * height > THUMBNAIL_MAX_PIXELS
end
end end
end end
+1 -20
View File
@@ -1,29 +1,10 @@
module Message::Broadcasts module Message::Broadcasts
def broadcast_create def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ] broadcast_append_to room, :messages, target: [ room, :messages ]
broadcast_unread_room_later ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end end
def broadcast_remove def broadcast_remove
broadcast_remove_to room, :messages broadcast_remove_to room, :messages
end end
# Fanned out to the room's members rather than published on one global stream, so
# that the timing of activity in a room only reaches people who are in it. Of those,
# only the ones who still have it unread or are in it now: by the time this runs,
# someone may have opened the room and moved on, and would see it marked unread again.
def broadcast_unread_room
payload = ActiveSupport::JSON.encode(roomId: room.id, at: created_at.to_f)
room.memberships.unread.or(room.memberships.connected).pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
end
end
private
# The fanout is one publish per member, which in a big room takes far longer than the
# rest of posting a message, so it runs in a job rather than while the poster waits.
def broadcast_unread_room_later
Message::BroadcastUnreadRoomJob.perform_later(self)
end
end end
+5 -34
View File
@@ -3,31 +3,9 @@ module Message::Pagination
PAGE_SIZE = 40 PAGE_SIZE = 40
# Expose Rails' collection-preloading hook while preserving Array pagination and
# validators. The renderer passes only cache misses to preload_associations.
class Page < Array
def self.load(relation, direction, size)
new(relation.skip_preloading!.public_send(direction, size), relation)
end
def initialize(records, relation)
super(records)
@relation = relation
end
def loaded?
true
end
def preload_associations(records)
@relation.preload_associations(records)
end
end
included do included do
# Keep presentation data lazy until the collection cache knows which messages missed. scope :last_page, -> { ordered.last(PAGE_SIZE) }
scope :last_page, -> { last_page_of(PAGE_SIZE) } scope :first_page, -> { ordered.first(PAGE_SIZE) }
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
scope :before, ->(message) { where("created_at < ?", message.created_at) } scope :before, ->(message) { where("created_at < ?", message.created_at) }
scope :after, ->(message) { where("created_at > ?", message.created_at) } scope :after, ->(message) { where("created_at > ?", message.created_at) }
@@ -36,23 +14,16 @@ module Message::Pagination
scope :page_after, ->(message) { after(message).first_page } scope :page_after, ->(message) { after(message).first_page }
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page } scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
# Sorting on +created_at, which no index covers, has SQLite find the few messages updated since scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
# through (room_id, updated_at) and sort just those, instead of walking the whole room by
# (room_id, created_at) looking for them.
scope :page_updated_since, ->(time) { Page.load(where("updated_at > ?", time).reorder(Arel.sql("+messages.created_at")), :last, PAGE_SIZE) }
end end
class_methods do class_methods do
def last_page_of(size)
Page.load(ordered, :last, size)
end
def page_around(message) def page_around(message)
Page.new(page_before(message) + [ message ] + page_after(message), ordered) page_before(message) + [ message ] + page_after(message)
end end
def paged? def paged?
offset(PAGE_SIZE).exists? count > PAGE_SIZE
end end
end end
end end
+2 -43
View File
@@ -3,45 +3,10 @@ module Message::Searchable
included do included do
after_create_commit :create_in_index after_create_commit :create_in_index
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") } after_update_commit :update_in_index
after_update_commit :update_in_index, if: :indexed_text_changed?
after_destroy_commit :remove_from_index after_destroy_commit :remove_from_index
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered } scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
end
class_methods do
# Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than
# parsing them as operators, which fails on a query like "AND" or "salt AND".
def match_terms(query)
query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ")
end
end
class_methods do
def search_reachable(user, query, size: 100)
relation = user.reachable_messages.search(query).with_presentation
probe = connection.select_rows(sanitize_sql([ <<~SQL, user.id, match_terms(query) ]))
SELECT m.id, mm.user_id IS NOT NULL FROM message_search_index idx
JOIN messages m ON m.id = idx.rowid
LEFT JOIN memberships mm ON mm.room_id = m.room_id AND mm.user_id = ?
WHERE idx.body MATCH ? ORDER BY idx.rowid DESC LIMIT 1000
SQL
ids = probe.filter_map { |id, reachable| id if reachable == 1 }.first(size)
# A scoped fallback preserves older reachable hits behind a large private history.
if ids.size < size && probe.size == 1000
relation.last_page_of_matches(size)
else
Message::Pagination::Page.load(relation.where(id: ids).reorder(:id), :last, size)
end
end
# Orders by the index's rowid, which is the message id, so SQLite walks the full-text index
# newest first and stops at the page. Ordering by created_at sorted every match before paging.
def last_page_of_matches(size)
Message::Pagination::Page.load(reorder("idx.rowid"), :last, size)
end
end end
private private
@@ -49,12 +14,6 @@ module Message::Searchable
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
end end
# Boosts touch their message too: rewrite the index only when the body was saved or the attachment replaced.
# Active Storage clears attachment_changes in its own after_commit, before this one runs.
def indexed_text_changed?
association(:rich_text_body).target&.saved_change_to_body? || @attachment_replaced
end
def update_in_index def update_in_index
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
end end
+1 -12
View File
@@ -5,7 +5,6 @@ class Opengraph::Fetch
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html" ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
MAX_BODY_SIZE = 5.megabytes MAX_BODY_SIZE = 5.megabytes
MAX_REDIRECTS = 10 MAX_REDIRECTS = 10
TIMEOUT = 7.seconds # to connect (TLS included), and for each read and write, as Webhook does
class TooManyRedirectsError < StandardError; end class TooManyRedirectsError < StandardError; end
class RedirectDeniedError < StandardError; end class RedirectDeniedError < StandardError; end
@@ -23,14 +22,9 @@ 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,
# 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, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http| Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") do |http|
http.request request_class.new(url) do |response| 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"])
@@ -44,11 +38,6 @@ class Opengraph::Fetch
raise TooManyRedirectsError raise TooManyRedirectsError
end end
# Without max_retries: 0, Net::HTTP sends a GET or HEAD that timed out a second time.
def timeouts
{ open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT, max_retries: 0 }
end
def resolve_redirect(location) def resolve_redirect(location)
url = URI.parse(location) url = URI.parse(location)
raise RedirectDeniedError unless url.is_a?(URI::HTTP) raise RedirectDeniedError unless url.is_a?(URI::HTTP)
+2 -56
View File
@@ -1,61 +1,7 @@
require "restricted_http/private_network_guard"
class Push::Subscription < ApplicationRecord class Push::Subscription < ApplicationRecord
PERMITTED_ENDPOINT_HOSTS = %w[
jmt17.google.com
fcm.googleapis.com
updates.push.services.mozilla.com
web.push.apple.com
notify.windows.com
].freeze
belongs_to :user belongs_to :user
validates :endpoint, presence: true def notification(**params)
validate :validate_endpoint_url WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
def notification(badge: user.memberships.unread.count, **params)
# Defer DNS lookup to the delivery worker to prevent rebinding
WebPush::Notification.new(**params, badge: badge, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
end end
# Validate at point of use, not just when saved.
def resolved_endpoint_ip
RestrictedHTTP::PrivateNetworkGuard.resolve(endpoint_uri.host) if permitted_endpoint_uri?
rescue RestrictedHTTP::Violation, Surfguard::Unresolvable
nil
end
private
# Validate endpoint shape. Belt & suspenders.
def permitted_endpoint_uri?
endpoint_uri&.scheme == "https" && endpoint_uri.port == 443 && permitted_endpoint_host?
end
def endpoint_uri
URI.parse(endpoint) if endpoint.present?
rescue URI::InvalidURIError
nil
end
def validate_endpoint_url
if endpoint_uri.nil?
errors.add(:endpoint, "is not a valid URL")
elsif endpoint_uri.scheme != "https"
errors.add(:endpoint, "must use HTTPS")
elsif endpoint_uri.port != 443
errors.add(:endpoint, "must use the default HTTPS port")
elsif !permitted_endpoint_host?
errors.add(:endpoint, "is not a permitted push service")
elsif resolved_endpoint_ip.nil?
errors.add(:endpoint, "resolves to a private or invalid IP address")
end
end
def permitted_endpoint_host?
host = endpoint_uri&.host&.downcase
host.present? && PERMITTED_ENDPOINT_HOSTS.any? do |permitted|
host == permitted || host.end_with?(".#{permitted}")
end
end
end end
-39
View File
@@ -22,8 +22,6 @@ class Room < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :creator, class_name: "User", default: -> { Current.user }
validate :direct_rooms_keep_their_type, on: :update
scope :opens, -> { where(type: "Rooms::Open") } scope :opens, -> { where(type: "Rooms::Open") }
scope :closeds, -> { where(type: "Rooms::Closed") } scope :closeds, -> { where(type: "Rooms::Closed") }
scope :directs, -> { where(type: "Rooms::Direct") } scope :directs, -> { where(type: "Rooms::Direct") }
@@ -45,34 +43,6 @@ class Room < ApplicationRecord
end end
end end
# Takes the room away from its members at once, and leaves its messages to Room::DestroyJob. Destroying them in
# the room's own transaction held the database's write lock, and stopped every other write, until the last one.
# An open room is closed first, so that no one who joins the account before the job runs is let in.
def destroy_later
room = open? ? becomes!(Rooms::Closed) : self
former_member_ids = transaction do
room.save!
room.memberships.pluck(:user_id).tap { room.memberships.delete_all }
end
Room::DestroyJob.perform_later(room, former_member_ids)
end
# Deleting the memberships skips the reset that revoking one does, so the former members get it here, all at once,
# before the messages go: their connections stop receiving the room's streams. It's done in the job, not the
# request, because it costs a Redis round trip per member.
def reset_remote_connections_of(user_ids)
User.where(id: user_ids).find_each(&:reset_remote_connections)
end
# Each message is destroyed in its own transaction, so other writes get through in between. Any message posted
# meanwhile goes with the room.
def destroy_one_message_at_a_time
messages.find_each(&:destroy)
destroy
end
def receive(message) def receive(message)
unread_memberships(message) unread_memberships(message)
push_later(message) push_later(message)
@@ -95,15 +65,6 @@ class Room < ApplicationRecord
end end
private private
# Open and closed rooms convert into each other freely. A direct room can't become
# either: its participants agreed to a private conversation, not to one whose
# audience someone else gets to widen afterwards.
def direct_rooms_keep_their_type
if type_changed? && type_was == "Rooms::Direct"
errors.add :type, "can't be changed for a direct room"
end
end
def unread_memberships(message) def unread_memberships(message)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current) memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end end
-3
View File
@@ -53,12 +53,9 @@ class Room::MessagePusher
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids) relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
end end
# Banning keeps the user's subscriptions, so that unbanning brings their notifications back,
# but nobody who can't sign in should be sent what's said in their rooms meanwhile.
def relevant_subscriptions def relevant_subscriptions
Push::Subscription Push::Subscription
.joins(user: :memberships) .joins(user: :memberships)
.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: message.creator))
end end
+5 -8
View File
@@ -7,15 +7,12 @@ class Rooms::Direct < Room
end end
private private
# Among the first user's rooms, the one whose members are exactly these users: as many # FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
# memberships as users, and all of them theirs. # which could be to store the membership id list as a hash on the room, and use that for lookup.
def find_for(users) def find_for(users)
user_ids = users.pluck(:id).uniq all.joins(:users).detect do |room|
Set.new(room.user_ids) == Set.new(users.pluck(:id))
where(id: Membership.where(user_id: user_ids.first).select(:room_id)) end
.joins(:memberships).group(:id)
.having("COUNT(*) = :size AND COUNT(CASE WHEN memberships.user_id IN (:user_ids) THEN 1 END) = :size", size: user_ids.size, user_ids: user_ids)
.first
end end
end end
+3 -12
View File
@@ -7,22 +7,13 @@ class Session < ApplicationRecord
before_create { self.last_active_at ||= Time.now } before_create { self.last_active_at ||= Time.now }
validate :user_password_unchanged, on: :create def self.start!(user_agent:, ip_address:)
create! user_agent: user_agent, ip_address: ip_address
end
def resume(user_agent:, ip_address:) 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
+1 -38
View File
@@ -18,10 +18,8 @@ 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}%") }
@@ -52,43 +50,8 @@ 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
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
# as insert_all did.
def grant_membership_to_open_rooms def grant_membership_to_open_rooms
now = Time.current Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
Membership.connection.execute Membership.sanitize_sql([ <<~SQL, id, now, now ])
insert into memberships (room_id, user_id, created_at, updated_at)
select id, ?, ?, ? from rooms where type = 'Rooms::Open'
on conflict do nothing
SQL
end end
def deactived_email_address def deactived_email_address
+1 -7
View File
@@ -2,9 +2,7 @@ module User::Avatar
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
has_one_attached :avatar do |attachable| has_one_attached :avatar
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
end
end end
class_methods do class_methods do
@@ -16,8 +14,4 @@ module User::Avatar
def avatar_token def avatar_token
signed_id(purpose: :avatar) signed_id(purpose: :avatar)
end end
def avatar_variant
avatar.variant(:square).processed if avatar.variable?
end
end end
-5
View File
@@ -1,11 +1,6 @@
module User::Bannable module User::Bannable
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do
# Administrators still see banned users, so they can lift the ban.
scope :visible_to, ->(user) { user.can_administer? ? where(status: %i[ active banned ]) : active }
end
def ban def ban
transaction do transaction do
create_bans_from_sessions create_bans_from_sessions
+1 -8
View File
@@ -1,18 +1,11 @@
module User::Mentionable module User::Mentionable
include ActionText::Attachable include ActionText::Attachable
MENTION_CONTENT_TYPE = "application/vnd.campfire.mention"
def attachable_content_type
MENTION_CONTENT_TYPE
end
def to_attachable_partial_path def to_attachable_partial_path
"users/mention" "users/mention"
end end
# How a mention appears inside the editor, matching the prompt's editor template. def to_trix_content_attachment_partial_path
def to_editor_content_attachment_partial_path
"users/mention" "users/mention"
end end
Loaded 100 of 245 files, more files were not shown because too many files have changed in this diff. Show more