Compare commits

..
Author SHA1 Message Date
Jeremy Daer d1376a97ee Serve Trix from the action_text-trix gem instead of a vendored copy
The importmap pinned a hand-vendored copy of Trix 2.1.19
(vendor/javascript/trix.esm.min.js) that dependency tooling can't see.
The action_text-trix gem already in the bundle ships the same 2.1.19
release as app/assets/javascripts/trix.js and adds it to the asset
precompile list, so pin against that instead and drop the vendored blob.
Trix updates now arrive through ordinary gem bumps, visible to
Dependabot and security advisories.

The gem's build is UMD rather than ESM, which makes no difference here:
application.js imports "trix" only for its side effect of registering
the trix-editor and trix-toolbar custom elements, and the UMD build does
that via its globalThis branch. No bindings are imported from the
module, and Trix styling remains app-owned in
app/assets/stylesheets/actiontext.css.
2026-08-10 20:58:41 -07:00
130 changed files with 748 additions and 3191 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 -10
View File
@@ -44,15 +44,15 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -80,7 +80,7 @@ jobs:
- name: Extract Docker metadata (tags, labels) with arch suffix
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -98,7 +98,7 @@ jobs:
- name: Build and push (${{ matrix.platform }})
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: Dockerfile
@@ -118,7 +118,7 @@ jobs:
- name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }}
@@ -139,10 +139,10 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Set up Docker Buildx (for imagetools)
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -159,7 +159,7 @@ jobs:
- name: Compute base tags (no suffix)
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -207,7 +207,7 @@ jobs:
done <<< "$tags"
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
- name: Cosign sign all tags (keyless OIDC)
shell: bash
+1 -1
View File
@@ -1 +1 @@
3.4.10
3.4.5
+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.5
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
+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"
+12 -23
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.5)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -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.20.0)
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)
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)
@@ -251,7 +242,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
openssl (4.0.2)
openssl (3.3.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.9.0)
@@ -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,10 +367,10 @@ GEM
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.6-x86_64-darwin)
sqlite3 (2.9.6-x86_64-linux-gnu)
sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.5-x86_64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
@@ -398,9 +389,9 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
web-push (3.1.0)
web-push (3.0.2)
jwt (~> 3.0)
openssl (>= 3.0)
openssl (~> 3.0)
webmock (3.25.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@@ -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
-15
View File
@@ -49,21 +49,6 @@ If you'd rather run the Docker image yourself, you can read more about that in t
> that people have someone to contact if they need help with their account. If that bothers you, put in any
> email address you want and create yourself a new admin account.
## Other implementations
Campfire also has implementations in Django, Laravel, Express, Elixir, Go and Rust:
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) |
|---|---:|---:|---:|---:|---:|---:|---:|
| Room page | 241 | 170 | 164 | 559 | 722 | 3,860 | 36,260 |
| Messages page | 413 | 196 | 175 | 777 | 1,053 | 5,573 | 40,872 |
| Sidebar | 552 | 615 | 715 | 4,125 | 1,275 | 19,753 | 34,672 |
| Search | 435 | 315 | 305 | 1,294 | 1,156 | 7,053 | 33,299 |
| Post a message | 273 | 154 | 137 | 256 | 801 | 4,767 | 6,896 |
Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395,
with four hardware threads allocated to each app.
## Development
You are welcome - and encouraged - to modify Campfire to your liking.
+149 -147
View File
@@ -1,160 +1,165 @@
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
so everything follows Campfire's light/dark color scheme */
.lexxy-content {
--lexxy-color-canvas: transparent;
--lexxy-color-text: var(--color-text);
--lexxy-color-text-subtle: var(--color-text);
--lexxy-color-link: var(--color-link);
--lexxy-color-ink: var(--color-text);
--lexxy-color-ink-inverted: var(--color-text-reversed);
--lexxy-color-ink-medium: var(--color-text);
--lexxy-color-ink-lighter: var(--color-border-darker);
--lexxy-color-ink-lightest: var(--color-bg);
--lexxy-color-selected: var(--color-selected, var(--color-bg));
--lexxy-color-code-bg: var(--color-bg);
trix-toolbar {
display: none;
inset: auto 0 0 0;
min-width: 0;
position: sticky;
z-index: 3;
/* Campfire's message lines stack without extra block margins */
--lexxy-content-margin: 0;
.trix-dialog {
bottom: 3rem !important;
top: auto !important;
}
.trix-button--icon {
height: auto !important;
}
.trix-dialog {
background-color: var(--color-bg) !important;
border-radius: 1rem !important;
border: 2px solid var(--color-text) !important;
box-shadow: none !important;
color: var(--color-text) !important;
gap: var(--inline-space);
padding: 0 !important;
overflow: clip;
.trix-input {
background: transparent !important;
border: 0 !important;
color: var(--color-text) !important;
font-size: max(16px, 1em) !important;
padding: var(--input-padding, 0.5em 0.8em) !important;
}
.trix-button {
color: var(--color-text) !important;
}
}
.trix-dialog--link {
.trix-dialog__link-fields {
align-items: center;
padding-right: 0.3em;
}
[value="Link"],
[value="Unlink"] {
width: 2.6em;
aspect-ratio: 1 / 1;
background-position: center center;
background-repeat: no-repeat;
text-indent: -9999%;
padding: 0;
}
[value="Link"] { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.948 5.258a4.337 4.337 0 0 0-6.108 0L11.217 6.87a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l1.623-1.613a2.323 2.323 0 0 1 3.271 0 2.29 2.29 0 0 1 0 3.251l-2.393 2.38a3.021 3.021 0 0 1-4.255 0l-.05-.049a1.007 1.007 0 0 0-1.418 0 .993.993 0 0 0 0 1.41l.05.049a5.036 5.036 0 0 0 7.091 0l2.394-2.38a4.275 4.275 0 0 0 0-6.072Zm-13.683 13.6a4.337 4.337 0 0 0 6.108 0l1.262-1.255a.993.993 0 0 0 0-1.41 1.007 1.007 0 0 0-1.418 0L9.954 17.45a2.323 2.323 0 0 1-3.27 0 2.29 2.29 0 0 1 0-3.251l2.344-2.331a2.579 2.579 0 0 1 3.631 0c.392.39 1.027.39 1.419 0a.993.993 0 0 0 0-1.41 4.593 4.593 0 0 0-6.468 0l-2.345 2.33a4.275 4.275 0 0 0 0 6.072Z' clip-rule='evenodd'/%3E%3C/svg%3E"); }
[value="Unlink"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.954 5.264a3.58 3.58 0 0 0-5.04 0l-2.157 2.144a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l2.157-2.144a1.565 1.565 0 0 1 2.204 0c.607.603.609 1.58.005 2.186l-2.348 2.355a.993.993 0 0 0 .006 1.41 1.007 1.007 0 0 0 1.419-.007l2.347-2.354a3.528 3.528 0 0 0-.01-5ZM5.257 18.852a3.59 3.59 0 0 0 5.041.015l2.6-2.554a.993.993 0 0 0 .01-1.41 1.007 1.007 0 0 0-1.419-.009l-2.6 2.554c-.615.603-1.603.6-2.214-.006a1.554 1.554 0 0 1-.012-2.195l2.539-2.58a.993.993 0 0 0-.016-1.41 1.007 1.007 0 0 0-1.418.015l-2.539 2.58a3.54 3.54 0 0 0 .028 5Z' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-width='2' d='M10.233 5.78V3.769M5.968 9.802H4.21M7.343 7.107 5.968 5.781M14.348 17.523v2.013M18.613 13.502h1.758M17.238 16.196l1.375 1.327'/%3E%3C/svg%3E%0A");
border-top-right-radius: 1em !important;
border-bottom-right-radius: 1em !important;
}
}
}
lexxy-editor {
trix-editor {
&.input {
--lexxy-editor-padding: 0;
/* One line high when empty. Lexxy derives this from attr(rows lh, 8lh),
whose resolution differs between Chrome versions — pin it instead of
risking the 8-line fallback */
--lexxy-editor-rows: 1lh;
background: transparent;
border: 0;
border-radius: 0;
min-height: 24px;
}
.attachment--content {
margin: 0;
display: inline-flex;
}
}
/* The mentions menu needs a solid surface — the editor's transparent canvas
would let messages show through it */
.lexxy-prompt-menu {
--lexxy-color-canvas: var(--color-bg);
border: 1px solid var(--color-border-dark);
}
/* Same for the code block language picker: with a transparent canvas the
native dropdown renders white-on-white in dark mode */
lexxy-editor lexxy-code-language-picker select {
background-color: var(--color-bg);
color: var(--color-text);
option {
background-color: var(--color-bg);
color: var(--color-text);
}
}
/* The toolbar only shows when rich text mode is toggled on (see composer.css) */
lexxy-toolbar {
/* Wide, airy button spacing like the Trix toolbar */
--lexxy-toolbar-gap: 1.3em;
/* Match the compact size of the Trix toolbar buttons */
--lexxy-toolbar-button-size: 1.7em;
--lexxy-toolbar-icon-size: 1.125em;
display: none;
border-block-end: 0;
min-width: 0;
z-index: 3;
/* Tools Campfire doesn't offer, matching the Trix toolbar: underline,
color highlights, tables, dividers and undo/redo (history tools were
hidden under Trix as well) */
button[name="underline"],
button[name="table"],
button[name="divider"],
button[name="undo"],
button[name="redo"],
lexxy-highlight-dropdown {
display: none !important;
}
/* No group separator pipes, like the Trix toolbar */
.lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after {
display: none;
}
/* Dropdown panels: a solid surface with a uniform radius. Lexxy derives
the panel's radius and gap from --lexxy-toolbar-gap, which Campfire
repurposes for button spacing */
.lexxy-editor__toolbar-dropdown [data-dropdown-panel] {
--lexxy-color-canvas: var(--color-bg);
--lexxy-color-ink-lightest: var(--color-message-bg);
--lexxy-color-selected: var(--color-selected);
--lexxy-color-selected-hover: var(--color-message-bg);
border: 1px solid var(--color-border-dark);
border-radius: 0.5em;
gap: 0.25em;
padding: 0.25em;
}
.lexxy-editor__toolbar-dropdown > .lexxy-editor__toolbar-button[aria-expanded="true"] {
border-radius: var(--lexxy-radius);
}
}
/* Attachment markup contains formatting whitespace that must not turn into
line breaks under the editor's white-space: pre-wrap */
lexxy-editor action-text-attachment {
white-space: normal;
}
action-text-attachment[content-type~="application/vnd.campfire.mention"] {
display: inline-flex;
margin: 0;
padding: 0;
position: relative;
}
action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed"] {
lexxy-editor & {
display: block;
inline-size: fit-content;
max-inline-size: 100%;
}
/* No selection background — it peeks through the card's rounded corners.
The lexxy-editor prefix out-specifies Lexxy's own selection rule. */
lexxy-editor &.node--selected {
--lexxy-attachment-bg-color: transparent;
}
/* A prominent remove button on the card's top edge, horizontally
centered, like the Trix one. The lexxy-editor prefix out-specifies
Lexxy's own placement rules. */
lexxy-editor & lexxy-node-delete-button {
inset-block-start: 0;
inset-inline-start: 50%;
inset-inline-end: auto;
transform: translate(-50%, -50%);
.lexxy-floating-controls__group {
--button-size: 2.2em;
background-color: var(--color-negative);
border-radius: 50%;
color: white;
.attachment__toolbar {
top: 0;
z-index: 2;
}
}
}
.lexxy-content {
.trix-button-group--file-tools,
.trix-button-group--history-tools {
display: none !important;
}
.trix-button-group,
.trix-button {
border: 0 !important;
margin: 0 !important;
border-radius: 0.3em !important;
box-shadow: none;
&::before {
opacity: 1 !important;
}
@media (prefers-color-scheme: dark) {
&::before {
filter: invert(100);
}
}
&.trix-active {
background-color: var(--color-text) !important;
&::before {
filter: invert(100);
}
@media (prefers-color-scheme: dark) {
&::before {
filter: invert(0);
}
}
}
&.trix-button--remove {
background-color: var(--color-negative);
border-radius: 1.8em !important;
&::before {
filter: invert(100);
background-size: 70%;
background-image: url("minus.svg");
}
}
}
[data-trix-content-type~='application/vnd.campfire.mention'] {
.trix-button-group {
display: inline;
}
}
[data-trix-content-type~="application/vnd.actiontext.opengraph-embed"] {
* {
text-decoration: none;
}
trix-editor & {
display: block;
inline-size: fit-content;
max-inline-size: 100%;
}
}
.trix-button:not(.trix-button--remove) {
@media (any-hover: hover) {
&:where(:not(:active):hover) {
filter: brightness(0.9);
background-color: var(--color-bg);
}
}
&:disabled {
opacity: 0.33;
}
}
trix-editor,
.trix-content {
pre {
background-color: var(--color-bg) !important;
border-radius: 0.5em;
@@ -174,13 +179,11 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
blockquote {
border: 0 solid var(--color-border-darker) !important;
border-inline-start-width: var(--blockquote-border) !important;
font-style: normal;
margin: 0 var(--blockquote-margin) !important;
padding-inline-start: var(--blockquote-padding) !important;
}
cite {
display: block;
margin-inline-start: calc(var(--blockquote-padding));
font-style: normal;
font-size: 0.8rem;
@@ -196,14 +199,13 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
color: var(--color-link);
}
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */
ul:not(.lexxy-prompt-menu),
ul,
ol {
margin: 0 !important;
padding: 0 !important;
}
li:not(.lexxy-prompt-menu__item) {
li {
margin: 0 0 0 var(--inline-space-double) !important;
padding: 0 !important;
}
+1 -1
View File
@@ -33,7 +33,7 @@
}
}
action-text-attachment[content-type~='application/vnd.campfire.mention'] {
[data-trix-content-type~='application/vnd.campfire.mention'] {
display: inline-flex;
margin: 0;
padding: 0;
+4 -40
View File
@@ -100,42 +100,16 @@
}
.composer--rich-text {
lexxy-toolbar {
display: flex;
trix-toolbar {
display: block;
}
lexxy-editor.input {
trix-editor.input {
margin-block-start: var(--block-space-half);
}
}
/* In the message composer the toolbar goes below the text, on the same
row as the surrounding action buttons, like the Trix toolbar did.
The edit composer keeps it on top. */
#composer.composer--rich-text lexxy-editor.input {
display: flex;
flex-direction: column;
/* Sit the toolbar row on the same baseline as the composer's round
buttons, which pull themselves down with negative margins */
margin-block-end: -0.25em;
.lexxy-editor__content {
order: -1;
}
lexxy-toolbar {
margin-block-start: 0.4em;
/* Dropdowns open upward — the toolbar sits at the bottom of the screen */
[data-dropdown-panel] {
inset-block-start: auto;
inset-block-end: 100%;
}
}
}
.composer--edit lexxy-toolbar {
.composer--edit trix-toolbar {
border-block-end: 1px solid var(--color-border);
margin-inline: -0.8em;
padding-inline: 0.3em;
@@ -143,16 +117,6 @@
}
}
/* The editor's container-type prevents it from sizing the message bubble by
its content, so give the bubble the full available width while editing */
.message__body:has(.composer--edit) {
inline-size: 100%;
.message__body-content--editing {
flex-grow: 1;
}
}
/* Typing indicator */
.typing-indicator {
inset-block-start: var(--indicator-position, 0.15rem);
+5 -10
View File
@@ -1,5 +1,5 @@
.lexxy-content {
trix-editor,
.trix-content {
.og-embed {
background: var(--color-bg);
border: 1px solid var(--color-border-dark);
@@ -15,7 +15,7 @@
flex-direction: row;
}
&.og-embed--twitter-avatar {
.cf-twitter-avatar & {
flex-direction: row;
}
}
@@ -35,11 +35,10 @@
.og-embed__title {
font-weight: 600;
text-decoration: underline;
/* Underline the link itself so the line is link-colored */
a {
color: var(--color-link);
text-decoration: underline;
}
}
@@ -54,11 +53,7 @@
inline-size: 15dvw;
}
/* block-size/border-radius guard against Lexxy's 1em round avatar
styling for custom attachment images */
> * {
block-size: auto;
border-radius: 0;
object-position: top right;
inline-size: 100%;
object-fit: contain;
@@ -68,7 +63,7 @@
}
}
.og-embed--twitter-avatar & {
.cf-twitter-avatar & {
aspect-ratio: 1;
border-radius: 50%;
inline-size: 2lh;
+1 -1
View File
@@ -537,7 +537,7 @@ img.message__attachment {
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
text-shadow: 0 0 3px var(--color-text-reversed);
.lexxy-content {
.trix-content {
font-size: 2.4em;
line-height: 1.4;
}
@@ -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
+4 -8
View File
@@ -37,11 +37,7 @@ class MessagesController < ApplicationController
@message.update!(message_params)
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
respond_to do |format|
format.html { redirect_to room_message_url(@room, @message) }
format.json { render :show }
end
redirect_to room_message_url(@room, @message)
end
def destroy
@@ -62,11 +58,11 @@ class MessagesController < ApplicationController
def find_paged_messages
case
when params[:before].present?
@room.messages.with_presentation.page_before(@room.messages.find(params[:before]))
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
when params[:after].present?
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
else
@room.messages.with_presentation.last_page
@room.messages.with_creator.last_page
end
end
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
before_action :set_last_updated_at
def show
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
end
private
+2 -2
View File
@@ -44,9 +44,9 @@ class RoomsController < ApplicationController
end
def find_messages
messages = @room.messages.with_presentation
messages = @room.messages.with_creator.with_attachment_details.with_boosts
if params[:message_id].present? && (show_first_message = messages.find_by(id: params[:message_id]))
if show_first_message = messages.find_by(id: params[:message_id])
@messages = messages.page_around(show_first_message)
else
@messages = messages.last_page
+1 -1
View File
@@ -20,7 +20,7 @@ class SearchesController < ApplicationController
private
def set_messages
if query.present?
@messages = Current.user.reachable_messages.search(query).with_presentation.last_page_of(100)
@messages = Current.user.reachable_messages.search(query).last(100)
else
@messages = Message.none
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
+7 -2
View File
@@ -2,13 +2,18 @@ class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
def show
@direct_memberships, @other_memberships = Current.user.memberships.visible.with_ordered_room.partition { |membership| membership.room.direct? }
@direct_memberships = @direct_memberships.sort_by { |membership| membership.room.updated_at }.reverse
all_memberships = Current.user.memberships.visible.with_ordered_room
@direct_memberships = extract_direct_memberships(all_memberships)
@other_memberships = all_memberships.without(@direct_memberships)
@direct_placeholder_users = find_direct_placeholder_users
end
private
def extract_direct_memberships(all_memberships)
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
end
def find_direct_placeholder_users
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
+1 -11
View File
@@ -1,13 +1,3 @@
module ContentFilters
# Formatting the rich text editor produces that Rails' sanitizers don't
# allow by default. A message passes through three sanitization layers on
# its way to the screen, and each builds on this list:
#
# * SanitizeTags strips disallowed markup from the message body
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
# * 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, SanitizeAttributes)
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
+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()
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
#fetchAutocompletables(url) {
if (url) {
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
return fetch(url, { as: "json" }).then(response => response.json())
} else {
return Promise.resolve()
}
@@ -0,0 +1,48 @@
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
import { PUNCTUATION_PATTERN } from "lib/autocomplete/constants"
export default class extends BaseAutocompleteHandler {
get pattern() {
return new RegExp(`^@(.*?)(${PUNCTUATION_PATTERN.source}*)$`)
}
insertAutocompletable(autocompletable, range, terminator, options = {}) {
const attachment = this.#createAttachmentForAutocompletable(autocompletable)
this.#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range, options)
}
// Override to set selector's position relative to the cursor in the editor
getOffsetsAtPosition(position) {
return this.#getOffsetsFromEditorAtPosition(this.#editor, position)
}
#createAttachmentForAutocompletable(mentionable) {
const mention = `
<span class="mention" sgid=${mentionable.sgid}>
<img src="${mentionable.avatar_url}" class="avatar" alt="${mentionable.name}">
${mentionable.name}
</span>
`
return new Trix.Attachment({
content: mention,
contentType: "application/vnd.campfire.mention",
sgid: mentionable.sgid
})
}
#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range) {
if (range) { this.#editor.setSelectedRange(range) }
this.#editor.insertAttachment(attachment)
this.#editor.insertString(terminator)
}
get #editor() {
return this.element.editor
}
#getOffsetsFromEditorAtPosition(editor, position) {
const rect = this.#editor.getClientRectAtPosition(position)
return rect ? rect : {}
}
}
@@ -1,24 +0,0 @@
import * as Lexxy from "lexxy"
import CiteNode from "lib/rich_text/cite_node"
export default class CampfireRichTextExtension extends Lexxy.Extension {
get allowedElements() {
return [
"cite",
"figure",
"figcaption",
"actiontext-opengraph-embed",
{ tag: "div", attributes: [ "sgid" ] },
{ tag: "span", attributes: [ "sgid" ] },
{ tag: "img", attributes: [ "alt" ] },
{ tag: "a", attributes: [ "rel", "target" ] }
]
}
get lexicalExtension() {
return this.defineExtension({
name: "campfire/rich-text",
nodes: [ CiteNode ]
})
}
}
-39
View File
@@ -1,39 +0,0 @@
import * as Lexxy from "lexxy"
const { ElementNode } = Lexxy.Lexical
export default class CiteNode extends ElementNode {
static getType() {
return "cite"
}
static clone(node) {
return new CiteNode(node.__key)
}
static importJSON(serializedNode) {
return new CiteNode().updateFromJSON(serializedNode)
}
static importDOM() {
return {
cite: () => ({ conversion: () => ({ node: new CiteNode() }), priority: 1 })
}
}
exportJSON() {
return { ...super.exportJSON(), type: "cite" }
}
createDOM() {
return document.createElement("cite")
}
updateDOM() {
return false
}
exportDOM() {
return { element: document.createElement("cite") }
}
}
@@ -0,0 +1,80 @@
import { post } from "@rails/request.js"
import { truncateString } from "helpers/string_helpers"
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
export default class OpengraphEmbedOperation {
constructor(paste) {
this.paste = paste
this.editor = this.paste.editor
this.url = this.paste.string
this.abortController = new AbortController()
}
perform() {
return this.#createOpenGraphMetadataRequest()
.then(response => response.json)
.then(this.#insertOpengraphAttachment.bind(this))
.catch(() => null)
}
abort() {
this.abortController.abort()
}
#createOpenGraphMetadataRequest() {
return post("/unfurl_link", {
body: { url: this.url },
contentType: "application/json",
signal: this.abortController.signal
})
}
#insertOpengraphAttachment(response) {
if (this.#shouldInsertOpengraphPreview) {
const currentRange = this.editor.getSelectedRange()
this.editor.setSelectedRange(this.editor.getSelectedRange())
this.editor.recordUndoEntry("Insert Opengraph preview for Pasted URL")
this.editor.insertAttachment(this.#createOpengraphAttachment(response))
this.editor.setSelectedRange(currentRange)
}
}
get #shouldInsertOpengraphPreview() {
return this.editor.getDocument().toString().includes(this.url)
}
#createOpengraphAttachment(response) {
const { title, url, image, description } = response
const html = this.#generateOpengraphEmbedHTML({ title, url, image, description })
return new Trix.Attachment({
contentType: "application/vnd.actiontext.opengraph-embed",
content: html,
filename: title,
href: url,
url: image,
caption: description
})
}
#generateOpengraphEmbedHTML(embed) {
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(embed) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ''}">
<div class="og-embed">
<div class="og-embed__content">
<div class="og-embed__title">${truncateString(embed.title, 560)}</div>
<div class="og-embed__description">${truncateString(embed.description, 560)}</div>
</div>
<div class="og-embed__image">
<img src="${embed.image}" class="image" alt="" />
</div>
</div>
</actiontext-opengraph-embed>`
}
#isTwitterAvatar(embed) {
return embed.image.startsWith(TWITTER_AVATAR_URL_PREFIX)
}
}
@@ -0,0 +1,39 @@
export default class Paste {
constructor(range, editor, document) {
this.range = range
this.editor = editor
this.document = document
if (this.document == null) { this.document = this.editor.getDocument() }
this.string = this.document.getStringAtRange(this.range)
}
isURL() {
return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/.test(this.string)
}
getPathname() {
const a = document.createElement("a")
a.href = this.string
return a.pathname
}
isLinked() {
const {href} = this.getCommonAttributes()
return (href != null) && (href !== this.string)
}
getCommonAttributes() {
return this.document.getCommonAttributesAtRange(this.range)
}
getSignificantPaste() {
return new this.constructor(this.getSignificantRange(), this.editor, this.document)
}
getSignificantRange() {
const significantString = this.string.trim()
const startOffset = this.range[0] + this.string.indexOf(significantString)
const endOffset = startOffset + significantString.length
return [startOffset, endOffset]
}
}
@@ -0,0 +1,59 @@
import OpengraphEmbedOperation from "lib/rich_text/unfurl/lib/opengraph_embed_operation"
import Paste from "lib/rich_text/unfurl/lib/paste"
const performOperation = (function() {
let operation = null
let requestId = null
return function(operationToPerform) {
operation?.abort()
cancelAnimationFrame(requestId)
requestId = requestAnimationFrame(function() {
operation = operationToPerform
operation.perform().then(() => operation = null)
})
}
})()
export default class Unfurler {
install() {
this.#addEventListeners()
}
#addEventListeners() {
addEventListener("trix-initialize", function(event) {
if (this.#editorElementPermitsAttribute(event.target, "href")) {
return event.target.addEventListener("trix-paste", this.#didPaste.bind(this))
}
}.bind(this))
}
#didPaste(event) {
const {range} = event.paste
const {editor} = event.target
if (range != null) {
const paste = new Paste(range, editor).getSignificantPaste()
if (paste.isURL()) {
if (this.#editorElementPermitsOpengraphAttachment(event.target)) {
performOperation(new OpengraphEmbedOperation(paste))
}
}
}
}
#editorElementPermitsAttribute(element, attributeName) {
if (element.hasAttribute("data-permitted-attributes")) {
return Array.from(element.getAttribute("data-permitted-attributes").split(" ")).includes(attributeName)
} else {
return true
}
}
#editorElementPermitsOpengraphAttachment(element) {
const permittedAttachmentTypes = element.getAttribute("data-permitted-attachment-types")
return permittedAttachmentTypes && permittedAttachmentTypes.includes("application/vnd.actiontext.opengraph-embed")
}
}
+6 -17
View File
@@ -18,7 +18,7 @@ export default class ClientMessage {
body,
messageTimestamp: Math.floor(now.getTime()),
messageDatetime: now.toISOString(),
messageClasses: this.#messageClassesFromNode(node),
messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
})
}
@@ -52,37 +52,26 @@ export default class ClientMessage {
}
}
#messageClassesFromNode(node) {
if (this.#containsOnlyEmoji(this.#plainTextFromNode(node))) {
return "message--emoji"
} else {
return ""
}
}
#isPlayCommand(node) {
return this.#matchPlayCommand(node)
}
#matchPlayCommand(node) {
return this.#plainTextFromNode(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
return this.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
}
#plainTextFromNode(node) {
if (this.#isRichText(node)) {
return node.toString().trim()
} else {
return node
}
#stripWrapperElement(node) {
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
}
#isRichText(node) {
return typeof(node) != "string"
}
#richTextContent(node) {
return `<div class="lexxy-content">${node.value}</div>`
return `<div class="trix-content">${node.innerHTML}</div>`
}
+1 -15
View File
@@ -76,21 +76,7 @@ export default class MessageFormatter {
}
#highlightCodeBlock(block) {
this.#normalizeLineBreaks(block)
if (this.#isPlainText(block)) {
const language = block.dataset.language
if (language && window.hljs.getLanguage(language)) {
block.classList.add(`language-${language}`)
}
window.hljs.highlightElement(block)
}
}
// Lexxy breaks code block lines with <br>, Trix-era blocks used newlines
#normalizeLineBreaks(block) {
block.querySelectorAll("br").forEach(br => br.replaceWith("\n"))
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
}
#isPlainText(element) {
+3 -3
View File
@@ -14,11 +14,11 @@ class Message < ApplicationRecord
scope :ordered, -> { order(:created_at) }
scope :with_creator, -> { preload(creator: :avatar_attachment) }
scope :with_attachment_details, -> {
with_rich_text_body_and_embeds.with_attached_attachment
with_rich_text_body_and_embeds
with_attached_attachment
.includes(attachment_blob: :variant_records)
}
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
scope :with_boosts, -> { includes(boosts: :booster) }
def plain_text_body
body.to_plain_text.presence || attachment&.filename&.to_s || ""
+1 -3
View File
@@ -12,10 +12,8 @@ module Message::Broadcasts
# 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
payload = ActiveSupport::JSON.encode(roomId: room.id)
room.memberships.pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
end
end
end
+3 -29
View File
@@ -3,31 +3,9 @@ module Message::Pagination
PAGE_SIZE = 40
# Expose Rails' collection-preloading hook while preserving Array pagination and
# validators. The renderer passes only cache misses to preload_associations.
class Page < Array
def self.load(relation, direction, size)
new(relation.skip_preloading!.public_send(direction, size), relation)
end
def initialize(records, relation)
super(records)
@relation = relation
end
def loaded?
true
end
def preload_associations(records)
@relation.preload_associations(records)
end
end
included do
# Keep presentation data lazy until the collection cache knows which messages missed.
scope :last_page, -> { last_page_of(PAGE_SIZE) }
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
scope :last_page, -> { ordered.last(PAGE_SIZE) }
scope :first_page, -> { ordered.first(PAGE_SIZE) }
scope :before, ->(message) { where("created_at < ?", message.created_at) }
scope :after, ->(message) { where("created_at > ?", message.created_at) }
@@ -40,12 +18,8 @@ module Message::Pagination
end
class_methods do
def last_page_of(size)
Page.load(ordered, :last, size)
end
def page_around(message)
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
page_before(message) + [ message ] + page_after(message)
end
def paged?
+1 -9
View File
@@ -6,15 +6,7 @@ module Message::Searchable
after_update_commit :update_in_index
after_destroy_commit :remove_from_index
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
end
class_methods do
# Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than
# parsing them as operators, which fails on a query like "AND" or "salt AND".
def match_terms(query)
query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ")
end
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
end
private
+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
+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
@@ -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 -1
View File
@@ -1,7 +1,7 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
<% cache [ message, "presentation-v3" ] do %>
<% cache [ message, "presentation-v2" ] 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 -1
View File
@@ -7,7 +7,7 @@
<%= turbo_frame_tag message, :new_boost do %>
<div class="flex-inline message__boost-inline" data-controller="soft-keyboard">
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", data: { action: "soft-keyboard#open" } do %>
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", action: "soft-keyboard#open" do %>
<%= image_tag "boost.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Add a boost</span>
<% 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 %>
+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">
+1 -1
View File
@@ -54,7 +54,7 @@
</div>
<%= search_results_tag do %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
<%= render @messages %>
<% end %>
</div>
+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
-26
View File
@@ -1,26 +0,0 @@
# Message benchmarks
The drivers use Ruby's standard library and Docker; no separate load generator is required.
Provide a frozen baseline source directory, an isolated seed containing `db/`, `storage/`
and `labels.json`, and a matching application image with compiled assets.
```sh
ruby bench/compare_message_hot_paths.rb --baseline PATH --baseline-ref SHA --seed SEED
ruby bench/compare_http.rb --baseline PATH --seed SEED
ruby bench/compare_http.rb --baseline PATH --seed SEED --paths sidebar,search --concurrencies 16 --duration 10
```
Both drivers alternate before/after order and reset fixture storage for each run.
Results default to ignored `tmp/rails-optimization/results/`; override with `--output PATH`.
Use `--image` to override `campfire-reference:app`, and `--cpus` to override server CPUs
`8-11`. HTTP clients use CPUs `12-15` by default (`--client-cpus`). `--help` lists options.
The rendering probe checks exact response bodies, selected headers and unread payloads,
and records timing, queries and allocations with MemoryStore, frozen time and fixture-only
CSRF disabling. Unread fanout excludes adapter I/O.
The HTTP driver uses production Puma/Redis with one worker and five threads. Ruby threads
each maintain a keep-alive connection, request uncompressed responses, and consume the
whole body. Login uses normal CSRF protection; all warmup and measured responses must be
HTTP 200 without transport errors. Measurements exclude Thruster, TLS and gzip. Client
CPU, JIT warmup and GC can affect throughput; repeat runs and check client saturation.
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env ruby
# Compare warm production Puma/Redis requests using isolated seeded containers.
require "socket"
require_relative "support"
require_relative "http_client"
include BenchmarkSupport
options = parse_options("Compare HTTP throughput with Ruby keep-alive clients; every response must be HTTP 200.",
duration: 3.0, paths: "room,messages,sidebar,search", concurrencies: "1,16", client_cpus: "12-15",
output: File.join(WORK, "results/http"))
labels = JSON.parse(File.read(File.join(options[:seed], "labels.json")))
paths = {
"room" => "/rooms/#{labels.fetch('rooms.watercooler')}",
"messages" => "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
"sidebar" => "/users/me/sidebar", "search" => "/searches?q=coffee"
}.slice(*options[:paths].split(","))
abort "--paths must select room,messages,sidebar,search" unless paths.size == options[:paths].split(",").size && !paths.empty?
concurrencies = options[:concurrencies].split(",").map { |value| Integer(value) }
abort "--duration and --concurrencies must be positive" unless options[:duration].positive? && !concurrencies.empty? && concurrencies.all?(&:positive?)
run("taskset", "-pc", options[:client_cpus], Process.pid.to_s)
work = File.join(WORK, "http")
assets = prepare_assets(options[:image])
network = "cf-ruby-bench-#{Process.pid}"
redis = "#{network}-redis"
app = "#{network}-app"
begin
run("docker", "network", "create", network)
run("docker", "run", "-d", "--name", redis, "--network", network, "redis:7-alpine")
options[:rounds].times do |iteration|
sides = iteration.even? ? %w[before after] : %w[after before]
sides.each do |side|
remove_container(app)
data = File.join(work, "data")
prepare_storage(options[:seed], File.join(data, "storage"))
FileUtils.mkdir_p([ File.join(data, "tmp/pids"), File.join(data, "log") ])
run("docker", "exec", redis, "redis-cli", "FLUSHALL")
port = TCPServer.open("127.0.0.1", 0) { |socket| socket.addr[1] }
client = BenchmarkHTTPClient.new("http://127.0.0.1:#{port}")
source = side == "before" ? options[:baseline] : ROOT
command = [ "docker", "run", "-d", "--name", app, "--entrypoint", "", "--network", network,
"--cpuset-cpus", options[:cpus], "-p", "127.0.0.1:#{port}:3000" ]
command.concat mounts(source => "/rails", File.join(data, "storage") => "/rails/storage",
File.join(data, "tmp") => "/rails/tmp", File.join(data, "log") => "/rails/log", assets => "/rails/public/assets")
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", WEB_CONCURRENCY: 1, JOB_CONCURRENCY: 1, RAILS_MAX_THREADS: 5,
REDIS_URL: "redis://#{redis}:6379/0")
command.concat [ options[:image], "bundle", "exec", "puma", "-C", "config/puma.rb" ]
run(*command)
deadline = clock + 45
until client.ready?
if clock > deadline
File.write(File.join(work, "server.log"), run("docker", "logs", app))
raise "server did not become ready; see #{work}/server.log"
end
sleep 0.1
end
cookie = client.login(labels)
results = {}
paths.each do |name, path|
client.measure(path, cookie, concurrency: 1, duration: 3)
concurrencies.each do |concurrency|
results["#{name}_#{concurrency}"] = client.measure(path, cookie, concurrency: concurrency, duration: options[:duration])
end
end
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), results)
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
end
end
ensure
remove_container(app)
remove_container(redis)
Open3.capture3("docker", "network", "rm", network)
end
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env ruby
# Compare frozen Rails sources with isolated fixtures and an existing Ruby image.
require "digest"
require_relative "support"
include BenchmarkSupport
options = parse_options("Compare rendering queries, allocations, timing and response parity.",
baseline_ref: nil, rounds: 4, output: File.join(WORK, "results/requests"))
work = File.join(WORK, "runtime")
FileUtils.mkdir_p([ File.join(work, "tmp"), File.join(work, "log") ])
assets = prepare_assets(options[:image])
runs = { "before" => [], "after" => [] }
order = []
options[:rounds].times do |iteration|
sides = iteration.even? ? %w[before after] : %w[after before]
order << sides
sides.each do |side|
storage = File.join(work, "storage")
prepare_storage(options[:seed], storage)
source = side == "before" ? options[:baseline] : ROOT
command = [ "docker", "run", "--rm", "--entrypoint", "", "--cpuset-cpus", options[:cpus] ]
command.concat mounts(source => "/rails", storage => "/rails/storage", File.join(work, "tmp") => "/rails/tmp",
File.join(work, "log") => "/rails/log", assets => "/rails/public/assets", File.join(ROOT, "bench") => "/bench",
File.join(options[:seed], "labels.json") => "/bench-labels.json")
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", BENCH_LABELS: "/bench-labels.json")
command.concat [ options[:image], "bundle", "exec", "ruby", "-r", "/rails/config/environment.rb", "/bench/message_hot_paths.rb" ]
data = JSON.parse(run(*command))
runs[side] << data
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), data)
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
end
end
summary = {}
runs["before"].first.fetch("results").each_key do |name|
values = runs.transform_values { |rows| rows.map { |row| row.fetch("results").fetch(name) } }
%w[body_sha256 headers payload_sha256].each do |field|
expected = values["before"].first[field]
raise "#{name}: #{field} differs; no winning summary written" unless values.values.flatten.all? { |row| row[field] == expected }
end
summary[name] = values.transform_values do |rows|
fields = %w[milliseconds allocations queries].select { |field| rows.first.key?(field) }
fields.to_h { |field| [ field, median(rows.map { |row| median(row.fetch(field)) }) ] }
.merge("round_medians_ms" => rows.map { |row| median(row.fetch("milliseconds")) })
end
summary[name]["speedup"] = summary[name]["before"]["milliseconds"] / summary[name]["after"]["milliseconds"]
end
metadata = {
baseline_sha: options[:baseline_ref], candidate_head: run("git", "-C", ROOT, "rev-parse", "HEAD").strip,
candidate_app_diff_sha256: Digest::SHA256.hexdigest(run("git", "-C", ROOT, "diff", "--", "app")),
platform: RUBY_PLATFORM, cpus: options[:cpus], image: options[:image],
image_id: run("docker", "image", "inspect", "-f", "{{.Id}}", options[:image]).strip,
body_and_selected_header_parity: "exact across all measured baseline/candidate requests",
order: order, ruby: runs["before"].first.fetch("ruby"), rails: runs["before"].first.fetch("rails"),
limits: "In-process Rails requests, MemoryStore, frozen clock and fixture-controller CSRF disabled. Fanout excludes adapter I/O. Not network throughput or connection capacity."
}
write_json(File.join(options[:output], "summary.json"), metadata: metadata, results: summary)
summary.each do |name, value|
puts "%s: %.2f -> %.2f ms (%.2fx)" % [ name, value["before"]["milliseconds"], value["after"]["milliseconds"], value["speedup"] ]
end
-102
View File
@@ -1,102 +0,0 @@
require "cgi"
require "net/http"
class BenchmarkHTTPClient
def initialize(base)
@base = URI(base)
end
def ready?
connection.start { |http| http.get("/up").code == "200" }
rescue IOError, SystemCallError, Timeout::Error, SocketError
false
end
def login(labels)
cookies = {}
connection.start do |http|
response = http.get("/session/new", "Accept-Encoding" => "identity")
raise "sign-in page: HTTP #{response.code}" unless response.code == "200"
merge_cookies(cookies, response)
token = response.body[/<meta name="csrf-token" content="([^"]*)"/, 1]
raise "sign-in page has no CSRF token" unless token
request = Net::HTTP::Post.new("/session")
request["Cookie"] = cookie_header(cookies)
request["Origin"] = @base.to_s
request["Sec-Fetch-Site"] = "same-origin"
request.set_form_data(email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all"),
authenticity_token: CGI.unescapeHTML(token))
response = http.request(request)
merge_cookies(cookies, response)
raise "login failed: HTTP #{response.code}" unless response.code == "302" && cookies.key?("session_token")
end
cookie_header(cookies)
end
def measure(path, cookie, concurrency:, duration:)
start = clock
deadline = start + duration
workers = Array.new(concurrency) do
Thread.new do
result = { latencies: [], statuses: Hash.new(0), bytes: 0, errors: 0 }
while clock < deadline
begin
connection.start do |http|
while clock < deadline
requested = clock
response = http.get(path, "Cookie" => cookie, "Accept-Encoding" => "identity")
result[:latencies] << (clock - requested) * 1000
result[:statuses][response.code] += 1
result[:bytes] += response.body.bytesize
end
end
rescue IOError, SystemCallError, Timeout::Error, SocketError, Net::HTTPBadResponse
result[:errors] += 1
sleep 0.01
end
end
result
end
end
samples = workers.map(&:value)
elapsed = clock - start
latencies = samples.flat_map { |sample| sample[:latencies] }.sort
statuses = Hash.new(0)
samples.each { |sample| sample[:statuses].each { |status, count| statuses[status] += count } }
errors = samples.sum { |sample| sample[:errors] }
raise "#{path}: HTTP statuses #{statuses}, #{errors} transport errors" unless errors.zero? && statuses.keys == [ "200" ]
{ path: path, conc: concurrency, gzip: false, secs: elapsed, rps: latencies.size / elapsed,
ok: latencies.size, statuses: statuses, errors: errors,
avg_bytes: samples.sum { |sample| sample[:bytes] } / latencies.size,
latency_ms: { p50: percentile(latencies, 0.50), p95: percentile(latencies, 0.95), p99: percentile(latencies, 0.99) } }
end
private
def connection
Net::HTTP.new(@base.host, @base.port, nil).tap do |http|
http.open_timeout = 1
http.read_timeout = 5
http.write_timeout = 5
http.max_retries = 0
end
end
def merge_cookies(cookies, response)
response.get_fields("set-cookie").to_a.each do |header|
name, value = header.split(";", 2).first.split("=", 2)
cookies[name] = value
end
end
def cookie_header(cookies)
cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
end
def percentile(values, fraction)
values[(values.size * fraction).ceil - 1]
end
def clock
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
-92
View File
@@ -1,92 +0,0 @@
# Run through compare_message_hot_paths.rb against isolated production fixtures.
require "json"
require "digest"
require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers
travel_to Time.utc(2026, 10, 4, 12)
# Keep framework/rendering measurements independent of Redis/network variance.
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionView::PartialRenderer.collection_cache = Rails.cache
ApplicationController.cache_store = Rails.cache
ApplicationController.descendants.each do |controller|
controller.cache_store = Rails.cache
controller.allow_forgery_protection = false
end
ApplicationController.allow_forgery_protection = false
labels = JSON.parse(File.read(ENV.fetch("BENCH_LABELS")))
client = ActionDispatch::Integration::Session.new(Rails.application)
client.host! "campfire-benchmark.test"
client.post "/session", params: { email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all") }
raise "login failed" unless client.response.redirect?
paths = {
room: "/rooms/#{labels.fetch('rooms.watercooler')}",
messages: "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
sidebar: Rails.application.routes.url_helpers.user_sidebar_path,
search: "/searches?q=coffee"
}
queries = []
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, event|
queries << event[:sql] unless event[:cached] || event[:name] == "SCHEMA"
end
results = {}
paths.each do |name, path|
%w[cold warm].each do |cache|
2.times { client.get path }
times, counts, allocations, hashes, headers = [], [], [], [], []
Integer(ENV.fetch("BENCH_ITERATIONS", "20")).times do
Rails.cache.clear if cache == "cold"
queries.clear
start_allocations = GC.stat(:total_allocated_objects)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
client.get path
times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
allocations << GC.stat(:total_allocated_objects) - start_allocations
raise "#{name}: HTTP #{client.response.status}" unless client.response.status == 200
counts << queries.length
hashes << Digest::SHA256.hexdigest(client.response.body)
headers << client.response.headers.slice("content-type", "etag", "cache-control", "vary")
end
results["#{name}_#{cache}"] = { milliseconds: times, queries: counts, allocations: allocations,
body_sha256: hashes.uniq, headers: headers.uniq, body_bytes: client.response.body.bytesize }
end
end
ActiveSupport::Notifications.unsubscribe(subscriber)
# Real Action Cable encoding/instrumentation, with adapter I/O removed. Keep fanout
# separate from network delivery capacity, which this probe does not measure.
room = Room.find(labels.fetch("rooms.watercooler"))
class BenchmarkPubsub
attr_reader :messages
def initialize
@messages = []
end
def broadcast(stream, payload)
@messages << [ stream, payload ]
end
end
adapter = BenchmarkPubsub.new
ActionCable.server.instance_variable_set(:@pubsub, adapter)
message = Message.new(room: room)
recipient_ids = (1..1000).to_a
memberships = Object.new
memberships.define_singleton_method(:pluck) { |_| recipient_ids }
room.define_singleton_method(:memberships) { memberships }
fanout_times, fanout_allocations = [], []
20.times do
adapter.messages.clear
allocated = GC.stat(:total_allocated_objects)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
message.send(:broadcast_unread_room)
fanout_times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
fanout_allocations << GC.stat(:total_allocated_objects) - allocated
raise "wrong fanout count" unless adapter.messages.size == recipient_ids.size
raise "wrong payload" unless adapter.messages.all? { |_, body| JSON.parse(body) == { "roomId" => room.id } }
end
results["unread_fanout_1000"] = { milliseconds: fanout_times, allocations: fanout_allocations,
payload_sha256: Digest::SHA256.hexdigest(adapter.messages.first.last) }
puts JSON.pretty_generate({ ruby: RUBY_VERSION, rails: Rails.version, iterations: ENV.fetch("BENCH_ITERATIONS", "20").to_i,
cache: "MemoryStore", results: results })
-84
View File
@@ -1,84 +0,0 @@
require "fileutils"
require "json"
require "open3"
require "optparse"
module BenchmarkSupport
ROOT = File.expand_path("..", __dir__)
WORK = File.join(ROOT, "tmp/rails-optimization")
def parse_options(description, defaults)
options = { baseline: nil, seed: nil, image: "campfire-reference:app", cpus: "8-11", rounds: 2 }.merge(defaults)
parser = OptionParser.new do |parser|
parser.banner = "Usage: ruby #{$PROGRAM_NAME} --baseline PATH --seed PATH [options]\n#{description}"
options.each do |name, default|
type = case default
when Integer then Integer
when Float then Float
else String
end
parser.on("--#{name.to_s.tr('_', '-')} VALUE", type) { |value| options[name] = value }
end
parser.on("-h", "--help") { puts parser; exit }
end
parser.parse!
raise OptionParser::InvalidArgument, "unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
raise OptionParser::MissingArgument, "--baseline and --seed are required" unless options[:baseline] && options[:seed]
raise OptionParser::InvalidArgument, "--rounds must be even and at least 2" unless options[:rounds] >= 2 && options[:rounds].even?
%i[baseline seed output].each { |name| options[name] = File.expand_path(options.fetch(name)) }
options
rescue OptionParser::ParseError => error
abort "#{error.message}\n#{parser}"
end
def run(*command, input: nil)
output, errors, status = Open3.capture3(*command, stdin_data: input, binmode: true)
raise "#{command.first} failed (#{status.exitstatus}): #{errors}" unless status.success?
output
end
def remove_container(name)
Open3.capture3("docker", "rm", "-f", name)
end
def prepare_storage(seed, destination)
FileUtils.rm_rf(destination)
FileUtils.mkdir_p(destination)
FileUtils.cp_r(File.join(seed, "db"), File.join(destination, "db"))
FileUtils.cp_r(File.join(seed, "storage"), File.join(destination, "files"))
end
def prepare_assets(image)
assets = File.join(WORK, "runtime/assets")
FileUtils.mkdir_p(assets)
if Dir.empty?(assets)
archive = run("docker", "run", "--rm", "--entrypoint", "", image,
"tar", "-C", "/rails/public/assets", "-cf", "-", ".")
run("tar", "-xf", "-", "-C", assets, input: archive)
end
assets
end
def mounts(paths)
paths.flat_map { |host, target| [ "-v", "#{host}:#{target}" ] }
end
def environment(values)
values.flat_map { |name, value| [ "-e", "#{name}=#{value}" ] }
end
def write_json(path, data)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, JSON.pretty_generate(data) + "\n")
end
def median(values)
values = values.sort
middle = values.length / 2
values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
end
def clock
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
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")
+1 -14
View File
@@ -18,8 +18,7 @@ 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(":")
@@ -48,18 +47,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
+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.js"
pin "@rails/actiontext", to: "actiontext.js"
pin "highlight.js", to: "highlight.js/core.js"
pin_all_from "app/javascript/initializers", under: "initializers"
@@ -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 -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
@@ -1,5 +0,0 @@
class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2]
def change
add_index :messages, %i[ room_id created_at ]
end
end
Generated
+1 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2026_10_04_225000) do
ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "custom_styles"
@@ -101,7 +101,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_04_225000) do
t.integer "room_id", null: false
t.datetime "updated_at", null: false
t.index ["creator_id"], name: "index_messages_on_creator_id"
t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at"
t.index ["room_id"], name: "index_messages_on_room_id"
end
-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
-16
View File
@@ -1,16 +0,0 @@
# Bot requests carry the bot key as a URL path segment (/rooms/:room_id/:bot_key/...).
# config.filter_parameters redacts query and form parameters but never path segments,
# so the key would otherwise be written verbatim to the request log. Redact it wherever
# it appears in a formatted log line.
class LogScrubbingFormatter < ::Logger::Formatter
BOT_KEY_IN_PATH = %r{(/rooms/\d+/)\d+-[A-Za-z0-9]+}
def call(severity, time, progname, message)
scrub(super)
end
private
def scrub(line)
line.gsub(BOT_KEY_IN_PATH, '\1[FILTERED]')
end
end
+62 -15
View File
@@ -1,29 +1,76 @@
require "surfguard"
require "ipaddr"
require "resolv"
module RestrictedHTTP
class Violation < StandardError; end
# The address policy lives in the surfguard gem so this app, Basecamp, HEY and
# Fizzy classify "internal" the same way instead of each keeping a copy that
# drifts. The callers here hand a bare hostname in and pin the address that
# comes back, so this stays a hostname-in, address-out shim.
module PrivateNetworkGuard
extend self
# A hostname that resolves to nothing (NXDOMAIN, timeout, empty answer)
# raises Surfguard::Unresolvable, which we let propagate as a lookup failure
# -- the same way the old Resolv.getaddress guard raised Resolv::ResolvError,
# and the callers here already treat it as a fetch failure. The Violation is
# reserved for a host that resolves to a blocked address (an empty list back
# from resolve_public_ips), so a transient DNS miss is never misreported as
# an SSRF attempt.
# IPv4 special-use ranges (RFC 5735/6890) not already covered by the
# private?/loopback?/link_local? predicates in #disallowed_ipv4?.
DISALLOWED_IPV4 = %w[
0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24
198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
].map { |cidr| IPAddr.new(cidr) }.freeze
# IPv6 special-use ranges not caught by the predicates. 6to4 (2002::/16) and
# Teredo (2001::/32) are deprecated transition mechanisms with no legitimate
# fetch target, so they are blocked outright. ULA (fc00::/7, incl. the AWS
# IMDSv6 address fd00:ec2::254), link-local, and loopback are covered by the
# predicates in #disallowed_ipv6?.
DISALLOWED_IPV6 = %w[
::/128 100::/64 2001::/32 2001:2::/48 2001:db8::/32 2002::/16
fec0::/10 ff00::/8
].map { |cidr| IPAddr.new(cidr) }.freeze
# NAT64 prefixes: the well-known prefix (RFC 6052/6146) and the local-use
# prefix (RFC 8215). An address here embeds an IPv4 target in its low 32
# bits; we extract it and re-check against the IPv4 rules so NAT64 to a
# public address still resolves while NAT64 to an internal address is
# blocked.
NAT64_PREFIXES = [
IPAddr.new("64:ff9b::/96"),
IPAddr.new("64:ff9b:1::/48")
].freeze
def resolve(hostname)
Surfguard.resolve_public_ips(hostname).first or
raise Violation.new("Attempt to access private IP via #{hostname}")
Resolv.getaddress(hostname).tap do |ip|
raise Violation.new("Attempt to access private IP via #{hostname}") if ip && private_ip?(ip)
end
end
def private_ip?(ip)
Surfguard.blocked_address?(ip)
ipaddr = IPAddr.new(ip)
# DNS never legitimately returns these embedded forms, so block them all
# regardless of the address they wrap.
if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat?
true
elsif ipaddr.ipv4?
disallowed_ipv4?(ipaddr)
elsif NAT64_PREFIXES.any? { |prefix| prefix.include?(ipaddr) }
disallowed_ipv4?(embedded_ipv4(ipaddr))
else
disallowed_ipv6?(ipaddr)
end
rescue IPAddr::InvalidAddressError
true
end
private
def disallowed_ipv4?(ipaddr)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
DISALLOWED_IPV4.any? { |range| range.include?(ipaddr) }
end
def disallowed_ipv6?(ipaddr)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) }
end
def embedded_ipv4(ipaddr)
IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join("."))
end
end
end
+8 -10
View File
@@ -1,18 +1,16 @@
class WebPush::Notification
def initialize(title:, body:, path:, badge:, endpoint:, endpoint_ip_resolver:, p256dh_key:, auth_key:)
def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:)
@title, @body, @path, @badge = title, body, path, badge
@endpoint, @endpoint_ip_resolver, @p256dh_key, @auth_key = endpoint, endpoint_ip_resolver, p256dh_key, auth_key
@endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key
end
def deliver(connection: nil)
if endpoint_ip = @endpoint_ip_resolver.call
WebPush.payload_send \
message: encoded_message,
endpoint: @endpoint, endpoint_ip: endpoint_ip, p256dh: @p256dh_key, auth: @auth_key,
vapid: vapid_identification,
connection: connection,
urgency: "high"
end
WebPush.payload_send \
message: encoded_message,
endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key,
vapid: vapid_identification,
connection: connection,
urgency: "high"
end
private
+1 -1
View File
@@ -3,7 +3,7 @@ class WebPush::Pool
attr_reader :delivery_pool, :invalidation_pool, :connection, :invalid_subscription_handler
def initialize(invalid_subscription_handler:)
@delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, max_queue: 10000)
@delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000)
@invalidation_pool = Concurrent::FixedThreadPool.new(1)
@connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150)
@invalid_subscription_handler = invalid_subscription_handler
-1
View File
@@ -6,5 +6,4 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
include SystemTestHelper
include RichTextEditorHelper
end
@@ -1,73 +0,0 @@
require "test_helper"
class ActiveStorageAuthenticationTest < ActionDispatch::IntegrationTest
setup do
host! "once.campfire.test"
end
test "direct upload metadata endpoint rejects anonymous callers" do
get new_session_url
assert_response :success
assert_no_difference -> { ActiveStorage::Blob.count } do
post rails_direct_uploads_url, params: blob_params, as: :json
end
assert_response :unauthorized
end
test "direct upload metadata endpoint allows authenticated users" do
sign_in :david
assert_difference -> { ActiveStorage::Blob.count }, 1 do
post rails_direct_uploads_url, params: blob_params, as: :json
end
assert_response :success
end
test "disk service upload endpoint rejects anonymous callers" do
sign_in :david
post rails_direct_uploads_url, params: blob_params, as: :json
assert_response :success
upload_path = URI.parse(response.parsed_body.dig("direct_upload", "url")).request_uri
anonymous = open_session
anonymous.host! "once.campfire.test"
anonymous.put upload_path,
params: attachment_bytes,
headers: { "Content-Type" => "application/octet-stream" }
assert_equal 401, anonymous.status
end
test "disk service download endpoint stays public" do
ActiveStorage::Current.url_options = { host: "once.campfire.test", protocol: "https" }
blob = ActiveStorage::Blob.create_and_upload! \
io: StringIO.new(attachment_bytes), filename: "hi.txt", content_type: "text/plain"
download_path = URI.parse(blob.url).request_uri
anonymous = open_session
anonymous.host! "once.campfire.test"
anonymous.get download_path
assert_equal 200, anonymous.status
assert_equal attachment_bytes, anonymous.response.body
ensure
blob&.purge
end
private
def attachment_bytes
"hello!"
end
def blob_params
{ blob: {
filename: "quota.bin",
byte_size: attachment_bytes.bytesize,
checksum: Digest::MD5.base64digest(attachment_bytes),
content_type: "application/octet-stream"
} }
end
end
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more