Compare commits

..
Author SHA1 Message Date
Kevin McConnell c1d304999d Add ONCE backup and restore hooks
By default, ONCE pauses the application container when taking a backup.
This guarantees a consistent snapshot of the data, but for a large
installation and/or a slow destination path (like a network mount), that
pause could be a bit disruptive to live traffic.

But ONCE also support a pause-free path: if a `pre-backup` hook is
present in the container, ONCE will run that and skip the pause. So by
supplying necessary hooks, we can avoid any disruption from backups.

- `pre-backup` runs the existing script/admin/prepare-backup, which uses
  SQLite's online backup API to get a consistent snapshop. k

- `post-restore` moves our snaphotted file back into place, and removes
  the orphaned sidecar files
2026-08-03 12:30:54 +01:00
226 changed files with 964 additions and 7013 deletions

No files matched your search

-3
View File
@@ -50,6 +50,3 @@
# Ignore test files
/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-patch-days: 2
default-days: 7
exclude:
- "brakeman"
# No docker ecosystem. Our base image tag is a build arg
# (`FROM ruby:$RUBY_VERSION-slim`), which the updater cannot read, so the
# entry only ever matched nothing — worse than no entry, because it looked
# like coverage. Ruby base image bumps are a manual, reviewed step here, as
# they are across the rest of the fleet. Keep Dockerfile, Dockerfile-export
# and .ruby-version on the same version by hand.
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
cooldown:
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:
branches: [ main ]
pull_request:
branches: [ main, "release-*" ]
branches: [ main ]
permissions: {}
@@ -16,12 +16,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -35,12 +35,12 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -48,29 +48,6 @@ jobs:
- name: Lint code for consistent style
run: bin/rubocop
herb:
name: Herb check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Check that every HTML template compiles through Herb
run: bin/rails herb:check
lint-actions:
name: GitHub Actions audit
runs-on: ubuntu-latest
@@ -78,15 +55,15 @@ jobs:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Run actionlint
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
- name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
with:
advanced-security: false
@@ -102,15 +79,15 @@ jobs:
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
@@ -120,9 +97,6 @@ jobs:
- name: Run tests
run: bin/rails db:setup test
- name: Test benchmark response validation
run: bundle exec ruby bench/test_http_client.rb
test_system:
runs-on: ubuntu-latest
permissions:
@@ -135,15 +109,15 @@ jobs:
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
+10 -10
View File
@@ -44,15 +44,15 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- 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
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -80,7 +80,7 @@ jobs:
- name: Extract Docker metadata (tags, labels) with arch suffix
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -98,7 +98,7 @@ jobs:
- name: Build and push (${{ matrix.platform }})
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: Dockerfile
@@ -118,7 +118,7 @@ jobs:
- name: Attest image provenance (per-arch)
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:
subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }}
@@ -139,10 +139,10 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- 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
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -159,7 +159,7 @@ jobs:
- name: Compute base tags (no suffix)
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -207,7 +207,7 @@ jobs:
done <<< "$tags"
- 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)
shell: bash
+1 -1
View File
@@ -1 +1 @@
3.4.10
3.4.5
+2 -15
View File
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.10
ARG RUBY_VERSION=3.4.5
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
@@ -9,23 +9,10 @@ WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y ca-certificates curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archive
# Naming ca-certificates above takes the distribution's current trusted roots rather than the
# ones frozen into the base image. Fail the build if either of these withdrawn roots is still
# where Ruby's OpenSSL looks for trusted certificates.
RUN ruby -ropenssl -e ' \
file = ENV[OpenSSL::X509::DEFAULT_CERT_FILE_ENV] || OpenSSL::X509::DEFAULT_CERT_FILE; \
dir = ENV[OpenSSL::X509::DEFAULT_CERT_DIR_ENV] || OpenSSL::X509::DEFAULT_CERT_DIR; \
pems = [file, *Dir.glob(File.join(dir, "*"))].select { |path| File.file?(path) } \
.flat_map { |path| File.read(path).scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m) }; \
subjects = pems.map { |pem| OpenSSL::X509::Certificate.new(pem).subject.to_s }.uniq; \
abort "Only #{subjects.size} roots in #{file} and #{dir}" if subjects.size < 100; \
withdrawn = subjects.grep(%r{/CN=(emSign ECC Root CA - G3|AAA Certificate Services)\z}); \
abort "Withdrawn roots still trusted: #{withdrawn}" if withdrawn.any?'
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
+1 -1
View File
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.10
ARG RUBY_VERSION=3.4.7
FROM ruby:$RUBY_VERSION-slim as base
# 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 "stimulus-rails"
# Rich text
gem "lexxy", "~> 0.9.24"
# Media handling
gem "image_processing", ">= 1.2"
@@ -43,7 +40,7 @@ gem "rqrcode"
gem "rails_autolink"
gem "geared_pagination"
gem "jbuilder"
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
gem "net-http-persistent"
gem "kredis"
gem "platform_agent"
gem "thruster"
+54 -78
View File
@@ -1,9 +1,3 @@
GIT
remote: https://github.com/basecamp/surfguard.git
revision: 59e278c01a537755f22791429051891949231ead
specs:
surfguard (0.2.0)
GIT
remote: https://github.com/hotwired/turbo-rails.git
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
@@ -23,16 +17,16 @@ GIT
GIT
remote: https://github.com/rails/propshaft.git
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623
revision: e49a9de659ff27462015e54dd832e86e762a6ddc
specs:
propshaft (1.3.2)
propshaft (1.2.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
GIT
remote: https://github.com/rails/rails.git
revision: e3d5c569d217c56c022b04acb127a8c24dbe9685
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -63,10 +57,10 @@ GIT
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.7)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.2.0.alpha)
action_text-trix (~> 2.1.16)
action_text-trix (~> 2.1.15)
actionpack (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activestorage (= 8.2.0.alpha)
@@ -77,12 +71,11 @@ GIT
activesupport (= 8.2.0.alpha)
builder (~> 3.1)
erubi (~> 1.11)
herb (>= 0.10)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.7)
rails-html-sanitizer (~> 1.6)
activejob (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
globalid (>= 1.4.0)
globalid (>= 0.3.6)
activemodel (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
activerecord (8.2.0.alpha)
@@ -94,7 +87,7 @@ GIT
activejob (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
marcel (~> 2.0)
marcel (~> 1.0)
activesupport (8.2.0.alpha)
base64
bigdecimal
@@ -105,8 +98,6 @@ GIT
json
logger (>= 1.4.2)
minitest (>= 5.1)
psych (>= 4)
ractor-dispatch (>= 0.3.0)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
@@ -145,8 +136,8 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (4.1.3)
brakeman (8.1.0)
bigdecimal (3.3.1)
brakeman (8.0.5)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -162,7 +153,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.8)
concurrent-ruby (1.3.7)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -173,11 +164,10 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.3)
erb (6.0.7)
erb (6.0.4)
erubi (1.13.1)
faker (3.8.0)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
@@ -185,28 +175,23 @@ GEM
geared_pagination (1.2.0)
activesupport (>= 5.0)
addressable (>= 2.5.0)
globalid (1.4.0)
globalid (1.3.0)
activesupport (>= 6.1)
hashdiff (1.2.0)
herb (0.11.0-aarch64-linux-gnu)
herb (0.11.0-arm64-darwin)
herb (0.11.0-x86_64-darwin)
herb (0.11.0-x86_64-linux-gnu)
i18n (1.15.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.9.4)
irb (1.18.0)
io-console (0.8.1)
irb (1.15.3)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.21.2)
json (2.20.0)
jwt (3.2.0)
base64
kredis (1.8.0)
@@ -214,41 +199,39 @@ GEM
activesupport (>= 6.0.0)
redis (>= 4.2, < 6)
language_server-protocol (3.17.0.5)
lexxy (0.9.24)
rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (2.1.0)
marcel (1.1.0)
matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mocha (3.1.0)
minitest (5.26.2)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
mono_logger (1.1.2)
multi_json (1.17.0)
mustermann (3.0.4)
ruby2_keywords (~> 0.0.1)
net-imap (0.6.7)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.4.0)
net-protocol (0.2.2)
timeout
net-smtp (0.5.2)
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
@@ -259,7 +242,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
openssl (4.0.2)
openssl (3.3.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.9.0)
@@ -268,18 +251,18 @@ GEM
platform_agent (1.0.1)
activesupport (>= 5.2.0)
useragent (~> 0.16.3)
pp (0.6.4)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
psych (5.5.0)
prism (1.4.0)
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
puma (7.2.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.7)
rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
@@ -289,9 +272,8 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rackup (2.2.1)
rack (>= 3)
ractor-dispatch (0.3.0)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -304,24 +286,19 @@ GEM
activesupport (> 3.1)
railties (> 3.1)
rainbow (3.1.1)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
rake (13.3.1)
rdoc (6.16.1)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
psych (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.30.1)
redis-client (0.25.2)
connection_pool
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.11.2)
reline (0.7.0)
reline (0.6.3)
io-console (~> 0.5)
resque (2.7.0)
mono_logger (~> 1)
@@ -369,9 +346,9 @@ GEM
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (3.7.0)
rubyzip (3.0.2)
securerandom (0.4.1)
selenium-webdriver (4.49.0)
selenium-webdriver (4.35.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -390,18 +367,18 @@ GEM
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.6-x86_64-darwin)
sqlite3 (2.9.6-x86_64-linux-gnu)
sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.5-x86_64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.5.0)
thruster (0.1.23-aarch64-linux)
thruster (0.1.23-arm64-darwin)
thruster (0.1.23-x86_64-darwin)
thruster (0.1.23-x86_64-linux)
stringio (3.1.8)
thor (1.4.0)
thruster (0.1.15-aarch64-linux)
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
tilt (2.6.1)
timeout (0.6.1)
tsort (0.2.0)
@@ -412,9 +389,9 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
web-push (3.1.0)
web-push (3.0.2)
jwt (~> 3.0)
openssl (>= 3.0)
openssl (~> 3.0)
webmock (3.25.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@@ -426,7 +403,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.8.3)
zeitwerk (2.7.3)
PLATFORMS
aarch64-linux
@@ -447,8 +424,8 @@ DEPENDENCIES
importmap-rails!
jbuilder
kredis
lexxy (~> 0.9.24)
mocha
net-http-persistent
ostruct
platform_agent
propshaft!
@@ -465,7 +442,6 @@ DEPENDENCIES
sentry-ruby
sqlite3
stimulus-rails
surfguard!
thruster
turbo-rails!
web-push
-17
View File
@@ -49,23 +49,6 @@ If you'd rather run the Docker image yourself, you can read more about that in t
> that people have someone to contact if they need help with their account. If that bothers you, put in any
> email address you want and create yourself a new admin account.
## 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.
+149 -147
View File
@@ -1,160 +1,165 @@
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
so everything follows Campfire's light/dark color scheme */
.lexxy-content {
--lexxy-color-canvas: transparent;
--lexxy-color-text: var(--color-text);
--lexxy-color-text-subtle: var(--color-text);
--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);
trix-toolbar {
display: none;
inset: auto 0 0 0;
min-width: 0;
position: sticky;
z-index: 3;
/* Campfire's message lines stack without extra block margins */
--lexxy-content-margin: 0;
.trix-dialog {
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 {
--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;
}
.attachment--content {
margin: 0;
display: inline-flex;
}
}
/* The mentions menu needs a solid surface — the editor's transparent canvas
would let messages show through it */
.lexxy-prompt-menu {
--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;
.attachment__toolbar {
top: 0;
z-index: 2;
}
}
}
.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 {
background-color: var(--color-bg) !important;
border-radius: 0.5em;
@@ -174,13 +179,11 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
blockquote {
border: 0 solid var(--color-border-darker) !important;
border-inline-start-width: var(--blockquote-border) !important;
font-style: normal;
margin: 0 var(--blockquote-margin) !important;
padding-inline-start: var(--blockquote-padding) !important;
}
cite {
display: block;
margin-inline-start: calc(var(--blockquote-padding));
font-style: normal;
font-size: 0.8rem;
@@ -196,14 +199,13 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
color: var(--color-link);
}
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */
ul:not(.lexxy-prompt-menu),
ul,
ol {
margin: 0 !important;
padding: 0 !important;
}
li:not(.lexxy-prompt-menu__item) {
li {
margin: 0 0 0 var(--inline-space-double) !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;
margin: 0;
padding: 0;
+4 -40
View File
@@ -100,42 +100,16 @@
}
.composer--rich-text {
lexxy-toolbar {
display: flex;
trix-toolbar {
display: block;
}
lexxy-editor.input {
trix-editor.input {
margin-block-start: var(--block-space-half);
}
}
/* In the message composer the toolbar goes below the text, on the same
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 {
.composer--edit trix-toolbar {
border-block-end: 1px solid var(--color-border);
margin-inline: -0.8em;
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 {
inset-block-start: var(--indicator-position, 0.15rem);
+5 -10
View File
@@ -1,5 +1,5 @@
.lexxy-content {
trix-editor,
.trix-content {
.og-embed {
background: var(--color-bg);
border: 1px solid var(--color-border-dark);
@@ -15,7 +15,7 @@
flex-direction: row;
}
&.og-embed--twitter-avatar {
.cf-twitter-avatar & {
flex-direction: row;
}
}
@@ -35,11 +35,10 @@
.og-embed__title {
font-weight: 600;
text-decoration: underline;
/* Underline the link itself so the line is link-colored */
a {
color: var(--color-link);
text-decoration: underline;
}
}
@@ -54,11 +53,7 @@
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;
inline-size: 100%;
object-fit: contain;
@@ -68,7 +63,7 @@
}
}
.og-embed--twitter-avatar & {
.cf-twitter-avatar & {
aspect-ratio: 1;
border-radius: 50%;
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);
text-shadow: 0 0 3px var(--color-text-reversed);
.lexxy-content {
.trix-content {
font-size: 2.4em;
line-height: 1.4;
}
+2 -25
View File
@@ -2,42 +2,19 @@ module ApplicationCable
class Connection < ActionCable::Connection::Base
include Authentication::SessionLookup
SESSION_CHECK_INTERVAL = 1.minute
identified_by :current_user
def connect
self.current_user = find_verified_user
@session_checked_at = Time.now
end
# Signing out or changing the password ends sessions and then disconnects their connections, but a connection can
# miss that: one that found its session just before isn't listening yet. So on the server's heartbeat, which
# reaches every open connection, each one looks its session up again now and then, and closes once it's gone. A
# rejected connection has no session, and stays registered until its socket closes.
def beat
super
check_session if @session_checked_at&.before?(SESSION_CHECK_INTERVAL.ago)
end
private
def find_verified_user
if @verified_session = find_session_by_cookie
@verified_session.user
if verified_session = find_session_by_cookie
verified_session.user
else
reject_unauthorized_connection
end
end
def check_session
@session_checked_at = Time.now
perform_work -> { close_unless_session_remains }, :call
end
def close_unless_session_remains
unless Session.exists?(@verified_session.id)
close reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized]
end
end
end
end
@@ -1,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
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
-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
# 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
stream_from self.class.stream_name_for(current_user.id)
stream_from "unread_rooms"
end
end
+1 -1
View File
@@ -2,7 +2,7 @@ class Accounts::UsersController < ApplicationController
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
def index
set_page_and_extract_portion_from User.visible_to(Current.user).ordered.member, per_page: 500
set_page_and_extract_portion_from User.active.ordered.without_bots, per_page: 500
end
def update
+11 -3
View File
@@ -3,9 +3,9 @@ class AccountsController < ApplicationController
before_action :set_account
def edit
users = User.visible_to(Current.user).ordered
@administrators = users.administrator
set_page_and_extract_portion_from users.member, per_page: 500
users = account_users.ordered.without_bots
@administrators, @members = users.partition(&:administrator?)
set_page_and_extract_portion_from users, per_page: 500
end
def update
@@ -21,4 +21,12 @@ class AccountsController < ApplicationController
def account_params
params.require(:account).permit(:name, :logo, settings: {})
end
def account_users
if Current.user.can_administer?
User.where(status: [ :active, :banned ])
else
User.active
end
end
end
+1 -1
View File
@@ -1,4 +1,4 @@
class ApplicationController < ActionController::Base
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
end
@@ -1,26 +1,11 @@
class Autocompletable::UsersController < ApplicationController
def index
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
private
def find_autocompletable_users
if query.present?
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
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
end
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
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
authenticated_as session
end
end
@@ -71,13 +71,6 @@ module Authentication
Current.session&.destroy!
reset_session
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
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
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
@@ -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
before_action :set_message
before_action :set_boost, only: :destroy
def index
end
@@ -16,6 +15,7 @@ class Messages::BoostsController < ApplicationController
end
def destroy
@boost = Current.user.boosts.find(params[:id])
@boost.destroy!
broadcast_remove
@@ -26,17 +26,13 @@ class Messages::BoostsController < ApplicationController
@message = Current.user.reachable_messages.find(params[:message_id])
end
def set_boost
@boost = @message.boosts.find_by!(id: params[:id], booster: Current.user)
end
def boost_params
params.require(:boost).permit(:content)
end
def broadcast_create
@boost.broadcast_append_to @boost.message.room, :messages,
target: helpers.dom_id(@boost.message, :boosts), partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
end
def broadcast_remove
+9 -49
View File
@@ -1,64 +1,24 @@
class Messages::ByBotsController < MessagesController
include RawRequestBody
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
allow_bot_access only: :create
def create
super
head :created, location: message_url(@message)
end
def destroy
super
head :no_content
end
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
if params[:attachment]
params.permit(:attachment)
else
{ body: raw_request_body }
reading(request.body) { |body| { body: body } }
end
end
def reading(io)
io.rewind
yield io.read.force_encoding("UTF-8")
ensure
io.rewind
end
end
+4 -8
View File
@@ -37,11 +37,7 @@ class MessagesController < ApplicationController
@message.update!(message_params)
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
respond_to do |format|
format.html { redirect_to room_message_url(@room, @message) }
format.json { render :show }
end
redirect_to room_message_url(@room, @message)
end
def destroy
@@ -62,11 +58,11 @@ class MessagesController < ApplicationController
def find_paged_messages
case
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?
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
else
@room.messages.with_presentation.last_page
@room.messages.with_creator.last_page
end
end
@@ -42,12 +42,6 @@ class Rooms::ClosedsController < RoomsController
@room = @room.becomes!(Rooms::Closed)
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
User.where(id: grantee_ids)
end
+1 -6
View File
@@ -29,13 +29,8 @@ class Rooms::DirectsController < RoomsController
end
end
# All users in a direct room can administer it. Only direct rooms, though: this
# relaxation is why room_scope below has to keep every other type out of reach.
# All users in a direct room can administer it
def ensure_can_administer
true
end
def room_scope
Current.user.rooms.directs
end
end
@@ -40,12 +40,6 @@ class Rooms::OpensController < RoomsController
@room = @room.becomes!(Rooms::Open)
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)
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
end
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
before_action :set_last_updated_at
def show
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
end
private
+4 -10
View File
@@ -12,7 +12,7 @@ class RoomsController < ApplicationController
end
def destroy
@room.destroy_later
@room.destroy
broadcast_remove_room
redirect_to root_url
@@ -20,19 +20,13 @@ class RoomsController < ApplicationController
private
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
else
redirect_to root_url, alert: "Room not found or inaccessible"
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
head :forbidden unless Current.user.can_administer?(@room)
end
@@ -44,9 +38,9 @@ class RoomsController < ApplicationController
end
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)
else
@messages = messages.last_page
+1 -1
View File
@@ -20,7 +20,7 @@ class SearchesController < ApplicationController
private
def set_messages
if query.present?
@messages = Message.search_reachable(Current.user, query)
@messages = Current.user.reachable_messages.search(query).last(100)
else
@messages = Message.none
end
@@ -11,7 +11,5 @@ class Sessions::TransfersController < ApplicationController
else
head :bad_request
end
rescue ActiveRecord::RecordInvalid
head :bad_request
end
end
-2
View File
@@ -14,8 +14,6 @@ class SessionsController < ApplicationController
else
render_rejection :unauthorized
end
rescue ActiveRecord::RecordInvalid
render_rejection :unauthorized
end
def destroy
+3 -14
View File
@@ -1,9 +1,8 @@
class UnfurlLinksController < ApplicationController
# The pasted link decides which hosts an unfurl looks up and fetches from, and how quickly they answer.
DEADLINE = 10.seconds
def create
if opengraph = unfurl(url_param)
opengraph = Opengraph::Metadata.from_url(url_param)
if opengraph.valid?
render json: opengraph
else
head :no_content
@@ -14,14 +13,4 @@ class UnfurlLinksController < ApplicationController
def url_param
params.require(:url)
end
# One deadline for everything the link leads to: DNS lookups, redirects, the page, the image check.
def unfurl(url)
Timeout.timeout(DEADLINE) do
Opengraph::Metadata.from_url(url).then { |opengraph| opengraph if opengraph.valid? }
end
rescue Timeout::Error
Rails.logger.warn "Gave up unfurling #{url} after #{DEADLINE.inspect}"
nil
end
end
+2 -38
View File
@@ -1,26 +1,14 @@
class Users::ProfilesController < ApplicationController
before_action :set_user
# Limited like signing in, so the current password can't be guessed here instead.
rate_limit to: 10, within: 3.minutes, only: :update, if: :answering_password_challenge?,
with: -> { redirect_to user_profile_url, alert: "Too many attempts. Try again in a few minutes." }
def show
@direct_memberships, @shared_memberships =
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
end
def update
if @user.update user_params
start_new_session_for @user if @user.saved_change_to_password_digest?
redirect_to user_profile_url, notice: update_notice
else
redirect_to user_profile_url, alert: update_alert
end
rescue ActiveRecord::RecordInvalid
# The password changed again before this browser's new session could start, and that change signed it out.
redirect_to new_session_url
@user.update user_params
redirect_to user_profile_url, notice: update_notice
end
private
@@ -28,35 +16,11 @@ class Users::ProfilesController < ApplicationController
@user = Current.user
end
# Always with the challenge, so the model checks it whenever the save would change the password, however the
# request spelled the new one.
def user_params
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
.merge(password_challenge: password_challenge)
end
# Only a string is a challenge, so the rate limit counts exactly what the model checks.
def password_challenge
challenge = params.dig(:user, :password_challenge)
challenge.is_a?(String) ? challenge : ""
end
# Guessing the current password means sending one, so this is what the rate limit counts.
def answering_password_challenge?
!password_challenge.empty?
end
def update_notice
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
end
def update_alert
if !@user.errors.include?(:password_challenge)
@user.errors.full_messages.to_sentence
elsif password_challenge.empty?
"Enter your current password to change it."
else
"That isn't your current password."
end
end
end
@@ -5,15 +5,13 @@ class Users::PushSubscriptionsController < ApplicationController
end
def create
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params)
subscription.user_agent = request.user_agent if subscription.new_record?
# Existing endpoints must pass current validations too
if subscription.valid?
head register_while_signed_in(subscription) ? :ok : :unauthorized
if subscription = @push_subscriptions.find_by(push_subscription_params)
subscription.touch
else
head :unprocessable_entity
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
end
head :ok
end
def destroy
@@ -26,24 +24,6 @@ class Users::PushSubscriptionsController < ApplicationController
@push_subscriptions = Current.user.push_subscriptions
end
# A password change ends every session and subscription. A request that authenticated just before it mustn't
# put one back, so the session is checked under the same write lock as the save, and the endpoint is looked up
# again there, past the query cache, so two tabs posting at once register it once. Validation, which resolves
# the endpoint's address, runs before taking the lock.
def register_while_signed_in(subscription)
Push::Subscription.uncached do
Push::Subscription.transaction do
if Session.exists?(Current.session.id)
if existing = @push_subscriptions.find_by(push_subscription_params)
existing.touch
else
subscription.save!(validate: false)
end
end
end
end
end
def push_subscription_params
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
end
+7 -3
View File
@@ -2,14 +2,18 @@ class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
def show
visible_memberships = Current.user.memberships.visible
@direct_memberships = visible_memberships.with_direct_rooms
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms
all_memberships = Current.user.memberships.visible.with_ordered_room
@direct_memberships = extract_direct_memberships(all_memberships)
@other_memberships = all_memberships.without(@direct_memberships)
@direct_placeholder_users = find_direct_placeholder_users
end
private
def extract_direct_memberships(all_memberships)
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
end
def find_direct_placeholder_users
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
+1 -11
View File
@@ -1,13 +1,3 @@
module ContentFilters
# Formatting the rich text editor produces that Rails' sanitizers don't
# 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)
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
end
@@ -1,22 +1,18 @@
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
TWITTER_DOMAINS = %w[ x.com twitter.com ]
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
def applicable?
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
end
def apply
if trix_body?
remove_link_text_from_wrapping_div
else
remove_link_paragraphs
end
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
end
private
TWITTER_DOMAINS = %w[ x.com twitter.com ]
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
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
def unfurled_links
@@ -39,20 +35,4 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
def twitter_url?(url)
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
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
@@ -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
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?
true
end
@@ -12,6 +8,9 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter
end
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
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
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
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
# Previews are made when the message is posted, within limits. One that wasn't, because the file was too large or
# the preview failed, isn't attempted again from here: its URL would make it on every view.
def render_preview
case
when message.attachment.video?
if message.attachment.video?
video_preview_tag
when message.attachment.representation(:thumb).processed?
lightboxed_image_preview_tag
else
render_link
lightboxed_image_preview_tag
end
end
@@ -35,17 +30,11 @@ class Messages::AttachmentPresentation
inline_media_dimension_constraints(width, height) do
tag.video \
src: rails_blob_path(message.attachment), poster: video_poster_url,
src: rails_blob_path(message.attachment), poster: url_for(message.attachment.preview(format: :webp, resize_to_limit: [ Message::THUMBNAIL_MAX_WIDTH, Message::THUMBNAIL_MAX_HEIGHT ])),
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
end
end
# A preview is processed once the frame is drawn, but the poster is a variant of that frame, which can fail on its own.
def video_poster_url
poster = message.attachment.preview(:poster)
url_for(poster) if poster.processed? && poster.image.variant(poster.variation).processed?
end
def lightboxed_image_preview_tag
width, height = preview_dimensions
+1 -28
View File
@@ -1,12 +1,4 @@
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, &)
tag.div id: "message-area", class: "message-area", contents: true, data: {
controller: "messages presence drop-target",
@@ -39,7 +31,6 @@ module MessagesHelper
controller: "reply",
user_id: message.creator_id,
message_id: message.id,
client_message_id: message.client_message_id,
message_timestamp: message_timestamp_milliseconds,
message_updated_at: message.updated_at.to_fs(:epoch),
sort_value: message_timestamp_milliseconds,
@@ -66,7 +57,7 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
@@ -76,24 +67,6 @@ module MessagesHelper
end
private
# auto_link finds URLs and email addresses with regular expressions, which
# can't tell an attribute value from text. Hand it one text node at a time,
# escaped as the serializer writes text, so a link can only land between tags.
def auto_link_text(html)
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
if node.text.match?(LINKABLE_TEXT)
text = node.to_html
linked = auto_link(text, html: { target: "_blank" })
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
end
end
# A <pre> starting with a newline needs an extra one written out to survive the next parse
fragment.to_html(preserve_newline: true).html_safe
end
def messages_actions
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
end
+5 -27
View File
@@ -1,33 +1,11 @@
module RichTextHelper
def rich_text_data_actions
# submitByKeyboard runs in the capture phase so it can submit on Enter
# before the editor turns the keystroke into a newline
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
end
default_actions =
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
def mention_prompt_tag(room)
tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id),
"remote-filtering": true, "empty-results": "No matches"
end
autocomplete_actions =
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
# The editor keeps an attachment's content as it finds it, so every
# 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)
[ default_actions, autocomplete_actions ].join(" ")
end
end
+3 -3
View File
@@ -73,12 +73,12 @@ module RoomsHelper
def composer_data_actions
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
attachment_actions =
"lexxy:file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
trix_attachment_actions =
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
remaining_actions =
"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
-1
View File
@@ -2,7 +2,6 @@ module TranslationsHelper
TRANSLATIONS = {
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
current_password: { "🇺🇸": "Enter your current password", "🇪🇸": "Introduce tu contraseña actual", "🇫🇷": "Saisissez votre mot de passe actuel", "🇮🇳": "अपना वर्तमान पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr aktuelles Passwort ein", "🇧🇷": "Insira sua senha atual", "🇯🇵": "現在のパスワードを入力してください" },
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
@@ -1,7 +0,0 @@
module Users::PushSubscriptionsHelper
# Names the session without revealing its token, so the browser can tell when it's in a new one. The creation
# time keeps an id reused by a new database from looking like the same session.
def push_subscription_session_marker
Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session
end
end
+2
View File
@@ -1,4 +1,6 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "trix"
import "@rails/actiontext"
import "initializers"
import "controllers"
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus"
import FileUploader from "models/file_uploader"
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
import { escapeHTML } from "helpers/string_helpers"
import { escapeHTML } from "helpers/dom_helpers"
export default class extends Controller {
static classes = ["toolbar"]
@@ -12,21 +12,11 @@ export default class extends Controller {
#files = []
connect() {
this.#restoreDraft()
if (!this.#usingTouchDevice) {
onNextEventLoopTick(() => this.textTarget.focus())
}
}
saveDraft() {
if (this.textTarget.isBlank) {
localStorage.removeItem(this.#draftKey)
} else {
localStorage.setItem(this.#draftKey, this.textTarget.value)
}
}
submit(event) {
event.preventDefault()
@@ -54,20 +44,21 @@ export default class extends Controller {
}
replaceMessageContent(content) {
this.textTarget.value = content
this.textTarget.focus()
this.textTarget.selection.placeCursorAtTheEnd()
const editor = this.textTarget.editor
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) {
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
const metaEnter = event.metaKey || event.ctrlKey
const plainEnter = !event.shiftKey && !event.isComposing
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
event.stopPropagation()
this.submit(event)
}
}
@@ -117,19 +108,6 @@ export default class extends Controller {
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() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}
@@ -148,7 +126,7 @@ export default class extends Controller {
}
#validInput() {
return !this.textTarget.isBlank
return this.textTarget.textContent.trim().length > 0
}
async #submitFiles() {
@@ -181,7 +159,6 @@ export default class extends Controller {
#reset() {
this.textTarget.value = ""
localStorage.removeItem(this.#draftKey)
}
#updateFileList() {
@@ -46,7 +46,6 @@ export default class extends Controller {
}
messageTargetConnected(target) {
this.#reconcilePendingMessage(target)
this.#formatter.format(target, ThreadStyle.thread)
}
@@ -90,7 +89,7 @@ export default class extends Controller {
}
async editMyLastMessage() {
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
if (editorEmpty && this.#paginator.upToDate) {
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
@@ -126,25 +125,6 @@ export default class extends Controller {
// Internal
// The composer inserts a pending message client-side using the client_message_id
// as its DOM id. The server broadcast now renders the real message with a DOM id
// derived from the record's primary key, so it no longer replaces the pending
// element by id. Reconcile by matching the server message's client_message_id and
// dropping our own pending placeholder. Only elements flagged data-pending-message
// (client-side placeholders, never broadcast) are removed, so a message another
// member posted reusing this client_message_id can never displace a real one.
#reconcilePendingMessage(target) {
const clientMessageId = target.dataset.clientMessageId
if (target.dataset.messageId && clientMessageId) {
const selector = `[data-pending-message][data-client-message-id="${CSS.escape(clientMessageId)}"]`
this.messagesTarget.querySelectorAll(selector).forEach((pending) => {
if (pending !== target) pending.remove()
})
}
}
async #ensureUpToDate() {
if (!this.#paginator.upToDate) {
await this.#paginator.resetToLastPage()
@@ -106,8 +106,7 @@ export default class extends Controller {
async #syncPushSubscription(subscription) {
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
if (response.unprocessableEntity) subscription.unsubscribe()
if (!response.ok) subscription.unsubscribe()
}
async #requestPermissionAndSubscribe(registration) {
@@ -1,32 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
const SYNCED_SESSION_KEY = "push-subscription-synced-session"
// A password change removes the account's push subscriptions, the one this browser holds included. Post it again
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
// browser's own subscription alone: the next session tries again.
export default class extends Controller {
static values = { url: String, session: String }
async connect() {
if (this.#allowed && this.sessionValue && localStorage.getItem(SYNCED_SESSION_KEY) !== this.sessionValue) {
const registration = await navigator.serviceWorker.getRegistration(window.location.origin)
const subscription = await registration?.pushManager?.getSubscription()
if (Notification.permission === "granted" && subscription) {
await post(this.urlValue, { body: this.#payload(subscription), responseKind: "turbo-stream" })
localStorage.setItem(SYNCED_SESSION_KEY, this.sessionValue)
}
}
}
get #allowed() {
return navigator.serviceWorker && window.Notification
}
#payload(subscription) {
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
}
}
@@ -16,7 +16,7 @@ export default class extends Controller {
})
}
#read = ({ room_id, at }) => {
this.dispatch("read", { detail: { roomId: room_id, at } })
#read = ({ room_id }) => {
this.dispatch("read", { detail: { roomId: room_id } })
}
}
@@ -11,7 +11,7 @@ export default class extends Controller {
}
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)
}
@@ -23,12 +23,12 @@ export default class extends Controller {
}
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
}
#stripMentionAttachments(node) {
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
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" ]
#disconnected = true
#connection = 0
#discardDirectEditor = false
#readAt = new Map()
async connect() {
this.element.addEventListener("turbo:click", this.#directNavigation)
this.element.addEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
connected: this.#channelConnected.bind(this),
disconnected: this.#channelDisconnected.bind(this),
@@ -22,11 +17,7 @@ export default class extends Controller {
}
disconnect() {
this.element.removeEventListener("turbo:click", this.#directNavigation)
this.element.removeEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
this.#discardDirectEditor = false
ignoringBriefDisconnects(this.element, () => {
this.#channelDisconnected()
this.channel?.unsubscribe()
this.channel = null
})
@@ -36,58 +27,19 @@ export default class extends Controller {
this.read({ detail: { roomId: Current.room.id } })
}
read({ detail: { roomId, at } }) {
read({ detail: { roomId } }) {
const room = this.#findRoomTarget(roomId)
if (at) {
this.#readAt.set(Number(roomId), Math.max(Number(at), this.#readAt.get(Number(roomId)) ?? 0))
}
if (room) {
room.classList.remove(this.unreadClass)
this.dispatch("read", { detail: { targetId: roomId } })
}
}
// Preserve the live picker through background refreshes, but allow deliberate navigation.
#directNavigation = event => {
if (event.target.closest("a")?.dataset.turboFrame === this.element.id) {
this.#discardDirectEditor = true
}
}
#preserveDirectEditor = event => {
if (event.target !== this.element) return
const discard = this.#discardDirectEditor
this.#discardDirectEditor = false
if (discard) return
const current = this.element.querySelector("#direct_rooms_control")
const incoming = event.detail.newFrame.querySelector("#direct_rooms_control")
if (!current?.querySelector('[data-autocomplete-target="input"]') || !incoming) return
// Turbo's Bardo moves the actual element, keeping input, selections and controllers.
const controls = [ current, incoming ].map(element => [ element, element.hasAttribute("data-turbo-permanent") ])
for (const [ element ] of controls) element.setAttribute("data-turbo-permanent", "")
const render = event.detail.render
event.detail.render = (...args) => {
try {
return render(...args)
} finally {
for (const [ element, permanent ] of controls) element.toggleAttribute("data-turbo-permanent", permanent)
}
}
}
async #channelConnected() {
#channelConnected() {
if (this.#disconnected) {
this.#disconnected = false
const connection = ++this.#connection
// Reloading an unfinished frame aborts its response body reader.
await this.element.loaded
if (this.element.isConnected && !this.#disconnected && connection === this.#connection) {
this.element.reload()
}
this.element.reload()
}
}
@@ -95,11 +47,11 @@ export default class extends Controller {
this.#disconnected = true
}
#unread({ roomId, at }) {
#unread({ roomId }) {
const unreadRoom = this.#findRoomTarget(roomId)
if (unreadRoom) {
if (Current.room.id != roomId && !this.#readSince(roomId, at)) {
if (Current.room.id != roomId) {
unreadRoom.classList.add(this.unreadClass)
}
@@ -107,12 +59,6 @@ export default class extends Controller {
}
}
// Notices fan out one member at a time, so one can arrive after the member has already
// read the room in another tab. It still reorders the room, but doesn't mark it unread.
#readSince(roomId, at) {
return Number(at) <= this.#readAt.get(Number(roomId))
}
#findRoomTarget(roomId) {
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
}
@@ -1,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
}
export function escapeHTML(html) {
const div = document.createElement("div")
div.textContent = html
return div.innerHTML
}
export function parseHTMLFragment(html) {
const template = document.createElement("template")
template.innerHTML = html
-6
View File
@@ -1,5 +1,3 @@
const HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }
export function truncateString(string, length, omission = "…") {
if (string.length <= length) {
return string
@@ -7,7 +5,3 @@ export function truncateString(string, 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 CampfireRichTextExtension from "lib/rich_text/campfire_extension"
import Unfurler from "lib/rich_text/unfurl/unfurler"
Lexxy.configure({
global: {
// Keep the content type of mention attachments (application/vnd.campfire.mention)
// that Campfire has used since its Trix days
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 },
// Support a `cite` block for attribution links
Trix.config.blockAttributes.cite = {
tagName: "cite",
inheritable: false,
}
// Trix offered a single heading level, rendered as h1
headings: [ "h1" ]
}
})
const unfurler = new Unfurler()
unfurler.install()
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
#fetchAutocompletables(url) {
if (url) {
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
return fetch(url, { as: "json" }).then(response => response.json())
} else {
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,
messageTimestamp: Math.floor(now.getTime()),
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) {
return this.#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) {
if (this.#isRichText(node)) {
return node.toString().trim()
} else {
return node
}
#stripWrapperElement(node) {
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
}
#isRichText(node) {
return typeof(node) != "string"
}
#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) {
this.#normalizeLineBreaks(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"))
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
}
#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 -2
View File
@@ -9,8 +9,7 @@ class Membership < ApplicationRecord
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
scope :with_direct_rooms, -> { includes(:room).joins(:room).merge(Room.directs).order(rooms: { updated_at: :desc }) }
scope :without_direct_rooms, -> { joins(:room).merge(Room.without_directs) }
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
scope :visible, -> { where.not(involvement: :invisible) }
scope :unread, -> { where.not(unread_at: nil) }
+7 -3
View File
@@ -14,16 +14,20 @@ class Message < ApplicationRecord
scope :ordered, -> { order(:created_at) }
scope :with_creator, -> { preload(creator: :avatar_attachment) }
scope :with_attachment_details, -> {
with_rich_text_body_and_embeds.with_attached_attachment
with_rich_text_body_and_embeds
with_attached_attachment
.includes(attachment_blob: :variant_records)
}
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
scope :with_boosts, -> { includes(boosts: :booster) }
def plain_text_body
body.to_plain_text.presence || attachment&.filename&.to_s || ""
end
def to_key
[ client_message_id ]
end
def content_type
case
when attachment? then "attachment"
+1 -19
View File
@@ -4,14 +4,9 @@ module Message::Attachment
THUMBNAIL_MAX_WIDTH = 1200
THUMBNAIL_MAX_HEIGHT = 800
# Decoding a picture, or a video's frame, costs in proportion to its pixels, however small the file. The largest
# phone photos have 200 million.
THUMBNAIL_MAX_PIXELS = 250_000_000
included do
has_one_attached :attachment do |attachable|
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
attachable.variant :poster, format: :webp, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
end
end
@@ -35,25 +30,12 @@ module Message::Attachment
attachment&.analyze
end
# A file that ffmpeg or libvips can't decode is still the message: post it without a preview.
def process_attachment_thumbnail
return if too_many_pixels_to_preview?
case
when attachment.video?
attachment.preview(:poster).processed
attachment.preview(format: :webp).processed
when attachment.representable?
attachment.representation(:thumb).processed
end
rescue ActiveStorage::PreviewError, Vips::Error => error
Rails.logger.warn "Posted #{attachment.filename} without a preview: #{error.class}: #{error.message.lines.first&.chomp}"
end
# Without a size, the analyzer couldn't read the file's header, and the previewer wouldn't either.
def too_many_pixels_to_preview?
if attachment.image? || attachment.video?
width, height = attachment.metadata.values_at(:width, :height)
width.nil? || height.nil? || width * height > THUMBNAIL_MAX_PIXELS
end
end
end
+1 -20
View File
@@ -1,29 +1,10 @@
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
broadcast_unread_room_later
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end
def broadcast_remove
broadcast_remove_to room, :messages
end
# Fanned out to the room's members rather than published on one global stream, so
# that the timing of activity in a room only reaches people who are in it. Of those,
# only the ones who still have it unread or are in it now: by the time this runs,
# someone may have opened the room and moved on, and would see it marked unread again.
def broadcast_unread_room
payload = ActiveSupport::JSON.encode(roomId: room.id, at: created_at.to_f)
room.memberships.unread.or(room.memberships.connected).pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
end
end
private
# The fanout is one publish per member, which in a big room takes far longer than the
# rest of posting a message, so it runs in a job rather than while the poster waits.
def broadcast_unread_room_later
Message::BroadcastUnreadRoomJob.perform_later(self)
end
end
+5 -34
View File
@@ -3,31 +3,9 @@ module Message::Pagination
PAGE_SIZE = 40
# Expose Rails' collection-preloading hook while preserving Array pagination and
# validators. The renderer passes only cache misses to preload_associations.
class Page < Array
def self.load(relation, direction, size)
new(relation.skip_preloading!.public_send(direction, size), relation)
end
def initialize(records, relation)
super(records)
@relation = relation
end
def loaded?
true
end
def preload_associations(records)
@relation.preload_associations(records)
end
end
included do
# Keep presentation data lazy until the collection cache knows which messages missed.
scope :last_page, -> { last_page_of(PAGE_SIZE) }
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
scope :last_page, -> { ordered.last(PAGE_SIZE) }
scope :first_page, -> { ordered.first(PAGE_SIZE) }
scope :before, ->(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_created_since, ->(time) { where("created_at > ?", time).first_page }
# Sorting on +created_at, which no index covers, has SQLite find the few messages updated since
# through (room_id, updated_at) and sort just those, instead of walking the whole room by
# (room_id, created_at) looking for them.
scope :page_updated_since, ->(time) { Page.load(where("updated_at > ?", time).reorder(Arel.sql("+messages.created_at")), :last, PAGE_SIZE) }
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
end
class_methods do
def last_page_of(size)
Page.load(ordered, :last, size)
end
def page_around(message)
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
page_before(message) + [ message ] + page_after(message)
end
def paged?
offset(PAGE_SIZE).exists?
count > PAGE_SIZE
end
end
end
+2 -43
View File
@@ -3,45 +3,10 @@ module Message::Searchable
included do
after_create_commit :create_in_index
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
after_update_commit :update_in_index, if: :indexed_text_changed?
after_update_commit :update_in_index
after_destroy_commit :remove_from_index
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
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
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
end
private
@@ -49,12 +14,6 @@ module Message::Searchable
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
end
# Boosts touch their message too: rewrite the index only when the body was saved or the attachment replaced.
# Active Storage clears attachment_changes in its own after_commit, before this one runs.
def indexed_text_changed?
association(:rich_text_body).target&.saved_change_to_body? || @attachment_replaced
end
def update_in_index
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
end
+1 -12
View File
@@ -5,7 +5,6 @@ class Opengraph::Fetch
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
MAX_BODY_SIZE = 5.megabytes
MAX_REDIRECTS = 10
TIMEOUT = 7.seconds # to connect (TLS included), and for each read and write, as Webhook does
class TooManyRedirectsError < StandardError; end
class RedirectDeniedError < StandardError; end
@@ -23,14 +22,9 @@ class Opengraph::Fetch
end
private
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
# would no longer go to the checked IP.
#
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
def request(url, request_class, ip:)
MAX_REDIRECTS.times do
Net::HTTP.start(url.host, url.port, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") do |http|
http.request request_class.new(url) do |response|
if response.is_a?(Net::HTTPRedirection)
url, ip = resolve_redirect(response["location"])
@@ -44,11 +38,6 @@ class Opengraph::Fetch
raise TooManyRedirectsError
end
# Without max_retries: 0, Net::HTTP sends a GET or HEAD that timed out a second time.
def timeouts
{ open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT, max_retries: 0 }
end
def resolve_redirect(location)
url = URI.parse(location)
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
+2 -56
View File
@@ -1,61 +1,7 @@
require "restricted_http/private_network_guard"
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
validates :endpoint, presence: true
validate :validate_endpoint_url
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)
def notification(**params)
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
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
-39
View File
@@ -22,8 +22,6 @@ class Room < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
validate :direct_rooms_keep_their_type, on: :update
scope :opens, -> { where(type: "Rooms::Open") }
scope :closeds, -> { where(type: "Rooms::Closed") }
scope :directs, -> { where(type: "Rooms::Direct") }
@@ -45,34 +43,6 @@ class Room < ApplicationRecord
end
end
# Takes the room away from its members at once, and leaves its messages to Room::DestroyJob. Destroying them in
# the room's own transaction held the database's write lock, and stopped every other write, until the last one.
# An open room is closed first, so that no one who joins the account before the job runs is let in.
def destroy_later
room = open? ? becomes!(Rooms::Closed) : self
former_member_ids = transaction do
room.save!
room.memberships.pluck(:user_id).tap { room.memberships.delete_all }
end
Room::DestroyJob.perform_later(room, former_member_ids)
end
# Deleting the memberships skips the reset that revoking one does, so the former members get it here, all at once,
# before the messages go: their connections stop receiving the room's streams. It's done in the job, not the
# request, because it costs a Redis round trip per member.
def reset_remote_connections_of(user_ids)
User.where(id: user_ids).find_each(&:reset_remote_connections)
end
# Each message is destroyed in its own transaction, so other writes get through in between. Any message posted
# meanwhile goes with the room.
def destroy_one_message_at_a_time
messages.find_each(&:destroy)
destroy
end
def receive(message)
unread_memberships(message)
push_later(message)
@@ -95,15 +65,6 @@ class Room < ApplicationRecord
end
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)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end
-3
View File
@@ -53,12 +53,9 @@ class Room::MessagePusher
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
end
# Banning keeps the user's subscriptions, so that unbanning brings their notifications back,
# but nobody who can't sign in should be sent what's said in their rooms meanwhile.
def relevant_subscriptions
Push::Subscription
.joins(user: :memberships)
.merge(User.active)
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
end
+5 -8
View File
@@ -7,15 +7,12 @@ class Rooms::Direct < Room
end
private
# Among the first user's rooms, the one whose members are exactly these users: as many
# memberships as users, and all of them theirs.
# FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
# which could be to store the membership id list as a hash on the room, and use that for lookup.
def find_for(users)
user_ids = users.pluck(:id).uniq
where(id: Membership.where(user_id: user_ids.first).select(:room_id))
.joins(:memberships).group(:id)
.having("COUNT(*) = :size AND COUNT(CASE WHEN memberships.user_id IN (:user_ids) THEN 1 END) = :size", size: user_ids.size, user_ids: user_ids)
.first
all.joins(:users).detect do |room|
Set.new(room.user_ids) == Set.new(users.pluck(:id))
end
end
end
+3 -12
View File
@@ -7,22 +7,13 @@ class Session < ApplicationRecord
before_create { self.last_active_at ||= Time.now }
validate :user_password_unchanged, on: :create
def self.start!(user_agent:, ip_address:)
create! user_agent: user_agent, ip_address: ip_address
end
def resume(user_agent:, ip_address:)
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
end
end
private
# A password change ends every session, so one can't start on a password checked before the change. Validated in
# the save's transaction, which holds the database's write lock from the start, so a change commits either before
# this check, and fails it, or after the insert, and ends this session with the others. Read past the query cache,
# and compared here rather than in the query, which logs its values.
def user_password_unchanged
unless User.uncached { User.where(id: user_id).pick(:password_digest) } == user.password_digest
errors.add :user, :invalid
end
end
end
+1 -38
View File
@@ -18,10 +18,8 @@ class User < ApplicationRecord
enum :status, %i[ active deactivated banned ], default: :active
has_secure_password validations: false
validate :password_challenge_matches, if: :will_save_change_to_password_digest?, unless: -> { password_challenge.nil? }
after_create_commit :grant_membership_to_open_rooms
after_update :sign_out_all_sessions, if: :saved_change_to_password_digest?
scope :ordered, -> { order("LOWER(name)") }
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
@@ -52,43 +50,8 @@ class User < ApplicationRecord
end
private
# The profile always sets password_challenge, and it's checked whenever the save would change the password; scripts
# that reset one don't set it.
def password_challenge_matches
unless password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password_challenge)
errors.add :password_challenge, :invalid
end
end
# A new password is how someone shuts out whoever else got into their account, so it ends every session, copies
# of the changing browser's cookie included, and every push subscription; browsers still signed in post theirs
# again. Connections close once that commits, registered on the transaction because a later save in it would
# replace the saved change an after_commit condition reads.
def sign_out_all_sessions
sessions.delete_all
push_subscriptions.delete_all
self.class.current_transaction.after_commit { disconnect_signed_out_sessions }
end
# The password is saved by now, so a realtime service that can't be reached mustn't fail the change.
def disconnect_signed_out_sessions
reset_remote_connections
rescue => error
Rails.logger.warn "Could not disconnect remote connections after a password change: #{error.class}"
end
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
# as insert_all did.
def grant_membership_to_open_rooms
now = Time.current
Membership.connection.execute Membership.sanitize_sql([ <<~SQL, id, now, now ])
insert into memberships (room_id, user_id, created_at, updated_at)
select id, ?, ?, ? from rooms where type = 'Rooms::Open'
on conflict do nothing
SQL
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
end
def deactived_email_address
-5
View File
@@ -1,11 +1,6 @@
module User::Bannable
extend ActiveSupport::Concern
included do
# Administrators still see banned users, so they can lift the ban.
scope :visible_to, ->(user) { user.can_administer? ? where(status: %i[ active banned ]) : active }
end
def ban
transaction do
create_bans_from_sessions
+1 -8
View File
@@ -1,18 +1,11 @@
module User::Mentionable
include ActionText::Attachable
MENTION_CONTENT_TYPE = "application/vnd.campfire.mention"
def attachable_content_type
MENTION_CONTENT_TYPE
end
def to_attachable_partial_path
"users/mention"
end
# How a mention appears inside the editor, matching the prompt's editor template.
def to_editor_content_attachment_partial_path
def to_trix_content_attachment_partial_path
"users/mention"
end
+2 -9
View File
@@ -3,20 +3,13 @@ module User::Transferable
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
included do
# Bound to the password, so changing it also ends sign-in links made before.
generates_token_for :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION do
password_salt&.last(10)
end
end
class_methods do
def find_by_transfer_id(id)
find_by_token_for(:transfer, id)
find_signed(id, purpose: :transfer)
end
end
def transfer_id
generate_token_for(:transfer)
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
end
end
+1 -1
View File
@@ -63,7 +63,7 @@ class Webhook < ApplicationRecord
end
def extract_attachment_from(response)
if response.code == "200" && response.content_type && mime_type = Mime::Type.lookup(response.content_type)
if response.content_type && mime_type = Mime::Type.lookup(response.content_type)
ActiveStorage::Blob.create_and_upload! \
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
end
+6 -5
View File
@@ -76,9 +76,10 @@
<% end %>
<label class="switch">
<%= tag.input type: "checkbox", class: "switch__input",
checked: Current.account.settings.restrict_room_creation_to_administrators?,
data: { action: "change->form#submit" } %>
<input type="checkbox"
class="switch__input"
<%= "checked" if Current.account.settings.restrict_room_creation_to_administrators? %>
data-action="change->form#submit">
<span class="switch__btn round"></span>
<span class="for-screen-reader">
Must be admin to create new rooms
@@ -101,11 +102,11 @@
<turbo-frame id="account_users">
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
<% if @administrators.any? && @page.records.any? %>
<% if @administrators.any? && @members.any? %>
<hr class="separator full-width" style="--border-style: solid">
<% end %>
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
</turbo-frame>
</menu>
@@ -1,11 +1,11 @@
<figure class="attachment attachment--content attachment--og">
<actiontext-opengraph-embed>
<div class="og-embed gap <%= "og-embed--twitter-avatar" if opengraph_embed.twitter_avatar? %>">
<div class="og-embed gap">
<div class="og-embed__content">
<div class="og-embed__title">
<%= link_to_if opengraph_embed.href.present?, truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
</div>
<div class="og-embed__description"><%= truncate(opengraph_embed.description.to_s, length: 560, omission: "…") %></div>
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %></div>
</div>
<% if opengraph_embed.url %>
<div class="og-embed__image">
@@ -1,13 +0,0 @@
<%# Files are posted as a message's own attachment, which gets its preview when it's posted; the composer adds none to
the rich text. One that's there anyway is shown by its name, with no preview: Action Text's partial links to a URL
that makes one on view, and nothing else in the app would make it. %>
<figure class="attachment attachment--file attachment--<%= blob.filename.extension %>">
<figcaption class="attachment__caption">
<% if caption = blob.try(:caption) %>
<%= caption %>
<% else %>
<span class="attachment__name"><%= blob.filename %></span>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<% end %>
</figcaption>
</figure>
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more