Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot] 02a3922595 Initial plan 2026-10-05 14:25:10 +00:00
Stanko Krtalić c73ea37e0c Merge pull request #300 from rubys/fix-autocomplete-json-accept
Ask for JSON when autocompleting people to ping
2026-10-05 16:17:10 +02:00
Stanko Krtalić e0e31846c9 Merge pull request #299 from rubys/fix-boost-soft-keyboard-action
Wire the inline boost link to the soft keyboard
2026-10-05 16:16:25 +02:00
Stanko Krtalić f2bdfde610 Merge pull request #298 from namespaceMarcello/search-operator-words
Search for operator words instead of parsing them
2026-10-05 16:15:23 +02:00
Stanko Krtalić acef0c71cf Merge pull request #295 from namespaceMarcello/messages-room-created-at-index
Index messages by room and creation time
2026-10-05 15:53:19 +02:00
David Heinemeier Hansson 345e63718f Update README with latest Campfire benchmark comparison (#315) 2026-10-05 07:52:23 -04:00
Marcello CostagliolaandClaude Opus 5.5 e12651240c Drop the query plan test
The index is a schema change; a test that pins SQLite's plan for each
pagination scope locks in more than the change needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 13:51:56 +02:00
Sam RubyandClaude Opus 5.5 b8be941b99 Ask for JSON when autocompleting people to ping
`as: "json"` is a @rails/request.js option; plain fetch ignores it and
sends `Accept: */*`, so Autocompletable::UsersController answered with
its HTML format and response.json() threw. The new ping form never
showed any suggestions. Send an explicit Accept header instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Sam RubyandClaude Opus 5.5 c057484dfb Wire the inline boost link to the soft keyboard
link_to passed `action: "soft-keyboard#open"` as a plain option, so it
rendered as an `action` attribute, which Stimulus never reads. The link
in messages/_actions.html.erb already passes it under `data:`; this one
now does too, so tapping "Add a boost" on a touch device opens the
keyboard for the boost form as the menu's link does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Marcello CostagliolaandClaude Opus 5.5 74bdbb0764 Keep the room_id index and date the migration in UTC
The composite index doesn't serve everything the room_id one did. In
SQLite that index is (room_id, rowid), so it also reads a room's messages
in id order, and it is the narrower one to count a room with. Keeping both,
as memberships already does, leaves those lookups as they were.

The migration was named after local time, two hours ahead of the UTC
timestamp Rails generates; it now carries a UTC one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:39:36 +02:00
Marcello CostagliolaandClaude Opus 5.5 6cdcb459b9 Search for operator words instead of parsing them
The search box keeps only word characters, but FTS5 still reads AND, OR,
NOT and NEAR in the query as operators, so searching for "AND" or for
"salt AND" raised "fts5: syntax error" and answered with a 500. Quoting
each word makes FTS5 look for it as text, which is how it already treats
the same words in lower case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:08:53 +02:00
Marcello CostagliolaandClaude Opus 5.5 5cb4316133 Index messages by room and creation time
Every page of a room's messages (opening the room, scrolling back, the
refresh after reconnecting, the bot API) selects WHERE room_id = ? ORDER BY
created_at LIMIT 40. With only the room_id index, SQLite reads every
message in the room and sorts them in a temporary B-tree to return 40, so
these requests grow with the room's history.

A composite index on (room_id, created_at) lets SQLite read the 40 rows in
order straight from the index. It covers every lookup the room_id index
served, so that one goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 00:55:24 +02:00
David Heinemeier Hansson 254dd1d46f Add Django and Laravel benchmarks and label the baseline Rails (#294) 2026-10-04 18:18:31 -04:00
David Heinemeier Hansson 6c7f8fa15f Document other Campfire implementations and benchmarks (#293)
* Document other Campfire implementations and benchmarks

* Remeasure README benchmarks with the latest Ruby optimizations

* Shorten the README benchmark context
2026-10-04 14:10:38 -04:00
David Heinemeier Hansson 659f95748a Preload only uncached messages and reduce rendering overhead (#292)
* Preload only uncached messages and reduce rendering overhead

* Keep benchmark summaries without raw JSON results

* Use Ruby benchmark drivers and keep generated results out of the repo
2026-10-04 12:36:36 -04:00
Stanko Krtalić 90b330024d Merge pull request #284 from basecamp/mention-menu-spacing
Fix mention menu spacing and check the registry login before releasing
2026-09-26 13:03:34 +02:00
Stanko K.R. e5cfffda67 Check the registry login before releasing
The GitHub release was created before the image push failed on a missing
docker login, leaving the release half done.
2026-09-26 11:16:03 +02:00
Stanko K.R. 87ba7aa3cc Keep the message body list styles out of the mention menu
The composer editor carries the lexxy-content class, so the !important
list rules for message bodies hit the menu's ul and li too, leaving the
items cramped and pushed in from the left.
2026-09-26 11:16:03 +02:00
Stanko Krtalić 12249b9b3a Merge pull request #283 from basecamp/composer-drafts
Keep an unsent message as a draft while switching rooms
2026-09-26 10:43:55 +02:00
Stanko K.R. 5c5821a395 Keep an unsent message as a draft while switching rooms
The composer stores what's being written in localStorage per room and
restores it when the room is opened again. Sending clears it.
2026-09-26 10:38:13 +02:00
Stanko Krtalić 8fa6138fb9 Merge pull request #254 from basecamp/dependabot/bundler/web-push-3.1.0
build(deps): bump web-push from 3.0.2 to 3.1.0
2026-09-26 10:28:20 +02:00
Stanko Krtalić beaf467a38 Merge pull request #257 from basecamp/dependabot/bundler/mocha-3.1.0
build(deps-dev): bump mocha from 2.7.1 to 3.1.0
2026-09-26 10:27:58 +02:00
Stanko Krtalić 9a258bd1a0 Merge pull request #224 from basecamp/replace-trix-with-lexxy
Replace Trix with Lexxy
2026-09-26 10:27:29 +02:00
Stanko Krtalić f8e36e9761 Merge pull request #282 from basecamp/dependabot/bundler/rubyzip-3.4.0
build(deps-dev): bump rubyzip from 3.0.2 to 3.4.0
2026-09-26 10:23:25 +02:00
Stanko Krtalić 83e7a7f4c9 Merge pull request #281 from basecamp/dependabot/github_actions/github-actions-f89073ee97
build(deps): bump the github-actions group with 4 updates
2026-09-26 10:23:12 +02:00
Stanko Krtalić eadbaab7d2 Merge pull request #262 from excid3/redis-url-env
Support Redis configuration with REDIS_URL env var
2026-09-26 10:22:53 +02:00
Stanko K.R. b5d3543e64 Test that Trix-formatted messages render and survive editing 2026-09-26 10:21:40 +02:00
Stanko K.R. 1694accbf8 Let tables through the message sanitizers
Lexxy imports pasted and Markdown tables, and the tag sanitizer dropped
them with everything in them on render.
2026-09-26 10:04:38 +02:00
Stanko K.R. 8bdff5ddc6 Run the link preview hardening tests against both stored forms
Every case only exercised the Trix attribute form, so dropping the URL
validation from the Lexxy content parser went unnoticed.
2026-09-26 09:59:13 +02:00
Stanko K.R. 6acad0549d Rebuild every attachment before editing a message
The editor keeps an attachment's content as it finds it, so a hand-written
embed with a url but no href reached the editor with its markup unvalidated,
and a mention saved under Trix's editor carried the generic octet-stream
content type the editor doesn't permit and was dropped on save. Rebuilding
each attachment from its attachable renders the hardened preview partial
and restores the mention content type.
2026-09-26 09:51:05 +02:00
dependabot[bot] 6e7dd2fa0a build(deps-dev): bump rubyzip from 3.0.2 to 3.4.0
Bumps [rubyzip](https://github.com/rubyzip/rubyzip) from 3.0.2 to 3.4.0.
- [Release notes](https://github.com/rubyzip/rubyzip/releases)
- [Changelog](https://github.com/rubyzip/rubyzip/blob/main/Changelog.md)
- [Commits](https://github.com/rubyzip/rubyzip/compare/v3.0.2...v3.4.0)

---
updated-dependencies:
- dependency-name: rubyzip
  dependency-version: 3.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-26 07:33:43 +00:00
Stanko K.R. eab554aa4b Adapt the Lexxy composer to main's link preview hardening
Main strips a javascript: href from a preview node before it's parsed, so
tell legacy Trix attachments apart by their filename instead. escapeHTML
moved to the string helpers, and the unfurling system test now drives
the Lexxy editor.
2026-09-26 09:26:53 +02:00
Stanko K.R. 1bb7ef7c17 Fix Codex's code review comments 2026-09-26 09:13:44 +02:00
Stanko K.R. c38e77a897 Ensure backwards compatibility with Trix 2026-09-26 09:13:35 +02:00
Stanko K.R. 9b1602d088 Polish 2026-09-26 09:13:27 +02:00
Stanko K.R. 47bc5f5425 Replace Trix with Lexxy 2026-09-26 09:13:27 +02:00
dependabot[bot] 07f2482970 build(deps): bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [ruby/setup-ruby](https://github.com/ruby/setup-ruby), [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action), [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `ruby/setup-ruby` from 1.321.0 to 1.324.0
- [Release notes](https://github.com/ruby/setup-ruby/releases)
- [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb)
- [Commits](https://github.com/ruby/setup-ruby/compare/95ef2b042f9d7a56d8268cba8559e2842e2ad01b...a0102e0972be65f351c307e2d64b9314a57c8073)

Updates `zizmorcore/zizmor-action` from 0.6.3 to 0.6.4
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/70fb788f84895a7701f5643d103d587e460b5c99...cc914d7f3750a2d13d75c7f184a1060aa0e9d482)

Updates `docker/setup-buildx-action` from 4.3.0 to 4.4.1
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e...f87e5991a6d7451dcb8d9637bfbc97413f497069)

Updates `docker/build-push-action` from 7.3.0 to 7.4.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a...c3c9e263c25d99ce0380d002d59b67737d91b0dc)

---
updated-dependencies:
- dependency-name: ruby/setup-ruby
  dependency-version: 1.324.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-25 19:35:29 +00:00
Sam RubyandClaude Opus 5.5 91d294f4a0 Mint the tampered attachable sgid without extending a Room
The companion to #279: the same per-instance `extend` appeared in
test/lib/rails_ext/action_text_attachables_test.rb. `attachable_sgid` is
`to_sgid(expires_in: nil, for: ActionText::Attachable::LOCATOR_NAME).to_s`,
so mint that directly; the minted bytes and the assertion are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:29:07 -05:00
Jeremy Daer 9ff6b4a381 Keep .claude out of the Docker build context (#266)
Agent worktrees under .claude/worktrees are whole extra checkouts of
this repo, and the build context picks them up. .claude is local
session state and never belongs in an image.
2026-09-22 14:09:33 -05:00
Jeremy Daer e3b4cbc674 Bump surfguard to the published 0.2.0 (#271)
Moves the pin from the pre-release 910be91 (0.1.0) to 59e278c, the v0.2.0
tag Fizzy already runs. The default policy now admits IPv6 only inside
IANA-allocated unicast prefixes instead of default-allowing reserved and
unallocated space. No call-site changes: both shim entry points take the
new policy keyword's default.
2026-09-22 14:09:24 -05:00
dependabot[bot] b7da9c63f7 build(deps): bump zizmorcore/zizmor-action in the github-actions group (#277)
Bumps the github-actions group with 1 update: [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action).


Updates `zizmorcore/zizmor-action` from 0.6.2 to 0.6.3
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/3dc1ecc9bcb9e94e9b2c709687979e1298497054...70fb788f84895a7701f5643d103d587e460b5c99)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 14:09:10 -05:00
Rosa Gutierrez ed0f9a5dba Merge pull request #279 from rubys/attachment-test-without-extend
Mint the tampered sgid without extending a Room
2026-09-20 21:08:13 +02:00
Sam Ruby 0ed0af44ef Mint the tampered sgid without extending a Room
The invalid-sgid test made one live Room attachable — `rooms(:pets).tap
{ |r| r.extend ActionText::Attachable }` — only to call
`attachable_sgid` on it. That method is `to_sgid(expires_in: nil,
for: ActionText::Attachable::LOCATOR_NAME).to_s`, so the test can mint
the same sgid directly and drop the per-instance extend; the assertion
(a signature that does not verify resolves to MissingAttachable) is
unchanged, and so are the bytes it tampers with.
2026-09-20 11:19:14 -04:00
Rosa Gutierrez 977cbcd135 Merge pull request #276 from basecamp/card-7348960853-room-render-injection
Render link previews only from web URLs
2026-09-11 21:29:14 +02:00
Rosa GutierrezandClaude Opus 5 5742dfaf73 Cover markup-only OpenGraph title and description in link previews
Link previews fetch a page's OpenGraph title and description, and
Opengraph::Metadata strips tags from both before the values reach the
browser. When a field consists entirely of a markup tag, stripping
leaves it blank, the metadata fails its presence validation, and the
unfurl endpoint returns no content, so no preview is produced.

Add regression tests at the model and controller layers that pin this:
a title or description made only of a markup tag is stripped to blank
and rejected, and the endpoint answers 204. The existing sanitize tests
only cover fields that keep non-blank text after stripping, so this
blank-and-rejected path was previously untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 21:11:13 +02:00
Rosa GutierrezandClaude Opus 5 8722057545 Keep one escapeHTML, and make it safe in an attribute
There were two: the one in dom_helpers escaped through a text node, which
leaves double quotes alone, so it could not have closed the hole in the
link preview's img src.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 21:10:57 +02:00
Rosa GutierrezandClaude Opus 5 e6022a52c3 Assert only that the preview image gained no extra attributes
Pinning the exact attribute set also pinned which of them Trix's own
sanitizer keeps, which is not what this test is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 21:10:57 +02:00
Rosa GutierrezandClaude Opus 5 c1ad057db8 Escape the OpenGraph image URL in link previews
Pasting a link builds the preview by interpolating the unfurled metadata
into an HTML string. The image URL went into src="..." unescaped, so a
page whose og:image carries a double quote closes the attribute early and
everything after it becomes attributes on the preview's img element.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 21:10:57 +02:00
Rosa GutierrezandClaude Opus 5 436ea06457 Read a preview's host as a name by its last label
A domain name ends in a word, which is what keeps it from reading as an
address. "0x7f.0.0.1" carries a dot and a letter, so the previous shape
check let it through while a browser fetched 127.0.0.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 20:25:35 +02:00
Rosa GutierrezandClaude Opus 5 9e19658ee0 Take a link preview's host as a domain name, not an address
A browser rewrites the many spellings of an address into one before it
fetches, so "http://2130706433/rooms/1" arrives at 127.0.0.1 while a
comparison here still reads the digits. A preview names a page on the
public internet, so require its host to look like a domain name and leave
the rewriting race alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 20:21:01 +02:00
Rosa GutierrezandClaude Opus 5 32e3e5aea8 Bust the cached message presentation
The room caches each message's rendered presentation, and its key can't
see the link preview partial, which ActionText renders by name rather than
through a render call the digestor can follow. Without a new version, a
message already in the cache would keep its old preview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 19:20:46 +02:00
Rosa GutierrezandClaude Opus 5 ef4b88d748 Compare a preview's host to ours with the escapes resolved
Ruby leaves a percent-escape in URI#host, so "https://%77ww.example.com"
read as a different host than the one Campfire answers on while a browser
unescaped it straight back to us. A host that carries an escape, or a
trailing dot, is now measured the way the browser will read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 19:14:59 +02:00
Rosa GutierrezandClaude Opus 5 eceec2898b Keep a link preview's link and image off this Campfire's own host
A preview belongs to the page it previews, so both URLs point somewhere
else. An absolute URL on our own host passed the scheme and host checks,
and every reader's browser fetched it with their session attached, which
turns a message into a GET request made on the reader's behalf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 19:08:54 +02:00
Rosa GutierrezandClaude Opus 5 c3ae67a2b6 Require a host on a link preview's link and image
Ruby parses "https:/rooms/1" as an HTTPS URL with no host, and a browser
resolves it against whatever origin Campfire is served from, so the scheme
check alone still let a message body aim the preview at a path here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 19:02:25 +02:00
Rosa GutierrezandClaude Opus 5 3a501cd32c Render link previews only from web URLs
A link preview's link and image come from attributes on the message body,
which the composer fills in from the unfurl the server performed. A body
written by hand can put anything in those attributes, and the preview
partial rendered them as they were.

Keep the link and the image only when they parse as absolute http or https
URLs, so nothing in a message body can aim either one at another scheme or
at a path on this Campfire, and render the title and the description as
text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
2026-09-11 18:58:04 +02:00
Jeremy Daer ef147d17db Redact bot key from request logs (#269)
The bot HTTP API carries 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 was written
verbatim to the request log (the "Started POST ..." line) and to any
log line echoing the pagination Link header.

Add a log formatter that redacts the bot-key path segment wherever it
appears in a formatted line, and wire it into the production logger.
2026-09-01 12:40:55 -07:00
Jeremy Daer 9dd19d0c95 Close live Action Cable connections on sign out (#268)
Action Cable authorizes a Connection once at the WebSocket handshake and
never re-checks it. Destroying the session record refuses future handshakes
and HTTP requests bearing the cookie, but a socket opened before sign out
keeps its handshake-time current_user and keeps authorizing new
subscriptions and delivering frames as the signed-out user.

Reset the user's remote connections when the session is terminated. Clients
tear down and reconnect: the signed-out device carries a destroyed session
and cleared cookie and is rejected at the fresh handshake, while the user's
other devices with still-valid sessions reconnect and stay live. This reuses
the existing reset_remote_connections primitive already used on membership
removal, for the same reason.

Run the disconnect last and best-effort, after the session record and cookie
are already gone, so sign out completes even when the realtime service is
unreachable.
2026-08-31 18:21:16 -07:00
Jeremy Daer 79eb9a5516 Require authentication for Active Storage direct uploads (#267)
Active Storage mounts its direct-upload write endpoints -- POST
/rails/active_storage/direct_uploads and the disk-service PUT at
/rails/active_storage/disk/:token -- on framework controllers that inherit
from ActiveStorage::BaseController, so they never pass through
ApplicationController's require_authentication. Anyone who can read the
public login page can lift a CSRF token and Rails session cookie, POST to
the metadata endpoint, and receive a signed disk PUT URL without holding a
Campfire session_token.

That is enough to allocate ActiveStorage::Blob rows and persist bytes to
disk anonymously. The blobs stay unattached (no message can be created
without an account) and nothing purges them, so an unauthenticated caller
can grow storage without bound. Because the recommended self-host layout
co-locates uploaded files and the SQLite database on one /rails/storage
volume, that growth eventually makes database writes fail -- blocking login
and messaging until an administrator frees space and purges the blobs.

Campfire uploads attachments through MessagesController as a normal
multipart POST and does not use direct uploads at all, so these endpoints
have no legitimate anonymous caller. Require a valid Campfire session
before the metadata endpoint allocates a blob or the disk endpoint accepts
an upload; both return 401 to anonymous callers. Serving (disk#show,
representations, blob redirects) is unchanged.
2026-08-30 10:24:41 -07:00
Jeremy Daer 94a48aacb6 Guard push-subscription endpoints against SSRF (#265)
* Guard push-subscription endpoints against SSRF

Web push delivery POSTed to the endpoint URL a user supplied when
registering a subscription, with no scheme, host, or private-network
check -- unlike the OpenGraph unfurl path, which already routes through
the shared SSRF address policy (surfguard). Any authenticated user could
register a subscription whose endpoint pointed at an internal address and
have the server fetch it on every chat message: blind SSRF for internal
recon and reachability probing, plus a thread-pool DoS on the delivery
pool.

Validate the endpoint when the subscription is saved: it must be HTTPS,
its host must belong to a known browser push service (allowlist), and it
must resolve to a public IP. On every delivery, re-resolve the host and
pin the connection to that public IP so a later DNS rebind can't redirect
the request to an internal address. If no public IP resolves at delivery
time -- a rebind, or a subscription that predates this validation --
delivery is skipped rather than falling back to re-resolving the raw
host.

Adds model, controller, and delivery-pinning tests, plus a DNS stub
helper for deterministic resolution in tests.

* Route push endpoint guarding through RestrictedHTTP::PrivateNetworkGuard

The endpoint SSRF check already delegated its private-network classification
to surfguard, but called Surfguard.resolve_public_ips directly rather than
through RestrictedHTTP::PrivateNetworkGuard -- the hostname-in, address-out
shim #241 established as the app's single guarded-outbound entry point and
that Opengraph::Fetch resolves through. Route push resolution through the same
guard so once-campfire keeps one place that resolves and classifies outbound
addresses. Behavior is unchanged: the guard raises Violation when a permitted
host resolves only to blocked addresses (previously an empty list -> nil) and
propagates Surfguard::Unresolvable for a host that resolves to nothing; both
map to a nil endpoint IP, which fails validation and skips delivery. The
allowlist, HTTPS/443 constraints, and per-delivery IP pinning are unchanged.

* Address push-SSRF review: defer DNS off enqueue path, disable proxy on pinned path, revalidate on re-registration

- Resolve the guarded endpoint IP lazily inside WebPush::Notification#deliver
  (on the bounded delivery worker) instead of eagerly when the notification is
  built on the serial enqueue path, so a slow resolver can't stall the push job
  before any delivery starts. resolved_endpoint_ip only reads the already-loaded
  endpoint attribute, so it is safe off the AR connection.
- Pin the delivery socket with an explicit nil proxy address so http_proxy/
  https_proxy can't route the request through a proxy that re-resolves the host
  and defeats the ipaddr pin.
- Revalidate an existing subscription on re-registration so a row predating
  endpoint validation gets the same 422 as a fresh create instead of being kept
  alive by touch.
- Regression tests: resolution deferred to delivery, pin survives proxy env,
  legacy invalid row rejected with 422.

* Bound the web-push delivery queue (max_queue, not the ignored queue_size)

Concurrent::ThreadPoolExecutor takes :max_queue; :queue_size was silently
ignored, leaving the delivery backlog unbounded (max_queue: 0). A flood of
valid push subscriptions could accumulate queued deliveries without limit --
more acute now that each delivery task also resolves DNS. Using max_queue: 10000
activates the intended cap; overflow raises RejectedExecutionError under the
default :abort policy, which deliver_later already rescues (push is best-effort,
retried on the next message).

* Trim push-SSRF guard comments to match house style

Apply the review suggestions on the push-subscription SSRF guard: replace
the verbose rationale comments with terse one-liners (or drop them where
the code speaks for itself). No behavior change -- the Surfguard-shim
routing, per-delivery public-IP pin, and bounded delivery queue are
untouched.
2026-08-28 11:44:39 -07:00
Chris Oliver c7ad91c088 Support Redis configuration with REDIS_URL env var 2026-08-24 11:26:38 -05:00
Jeremy Daer e63402d60e Merge pull request #259 from basecamp/dependabot/github_actions/github-actions-ed39cb2a14
build(deps): bump the github-actions group across 1 directory with 7 updates
2026-08-24 08:39:19 -07:00
dependabot[bot] 6e25ddd92b build(deps): bump the github-actions group across 1 directory with 7 updates
Bumps the github-actions group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4.3.1` | `7.0.1` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3.11.1` | `4.3.0` |
| [docker/login-action](https://github.com/docker/login-action) | `3.5.0` | `4.6.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `5.8.0` | `6.2.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6.18.0` | `7.3.0` |
| [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) | `3.0.0` | `4.2.2` |
| [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) | `3.9.2` | `4.1.2` |



Updates `actions/checkout` from 4.3.1 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.3.1...3d3c42e5aac5ba805825da76410c181273ba90b1)

Updates `docker/setup-buildx-action` from 3.11.1 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/e468171a9de216ec08956ac3ada2f0791b6bd435...37fe631027851001ddb9b187196cc803df7f5f0e)

Updates `docker/login-action` from 3.5.0 to 4.6.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/184bdaa0721073962dff0199f1fb9940f07167d1...dbcb813823bdd20940b903addbd779551569679f)

Updates `docker/metadata-action` from 5.8.0 to 6.2.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/c1e51972afc2121e065aed6d45c65596fe445f3f...dc802804100637a589fabce1cb79ff13a1411302)

Updates `docker/build-push-action` from 6.18.0 to 7.3.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/263435318d21b8e681c14492fe198d362a7d2c83...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a)

Updates `actions/attest-build-provenance` from 3.0.0 to 4.2.2
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/977bb373ede98d70efdf65b84cb5f73e068dcc2a...4d101475d8b20a2381f78447822ac1eab6504dd8)

Updates `sigstore/cosign-installer` from 3.9.2 to 4.1.2
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/d58896d6a1865668819e1d91763c7751a165e159...6f9f17788090df1f26f669e9d70d6ae9567deba6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/metadata-action
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.2.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 02:19:57 +00:00
Jeremy Daer fbc3fba364 Merge pull request #258 from basecamp/retire-docker-updater-and-unify-ruby
Retire the inert docker updater, bump setup-ruby, and put every Ruby pin on 3.4.10
2026-08-23 19:18:19 -07:00
Jeremy Daer b827d7ca9b Put every Ruby pin on 3.4.10
Three pins, two values: Dockerfile said 3.4.5, Dockerfile-export said
3.4.7, .ruby-version said 3.4.5 — the export image had already been
bumped on its own and nobody noticed the other two lagging. Both
Dockerfiles carry a comment telling you to keep them in step.

3.4.10 is the current 3.4.x. CI resolves its Ruby from .ruby-version,
so this is the version the tests now run under.
2026-08-22 12:30:43 -07:00
Jeremy Daer 22b6d4bdaf Bump ruby/setup-ruby to v1.321.0
v1.295.0 predates Ruby 3.4.10 — its baked-in version index stops at
3.4.9, so asking for anything newer fails with "Unknown version 3.4.10
for ruby on ubuntu-24.04". The next commit needs 3.4.10, and pinning
our Ruby to whatever an old action happens to know is backwards.

SHA verified against the v1.321.0 tag.
2026-08-22 12:30:43 -07:00
Jeremy Daer 431aad8b71 Retire the docker ecosystem from Dependabot
The base image tag is a build arg — `ARG RUBY_VERSION` plus
`FROM ruby:$RUBY_VERSION-slim` — and Dependabot's docker updater matches
literal tags, so this entry has never had anything to propose. It ran
green every week and reported nothing, which reads as coverage and
isn't.

No other repo in the fleet configures a docker ecosystem, and none
could: they all either interpolate a variable or pull from the internal
registry. Inlining the tag here to buy coverage would make this
Dockerfile the outlier instead, against Rails-generated boilerplate.
Ruby bumps stay a manual, human-decided step.
2026-08-22 02:21:49 -07:00
dependabot[bot] aa9fe42810 build(deps-dev): bump mocha from 2.7.1 to 3.1.0
Bumps [mocha](https://github.com/freerange/mocha) from 2.7.1 to 3.1.0.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.7.1...v3.1.0)

---
updated-dependencies:
- dependency-name: mocha
  dependency-version: 3.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-22 04:52:14 +00:00
dependabot[bot] db5275918f build(deps): bump web-push from 3.0.2 to 3.1.0
Bumps [web-push](https://github.com/pushpad/web-push) from 3.0.2 to 3.1.0.
- [Release notes](https://github.com/pushpad/web-push/releases)
- [Commits](https://github.com/pushpad/web-push/compare/v3.0.2...v3.1.0)

---
updated-dependencies:
- dependency-name: web-push
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-22 04:51:22 +00:00
Jeremy Daer 9310b002d7 Drop the unsupported semver cooldown keys from the docker ecosystem (#251)
The docker block copied bundler's cooldown wholesale, but Dependabot only
accepts semver-major/minor/patch-days for ecosystems whose versions it
classifies as semver, and container tags aren't. One invalid property
invalidates the entire file rather than the block it sits in, so since
this config landed in #248 the version updater has not run for any
ecosystem at all:

  Your .github/dependabot.yml contained invalid details
  The property '#/updates/2/cooldown/semver-major-days' is not supported
  for the package ecosystem 'docker'. (and -minor-, -patch-)

That is why #249's cooldown exclude for brakeman never took effect, and
why #250 had to bump the workflow linter pins by hand while every other
repo got a Dependabot PR. It also left `bin/brakeman --ensure-latest 15`
armed with nothing to disarm it: the lock pins brakeman 8.0.6, and CI
would have gone red roughly 15 days after 8.0.7 shipped.

Security updates were never affected — those don't read this file, which
is why the only four Dependabot runs here are single-gem security bumps.

docker keeps default-days, which is supported for every ecosystem.
2026-08-21 21:49:40 -07:00
Jeremy Daer dfd2dd2bca Bump actionlint and zizmor-action to current releases (#250)
Dependabot version updates are disabled for this repo, so the weekly
github-actions group never proposed these. The pins had been frozen since
March: zizmor-action v0.5.2 ships zizmor 1.23.1, which flags
secrets-outside-env at the default persona -- that moved to the auditor
persona in zizmor 1.24.0. v0.6.2 ships zizmor 1.29.0.

Both linters pass locally at the new versions.
2026-08-20 03:08:26 -07:00
Jeremy Daer c860b51109 Take the SSRF address policy from surfguard instead of keeping our own copy (#241)
* Take the SSRF address policy from surfguard instead of keeping our own copy

Four other apps carried this same classification and the five had drifted into
four different ideas of what "internal" means. It now comes from the surfguard
gem, which is their union. resolve and Violation keep their shapes, so the
opengraph callers are unchanged.

Two verdicts change.

SIIT (::ffff:0:0:0/96) is now recognised. It is the third way an IPv4 address
rides inside an IPv6 one and the only one Ruby has no predicate for --
ipv4_mapped?, ipv4_compat?, private?, loopback? and link_local? are all false
for ::ffff:0:a9fe:a9fe, so it fell through to the IPv6 branch unrecognised and
reached the metadata endpoint. Note the extra group: ::ffff:0:0:0/96 is not the
IPv4-mapped ::ffff:0:0/96 the guard already refused, and the two do not overlap.

The RFC 8215 local-use NAT64 block is now refused whole rather than decoded.
Reading its low 32 bits as an embedded IPv4 is only correct for a /96 Pref64;
the block can host any length from /32 to /96 and the position is not
recoverable from the address alone (RFC 6052 2.2), so the decode reads the
wrong octets. It is never globally routed, so refusing it costs nothing. The
well-known /96 is still decoded and re-checked, so DNS64 for public sites on
IPv6-only hosts keeps working.

Resolution moves from Resolv.getaddress to Resolv.getaddresses, so the guard
sees every address a host answers with rather than only the first.

* Distinguish a DNS lookup failure from a private-IP block in the guard

Advance the surfguard pin so resolve_public_ips raises Unresolvable when a
host resolves to nothing and returns an empty list only when it resolves to a
blocked address. The shim lets Unresolvable propagate as a lookup failure --
matching the old Resolv.getaddress behavior -- and reserves Violation for a
resolved-but-blocked address, so a transient DNS miss is no longer reported as
an SSRF attempt.
2026-08-20 01:59:19 -07:00
Jeremy Daer 487125dc4c Bump brakeman to 8.0.6 and stop --ensure-latest reddening CI (#249)
* Bump brakeman to 8.0.6

Brakeman 8.0.6 shipped 2026-08-12: corrected Rails 8.0 EOL date, added
Rails 8.1 and Ruby 4.0 EOL dates, and fixed command-injection false
positives.

brakeman's only runtime dependency is racc and its required ruby is
>= 3.2.0, both unchanged since 7.1.2, so this is a version bump with no
other movement in the graph.

* Give brakeman's --ensure-latest a 15-day grace period

Bare --ensure-latest exits 5 the moment a newer brakeman exists, so a
release turns this build red before anyone has a chance to react. That
is what happened on 2026-08-12 when 8.0.6 shipped.

The flag takes an optional minimum age in days and only complains once
the latest release is at least that old. 15 is the maximum it accepts;
brakeman rejects anything outside 1-15.

* Exempt brakeman from the dependabot cooldown

The grace period on --ensure-latest is only headroom if the bump lands
inside it. A weekly schedule plus a 7-day cooldown can take 14 days to
so much as open the PR, leaving a single day to merge it.

Excluding brakeman from the cooldown caps the delay at the weekly
schedule, comfortably inside the 15 days.
2026-08-19 09:27:49 -07:00
Jeremy Daer e33b7e50c9 Update mail to 2.9.1 for GHSA-mvxr-6m87-mv2q (#248)
The advisory (email address spoofing via malformed RFC 2047
encoded-words, CVE-2026-63435) landed in ruby-advisory-db on
2026-08-18, turning the gem audit red since the audit pulls a live
advisory DB. 2.9.1 is the patch release for exactly this CVE.
2026-08-18 16:47:08 -07:00
Mike Dalessio f1aec61336 dep: update sqlite3 to 2.9.6 (#243) 2026-08-12 10:03:33 -04:00
Stanko Krtalić baeccebd90 Merge pull request #238 from basecamp/dependabot/bundler/json-2.21.2
Bump json from 2.20.0 to 2.21.2
2026-08-11 14:41:14 +02:00
Stanko Krtalić 7ab769a9f9 Merge pull request #242 from basecamp/feat/bot-boost-update
Allow bots to delete their own boosts
2026-08-11 14:40:45 +02:00
Stanko KrtalićandCopilot Autofix powered by AI 1e9f681b3a Scope boosts to the current message
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-11 14:33:05 +02:00
Stanko K.R. 656215b186 Allow bots to delete their own boosts 2026-08-11 14:13:01 +02:00
Stanko Krtalić 39bab76e38 Merge pull request #239 from ronaldlokers/feat/bot-message-update
Allow bots to update and destroy their own messages
2026-08-11 13:45:27 +02:00
Stanko K.R. e8251401ce Adjust to match in-house style
- Remove comments that explain expected behaviour
- Use respond_to instead of separate methods
- Return the updated object on update
2026-08-11 13:42:48 +02:00
Ronald Lokers 3ca1dcbf77 Allow bots to update and destroy their own messages
Bots can only create. A lifecycle notification — an alert that fires and then
resolves, a deploy that starts and finishes, a backup that runs — therefore has
to post a second message, and the room becomes an append-only log of states
rather than a view of the current one.

Adds PATCH and DELETE inside the existing bot_key scope, routed to
Messages::ByBotsController. The body is read the way create reads it, so
updating a message is the same request shape as posting one.

No new authorization: both actions already run through ensure_can_administer,
and can_administer? grants access only to a record the user created, so a bot
key reaches that bot's own messages and no others. set_room narrows it again by
looking the room up through the bot's own memberships. A leaked bot key gains
what it could already do by posting: write to rooms that bot belongs to.

update answers head :ok rather than the redirect, which meant extracting the
update and its broadcast into update_message — calling super and then head
would double render, since the parent redirects inside the action. destroy
needs no split, because the parent renders implicitly like create does.
2026-08-11 13:33:01 +02:00
Stanko Krtalić 766bffae56 Merge pull request #190 from jpshackelford/feature/bot-read-messages
feat: Add bot API endpoints for reading messages and adding reactions
2026-08-11 13:24:19 +02:00
Stanko K.R. 80c9e7fbfe Adjust to the in-house style
- Use jbuilder instead of hashes
- Use resource instead of direct HTTP verbs
    Verbs only make sense if you have one or two routes, if there are
    multiple that emulate what resource does then it's better to use resource.
- Paginate using link headers
- Cache responses
2026-08-11 13:15:38 +02:00
Jeremy Daer d3f22d78e1 Harden message content filtering + bump thruster to 0.1.23 (#237)
* Bump thruster 0.1.15 → 0.1.23

* Scrub disallowed attributes on allowed tags in message rendering

SanitizeTags removes disallowed tags from message presentation, but
attributes on the tags it allows passed through untouched. Extend the
content-filter chain with a SanitizeAttributes filter that runs Rails'
safe-list sanitizer over the remaining markup, stripping event-handler
attributes and unsafe URI schemes as defense-in-depth alongside the
existing Content-Security-Policy.

Running it after SanitizeTags with the same allowed-tags list makes the
sanitizer's tag pass a no-op, preserving SanitizeTags' remove-not-unwrap
semantics while scrubbing attributes. The attribute allowlist is the
standard ActionText set (which keeps attachments intact) plus class,
which presentation styling relies on.
2026-08-10 15:55:02 -07:00
dependabot[bot] 6ed9331a97 Bump json from 2.20.0 to 2.21.2
Bumps [json](https://github.com/ruby/json) from 2.20.0 to 2.21.2.
- [Release notes](https://github.com/ruby/json/releases)
- [Changelog](https://github.com/ruby/json/blob/master/CHANGES.md)
- [Commits](https://github.com/ruby/json/compare/v2.20.0...v2.21.2)

---
updated-dependencies:
- dependency-name: json
  dependency-version: 2.21.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 20:17:26 +00:00
Stanko K.R. 2aa4141077 Port over automatic note generation 2026-08-04 17:13:07 +02:00
Stanko Krtalić 27e9257b2a Merge pull request #231 from basecamp/once-backup-hooks
Add ONCE backup and restore hooks
2026-08-04 17:11:19 +02:00
Jeremy Daer dd247a623b Merge pull request #232 from basecamp/security/room-type-scoping
Scope room administration, authorize message streams, and make unread rooms per-user
2026-08-03 16:27:38 -07:00
Jeremy Daer 3b509f55ca Scope the unread rooms stream per user
UnreadRoomsChannel streamed from a hardcoded global name, and every message
published its room id to it. Any authenticated user, including one with no
memberships at all, could subscribe and watch which rooms were active and exactly
when, across every closed room and direct conversation on the account. The browser
filters ids it doesn't recognise, but that happens after delivery.

Its sibling ReadRoomsChannel is already scoped per user; this mirrors it, and
message creation fans the notice out to the room's members instead of broadcasting
it to everyone. No message content was exposed either way, only the timing.
2026-08-03 14:55:19 -07:00
Jeremy Daer ee37809220 Authorize the room message stream at subscribe time
Message content is delivered over turbo streams, which ran on the stock
Turbo::StreamsChannel. That channel verifies the signature on the stream name and
nothing else. The name carries no expiry and no binding to a user, so one read off
the page while a member kept working after the membership was revoked.

Revocation made this worse rather than better. Membership#after_destroy_commit
disconnects the user with reconnect: true, and the client replays its subscriptions
on the new socket: RoomChannel re-checks membership and rejects, while the turbo
subscription re-verified only the signature and was accepted.

RoomMessagesChannel re-checks membership on every subscribe, deriving the room from
the verified stream name so there is no parameter to point elsewhere. Since the
subscriber names the channel it wants, the stock channel would otherwise be a way
around that check, so it now turns these stream names away and this is the only door.
2026-08-03 14:55:12 -07:00
Jeremy Daer 5c5c82b27a Scope room lookup to the type each controller administers
Rooms::DirectsController relaxes ensure_can_administer to true, because every
participant in a direct room may administer it. set_room was inherited unscoped,
though, so that relaxation applied to any room the caller was merely a member of:
DELETE /rooms/directs/<id> destroyed open and closed rooms and all their messages.

The same unscoped lookup let a direct room be loaded by the opens and closeds
controllers, where force_room_type promoted it. Promoting a DM to open grants every
user on the account membership and republishes the whole conversation, including the
other participant's messages; converting it to closed lets the initiator revise who
is in it and lock the other participant out.

Each controller now narrows room_scope to the types it may act on. Opens and closeds
keep reach into each other, since converting between them is a feature. Neither can
reach a direct room, and directs can only reach directs.

Room also refuses to change type away from Rooms::Direct, so the invariant holds for
any future caller of becomes! rather than only these two controllers.
2026-08-03 14:55:04 -07:00
Kevin McConnell 2d749057f9 Add ONCE backup and restore hooks
By default, ONCE pauses the application container when taking a backup.
This guarantees a consistent snapshot of the data, but for a large
installation and/or a slow destination path (like a network mount), that
pause could be a bit disruptive to live traffic.

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

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

- `post-restore` moves our snaphotted file back into place, and removes
  the orphaned sidecar files
2026-08-03 12:41:01 +01:00
Mike Dalessio d28ab55bf4 Document the self-hosted trust model in SECURITY.md (#230)
Nothing in the repository records that unrestricted bot webhook delivery is
deliberate, so the missing private-network guard reads as an oversight next to
the guarded unfurl path. Researchers report it as server-side request forgery,
repeatedly.
2026-07-31 13:09:59 -04:00
Stanko Krtalić 50f25cea80 Merge pull request #229 from basecamp/fix-version-number
Fix version number for CI built images
2026-07-30 18:51:01 +02:00
Stanko K.R. d79225ecd1 Fix version number for CI built images 2026-07-30 18:34:54 +02:00
Mike Dalessio b065b40a34 Disable libvips unfuzzed operations (#226)
and add test coverage for (un)supported file types.

The avatar and logo variants move into the models and return nil for content
types that are no longer variable, so the controllers fall back to the initials
avatar and stock logo icon instead of raising `ActiveStorage::InvariableError`.
2026-07-28 11:57:57 -04:00
John-Mason Shackelfordandopenhands 20ab5b1bc6 feat: Add bot API endpoint for adding reactions (boosts) to messages
Adds a new endpoint that allows bots to add emoji reactions (boosts) to messages:
POST /rooms/:room_id/:bot_key/messages/:message_id/boosts

This enables bots to acknowledge messages with reactions like 👀 (eyes) when
mentioned, providing immediate feedback to users before generating a full response.

The endpoint:
- Validates the bot is a member of the room
- Validates the message exists in the room
- Broadcasts the boost to connected clients via Turbo Streams
- Returns 201 Created on success, 404 if room/message not found

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 18:53:03 -04:00
John-Mason Shackelfordandopenhands e8c1349aac style: Use bot? predicate instead of role comparison
Changed `message.creator.role == "bot"` to `message.creator.bot?` per
Copilot code review. This is the idiomatic Rails pattern for enum checks
and consistent with how other role checks are done in the codebase.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 14:51:21 -04:00
John-Mason Shackelfordandopenhands c82a8d38c0 fix: Use correct room messages in pagination tests
Fixed test bug identified by Copilot code review: pagination tests were
using Message.first and Message.last which could return messages from
different rooms than the test's @room (watercooler).

Now explicitly using messages(:fourth) and messages(:thirteenth) which
are fixtures belonging to the watercooler room where bender bot is a member.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 14:45:02 -04:00
John-Mason Shackelfordandopenhands 057d56513e fix: Align create and index to both return 404 for non-member rooms
Both create and index now return HTTP 404 Not Found when a bot tries to
access a room it's not a member of. This is consistent with REST API
security best practices (not revealing resource existence) and ensures
read and write permissions are handled identically.

Changed create action to no longer call super (which rendered HTML) and
instead directly handle the request with proper JSON API error responses.

Added test to verify create returns 404 for non-member rooms.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 13:59:56 -04:00
John-Mason Shackelfordandopenhands 5340f3da01 fix: Ensure bot can only read messages from rooms it is a member of
Added explicit RecordNotFound handling to return 404 when a bot tries to
read messages from a room it's not a member of. This matches the security
model used by the create action.

Added tests to verify:
- Bot gets 404 when trying to read from room it's not a member of
- Bot can successfully read from room it IS a member of

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 13:56:02 -04:00
John-Mason Shackelfordandopenhands d4a56784e0 feat: Add bot API endpoint for reading room messages
Adds a new GET endpoint at /rooms/:room_id/:bot_key/messages that allows
bots to read messages from rooms they are members of.

The endpoint returns JSON with:
- Room info (id, name)
- Messages array with body (plain/html), created_at, and creator info
- Pagination info (oldest_id, newest_id, has_more)

Supports pagination via ?before=:id and ?after=:id query parameters,
consistent with the existing pagination in the messages controller.

This enables AI bots and other automated agents to understand conversation
context when responding to messages, rather than only receiving the single
message that triggered the webhook.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 13:53:17 -04:00
164 changed files with 3931 additions and 774 deletions

No files matched your search

+3
View File
@@ -50,3 +50,6 @@
# Ignore test files
/test/
# Agent session state — worktrees under .claude/ are whole extra checkouts of this repo
.claude
+8 -9
View File
@@ -20,13 +20,12 @@ updates:
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
exclude:
- "brakeman"
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
cooldown:
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
# 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.
+11 -11
View File
@@ -16,12 +16,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -35,12 +35,12 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -55,15 +55,15 @@ jobs:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run actionlint
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
- name: Run zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
env:
REDIS_URL: redis://localhost:6379/0
with:
+23 -10
View File
@@ -44,15 +44,15 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to GHCR
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -67,9 +67,20 @@ jobs:
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
- name: Compute app version
id: version
shell: bash
run: |
set -eu
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- name: Extract Docker metadata (tags, labels) with arch suffix
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -87,13 +98,15 @@ jobs:
- name: Build and push (${{ matrix.platform }})
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: Dockerfile
build-args: |
OCI_SOURCE=${{ env.SOURCE_URL }}
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
APP_VERSION=${{ steps.version.outputs.version }}
GIT_REVISION=${{ github.sha }}
platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -105,7 +118,7 @@ jobs:
- name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }}
@@ -126,10 +139,10 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Set up Docker Buildx (for imagetools)
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to GHCR
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -146,7 +159,7 @@ jobs:
- name: Compute base tags (no suffix)
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -194,7 +207,7 @@ jobs:
done <<< "$tags"
- name: Install Cosign
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Cosign sign all tags (keyless OIDC)
shell: bash
+1 -1
View File
@@ -1 +1 @@
3.4.5
3.4.10
+4 -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.5
ARG RUBY_VERSION=3.4.10
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
@@ -66,6 +66,9 @@ ENV HTTP_WRITE_TIMEOUT=300
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
COPY --from=build --chown=rails:rails /rails /rails
# Install ONCE backup/restore hooks
COPY --chmod=755 hooks /hooks
# Set version and revision
ARG APP_VERSION
ENV APP_VERSION=$APP_VERSION
+1 -1
View File
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.7
ARG RUBY_VERSION=3.4.10
FROM ruby:$RUBY_VERSION-slim as base
# Install the tools we need
+4
View File
@@ -26,6 +26,9 @@ 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"
@@ -41,6 +44,7 @@ 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"
+27 -16
View File
@@ -1,3 +1,9 @@
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
@@ -137,7 +143,7 @@ GEM
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (3.3.1)
brakeman (8.0.5)
brakeman (8.0.6)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -168,6 +174,7 @@ 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)
@@ -191,7 +198,7 @@ GEM
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.20.0)
json (2.21.2)
jwt (3.2.0)
base64
kredis (1.8.0)
@@ -199,12 +206,14 @@ 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.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
@@ -216,7 +225,7 @@ GEM
logger
mini_mime (1.1.5)
minitest (5.26.2)
mocha (2.7.1)
mocha (3.1.0)
ruby2_keywords (>= 0.0.5)
mono_logger (1.1.2)
multi_json (1.17.0)
@@ -242,7 +251,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
openssl (3.3.0)
openssl (4.0.2)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.9.0)
@@ -346,7 +355,7 @@ GEM
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (3.0.2)
rubyzip (3.4.0)
securerandom (0.4.1)
selenium-webdriver (4.35.0)
base64 (~> 0.2)
@@ -367,18 +376,18 @@ GEM
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
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)
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)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
thor (1.4.0)
thruster (0.1.15-aarch64-linux)
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
thruster (0.1.23-aarch64-linux)
thruster (0.1.23-arm64-darwin)
thruster (0.1.23-x86_64-darwin)
thruster (0.1.23-x86_64-linux)
tilt (2.6.1)
timeout (0.6.1)
tsort (0.2.0)
@@ -389,9 +398,9 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
web-push (3.0.2)
web-push (3.1.0)
jwt (~> 3.0)
openssl (~> 3.0)
openssl (>= 3.0)
webmock (3.25.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@@ -424,6 +433,7 @@ DEPENDENCIES
importmap-rails!
jbuilder
kredis
lexxy (~> 0.9.24)
mocha
net-http-persistent
ostruct
@@ -442,6 +452,7 @@ DEPENDENCIES
sentry-ruby
sqlite3
stimulus-rails
surfguard!
thruster
turbo-rails!
web-push
+19
View File
@@ -49,7 +49,26 @@ 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.
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
## Security
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
+22
View File
@@ -0,0 +1,22 @@
# Security
Report vulnerabilities through our [HackerOne program](https://hackerone.com/basecamp) or at
https://github.com/basecamp/once-campfire/security. Note that Campfire is in scope for our security
program, but is not bounty eligible.
## Trust model
Campfire is self-hosted and single-tenant, so the administrator is the server operator,
with shell, network, and database access already. Anything requiring the administrator
role grants nothing they do not already have, and is not a vulnerability.
We do want reports of anything a **non-administrator** can reach, and of any credential or
network path held by the Campfire process but not by the operator's own shell.
## Intentional behavior
Bot webhook URLs are unrestricted: an administrator can point one at any address,
including internal ones, because operators legitimately wire bots to their own services.
Link unfurling is different because any member can trigger it by pasting a URL, so it
validates destinations through `RestrictedHTTP::PrivateNetworkGuard`. The difference is who
picks the destination.
+130 -132
View File
@@ -1,165 +1,160 @@
trix-toolbar {
display: none;
inset: auto 0 0 0;
min-width: 0;
position: sticky;
z-index: 3;
/* 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-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;
}
}
/* Campfire's message lines stack without extra block margins */
--lexxy-content-margin: 0;
}
trix-editor {
lexxy-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;
.attachment__toolbar {
top: 0;
z-index: 2;
}
}
}
.trix-button-group--file-tools,
.trix-button-group--history-tools {
display: none !important;
/* 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);
}
.trix-button-group,
.trix-button {
border: 0 !important;
margin: 0 !important;
border-radius: 0.3em !important;
box-shadow: none;
/* 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);
&::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");
}
option {
background-color: var(--color-bg);
color: var(--color-text);
}
}
[data-trix-content-type~='application/vnd.campfire.mention'] {
.trix-button-group {
display: inline;
/* 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);
}
}
[data-trix-content-type~="application/vnd.actiontext.opengraph-embed"] {
* {
text-decoration: none;
}
/* 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;
}
trix-editor & {
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%;
}
}
.trix-button:not(.trix-button--remove) {
@media (any-hover: hover) {
&:where(:not(:active):hover) {
filter: brightness(0.9);
background-color: var(--color-bg);
/* 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;
}
}
&:disabled {
opacity: 0.33;
}
}
trix-editor,
.trix-content {
.lexxy-content {
pre {
background-color: var(--color-bg) !important;
border-radius: 0.5em;
@@ -179,11 +174,13 @@ trix-editor,
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;
@@ -199,13 +196,14 @@ trix-editor,
color: var(--color-link);
}
ul,
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */
ul:not(.lexxy-prompt-menu),
ol {
margin: 0 !important;
padding: 0 !important;
}
li {
li:not(.lexxy-prompt-menu__item) {
margin: 0 0 0 var(--inline-space-double) !important;
padding: 0 !important;
}
+1 -1
View File
@@ -33,7 +33,7 @@
}
}
[data-trix-content-type~='application/vnd.campfire.mention'] {
action-text-attachment[content-type~='application/vnd.campfire.mention'] {
display: inline-flex;
margin: 0;
padding: 0;
+40 -4
View File
@@ -100,16 +100,42 @@
}
.composer--rich-text {
trix-toolbar {
display: block;
lexxy-toolbar {
display: flex;
}
trix-editor.input {
lexxy-editor.input {
margin-block-start: var(--block-space-half);
}
}
.composer--edit trix-toolbar {
/* 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 {
border-block-end: 1px solid var(--color-border);
margin-inline: -0.8em;
padding-inline: 0.3em;
@@ -117,6 +143,16 @@
}
}
/* 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);
+10 -5
View File
@@ -1,5 +1,5 @@
trix-editor,
.trix-content {
.lexxy-content {
.og-embed {
background: var(--color-bg);
border: 1px solid var(--color-border-dark);
@@ -15,7 +15,7 @@ trix-editor,
flex-direction: row;
}
.cf-twitter-avatar & {
&.og-embed--twitter-avatar {
flex-direction: row;
}
}
@@ -35,10 +35,11 @@ trix-editor,
.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;
}
}
@@ -53,7 +54,11 @@ trix-editor,
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;
@@ -63,7 +68,7 @@ trix-editor,
}
}
.cf-twitter-avatar & {
.og-embed--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);
.trix-content {
.lexxy-content {
font-size: 2.4em;
line-height: 1.4;
}
@@ -0,0 +1,13 @@
# Prepended onto Turbo::StreamsChannel. The subscriber names the channel it wants, so
# authorizing room messages only in RoomMessagesChannel would leave the stock channel as
# a way around it: same signed stream name, no membership check. Turn those names away
# here and RoomMessagesChannel becomes the only door.
module RoomStreamsAreAuthorized
def subscribed
if RoomMessagesChannel.guarded_stream?(verified_stream_name_from_params)
reject
else
super
end
end
end
+55
View File
@@ -0,0 +1,55 @@
# Authorizes the room message stream when the subscription is made, so that revoking a
# membership actually stops delivery.
#
# Turbo's stock channel verifies only the signature on the stream name. That name carries
# no expiry and no binding to a user, so one harvested while a member keeps working after
# the membership is gone. Reconnecting makes it worse rather than better: revoking a
# membership disconnects the user with reconnect: true, and the client then replays its
# subscriptions on the fresh socket.
#
# The room is derived from the verified stream name rather than taken as a parameter, so
# there's nothing for a subscriber to point somewhere else. The subscriber also doesn't
# get to choose the channel: Turbo::StreamsChannel turns these stream names away, so this
# is the only way onto them. See config/initializers/turbo_streams_authorization.rb.
class RoomMessagesChannel < ApplicationCable::Channel
extend Turbo::Streams::StreamName
include Turbo::Streams::StreamName::ClassMethods
STREAM_SUFFIX = "messages"
class << self
# True for the stream names this channel exists to guard, whoever is asking.
def guarded_stream?(stream_name)
stream_name.to_s.split(":", 2).second == STREAM_SUFFIX
end
def subscribable_room(user, stream_name)
gid_param, suffix = stream_name.to_s.split(":", 2)
if suffix == STREAM_SUFFIX && room = room_from(gid_param)
user.rooms.find_by(id: room.id)
end
end
private
def room_from(gid_param)
GlobalID::Locator.locate gid_param, only: Room
rescue ActiveRecord::RecordNotFound
nil
end
end
def subscribed
if stream_name = authorized_stream_name
stream_from stream_name
else
reject
end
end
private
def authorized_stream_name
stream_name = verified_stream_name_from_params
stream_name if stream_name.present? && self.class.subscribable_room(current_user, stream_name)
end
end
+8 -1
View File
@@ -1,5 +1,12 @@
class UnreadRoomsChannel < ApplicationCable::Channel
# Scoped per user, like ReadRoomsChannel. A single global stream would tell every
# authenticated connection when any room on the account is active, including closed
# rooms and direct conversations they're not part of.
def self.stream_name_for(user_id)
"user_#{user_id}_unreads"
end
def subscribed
stream_from "unread_rooms"
stream_from self.class.stream_name_for(current_user.id)
end
end
+4 -8
View File
@@ -8,9 +8,8 @@ class Accounts::LogosController < ApplicationController
if stale?(etag: Current.account)
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
if Current.account&.logo&.attached?
logo = Current.account.logo.variant(logo_variant).processed
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
if (logo_variant = Current.account&.logo_variant(logo_size))
send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
else
send_stock_icon
end
@@ -23,9 +22,6 @@ class Accounts::LogosController < ApplicationController
end
private
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
def send_png_file(path)
send_file path, content_type: "image/png", disposition: :inline
end
@@ -38,8 +34,8 @@ class Accounts::LogosController < ApplicationController
end
end
def logo_variant
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
def logo_size
small_logo? ? :small : :large
end
def small_logo?
@@ -1,11 +1,26 @@
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
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
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
end
def users_scope
@@ -0,0 +1,9 @@
module ActiveStorageAuthentication
extend ActiveSupport::Concern
include Authentication::SessionLookup
private
def require_active_storage_authentication
head :unauthorized unless find_session_by_cookie
end
end
@@ -71,6 +71,13 @@ 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)
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,39 @@
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,5 +1,6 @@
class Messages::BoostsController < ApplicationController
before_action :set_message
before_action :set_boost, only: :destroy
def index
end
@@ -15,7 +16,6 @@ class Messages::BoostsController < ApplicationController
end
def destroy
@boost = Current.user.boosts.find(params[:id])
@boost.destroy!
broadcast_remove
@@ -26,6 +26,10 @@ 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
+49 -9
View File
@@ -1,24 +1,64 @@
class Messages::ByBotsController < MessagesController
allow_bot_access only: :create
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
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
reading(request.body) { |body| { body: body } }
{ body: raw_request_body }
end
end
def reading(io)
io.rewind
yield io.read.force_encoding("UTF-8")
ensure
io.rewind
end
end
+8 -4
View File
@@ -37,7 +37,11 @@ class MessagesController < ApplicationController
@message.update!(message_params)
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
redirect_to room_message_url(@room, @message)
respond_to do |format|
format.html { redirect_to room_message_url(@room, @message) }
format.json { render :show }
end
end
def destroy
@@ -58,11 +62,11 @@ class MessagesController < ApplicationController
def find_paged_messages
case
when params[:before].present?
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
@room.messages.with_presentation.page_before(@room.messages.find(params[:before]))
when params[:after].present?
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
else
@room.messages.with_creator.last_page
@room.messages.with_presentation.last_page
end
end
@@ -42,6 +42,12 @@ class Rooms::ClosedsController < RoomsController
@room = @room.becomes!(Rooms::Closed)
end
# Open and closed rooms convert into each other, so both are in reach here. Direct
# rooms never are: converting one would let its creator revise who's in it.
def room_scope
Current.user.rooms.without_directs
end
def grantees
User.where(id: grantee_ids)
end
+6 -1
View File
@@ -29,8 +29,13 @@ class Rooms::DirectsController < RoomsController
end
end
# All users in a direct room can administer it
# All users in a direct room can administer it. Only direct rooms, though: this
# relaxation is why room_scope below has to keep every other type out of reach.
def ensure_can_administer
true
end
def room_scope
Current.user.rooms.directs
end
end
@@ -40,6 +40,12 @@ class Rooms::OpensController < RoomsController
@room = @room.becomes!(Rooms::Open)
end
# Open and closed rooms convert into each other, so both are in reach here. Direct
# rooms never are: promoting one would republish its history to the whole account.
def room_scope
Current.user.rooms.without_directs
end
def broadcast_create_room(room)
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
end
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
before_action :set_last_updated_at
def show
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
@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)
end
private
+9 -3
View File
@@ -20,13 +20,19 @@ class RoomsController < ApplicationController
private
def set_room
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
if room = room_scope.find_by(id: params[:room_id] || params[:id])
@room = room
else
redirect_to root_url, alert: "Room not found or inaccessible"
end
end
# Subclasses narrow this to the room types they're allowed to act on, so that one
# room namespace can't be used to reach another's rooms.
def room_scope
Current.user.rooms
end
def ensure_can_administer
head :forbidden unless Current.user.can_administer?(@room)
end
@@ -38,9 +44,9 @@ class RoomsController < ApplicationController
end
def find_messages
messages = @room.messages.with_creator.with_attachment_details.with_boosts
messages = @room.messages.with_presentation
if show_first_message = messages.find_by(id: params[:message_id])
if params[:message_id].present? && (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).last(100)
@messages = Current.user.reachable_messages.search(query).with_presentation.last_page_of(100)
else
@messages = Message.none
end
+1 -4
View File
@@ -9,8 +9,7 @@ class Users::AvatarsController < ApplicationController
if stale?(etag: @user)
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
if @user.avatar.attached?
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
if (avatar_variant = @user.avatar_variant)
send_webp_blob_file avatar_variant.key
elsif @user.bot?
render_default_bot
@@ -26,8 +25,6 @@ class Users::AvatarsController < ApplicationController
end
private
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
def send_webp_blob_file(key)
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
end
@@ -6,12 +6,17 @@ class Users::PushSubscriptionsController < ApplicationController
def create
if subscription = @push_subscriptions.find_by(push_subscription_params)
subscription.touch
# Existing endpoints must pass current validations
if subscription.valid?
subscription.touch
head :ok
else
head :unprocessable_entity
end
else
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
head subscription.persisted? ? :ok : :unprocessable_entity
end
head :ok
end
def destroy
+2 -7
View File
@@ -2,18 +2,13 @@ class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
def show
all_memberships = Current.user.memberships.visible.with_ordered_room
@direct_memberships = extract_direct_memberships(all_memberships)
@other_memberships = all_memberships.without(@direct_memberships)
@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
@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)
+11 -1
View File
@@ -1,3 +1,13 @@
module ContentFilters
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
# 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)
end
@@ -1,18 +1,22 @@
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
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
if trix_body?
remove_link_text_from_wrapping_div
else
remove_link_paragraphs
end
end
private
TWITTER_DOMAINS = %w[ x.com twitter.com ]
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
def solo_unfurled_url
unfurled_links.first["href"] if unfurled_links.size == 1
ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1
end
def unfurled_links
@@ -35,4 +39,20 @@ 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
@@ -0,0 +1,47 @@
class ContentFilters::SanitizeAttributes < ActionText::Content::Filter
def applicable?
true
end
# Scrub attributes on the tags that SanitizeTags allows, using Rails' safe-list
# sanitizer so unsafe URI schemes and event-handler attributes are stripped.
# Runs after SanitizeTags, so passing the same allowed tags makes the tag pass
# a no-op and only attributes are scrubbed.
def apply
sanitizer.sanitize fragment.to_html, tags: allowed_tags, attributes: allowed_attributes
end
private
# Presentation styling relies on class attributes (e.g. unfurled link embeds),
# which the standard ActionText set doesn't include.
EXTRA_ALLOWED_ATTRIBUTES = %w[ class ]
# ActionText::ContentHelper.sanitizer is a single process-wide instance, and
# rails-html-sanitizer reuses one mutable permit scrubber that it reconfigures
# from the tags/attributes on every #sanitize call. Sharing it here would race
# our stricter tag set against ActionText's default rendering on concurrent
# requests, so use a dedicated instance of the same sanitizer class to keep the
# scrubber isolated.
def sanitizer
sanitizer_class.new
end
def sanitizer_class
ActionText::ContentHelper.sanitizer.class
end
def allowed_tags
ContentFilters::SanitizeTags::ALLOWED_TAGS
end
def allowed_attributes
standard_allowed_attributes + EXTRA_ALLOWED_ATTRIBUTES
end
# Mirrors ActionText::ContentHelper#sanitizer_allowed_attributes, which isn't
# exposed at the module level.
def standard_allowed_attributes
ActionText::ContentHelper.allowed_attributes ||
(sanitizer_class.allowed_attributes + ActionText::Attachment::ATTRIBUTES).to_a
end
end
+4 -3
View File
@@ -1,4 +1,8 @@
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
@@ -8,9 +12,6 @@ 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
@@ -1,24 +0,0 @@
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
+7 -1
View File
@@ -1,4 +1,9 @@
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",
@@ -57,7 +62,8 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
+27 -5
View File
@@ -1,11 +1,33 @@
module RichTextHelper
def rich_text_data_actions
default_actions =
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
# 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
autocomplete_actions =
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
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
[ default_actions, autocomplete_actions ].join(" ")
# 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)
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"
trix_attachment_actions =
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
attachment_actions =
"lexxy: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, trix_attachment_actions, remaining_actions ].join(" ")
[ drop_target_actions, drag_and_drop_actions, attachment_actions, remaining_actions ].join(" ")
end
end
-2
View File
@@ -1,6 +1,4 @@
// 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/dom_helpers"
import { escapeHTML } from "helpers/string_helpers"
export default class extends Controller {
static classes = ["toolbar"]
@@ -12,11 +12,21 @@ 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()
@@ -44,21 +54,20 @@ export default class extends Controller {
}
replaceMessageContent(content) {
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])
this.textTarget.value = content
this.textTarget.focus()
this.textTarget.selection.placeCursorAtTheEnd()
}
submitByKeyboard(event) {
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
const metaEnter = event.metaKey || event.ctrlKey
const plainEnter = !event.shiftKey && !event.isComposing
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
event.stopPropagation()
this.submit(event)
}
}
@@ -108,6 +117,19 @@ 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;
}
@@ -126,7 +148,7 @@ export default class extends Controller {
}
#validInput() {
return this.textTarget.textContent.trim().length > 0
return !this.textTarget.isBlank
}
async #submitFiles() {
@@ -159,6 +181,7 @@ 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 trix-editor").matches(":empty")
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
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><br>`
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><p><br></p>`
this.composerOutlet.replaceMessageContent(content)
}
@@ -23,7 +23,7 @@ export default class extends Controller {
}
get #bodyContent() {
const body = this.bodyTarget.querySelector(".trix-content").cloneNode(true)
const body = this.bodyTarget.querySelector(".lexxy-content").cloneNode(true)
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
}
@@ -1,46 +0,0 @@
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
}
}
@@ -0,0 +1,84 @@
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,12 +4,6 @@ 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,3 +1,5 @@
const HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }
export function truncateString(string, length, omission = "…") {
if (string.length <= length) {
return string
@@ -5,3 +7,7 @@ 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])
}
+17 -8
View File
@@ -1,10 +1,19 @@
import Unfurler from "lib/rich_text/unfurl/unfurler"
import * as Lexxy from "lexxy"
import CampfireRichTextExtension from "lib/rich_text/campfire_extension"
// Support a `cite` block for attribution links
Trix.config.blockAttributes.cite = {
tagName: "cite",
inheritable: false,
}
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 },
const unfurler = new Unfurler()
unfurler.install()
// Trix offered a single heading level, rendered as h1
headings: [ "h1" ]
}
})
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
#fetchAutocompletables(url) {
if (url) {
return fetch(url, { as: "json" }).then(response => response.json())
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
} else {
return Promise.resolve()
}
@@ -1,48 +0,0 @@
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 : {}
}
}
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,39 @@
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") }
}
}
@@ -1,80 +0,0 @@
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)
}
}
@@ -1,39 +0,0 @@
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]
}
}
@@ -1,59 +0,0 @@
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")
}
}
+17 -6
View File
@@ -18,7 +18,7 @@ export default class ClientMessage {
body,
messageTimestamp: Math.floor(now.getTime()),
messageDatetime: now.toISOString(),
messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
messageClasses: this.#messageClassesFromNode(node),
})
}
@@ -52,26 +52,37 @@ 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.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
return this.#plainTextFromNode(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
}
#stripWrapperElement(node) {
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
#plainTextFromNode(node) {
if (this.#isRichText(node)) {
return node.toString().trim()
} else {
return node
}
}
#isRichText(node) {
return typeof(node) != "string"
}
#richTextContent(node) {
return `<div class="trix-content">${node.innerHTML}</div>`
return `<div class="lexxy-content">${node.value}</div>`
}
+15 -1
View File
@@ -76,7 +76,21 @@ export default class MessageFormatter {
}
#highlightCodeBlock(block) {
if (this.#isPlainText(block)) window.hljs.highlightElement(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"))
}
#isPlainText(element) {
+9 -1
View File
@@ -1,6 +1,14 @@
class Account < ApplicationRecord
include Joinable
has_one_attached :logo
has_one_attached :logo do |attachable|
attachable.variant :large, resize_to_limit: [ 512, 512 ], format: :png
attachable.variant :small, resize_to_limit: [ 192, 192 ], format: :png
end
has_json :settings, restrict_room_creation_to_administrators: false
def logo_variant(size)
logo.variant(size).processed if logo.variable?
end
end
+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) }
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
def plain_text_body
body.to_plain_text.presence || attachment&.filename&.to_s || ""
+12 -1
View File
@@ -1,10 +1,21 @@
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
broadcast_unread_room
end
def broadcast_remove
broadcast_remove_to room, :messages
end
private
# Fanned out to the room's members rather than published on one global stream, so
# that the timing of activity in a room only reaches people who are in it.
def broadcast_unread_room
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
end
end
end
+29 -3
View File
@@ -3,9 +3,31 @@ 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
scope :last_page, -> { ordered.last(PAGE_SIZE) }
scope :first_page, -> { ordered.first(PAGE_SIZE) }
# 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 :before, ->(message) { where("created_at < ?", message.created_at) }
scope :after, ->(message) { where("created_at > ?", message.created_at) }
@@ -18,8 +40,12 @@ module Message::Pagination
end
class_methods do
def last_page_of(size)
Page.load(ordered, :last, size)
end
def page_around(message)
page_before(message) + [ message ] + page_after(message)
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
end
def paged?
+9 -1
View File
@@ -6,7 +6,15 @@ 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 ?", query).ordered }
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
end
class_methods do
# Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than
# parsing them as operators, which fails on a query like "AND" or "salt AND".
def match_terms(query)
query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ")
end
end
private
+55 -1
View File
@@ -1,7 +1,61 @@
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)
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
# 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)
end
# Validate at point of use, not just when saved.
def resolved_endpoint_ip
RestrictedHTTP::PrivateNetworkGuard.resolve(endpoint_uri.host) if permitted_endpoint_uri?
rescue RestrictedHTTP::Violation, Surfguard::Unresolvable
nil
end
private
# Validate endpoint shape. Belt & suspenders.
def permitted_endpoint_uri?
endpoint_uri&.scheme == "https" && endpoint_uri.port == 443 && permitted_endpoint_host?
end
def endpoint_uri
URI.parse(endpoint) if endpoint.present?
rescue URI::InvalidURIError
nil
end
def validate_endpoint_url
if endpoint_uri.nil?
errors.add(:endpoint, "is not a valid URL")
elsif endpoint_uri.scheme != "https"
errors.add(:endpoint, "must use HTTPS")
elsif endpoint_uri.port != 443
errors.add(:endpoint, "must use the default HTTPS port")
elsif !permitted_endpoint_host?
errors.add(:endpoint, "is not a permitted push service")
elsif resolved_endpoint_ip.nil?
errors.add(:endpoint, "resolves to a private or invalid IP address")
end
end
def permitted_endpoint_host?
host = endpoint_uri&.host&.downcase
host.present? && PERMITTED_ENDPOINT_HOSTS.any? do |permitted|
host == permitted || host.end_with?(".#{permitted}")
end
end
end
+11
View File
@@ -22,6 +22,8 @@ class Room < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
validate :direct_rooms_keep_their_type, on: :update
scope :opens, -> { where(type: "Rooms::Open") }
scope :closeds, -> { where(type: "Rooms::Closed") }
scope :directs, -> { where(type: "Rooms::Direct") }
@@ -65,6 +67,15 @@ class Room < ApplicationRecord
end
private
# Open and closed rooms convert into each other freely. A direct room can't become
# either: its participants agreed to a private conversation, not to one whose
# audience someone else gets to widen afterwards.
def direct_rooms_keep_their_type
if type_changed? && type_was == "Rooms::Direct"
errors.add :type, "can't be changed for a direct room"
end
end
def unread_memberships(message)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end
+7 -1
View File
@@ -2,7 +2,9 @@ module User::Avatar
extend ActiveSupport::Concern
included do
has_one_attached :avatar
has_one_attached :avatar do |attachable|
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
end
end
class_methods do
@@ -14,4 +16,8 @@ module User::Avatar
def avatar_token
signed_id(purpose: :avatar)
end
def avatar_variant
avatar.variant(:square).processed if avatar.variable?
end
end
+5 -3
View File
@@ -1,11 +1,13 @@
module User::Mentionable
include ActionText::Attachable
def to_attachable_partial_path
"users/mention"
MENTION_CONTENT_TYPE = "application/vnd.campfire.mention"
def attachable_content_type
MENTION_CONTENT_TYPE
end
def to_trix_content_attachment_partial_path
def to_attachable_partial_path
"users/mention"
end
+2
View File
@@ -24,6 +24,8 @@ class Webhook < ApplicationRecord
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
end
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
# and operators legitimately point bots at their own internal services.
def http
Net::HTTP.new(uri.host, uri.port).tap do |http|
http.use_ssl = (uri.scheme == "https")
@@ -1,11 +1,11 @@
<figure class="attachment attachment--content attachment--og">
<actiontext-opengraph-embed>
<div class="og-embed gap">
<div class="og-embed gap <%= "og-embed--twitter-avatar" if opengraph_embed.twitter_avatar? %>">
<div class="og-embed__content">
<div class="og-embed__title">
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
<%= link_to_if opengraph_embed.href.present?, truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
</div>
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %></div>
<div class="og-embed__description"><%= truncate(opengraph_embed.description.to_s, length: 560, omission: "…") %></div>
</div>
<% if opengraph_embed.url %>
<div class="og-embed__image">
@@ -0,0 +1,11 @@
<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>
@@ -0,0 +1 @@
<%= render partial: "autocompletable/users/prompt_item", collection: @page.records, as: :user %>
@@ -1,3 +1,3 @@
<div class="trix-content">
<div class="lexxy-content">
<%= yield -%>
</div>
+2 -1
View File
@@ -1,6 +1,7 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<% cache message do %>
<%# 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 %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
+18
View File
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,12 @@
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", action: "soft-keyboard#open" do %>
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", data: { action: "soft-keyboard#open" } do %>
<%= image_tag "boost.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Add a boost</span>
<% end %>
@@ -0,0 +1 @@
json.partial! "messages/boosts/boost", boost: @boost
@@ -0,0 +1 @@
json.array! @messages, partial: "messages/message", as: :message
@@ -0,0 +1 @@
json.partial! "messages/message", message: @message
+7 -6
View File
@@ -14,17 +14,18 @@
<% 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: "trix-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: "lexxy: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",
class: "input lexxy-content",
aria: { multiline: "true", label: "Edit message" },
autofocus: true,
data: {
controller: "rich-autocomplete",
action: rich_text_data_actions,
rich_autocomplete_url_value: autocompletable_users_path(room_id: @room.id) } %>
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
data: { action: rich_text_data_actions } do %>
<%= mention_prompt_tag @room %>
<% end %>
</div>
<%= link_to "Close editor and discard changes", room_message_path(@room, @message), data: { form_target: "cancel" }, hidden: true %>
+1 -1
View File
@@ -17,7 +17,7 @@
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
<%= turbo_stream_from @room, :messages %>
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
<%= button_to_jump_to_newest_message %>
<% end %>
+7 -6
View File
@@ -19,15 +19,16 @@
<div class="flex flex-column flex-item-grow min-width gap">
<%= form.rich_text_area :body,
rows: 1,
class: "input",
class: "input lexxy-content",
style: "order: -1",
aria: { multiline: "true", label: "Write a message" },
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
data: {
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" } %>
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 %>
</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 @messages %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
</div>
+4 -4
View File
@@ -1,4 +1,4 @@
<div class="mention" sgid="<%= user.attachable_sgid %>">
<%= avatar_tag user %>
<%= user.name %>
</div>
<%# 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>
+5
View File
@@ -0,0 +1,5 @@
json.cache! user do
json.(user, :id, :name, :role)
json.avatar_url fresh_user_avatar_url(user)
end
+26
View File
@@ -0,0 +1,26 @@
# 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
@@ -0,0 +1,74 @@
#!/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
@@ -0,0 +1,61 @@
#!/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
@@ -0,0 +1,102 @@
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
@@ -0,0 +1,92 @@
# 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
@@ -0,0 +1,84 @@
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")
ARGV.unshift("--ensure-latest", "15")
load Gem.bin_path("brakeman", "brakeman")
+31 -11
View File
@@ -15,9 +15,11 @@ include SSHKit::DSL
ARCH = "linux/amd64,linux/arm64"
BUILDER_NAME = "campfire-builder"
GIT_SHA = `git rev-parse HEAD`.strip
MANUAL_NOTES = ARGV.delete("--notes")
VERSION = ARGV[0]
DOCKER_IMAGE_NAME = "registry.once.com/campfire"
DOCKER_REGISTRY = "registry.once.com"
DOCKER_IMAGE_NAME = "#{DOCKER_REGISTRY}/campfire"
DOCKER_LATEST_IMAGE_NAME = [ DOCKER_IMAGE_NAME, :latest ].join(":")
DOCKER_TAGGED_IMAGE_NAME = [ DOCKER_IMAGE_NAME, GIT_SHA ].join(":")
@@ -28,7 +30,7 @@ EXPORT_REMOTE_USER = "app"
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
abort "You must specify a version, e.g. `bin/release 1.0.0`" unless VERSION
abort "You must specify a version, e.g. `bin/release 1.0.0` (pass --notes to write release notes manually)" unless VERSION
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
if current_branch != "main"
@@ -46,6 +48,18 @@ 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
@@ -57,19 +71,25 @@ on(:local) do
end
announcing "Creating GitHub release v#{VERSION}..." do
notes_file = "tmp/release_notes.md"
on(:local) do
execute :mkdir, "-p", "tmp"
if MANUAL_NOTES
notes_file = "tmp/release_notes.md"
File.write(notes_file, "# What's Changed\n\n- \n")
system ENV["EDITOR"] || "vim", notes_file
execute :mkdir, "-p", "tmp"
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
abort "Failed to create release; check that the version doesn't already exist"
File.write(notes_file, "# What's Changed\n\n- \n")
system ENV["EDITOR"] || "vim", notes_file
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
abort "Failed to create release; check that the version doesn't already exist"
end
execute :rm, notes_file
else
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--generate-notes"
abort "Failed to create release; check that the version doesn't already exist"
end
end
execute :rm, notes_file
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
default: &default
adapter: redis
url: redis://localhost:6379
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379" } %>
development:
<<: *default
+2 -1
View File
@@ -1,5 +1,6 @@
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.
@@ -50,7 +51,7 @@ Rails.application.configure do
# Log to STDOUT by default
config.logger = ActiveSupport::Logger.new(STDOUT)
.tap { |logger| logger.formatter = ::Logger::Formatter.new }
.tap { |logger| logger.formatter = LogScrubbingFormatter.new }
.then { |logger| ActiveSupport::TaggedLogging.new(logger) }
# Prepend all log lines with the following tags.
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more