Compare commits

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

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

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

Require an authenticated session on the two write actions
(DirectUploadsController#create and DiskController#update) by including the
existing Authentication concern. Blob serving stays public —
DiskController#show keeps its auth skip, and the Blobs/Representations
controllers are untouched — so message attachments and the account logo
keep loading. Because these controllers live in ActiveStorage::Engine and
only see the engine's url helpers, also include the application route
helpers so the concern can redirect to new_session_url on failure (302,
write blocked).
2026-06-15 13:00:29 -07:00
147 changed files with 869 additions and 3675 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
+11 -11
View File
@@ -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@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.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@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -55,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
@@ -82,12 +82,12 @@ jobs:
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@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
@@ -112,12 +112,12 @@ jobs:
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@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
+10 -23
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 }}
@@ -67,20 +67,9 @@ jobs:
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
- name: Compute app version
id: version
shell: bash
run: |
set -eu
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- name: Extract Docker metadata (tags, labels) with arch suffix
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,15 +87,13 @@ 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
build-args: |
OCI_SOURCE=${{ env.SOURCE_URL }}
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
APP_VERSION=${{ steps.version.outputs.version }}
GIT_REVISION=${{ github.sha }}
platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -118,7 +105,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 +126,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 +146,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 +194,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
+1 -4
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
@@ -66,9 +66,6 @@ ENV HTTP_WRITE_TIMEOUT=300
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
COPY --from=build --chown=rails:rails /rails /rails
# Install ONCE backup/restore hooks
COPY --chmod=755 hooks /hooks
# Set version and revision
ARG APP_VERSION
ENV APP_VERSION=$APP_VERSION
+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
-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"
@@ -44,7 +41,6 @@ gem "rails_autolink"
gem "geared_pagination"
gem "jbuilder"
gem "net-http-persistent"
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
gem "kredis"
gem "platform_agent"
gem "thruster"
+29 -40
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
@@ -143,7 +137,7 @@ GEM
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (3.3.1)
brakeman (8.0.6)
brakeman (8.0.4)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -159,13 +153,13 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.7)
date (3.5.1)
crass (1.0.6)
date (3.5.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -174,7 +168,6 @@ GEM
erubi (1.13.1)
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)
@@ -198,7 +191,7 @@ GEM
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.21.2)
json (2.13.2)
jwt (3.2.0)
base64
kredis (1.8.0)
@@ -206,14 +199,12 @@ 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)
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
@@ -225,7 +216,7 @@ GEM
logger
mini_mime (1.1.5)
minitest (5.26.2)
mocha (3.1.0)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
mono_logger (1.1.2)
multi_json (1.17.0)
@@ -233,7 +224,7 @@ GEM
ruby2_keywords (~> 0.0.1)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.6.4.1)
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
@@ -243,15 +234,15 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
nokogiri (1.19.3-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)
@@ -287,8 +278,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails_autolink (1.1.8)
actionview (> 3.1)
@@ -355,7 +346,7 @@ GEM
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (3.4.0)
rubyzip (3.0.2)
securerandom (0.4.1)
selenium-webdriver (4.35.0)
base64 (~> 0.2)
@@ -376,20 +367,20 @@ 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.7.3-aarch64-linux-gnu)
sqlite3 (2.7.3-arm64-darwin)
sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
thor (1.4.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)
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)
timeout (0.4.4)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
@@ -398,15 +389,15 @@ 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)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.8.2)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -433,7 +424,6 @@ DEPENDENCIES
importmap-rails!
jbuilder
kredis
lexxy (~> 0.9.24)
mocha
net-http-persistent
ostruct
@@ -452,7 +442,6 @@ DEPENDENCIES
sentry-ruby
sqlite3
stimulus-rails
surfguard!
thruster
turbo-rails!
web-push
+34 -32
View File
@@ -11,49 +11,51 @@ expect, including:
- @mentions
- API, with support for bot integrations
## Running your own Campfire instance
## Deploying with Docker
Campfire's Docker image contains everything needed for a fully-functional,
single-machine deployment. This includes the web app, background jobs, caching,
file serving, and SSL. You can use our pre-built image at
`ghcr.io/basecamp/once-campfire:latest`, or build your own from this repo.
file serving, and SSL.
### Deploying with ONCE
To persist storage of the database and file attachments, map a volume to `/rails/storage`.
The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once).
It will guide you through the initial set up and then keep your instance up to date automatically.
To configure additional features, you can set the following environment variables:
If you don't already have `once` installed, run this on the machine you want to run Campfire on:
- `SSL_DOMAIN` - enable automatic SSL via Let's Encrypt for the given domain name
- `DISABLE_SSL` - alternatively, set `DISABLE_SSL` to serve over plain HTTP
- `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` - set these to a valid keypair to
allow sending Web Push notifications. You can generate a new keypair by running
`/script/admin/create-vapid-key`
- `SENTRY_DSN` - to enable error reporting to sentry in production, supply your
DSN here
```sh
curl https://get.once.com | sh
```
For example:
`once` will launch as soon as the install is finished.
docker build -t campfire .
Choose Campfire from the list of applications, follow the instructions, and ONCE will take care of the rest.
docker run \
--publish 80:80 --publish 443:443 \
--restart unless-stopped \
--volume campfire:/rails/storage \
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
--env TLS_DOMAIN=chat.example.com \
campfire
If you prefer the command line to the dashboard, you can deploy directly:
## Running in development
```sh
once deploy ghcr.io/basecamp/once-campfire --host chat.example.com
```
bin/setup
bin/rails server
### Deploying with Docker
## Worth Noting
If you'd rather run the Docker image yourself, you can read more about that in the [self-hosting guide](docs/self-hosting.md).
When you start Campfire for the first time, you’ll be guided through
creating an admin account.
The email address of this admin account will be shown on the login page
so that people who forget their password know who to contact for help.
(You can change this email later in the settings)
> [!TIP]
> When you start Campfire for the first time, you'll be guided through a wizard to create an admin account.
> The email address that you enter for the admin account will be visible on the sign-in page, it's there so
> 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.
## Development
You are welcome - and encouraged - to modify Campfire to your liking.
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
## Security
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
Campfire is single-tenant: any rooms designated "public" will be accessible by
all users in the system. To support entirely distinct groups of customers, you
would deploy multiple instances of the application.
-22
View File
@@ -1,22 +0,0 @@
# Security
Report vulnerabilities through our [HackerOne program](https://hackerone.com/basecamp) or at
https://github.com/basecamp/once-campfire/security. Note that Campfire is in scope for our security
program, but is not bounty eligible.
## Trust model
Campfire is self-hosted and single-tenant, so the administrator is the server operator,
with shell, network, and database access already. Anything requiring the administrator
role grants nothing they do not already have, and is not a vulnerability.
We do want reports of anything a **non-administrator** can reach, and of any credential or
network path held by the Campfire process but not by the operator's own shell.
## Intentional behavior
Bot webhook URLs are unrestricted: an administrator can point one at any address,
including internal ones, because operators legitimately wire bots to their own services.
Link unfurling is different because any member can trigger it by pasting a URL, so it
validates destinations through `RestrictedHTTP::PrivateNetworkGuard`. The difference is who
picks the destination.
+149 -147
View File
@@ -1,160 +1,165 @@
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
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;
}
@@ -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
-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
+8 -4
View File
@@ -8,8 +8,9 @@ class Accounts::LogosController < ApplicationController
if stale?(etag: Current.account)
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
if (logo_variant = Current.account&.logo_variant(logo_size))
send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
if Current.account&.logo&.attached?
logo = Current.account.logo.variant(logo_variant).processed
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
else
send_stock_icon
end
@@ -22,6 +23,9 @@ class Accounts::LogosController < ApplicationController
end
private
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
def send_png_file(path)
send_file path, content_type: "image/png", disposition: :inline
end
@@ -34,8 +38,8 @@ class Accounts::LogosController < ApplicationController
end
end
def logo_size
small_logo? ? :small : :large
def logo_variant
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
end
def small_logo?
@@ -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
@@ -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,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
@@ -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,10 +26,6 @@ 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
+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
+1 -5
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
@@ -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
+1 -7
View File
@@ -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
+4 -1
View File
@@ -9,7 +9,8 @@ class Users::AvatarsController < ApplicationController
if stale?(etag: @user)
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
if (avatar_variant = @user.avatar_variant)
if @user.avatar.attached?
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
send_webp_blob_file avatar_variant.key
elsif @user.bot?
render_default_bot
@@ -25,6 +26,8 @@ class Users::AvatarsController < ApplicationController
end
private
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
def send_webp_blob_file(key)
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
end
@@ -6,17 +6,12 @@ class Users::PushSubscriptionsController < ApplicationController
def create
if subscription = @push_subscriptions.find_by(push_subscription_params)
# Existing endpoints must pass current validations
if subscription.valid?
subscription.touch
head :ok
else
head :unprocessable_entity
end
subscription.touch
else
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
head subscription.persisted? ? :ok : :unprocessable_entity
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
end
head :ok
end
def destroy
+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)
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
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
+1 -7
View File
@@ -1,9 +1,4 @@
module MessagesHelper
# auto_link re-sanitizes with Rails' default safe list, which 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
def message_area_tag(room, &)
tag.div id: "message-area", class: "message-area", contents: true, data: {
controller: "messages presence drop-target",
@@ -62,8 +57,7 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
+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
+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() {
@@ -89,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()
@@ -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,7 +23,7 @@ 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
}
@@ -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
}
}
@@ -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()
@@ -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 -9
View File
@@ -1,14 +1,6 @@
class Account < ApplicationRecord
include Joinable
has_one_attached :logo do |attachable|
attachable.variant :large, resize_to_limit: [ 512, 512 ], format: :png
attachable.variant :small, resize_to_limit: [ 192, 192 ], format: :png
end
has_one_attached :logo
has_json :settings, restrict_room_creation_to_administrators: false
def logo_variant(size)
logo.variant(size).processed if logo.variable?
end
end
+1 -10
View File
@@ -1,19 +1,10 @@
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
broadcast_unread_room
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end
def broadcast_remove
broadcast_remove_to room, :messages
end
private
# Fanned out to the room's members rather than published on one global stream, so
# that the timing of activity in a room only reaches people who are in it.
def broadcast_unread_room
room.memberships.pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
end
end
end
+1 -55
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(**params)
# Defer DNS lookup to the delivery worker to prevent rebinding
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
WebPush::Notification.new(**params, badge: 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
-11
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") }
@@ -67,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
+1 -7
View File
@@ -2,9 +2,7 @@ module User::Avatar
extend ActiveSupport::Concern
included do
has_one_attached :avatar do |attachable|
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
end
has_one_attached :avatar
end
class_methods do
@@ -16,8 +14,4 @@ module User::Avatar
def avatar_token
signed_id(purpose: :avatar)
end
def avatar_variant
avatar.variant(:square).processed if avatar.variable?
end
end
+3 -5
View File
@@ -1,13 +1,11 @@
module User::Mentionable
include ActionText::Attachable
MENTION_CONTENT_TYPE = "application/vnd.campfire.mention"
def attachable_content_type
MENTION_CONTENT_TYPE
def to_attachable_partial_path
"users/mention"
end
def to_attachable_partial_path
def to_trix_content_attachment_partial_path
"users/mention"
end
-2
View File
@@ -24,8 +24,6 @@ class Webhook < ApplicationRecord
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
end
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
# and operators legitimately point bots at their own internal services.
def http
Net::HTTP.new(uri.host, uri.port).tap do |http|
http.use_ssl = (uri.scheme == "https")
@@ -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,11 +0,0 @@
<lexxy-prompt-item search="<%= user.name %>" sgid="<%= user.attachable_sgid %>">
<template type="menu">
<span class="autocomplete__item flex align-center gap unpad">
<%= avatar_tag user %>
<span class="autocompletable__name"><%= user.name %></span>
</span>
</template>
<template type="editor">
<%= render "users/mention", user: user %>
</template>
</lexxy-prompt-item>
@@ -1 +0,0 @@
<%= render partial: "autocompletable/users/prompt_item", collection: @page.records, as: :user %>
@@ -1,3 +1,3 @@
<div class="lexxy-content">
<div class="trix-content">
<%= yield -%>
</div>
+1 -2
View File
@@ -1,7 +1,6 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
<% cache [ message, "presentation-v3" ] do %>
<% cache message do %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
-18
View File
@@ -1,18 +0,0 @@
json.cache! message do
json.(message, :id)
json.created_at message.created_at.utc
json.body do
json.plain_text message.plain_text_body
json.html message.body.to_s
end
json.creator message.creator, partial: "users/user", as: :user
json.room do
json.id message.room_id
end
json.url room_message_url(message.room, message)
end
@@ -1,12 +0,0 @@
json.cache! boost do
json.(boost, :id, :content)
json.created_at boost.created_at.utc
json.booster boost.booster, partial: "users/user", as: :user
json.message do
json.id boost.message_id
json.url room_message_url(boost.message.room, boost.message)
end
end
@@ -1 +0,0 @@
json.partial! "messages/boosts/boost", boost: @boost
@@ -1 +0,0 @@
json.array! @messages, partial: "messages/message", as: :message
@@ -1 +0,0 @@
json.partial! "messages/message", message: @message
+6 -7
View File
@@ -14,18 +14,17 @@
<% else %>
<div class="composer--edit composer--rich-text">
<%= form_with model: @message, url: room_message_path(@room, @message), id: dom_id(@message, :form), data: {
controller: "form", action: "lexxy:file-accept->form#preventAttachment keydown.esc->form#cancel keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent" } do |form| %>
controller: "form", action: "trix-file-accept->form#preventAttachment keydown.esc->form#cancel keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent" } do |form| %>
<div class="full-width input input--actor min-width fill-white">
<%= form.rich_text_area :body,
value: editable_body(@message),
rows: 1,
class: "input lexxy-content",
class: "input",
aria: { multiline: "true", label: "Edit message" },
autofocus: true,
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
data: { action: rich_text_data_actions } do %>
<%= mention_prompt_tag @room %>
<% end %>
data: {
controller: "rich-autocomplete",
action: rich_text_data_actions,
rich_autocomplete_url_value: autocompletable_users_path(room_id: @room.id) } %>
</div>
<%= link_to "Close editor and discard changes", room_message_path(@room, @message), data: { form_target: "cancel" }, hidden: true %>
+1 -1
View File
@@ -17,7 +17,7 @@
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
<%= turbo_stream_from @room, :messages %>
<%= button_to_jump_to_newest_message %>
<% end %>
+6 -7
View File
@@ -19,16 +19,15 @@
<div class="flex flex-column flex-item-grow min-width gap">
<%= form.rich_text_area :body,
rows: 1,
class: "input lexxy-content",
class: "input",
style: "order: -1",
aria: { multiline: "true", label: "Write a message" },
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
data: {
controller: "unfurl",
action: "#{rich_text_data_actions} lexxy:change->composer#saveDraft lexxy:insert-link->unfurl#unfurl",
composer_target: "text" } do %>
<%= mention_prompt_tag room %>
<% end %>
controller: "rich-autocomplete",
action: rich_text_data_actions,
rich_autocomplete_url_value: autocompletable_users_path(room_id: room.id),
permitted_attachment_types: "application/vnd.actiontext.opengraph-embed",
composer_target: "text" } %>
</div>
<label class="btn btn--borderless txt-small flex-item-no-shrink composer__attachment-btn input--file">
+4 -4
View File
@@ -1,4 +1,4 @@
<%# A span rather than a div: mentions render inline inside the <p> paragraphs
the editor produces, and block elements would make the browser split the
paragraph when parsing %>
<span class="mention" sgid="<%= user.attachable_sgid %>"><%= avatar_tag user %> <%= user.name %></span>
<div class="mention" sgid="<%= user.attachable_sgid %>">
<%= avatar_tag user %>
<%= user.name %>
</div>
-5
View File
@@ -1,5 +0,0 @@
json.cache! user do
json.(user, :id, :name, :role)
json.avatar_url fresh_user_avatar_url(user)
end
+1 -1
View File
@@ -2,6 +2,6 @@
require "rubygems"
require "bundler/setup"
ARGV.unshift("--ensure-latest", "15")
ARGV.unshift("--ensure-latest")
load Gem.bin_path("brakeman", "brakeman")
+11 -31
View File
@@ -15,11 +15,9 @@ include SSHKit::DSL
ARCH = "linux/amd64,linux/arm64"
BUILDER_NAME = "campfire-builder"
GIT_SHA = `git rev-parse HEAD`.strip
MANUAL_NOTES = ARGV.delete("--notes")
VERSION = ARGV[0]
DOCKER_REGISTRY = "registry.once.com"
DOCKER_IMAGE_NAME = "#{DOCKER_REGISTRY}/campfire"
DOCKER_IMAGE_NAME = "registry.once.com/campfire"
DOCKER_LATEST_IMAGE_NAME = [ DOCKER_IMAGE_NAME, :latest ].join(":")
DOCKER_TAGGED_IMAGE_NAME = [ DOCKER_IMAGE_NAME, GIT_SHA ].join(":")
@@ -30,7 +28,7 @@ EXPORT_REMOTE_USER = "app"
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
abort "You must specify a version, e.g. `bin/release 1.0.0` (pass --notes to write release notes manually)" unless VERSION
abort "You must specify a version, e.g. `bin/release 1.0.0`" unless VERSION
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
if current_branch != "main"
@@ -48,18 +46,6 @@ def announcing(message)
yield
end
announcing "Checking #{DOCKER_REGISTRY} login..." do
unless system "docker", "manifest", "inspect", DOCKER_LATEST_IMAGE_NAME, out: File::NULL, err: File::NULL
abort <<~MESSAGE
Not logged in to #{DOCKER_REGISTRY}. Run:
docker login #{DOCKER_REGISTRY}
The credentials are in the "chatinabox" entry in 1Password.
MESSAGE
end
end
on(:local) do
builder_found = test :docker, :buildx, :inspect, BUILDER_NAME
@@ -71,25 +57,19 @@ on(:local) do
end
announcing "Creating GitHub release v#{VERSION}..." do
notes_file = "tmp/release_notes.md"
on(:local) do
if MANUAL_NOTES
notes_file = "tmp/release_notes.md"
execute :mkdir, "-p", "tmp"
execute :mkdir, "-p", "tmp"
File.write(notes_file, "# What's Changed\n\n- \n")
system ENV["EDITOR"] || "vim", notes_file
File.write(notes_file, "# What's Changed\n\n- \n")
system ENV["EDITOR"] || "vim", notes_file
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
abort "Failed to create release; check that the version doesn't already exist"
end
execute :rm, notes_file
else
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--generate-notes"
abort "Failed to create release; check that the version doesn't already exist"
end
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
abort "Failed to create release; check that the version doesn't already exist"
end
execute :rm, notes_file
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
default: &default
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379" } %>
url: redis://localhost:6379
development:
<<: *default
+1 -2
View File
@@ -1,6 +1,5 @@
require "active_support/core_ext/integer/time"
require "active_support/core_ext/numeric/bytes"
require_relative "../../lib/rails_ext/log_scrubbing_formatter"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
@@ -51,7 +50,7 @@ Rails.application.configure do
# Log to STDOUT by default
config.logger = ActiveSupport::Logger.new(STDOUT)
.tap { |logger| logger.formatter = LogScrubbingFormatter.new }
.tap { |logger| logger.formatter = ::Logger::Formatter.new }
.then { |logger| ActiveSupport::TaggedLogging.new(logger) }
# Prepend all log lines with the following tags.
+2 -1
View File
@@ -5,7 +5,8 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@hotwired/turbo-rails", to: "turbo.js"
pin "@rails/actioncable", to: "actioncable.esm.js"
pin "@rails/request.js", to: "@rails--request.js" # @0.0.8
pin "lexxy", to: "lexxy.js"
pin "trix", to: "trix.esm.min.js" # @2.1.19
pin "@rails/actiontext", to: "actiontext.js"
pin "highlight.js", to: "highlight.js/core.js"
pin_all_from "app/javascript/initializers", under: "initializers"
+26
View File
@@ -2,4 +2,30 @@ ActiveSupport.on_load(:active_storage_blob) do
ActiveStorage::DiskController.after_action only: :show do
response.set_header("Cache-Control", "max-age=3600, public")
end
# Gate the ActiveStorage write path behind app authentication. These endpoints
# ship unauthenticated by Rails default; Campfire never uses direct upload for
# legit attachments (those go through MessagesController#create, and Trix file
# drops are disabled in the composer). Requiring a session on the write actions
# blocks anonymous blob writes and disk-fill while leaving blob serving public.
#
# ActiveStorage controllers live in ActiveStorage::Engine, so they see the
# engine's url helpers, not the main app's. Include the application helpers
# first so Authentication#request_authentication can redirect to new_session_url.
ActiveStorage::DirectUploadsController.include Rails.application.routes.url_helpers
ActiveStorage::DirectUploadsController.include Authentication # only action: #create
ActiveStorage::DiskController.include Rails.application.routes.url_helpers
ActiveStorage::DiskController.include Authentication
# Blob serving (#show) stays public so signed-token attachment URLs keep
# resolving for unauthenticated and bot clients alike.
ActiveStorage::DiskController.allow_unauthenticated_access only: :show
ActiveStorage::DiskController.allow_bot_access only: :show
# Including Authentication re-adds protect_from_forgery, but Active Storage's
# direct-upload service PUT (#update) carries only signed service headers and
# no authenticity token. Re-exempt it from CSRF so authenticated uploads can
# still store bytes; the signed URL token and the session check remain.
ActiveStorage::DiskController.skip_forgery_protection only: :update
end
@@ -1,15 +0,0 @@
# Active Storage mounts its direct-upload write endpoints
# (POST /rails/active_storage/direct_uploads and the disk-service PUT) on
# framework controllers that inherit from ActiveStorage::BaseController, so
# they never pass through ApplicationController's require_authentication.
# Campfire uploads attachments through MessagesController instead and does not
# use direct uploads at all, leaving these endpoints reachable by anyone who
# can read the public login page. Require a valid Campfire session before an
# anonymous caller can allocate a Blob or persist bytes to disk.
Rails.application.config.to_prepare do
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
ActiveStorage::DiskController.include ActiveStorageAuthentication
ActiveStorage::DiskController.before_action :require_active_storage_authentication, only: :update
end
-1
View File
@@ -1 +0,0 @@
Resque.redis = ENV.fetch("REDIS_URL") { "redis://localhost:6379" }
@@ -1,3 +0,0 @@
Rails.application.config.to_prepare do
Turbo::StreamsChannel.prepend RoomStreamsAreAuthorized
end
+1 -1
View File
@@ -1,2 +1,2 @@
Rails.application.config.app_version = ENV["APP_VERSION"].presence || ENV["GIT_REVISION"].presence || "0"
Rails.application.config.app_version = ENV.fetch("APP_VERSION", "0")
Rails.application.config.git_revision = ENV["GIT_REVISION"]
-9
View File
@@ -1,9 +0,0 @@
# Disable unfuzzed libvips operations.
#
# To block loaders we need to call `Vips.block` after Rails and image_processing set their
# defaults. Force the order of operations by autoloading the file now.
ActiveStorage::Transformers::Vips
Vips.block_untrusted(true)
Vips.block("VipsForeignLoadOpenslide", true) # prevent sqlite segfault in forked parallel workers
Rails.application.config.active_storage.variable_content_types -=
%w[ image/bmp image/vnd.microsoft.icon image/vnd.adobe.photoshop ]
+1 -19
View File
@@ -17,25 +17,7 @@ end
module WebPush::PersistentRequest
def perform
if endpoint_ip = @options[:endpoint_ip]
# Pin the connection to the public IP resolved (and guarded) by
# Push::Subscription so delivery can't be rebound to a private address
# between resolution and connect. Bypasses the shared persistent pool,
# which would re-resolve the host itself.
#
# The explicit nil proxy address disables proxy discovery from
# http_proxy/https_proxy. An egress proxy would open the TCP connection
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
# pin the destination and the DNS-rebinding guarantee would be lost. This
# path is already committed to a direct connection (it bypasses the pool);
# push delivery to public vendor endpoints goes direct.
http = Net::HTTP.new(uri.host, uri.port, nil)
http.ipaddr = endpoint_ip
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
elsif @options[:connection]
if @options[:connection]
http = @options[:connection]
else
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
+1 -7
View File
@@ -62,13 +62,7 @@ Rails.application.routes.draw do
resources :rooms do
resources :messages
nested do
scope path: ":bot_key", as: :bot, defaults: { format: :json } do
resources :messages, controller: "messages/by_bots", only: %i[ index create update destroy ] do
resources :boosts, controller: "messages/boosts/by_bots", only: %i[ create destroy ]
end
end
end
post ":bot_key/messages", to: "messages/by_bots#create", as: :bot_messages
scope module: "rooms" do
resource :refresh, only: :show
-69
View File
@@ -1,69 +0,0 @@
## Development
### Setting up
First, get everything installed and configured with:
```sh
bin/setup
```
This installs the system packages Campfire needs (SQLite, ffmpeg), the right Ruby version (via [mise](https://mise.jdx.dev)), and the app's gems; prepares the database; and starts Redis (in a Docker container called `campfire-redis`, if it isn't already running locally).
If you want to start over at any point, run:
```sh
bin/setup --reset
```
### Running the server
Start the development server with:
```sh
bin/dev
```
You'll be able to access the app at http://localhost:3000.
On first run you'll be guided through creating your admin account, and you can sign in with that account from then on.
Note that Campfire needs Redis (for Action Cable, caching, and background jobs), so if you've restarted your machine or stopped the container, `docker start campfire-redis` will bring it back.
### Web Push notifications
Campfire uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables:
- `VAPID_PRIVATE_KEY`
- `VAPID_PUBLIC_KEY`
You can generate a fresh pair (along with a secret key base, which you can ignore in development) by running:
```sh
script/admin/generate-secrets
```
### Running tests
Run the unit tests with:
```sh
bin/rails test
```
And the browser-based system tests with:
```sh
bin/rails test:system
```
Before pushing your changes, you can run the full CI suite locally - style checks, security audits, and all the tests - with a single command:
```sh
bin/ci
```
### Contributing
You are welcome - and encouraged - to modify Campfire to your liking.
If you'd like to contribute your changes back, please read our [contributing guide](../CONTRIBUTING.md) first.
-213
View File
@@ -1,213 +0,0 @@
## Self-hosting Campfire
Campfire's Docker image contains everything needed for a fully-functional, single-machine deployment.
This includes the web app, background jobs, caching, file serving, and SSL.
> [!TIP]
> The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once), which handles installation, updates, and backups for you. See the [README](../README.md#deploying-with-once) for details. This guide covers running the Docker image by hand.
We recommend using `ghcr.io/basecamp/once-campfire:latest`, which always points to the most recent tagged release - the most stable and battle-tested version of Campfire.
We provide a tagged release for every major, minor and patch version of Campfire, so you can also pin your deployment to a specific version if you want to avoid unexpected changes. For example:
```bash
# exactly version 1.4.4
ghcr.io/basecamp/once-campfire:1.4.4
# any 1.4.x version
# e.g. 1.4.4 or 1.4.5
# the last number is usually changed for bug fixes
ghcr.io/basecamp/once-campfire:1.4
# any 1.x version
# e.g. 1.4.4 or 1.5.0
# the middle number is usually changed for changes to, or addition of, features
ghcr.io/basecamp/once-campfire:1
```
If you want to live on the bleeding edge, the `main` tag tracks the main release branch instead.
It changes with every merged pull request, so it's the newest - but least battle-tested - version of Campfire.
To run it you'll need three things:
1. a machine that runs Docker
2. a mounted volume (so that your database and file attachments are kept around between restarts)
3. some environment variables for configuration
If you'd rather build the image yourself from your own copy of the source, you can do that too:
```sh
docker build -t campfire .
```
### Mounting a storage volume
Campfire keeps all of its storage - the database and uploaded file attachments - inside the path `/rails/storage`.
By default Docker containers don't persist storage between runs, so you'll want to mount a persistent volume into that location.
The simplest way to do this is with the `--volume` flag with `docker run`. For example:
```sh
docker run --volume campfire:/rails/storage ghcr.io/basecamp/once-campfire:latest
```
That will create a named volume (called `campfire`) and mount it into the correct path.
Docker will manage where that volume is actually stored on your server.
You can also specify the data location yourself, mount a network drive, and more.
Check the Docker documentation to find out more about what's available.
### Configuring with environment variables
To configure your Campfire installation, you can use environment variables.
At a minimum you'll want to configure your secret key and your SSL domain.
#### Secrets
Campfire needs a few secret values that are specific to your instance:
- `SECRET_KEY_BASE` - the basis for cryptographic features like signed cookies. This should be a long, unguessable random string.
- `VAPID_PRIVATE_KEY`/`VAPID_PUBLIC_KEY` - a key pair used for sending Web Push notifications.
You can generate them by running:
```sh
docker run --rm ghcr.io/basecamp/once-campfire:latest script/admin/generate-secrets
```
It prints a fresh set of values ready to set as environment variables:
```
SECRET_KEY_BASE=...
VAPID_PRIVATE_KEY=...
VAPID_PUBLIC_KEY=...
```
Keep them safe and reuse the same values across restarts and upgrades - changing them later will invalidate sessions and push notification subscriptions.
#### SSL
If you want the Campfire container to handle its own SSL (HTTPS) automatically (via Let's Encrypt), you just need to specify the domain name that you're running it on.
You can do that with the `TLS_DOMAIN` environment variable.
> [!NOTE]
> If you're using SSL, you'll want to allow traffic on ports 80 and 443.
So if you were running on `chat.example.com` you could enable SSL like this:
```sh
docker run --publish 80:80 --publish 443:443 --env TLS_DOMAIN=chat.example.com ...
```
If you are terminating SSL in some other proxy in front of Campfire, or aren't using SSL at all (for example, if you want to run it locally on your laptop), then you should set `DISABLE_SSL=true` instead and just publish port 80:
```sh
docker run --publish 80:80 --env DISABLE_SSL=true ...
```
#### Error reporting (optional)
To enable error reporting to Sentry in production, supply your DSN in the `SENTRY_DSN` environment variable.
To disable Sentry initialization entirely, set `SKIP_TELEMETRY=true`.
### Example
Putting it all together, here's a complete `docker run` invocation:
```sh
docker run \
--name campfire \
--publish 80:80 --publish 443:443 \
--restart unless-stopped \
--volume campfire:/rails/storage \
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
--env TLS_DOMAIN=chat.example.com \
ghcr.io/basecamp/once-campfire:latest
```
And here's an equivalent `docker-compose.yml` that you could use to run Campfire via `docker compose up`:
```yaml
services:
web:
image: ghcr.io/basecamp/once-campfire:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- SECRET_KEY_BASE=abcdefabcdef
- TLS_DOMAIN=chat.example.com
- VAPID_PRIVATE_KEY=myvapidprivatekey
- VAPID_PUBLIC_KEY=myvapidpublickey
volumes:
- campfire:/rails/storage
volumes:
campfire:
```
### First run
When you start Campfire for the first time, you'll be guided through creating an admin account.
> [!TIP]
> The email address of this admin account will be shown on the login page so that people who forget their password know who to contact for help.
> (You can change this email later in the settings.)
Campfire is single-tenant: any rooms designated "public" will be accessible by all users in the system.
To support entirely distinct groups of customers, you would deploy multiple instances of the application.
### Upgrading
All of Campfire's state lives in the mounted volume, so upgrading is a matter of pulling a newer image and recreating the container:
```sh
docker pull ghcr.io/basecamp/once-campfire:latest
```
Any pending database migrations run automatically when the container boots.
### Backups
To back up your instance, back up the contents of the `/rails/storage` volume.
Because the SQLite database may be written to at any moment, you shouldn't copy its files directly while Campfire is running.
Instead, first run `script/admin/prepare-backup` inside the running container to produce a consistent snapshot of the database (it's written to `storage/backups/` inside the volume):
```sh
docker exec campfire script/admin/prepare-backup
```
(If you're using Docker Compose, replace `docker exec campfire` with `docker compose exec web`)
Then archive the whole storage volume to a file on the host:
```sh
docker run --rm \
--user root \
--volume campfire:/rails/storage \
--volume "$PWD":/backup \
ghcr.io/basecamp/once-campfire:latest \
tar czf "/backup/campfire-backup.tar.gz" -C /rails storage
```
This gives you a `campfire-backup.tar.gz` in your current directory containing the database snapshot and all uploaded files.
Copy it somewhere safe, ideally off the machine.
To restore, extract the archive back into a (stopped) instance's volume, and replace the live database with the snapshot:
```sh
docker run --rm \
--user root \
--volume campfire:/rails/storage \
--volume "$PWD":/backup \
ghcr.io/basecamp/once-campfire:latest \
bash -c "tar xzf /backup/campfire-backup.tar.gz -C /rails &&
cp /rails/storage/backups/production.sqlite3 /rails/storage/db/production.sqlite3 &&
rm -f /rails/storage/db/production.sqlite3-wal /rails/storage/db/production.sqlite3-shm &&
chown -R rails:rails /rails/storage"
```
Then start Campfire again.
-12
View File
@@ -1,12 +0,0 @@
#!/bin/bash
set -euo pipefail
db_name="${RAILS_ENV:-production}.sqlite3"
snapshot="/rails/storage/backups/$db_name"
db="/rails/storage/db/$db_name"
if [ -f "$snapshot" ]; then
mkdir -p "$(dirname "$db")"
cp "$snapshot" "$db"
rm -f "$db-wal" "$db-shm"
fi
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
exec /rails/script/admin/prepare-backup
-11
View File
@@ -1,11 +0,0 @@
# The rich text editor produces formatting that Rails' default Action Text
# sanitizer strips on render. The additions are declared once in
# ContentFilters, shared by every sanitization layer.
Rails.application.config.to_prepare do
defaults = Class.new.include(ActionText::ContentHelper).new
ActionText::ContentHelper.allowed_tags =
(ActionText::ContentHelper.allowed_tags || defaults.sanitizer_allowed_tags) | ContentFilters::EDITOR_FORMATTING_TAGS
ActionText::ContentHelper.allowed_attributes =
(ActionText::ContentHelper.allowed_attributes || defaults.sanitizer_allowed_attributes) | ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
end
+8 -72
View File
@@ -2,7 +2,6 @@ class ActionText::Attachment::OpengraphEmbed
include ActiveModel::Model
OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
class << self
def from_node(node)
@@ -15,85 +14,18 @@ class ActionText::Attachment::OpengraphEmbed
end
private
# Trix serialized the embed's details as attributes of the
# <action-text-attachment> node. Lexxy only serializes the sgid, content
# and content-type, so newer attachments carry the details in their
# content markup instead.
def attributes_from_node(node)
if node["filename"].present?
{
href: web_url(node["href"]),
url: web_url(node["url"]),
filename: node["filename"],
description: node["caption"]
}
else
attributes_from_content(node["content"].to_s)
end
end
def attributes_from_content(content)
fragment = Nokogiri::HTML.fragment(content)
title = fragment.at_css(".og-embed__title")
link = title&.at_css("a")
{
href: web_url(link&.[]("href")),
url: web_url(fragment.at_css(".og-embed__image img")&.[]("src")),
filename: (link || title)&.text&.strip,
description: fragment.at_css(".og-embed__description")&.text&.strip
href: node["href"],
url: node["url"],
filename: node["filename"],
description: node["caption"]
}
end
# A link preview points at what we unfurled: an absolute http or https URL
# on some other host. Drop anything else a message body asks for, so it
# can't aim the preview's link or its image at this Campfire and have every
# reader's browser fetch it with their session attached.
def web_url(value)
return if value.blank?
parsed = URI.parse(value)
value if parsed.is_a?(URI::HTTP) && elsewhere?(parsed.host)
rescue URI::InvalidURIError
nil
end
# "https:/rooms/1" parses as HTTPS with no host at all, and a browser
# resolves both that and our own hostname against the origin Campfire is
# served from. A percent-escape hides our hostname from this comparison
# while a browser still unescapes it back to us, so an escaped host is out
# too, and neither case is anything an unfurl could have produced.
def elsewhere?(host)
return false unless named_host?(host)
canonical_host(host) != canonical_host(Current.request_host.to_s)
end
# A preview names a page on the public internet, so its host is a domain
# name, written plainly. A bare address is not one, and a browser rewrites
# the many spellings of an address ("2130706433", "0x7f.0.0.1") into a
# single one before it fetches, which is a race a comparison here loses.
def named_host?(host)
host.present? && host.exclude?("%") && host.include?(".") && domain_ending?(host.split(".").last)
end
# What keeps a name from reading as an address is its last label, which is
# a word: never a number, and never the hexadecimal spelling of one.
def domain_ending?(label)
label.match?(/[a-z]/i) && !label.match?(/\A0x/i)
end
def canonical_host(host)
host.downcase.delete_suffix(".")
end
end
attr_accessor :href, :url, :filename, :description
def twitter_avatar?
url.to_s.start_with?(TWITTER_AVATAR_URL_PREFIX)
end
def attachable_content_type
OPENGRAPH_EMBED_CONTENT_TYPE
end
@@ -105,4 +37,8 @@ class ActionText::Attachment::OpengraphEmbed
def to_partial_path
"action_text/attachables/opengraph_embed"
end
def to_trix_content_attachment_partial_path
"action_text/attachables/opengraph_embed"
end
end
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more