Compare commits

...
91 Commits
Author SHA1 Message Date
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
Donal McBreen 69e8cd7885 Merge pull request #225 from basecamp/security/ssrf-ipv6-transition-guard
Block IPv6 addresses that reach internal IPs in the unfurl guard
2026-07-21 12:34:38 +01:00
Donal McBreen c0cade51a1 Address Copilot review: require ipaddr and re-assert the socket port
The guard uses IPAddr but relied on something else loading it first;
require it explicitly. And the rebinding tests lost their port assertion
when the matchers were loosened for newer Net::HTTP keyword args, so
check the port alongside the IP again.
2026-07-21 11:53:37 +01:00
Donal McBreen 80fdd44622 Remove IPv4 ranges the IPAddr predicates already cover
The RFC1918, loopback, and link-local ranges in DISALLOWED_IPV4
duplicated the private?/loopback?/link_local? checks that run right
before the list scan, so they could never be the deciding factor. Keep
only the ranges the predicates don't catch.
2026-07-21 11:37:17 +01:00
Donal McBreen 4cfcc2a370 Re-check the IPv4 embedded in local-use NAT64 instead of blocking outright
Matches the fizzy guard: the local-use NAT64 prefix (64:ff9b:1::/48,
RFC 8215) embeds an IPv4 target in its low 32 bits just like the
well-known prefix, so run it through the same embedded-IPv4 recheck.
Local-use NAT64 to a public address now resolves (keeping unfurls
working for self-hosters on such networks) while local-use NAT64 to an
internal address stays blocked.
2026-07-20 17:13:18 +01:00
Donal McBreen 9085adcbb3 Block local-use NAT64 and IPv6 benchmarking ranges
The local-use NAT64 prefix (64:ff9b:1::/48, RFC 8215) embeds an IPv4
target like the well-known prefix does, but at a deployment-chosen
position we can't extract, so block the whole range outright.

Also block the IPv6 benchmarking range (2001:2::/48, RFC 5180) to match
the IPv4 benchmarking block on 198.18.0.0/15.
2026-07-20 16:49:40 +01:00
Donal McBreen 0de302c977 Fix unfurl rebinding tests for newer Ruby
Net::HTTP now passes an open_timeout: option to TCPSocket.open, so the mock
that matched exact positional arguments no longer matches. Match on the
host instead.
2026-07-20 16:20:15 +01:00
Donal McBreen 9fb419e469 Block IPv6 addresses that reach internal IPs in the unfurl guard
The guard blocked the usual private, loopback, and link-local ranges (and
the IPv4-mapped/-compatible IPv6 forms), but let through NAT64, 6to4, and
Teredo addresses, which can point at an internal IPv4, and CGNAT.

Now it pulls the IPv4 out of a NAT64 address and checks that (so NAT64 to a
public site still works), blocks 6to4 and Teredo outright, and adds the
missing IPv4 and IPv6 ranges.
2026-07-20 16:20:15 +01: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
142 changed files with 3287 additions and 672 deletions

No files matched your search

+3
View File
@@ -50,3 +50,6 @@
# Ignore test files # Ignore test files
/test/ /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-minor-days: 3
semver-patch-days: 2 semver-patch-days: 2
default-days: 7 default-days: 7
exclude:
- "brakeman"
- package-ecosystem: docker # No docker ecosystem. Our base image tag is a build arg
directory: "/" # (`FROM ruby:$RUBY_VERSION-slim`), which the updater cannot read, so the
schedule: # entry only ever matched nothing — worse than no entry, because it looked
interval: weekly # like coverage. Ruby base image bumps are a manual, reviewed step here, as
cooldown: # they are across the rest of the fleet. Keep Dockerfile, Dockerfile-export
semver-major-days: 7 # and .ruby-version on the same version by hand.
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
+11 -11
View File
@@ -16,12 +16,12 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with: with:
ruby-version: .ruby-version ruby-version: .ruby-version
bundler-cache: true bundler-cache: true
@@ -35,12 +35,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with: with:
ruby-version: .ruby-version ruby-version: .ruby-version
bundler-cache: true bundler-cache: true
@@ -55,15 +55,15 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Run actionlint - name: Run actionlint
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11 uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with: with:
advanced-security: false 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 run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- name: Checkout code - name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
env: env:
REDIS_URL: redis://localhost:6379/0 REDIS_URL: redis://localhost:6379/0
with: with:
@@ -112,12 +112,12 @@ jobs:
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- name: Checkout code - name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
env: env:
REDIS_URL: redis://localhost:6379/0 REDIS_URL: redis://localhost:6379/0
with: with:
+23 -10
View File
@@ -44,15 +44,15 @@ jobs:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Docker Buildx - 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 - name: Log in to GHCR
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -67,9 +67,20 @@ jobs:
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}" CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT" echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
- name: Compute app version
id: version
shell: bash
run: |
set -eu
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- name: Extract Docker metadata (tags, labels) with arch suffix - name: Extract Docker metadata (tags, labels) with arch suffix
id: meta id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with: with:
images: ${{ steps.vars.outputs.canonical }} images: ${{ steps.vars.outputs.canonical }}
tags: | tags: |
@@ -87,13 +98,15 @@ jobs:
- name: Build and push (${{ matrix.platform }}) - name: Build and push (${{ matrix.platform }})
id: build id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
context: . context: .
file: Dockerfile file: Dockerfile
build-args: | build-args: |
OCI_SOURCE=${{ env.SOURCE_URL }} OCI_SOURCE=${{ env.SOURCE_URL }}
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }} OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
APP_VERSION=${{ steps.version.outputs.version }}
GIT_REVISION=${{ github.sha }}
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
@@ -105,7 +118,7 @@ jobs:
- name: Attest image provenance (per-arch) - name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with: with:
subject-name: ${{ steps.vars.outputs.canonical }} subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }} subject-digest: ${{ steps.build.outputs.digest }}
@@ -126,10 +139,10 @@ jobs:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
steps: steps:
- name: Set up Docker Buildx (for imagetools) - name: Set up Docker Buildx (for imagetools)
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -146,7 +159,7 @@ jobs:
- name: Compute base tags (no suffix) - name: Compute base tags (no suffix)
id: meta id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with: with:
images: ${{ steps.vars.outputs.canonical }} images: ${{ steps.vars.outputs.canonical }}
tags: | tags: |
@@ -194,7 +207,7 @@ jobs:
done <<< "$tags" done <<< "$tags"
- name: Install Cosign - 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) - name: Cosign sign all tags (keyless OIDC)
shell: bash 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 # syntax = docker/dockerfile:1
# Make sure it matches the Ruby version in .ruby-version and Gemfile # Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.5 ARG RUBY_VERSION=3.4.10
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here # Rails app lives here
@@ -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 /usr/local/bundle /usr/local/bundle
COPY --from=build --chown=rails:rails /rails /rails COPY --from=build --chown=rails:rails /rails /rails
# Install ONCE backup/restore hooks
COPY --chmod=755 hooks /hooks
# Set version and revision # Set version and revision
ARG APP_VERSION ARG APP_VERSION
ENV APP_VERSION=$APP_VERSION ENV APP_VERSION=$APP_VERSION
+1 -1
View File
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1 # syntax = docker/dockerfile:1
# Make sure it matches the Ruby version in .ruby-version and Gemfile # Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.7 ARG RUBY_VERSION=3.4.10
FROM ruby:$RUBY_VERSION-slim as base FROM ruby:$RUBY_VERSION-slim as base
# Install the tools we need # 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 "turbo-rails", github: "hotwired/turbo-rails"
gem "stimulus-rails" gem "stimulus-rails"
# Rich text
gem "lexxy", "~> 0.9.24"
# Media handling # Media handling
gem "image_processing", ">= 1.2" gem "image_processing", ">= 1.2"
@@ -41,6 +44,7 @@ gem "rails_autolink"
gem "geared_pagination" gem "geared_pagination"
gem "jbuilder" gem "jbuilder"
gem "net-http-persistent" gem "net-http-persistent"
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
gem "kredis" gem "kredis"
gem "platform_agent" gem "platform_agent"
gem "thruster" 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 GIT
remote: https://github.com/hotwired/turbo-rails.git remote: https://github.com/hotwired/turbo-rails.git
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3 revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
@@ -137,7 +143,7 @@ GEM
bcrypt (3.1.22) bcrypt (3.1.22)
benchmark (0.5.0) benchmark (0.5.0)
bigdecimal (3.3.1) bigdecimal (3.3.1)
brakeman (8.0.5) brakeman (8.0.6)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.3) bundler-audit (0.9.3)
@@ -168,6 +174,7 @@ GEM
erubi (1.13.1) erubi (1.13.1)
faker (3.5.2) faker (3.5.2)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin) ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-darwin)
@@ -191,7 +198,7 @@ GEM
jbuilder (2.14.1) jbuilder (2.14.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.20.0) json (2.21.2)
jwt (3.2.0) jwt (3.2.0)
base64 base64
kredis (1.8.0) kredis (1.8.0)
@@ -199,12 +206,14 @@ GEM
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
redis (>= 4.2, < 6) redis (>= 4.2, < 6)
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
lexxy (0.9.24)
rails (>= 8.0.2)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.25.2) loofah (2.25.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.1)
logger logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
@@ -216,7 +225,7 @@ GEM
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.26.2) minitest (5.26.2)
mocha (2.7.1) mocha (3.1.0)
ruby2_keywords (>= 0.0.5) ruby2_keywords (>= 0.0.5)
mono_logger (1.1.2) mono_logger (1.1.2)
multi_json (1.17.0) multi_json (1.17.0)
@@ -242,7 +251,7 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
openssl (3.3.0) openssl (4.0.2)
ostruct (0.6.3) ostruct (0.6.3)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.9.0) parser (3.3.9.0)
@@ -346,7 +355,7 @@ GEM
ffi (~> 1.12) ffi (~> 1.12)
logger logger
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (3.0.2) rubyzip (3.4.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.35.0) selenium-webdriver (4.35.0)
base64 (~> 0.2) base64 (~> 0.2)
@@ -367,18 +376,18 @@ GEM
rack-protection (= 4.2.1) rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3) rack-session (>= 2.0.0, < 3)
tilt (~> 2.0) tilt (~> 2.0)
sqlite3 (2.9.5-aarch64-linux-gnu) sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.5-arm64-darwin) sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.5-x86_64-darwin) sqlite3 (2.9.6-x86_64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu) sqlite3 (2.9.6-x86_64-linux-gnu)
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.8) stringio (3.1.8)
thor (1.4.0) thor (1.4.0)
thruster (0.1.15-aarch64-linux) thruster (0.1.23-aarch64-linux)
thruster (0.1.15-arm64-darwin) thruster (0.1.23-arm64-darwin)
thruster (0.1.15-x86_64-darwin) thruster (0.1.23-x86_64-darwin)
thruster (0.1.15-x86_64-linux) thruster (0.1.23-x86_64-linux)
tilt (2.6.1) tilt (2.6.1)
timeout (0.6.1) timeout (0.6.1)
tsort (0.2.0) tsort (0.2.0)
@@ -389,9 +398,9 @@ GEM
unicode-emoji (4.2.0) unicode-emoji (4.2.0)
uri (1.1.1) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
web-push (3.0.2) web-push (3.1.0)
jwt (~> 3.0) jwt (~> 3.0)
openssl (~> 3.0) openssl (>= 3.0)
webmock (3.25.1) webmock (3.25.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
@@ -424,6 +433,7 @@ DEPENDENCIES
importmap-rails! importmap-rails!
jbuilder jbuilder
kredis kredis
lexxy (~> 0.9.24)
mocha mocha
net-http-persistent net-http-persistent
ostruct ostruct
@@ -442,6 +452,7 @@ DEPENDENCIES
sentry-ruby sentry-ruby
sqlite3 sqlite3
stimulus-rails stimulus-rails
surfguard!
thruster thruster
turbo-rails! turbo-rails!
web-push web-push
+4
View File
@@ -53,3 +53,7 @@ If you'd rather run the Docker image yourself, you can read more about that in t
You are welcome - and encouraged - to modify Campfire to your liking. 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. 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.
+122 -125
View File
@@ -1,165 +1,160 @@
trix-toolbar { /* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
display: none; so everything follows Campfire's light/dark color scheme */
inset: auto 0 0 0; .lexxy-content {
min-width: 0; --lexxy-color-canvas: transparent;
position: sticky; --lexxy-color-text: var(--color-text);
z-index: 3; --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 { /* Campfire's message lines stack without extra block margins */
bottom: 3rem !important; --lexxy-content-margin: 0;
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;
}
}
} }
trix-editor { lexxy-editor {
&.input { &.input {
--lexxy-editor-padding: 0;
/* One line high when empty. Lexxy derives this from attr(rows lh, 8lh),
whose resolution differs between Chrome versions — pin it instead of
risking the 8-line fallback */
--lexxy-editor-rows: 1lh;
background: transparent;
border: 0;
border-radius: 0;
min-height: 24px; min-height: 24px;
} }
.attachment--content { .attachment--content {
margin: 0; margin: 0;
display: inline-flex; display: inline-flex;
.attachment__toolbar {
top: 0;
z-index: 2;
}
} }
} }
.trix-button-group--file-tools, /* The mentions menu needs a solid surface — the editor's transparent canvas
.trix-button-group--history-tools { would let messages show through it */
.lexxy-prompt-menu {
--lexxy-color-canvas: var(--color-bg);
border: 1px solid var(--color-border-dark);
}
/* Same for the code block language picker: with a transparent canvas the
native dropdown renders white-on-white in dark mode */
lexxy-editor lexxy-code-language-picker select {
background-color: var(--color-bg);
color: var(--color-text);
option {
background-color: var(--color-bg);
color: var(--color-text);
}
}
/* The toolbar only shows when rich text mode is toggled on (see composer.css) */
lexxy-toolbar {
/* Wide, airy button spacing like the Trix toolbar */
--lexxy-toolbar-gap: 1.3em;
/* Match the compact size of the Trix toolbar buttons */
--lexxy-toolbar-button-size: 1.7em;
--lexxy-toolbar-icon-size: 1.125em;
display: none;
border-block-end: 0;
min-width: 0;
z-index: 3;
/* Tools Campfire doesn't offer, matching the Trix toolbar: underline,
color highlights, tables, dividers and undo/redo (history tools were
hidden under Trix as well) */
button[name="underline"],
button[name="table"],
button[name="divider"],
button[name="undo"],
button[name="redo"],
lexxy-highlight-dropdown {
display: none !important; display: none !important;
}
.trix-button-group,
.trix-button {
border: 0 !important;
margin: 0 !important;
border-radius: 0.3em !important;
box-shadow: none;
&::before {
opacity: 1 !important;
} }
@media (prefers-color-scheme: dark) { /* No group separator pipes, like the Trix toolbar */
&::before { .lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after {
filter: invert(100); display: none;
}
} }
&.trix-active { /* Dropdown panels: a solid surface with a uniform radius. Lexxy derives
background-color: var(--color-text) !important; 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);
&::before { border: 1px solid var(--color-border-dark);
filter: invert(100); border-radius: 0.5em;
gap: 0.25em;
padding: 0.25em;
} }
@media (prefers-color-scheme: dark) { .lexxy-editor__toolbar-dropdown > .lexxy-editor__toolbar-button[aria-expanded="true"] {
&::before { border-radius: var(--lexxy-radius);
filter: invert(0);
}
}
}
&.trix-button--remove {
background-color: var(--color-negative);
border-radius: 1.8em !important;
&::before {
filter: invert(100);
background-size: 70%;
background-image: url("minus.svg");
}
} }
} }
[data-trix-content-type~='application/vnd.campfire.mention'] { /* Attachment markup contains formatting whitespace that must not turn into
.trix-button-group { line breaks under the editor's white-space: pre-wrap */
display: inline; lexxy-editor action-text-attachment {
} white-space: normal;
} }
[data-trix-content-type~="application/vnd.actiontext.opengraph-embed"] { action-text-attachment[content-type~="application/vnd.campfire.mention"] {
* { display: inline-flex;
text-decoration: none; margin: 0;
} padding: 0;
position: relative;
}
trix-editor & { action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed"] {
lexxy-editor & {
display: block; display: block;
inline-size: fit-content; inline-size: fit-content;
max-inline-size: 100%; max-inline-size: 100%;
} }
}
.trix-button:not(.trix-button--remove) { /* No selection background — it peeks through the card's rounded corners.
@media (any-hover: hover) { The lexxy-editor prefix out-specifies Lexxy's own selection rule. */
&:where(:not(:active):hover) { lexxy-editor &.node--selected {
filter: brightness(0.9); --lexxy-attachment-bg-color: transparent;
background-color: var(--color-bg);
}
} }
&:disabled { /* A prominent remove button on the card's top edge, horizontally
opacity: 0.33; 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;
}
} }
} }
trix-editor, .lexxy-content {
.trix-content {
pre { pre {
background-color: var(--color-bg) !important; background-color: var(--color-bg) !important;
border-radius: 0.5em; border-radius: 0.5em;
@@ -179,11 +174,13 @@ trix-editor,
blockquote { blockquote {
border: 0 solid var(--color-border-darker) !important; border: 0 solid var(--color-border-darker) !important;
border-inline-start-width: var(--blockquote-border) !important; border-inline-start-width: var(--blockquote-border) !important;
font-style: normal;
margin: 0 var(--blockquote-margin) !important; margin: 0 var(--blockquote-margin) !important;
padding-inline-start: var(--blockquote-padding) !important; padding-inline-start: var(--blockquote-padding) !important;
} }
cite { cite {
display: block;
margin-inline-start: calc(var(--blockquote-padding)); margin-inline-start: calc(var(--blockquote-padding));
font-style: normal; font-style: normal;
font-size: 0.8rem; font-size: 0.8rem;
+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; display: inline-flex;
margin: 0; margin: 0;
padding: 0; padding: 0;
+40 -4
View File
@@ -100,16 +100,42 @@
} }
.composer--rich-text { .composer--rich-text {
trix-toolbar { lexxy-toolbar {
display: block; display: flex;
} }
trix-editor.input { lexxy-editor.input {
margin-block-start: var(--block-space-half); 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); border-block-end: 1px solid var(--color-border);
margin-inline: -0.8em; margin-inline: -0.8em;
padding-inline: 0.3em; 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 */
.typing-indicator { .typing-indicator {
inset-block-start: var(--indicator-position, 0.15rem); inset-block-start: var(--indicator-position, 0.15rem);
+10 -5
View File
@@ -1,5 +1,5 @@
trix-editor,
.trix-content { .lexxy-content {
.og-embed { .og-embed {
background: var(--color-bg); background: var(--color-bg);
border: 1px solid var(--color-border-dark); border: 1px solid var(--color-border-dark);
@@ -15,7 +15,7 @@ trix-editor,
flex-direction: row; flex-direction: row;
} }
.cf-twitter-avatar & { &.og-embed--twitter-avatar {
flex-direction: row; flex-direction: row;
} }
} }
@@ -35,10 +35,11 @@ trix-editor,
.og-embed__title { .og-embed__title {
font-weight: 600; font-weight: 600;
text-decoration: underline;
/* Underline the link itself so the line is link-colored */
a { a {
color: var(--color-link); color: var(--color-link);
text-decoration: underline;
} }
} }
@@ -53,7 +54,11 @@ trix-editor,
inline-size: 15dvw; inline-size: 15dvw;
} }
/* block-size/border-radius guard against Lexxy's 1em round avatar
styling for custom attachment images */
> * { > * {
block-size: auto;
border-radius: 0;
object-position: top right; object-position: top right;
inline-size: 100%; inline-size: 100%;
object-fit: contain; object-fit: contain;
@@ -63,7 +68,7 @@ trix-editor,
} }
} }
.cf-twitter-avatar & { .og-embed--twitter-avatar & {
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 50%; border-radius: 50%;
inline-size: 2lh; inline-size: 2lh;
+1 -1
View File
@@ -537,7 +537,7 @@ img.message__attachment {
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline); padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
text-shadow: 0 0 3px var(--color-text-reversed); text-shadow: 0 0 3px var(--color-text-reversed);
.trix-content { .lexxy-content {
font-size: 2.4em; font-size: 2.4em;
line-height: 1.4; 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 class UnreadRoomsChannel < ApplicationCable::Channel
# Scoped per user, like ReadRoomsChannel. A single global stream would tell every
# authenticated connection when any room on the account is active, including closed
# rooms and direct conversations they're not part of.
def self.stream_name_for(user_id)
"user_#{user_id}_unreads"
end
def subscribed def subscribed
stream_from "unread_rooms" stream_from self.class.stream_name_for(current_user.id)
end end
end end
+4 -8
View File
@@ -8,9 +8,8 @@ class Accounts::LogosController < ApplicationController
if stale?(etag: Current.account) if stale?(etag: Current.account)
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
if Current.account&.logo&.attached? if (logo_variant = Current.account&.logo_variant(logo_size))
logo = Current.account.logo.variant(logo_variant).processed send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
else else
send_stock_icon send_stock_icon
end end
@@ -23,9 +22,6 @@ class Accounts::LogosController < ApplicationController
end end
private private
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
def send_png_file(path) def send_png_file(path)
send_file path, content_type: "image/png", disposition: :inline send_file path, content_type: "image/png", disposition: :inline
end end
@@ -38,8 +34,8 @@ class Accounts::LogosController < ApplicationController
end end
end end
def logo_variant def logo_size
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT small_logo? ? :small : :large
end end
def small_logo? def small_logo?
@@ -1,11 +1,26 @@
class Autocompletable::UsersController < ApplicationController class Autocompletable::UsersController < ApplicationController
def index def index
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20 set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
respond_to do |format|
format.html { render layout: false } # <lexxy-prompt-item> elements for the mentions prompt
format.json
end
end end
private private
def find_autocompletable_users def find_autocompletable_users
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 end
def users_scope 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! Current.session&.destroy!
reset_session reset_session
remove_authentication_cookie remove_authentication_cookie
disconnect_remote_connections
end
def disconnect_remote_connections
Current.user&.reset_remote_connections
rescue => error
Rails.logger.warn "Could not disconnect remote connections on sign out: #{error.class}"
end end
def authenticated_as(session) def authenticated_as(session)
@@ -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 class Messages::BoostsController < ApplicationController
before_action :set_message before_action :set_message
before_action :set_boost, only: :destroy
def index def index
end end
@@ -15,7 +16,6 @@ class Messages::BoostsController < ApplicationController
end end
def destroy def destroy
@boost = Current.user.boosts.find(params[:id])
@boost.destroy! @boost.destroy!
broadcast_remove broadcast_remove
@@ -26,6 +26,10 @@ class Messages::BoostsController < ApplicationController
@message = Current.user.reachable_messages.find(params[:message_id]) @message = Current.user.reachable_messages.find(params[:message_id])
end end
def set_boost
@boost = @message.boosts.find_by!(id: params[:id], booster: Current.user)
end
def boost_params def boost_params
params.require(:boost).permit(:content) params.require(:boost).permit(:content)
end end
+49 -9
View File
@@ -1,24 +1,64 @@
class Messages::ByBotsController < MessagesController 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 def create
super super
head :created, location: message_url(@message) head :created, location: message_url(@message)
end end
def destroy
super
head :no_content
end
private private
def set_room
@room = Current.user.rooms.find_by(id: params[:room_id])
head :not_found unless @room
end
def ensure_body_or_attachment_present
if params[:attachment].blank? && raw_request_body.blank?
head :unprocessable_content
end
end
def set_pagination_headers
headers["X-Total-Count"] = @room.messages.count.to_s
if next_page = next_page_params
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
end
end
def next_page_params
if @messages.any?
if params[:after].present?
{ after: @messages.last.id } if @room.messages.after(@messages.last).exists?
else
{ before: @messages.first.id } if @room.messages.before(@messages.first).exists?
end
end
end
def message_params def message_params
if params[:attachment] if params[:attachment]
params.permit(:attachment) params.permit(:attachment)
else else
reading(request.body) { |body| { body: body } } { body: raw_request_body }
end end
end end
def reading(io)
io.rewind
yield io.read.force_encoding("UTF-8")
ensure
io.rewind
end
end end
+5 -1
View File
@@ -37,7 +37,11 @@ class MessagesController < ApplicationController
@message.update!(message_params) @message.update!(message_params)
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true } @message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
redirect_to room_message_url(@room, @message)
respond_to do |format|
format.html { redirect_to room_message_url(@room, @message) }
format.json { render :show }
end
end end
def destroy def destroy
@@ -42,6 +42,12 @@ class Rooms::ClosedsController < RoomsController
@room = @room.becomes!(Rooms::Closed) @room = @room.becomes!(Rooms::Closed)
end end
# Open and closed rooms convert into each other, so both are in reach here. Direct
# rooms never are: converting one would let its creator revise who's in it.
def room_scope
Current.user.rooms.without_directs
end
def grantees def grantees
User.where(id: grantee_ids) User.where(id: grantee_ids)
end end
+6 -1
View File
@@ -29,8 +29,13 @@ class Rooms::DirectsController < RoomsController
end end
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 def ensure_can_administer
true true
end end
def room_scope
Current.user.rooms.directs
end
end end
@@ -40,6 +40,12 @@ class Rooms::OpensController < RoomsController
@room = @room.becomes!(Rooms::Open) @room = @room.becomes!(Rooms::Open)
end end
# Open and closed rooms convert into each other, so both are in reach here. Direct
# rooms never are: promoting one would republish its history to the whole account.
def room_scope
Current.user.rooms.without_directs
end
def broadcast_create_room(room) def broadcast_create_room(room)
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room } broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
end end
+7 -1
View File
@@ -20,13 +20,19 @@ class RoomsController < ApplicationController
private private
def set_room 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 @room = room
else else
redirect_to root_url, alert: "Room not found or inaccessible" redirect_to root_url, alert: "Room not found or inaccessible"
end end
end end
# Subclasses narrow this to the room types they're allowed to act on, so that one
# room namespace can't be used to reach another's rooms.
def room_scope
Current.user.rooms
end
def ensure_can_administer def ensure_can_administer
head :forbidden unless Current.user.can_administer?(@room) head :forbidden unless Current.user.can_administer?(@room)
end end
+1 -4
View File
@@ -9,8 +9,7 @@ class Users::AvatarsController < ApplicationController
if stale?(etag: @user) if stale?(etag: @user)
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
if @user.avatar.attached? if (avatar_variant = @user.avatar_variant)
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
send_webp_blob_file avatar_variant.key send_webp_blob_file avatar_variant.key
elsif @user.bot? elsif @user.bot?
render_default_bot render_default_bot
@@ -26,8 +25,6 @@ class Users::AvatarsController < ApplicationController
end end
private private
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
def send_webp_blob_file(key) def send_webp_blob_file(key)
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
end end
@@ -6,12 +6,17 @@ class Users::PushSubscriptionsController < ApplicationController
def create def create
if subscription = @push_subscriptions.find_by(push_subscription_params) if subscription = @push_subscriptions.find_by(push_subscription_params)
# Existing endpoints must pass current validations
if subscription.valid?
subscription.touch subscription.touch
else
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
end
head :ok head :ok
else
head :unprocessable_entity
end
else
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
head subscription.persisted? ? :ok : :unprocessable_entity
end
end end
def destroy def destroy
+11 -1
View File
@@ -1,3 +1,13 @@
module ContentFilters 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 end
@@ -1,18 +1,22 @@
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
TWITTER_DOMAINS = %w[ x.com twitter.com ]
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
def applicable? def applicable?
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text) normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
end end
def apply def apply
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 end
private private
TWITTER_DOMAINS = %w[ x.com twitter.com ]
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
def solo_unfurled_url def solo_unfurled_url
unfurled_links.first["href"] if unfurled_links.size == 1 ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1
end end
def unfurled_links def unfurled_links
@@ -35,4 +39,20 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
def twitter_url?(url) def twitter_url?(url)
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) } url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
end end
def trix_body?
fragment.find_all("div").any?
end
def remove_link_text_from_wrapping_div
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
end
def remove_link_paragraphs
fragment.replace("p") do |node|
if node.at_css("action-text-attachment")
node
end
end
end
end end
@@ -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 class ContentFilters::SanitizeTags < ActionText::Content::Filter
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
p pre samp small span strong sub sup time tt ul var ] + ContentFilters::EDITOR_FORMATTING_TAGS +
[ ActionText::Attachment.tag_name, "figure", "figcaption" ]
def applicable? def applicable?
true true
end end
@@ -8,9 +12,6 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter
end end
private private
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
p pre samp small span strong sub sup time tt ul var ] + [ ActionText::Attachment.tag_name, "figure", "figcaption" ]
def not_allowed_tags_css_selector def not_allowed_tags_css_selector
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("") ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
end end
@@ -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 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, &) def message_area_tag(room, &)
tag.div id: "message-area", class: "message-area", contents: true, data: { tag.div id: "message-area", class: "message-area", contents: true, data: {
controller: "messages presence drop-target", controller: "messages presence drop-target",
@@ -57,7 +62,8 @@ module MessagesHelper
when "sound" when "sound"
message_sound_presentation(message) message_sound_presentation(message)
else 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 end
rescue Exception => e rescue Exception => e
Sentry.capture_exception(e, extra: { message: message }) Sentry.capture_exception(e, extra: { message: message })
+27 -5
View File
@@ -1,11 +1,33 @@
module RichTextHelper module RichTextHelper
def rich_text_data_actions def rich_text_data_actions
default_actions = # submitByKeyboard runs in the capture phase so it can submit on Enter
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard" # before the editor turns the keystroke into a newline
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
end
autocomplete_actions = def mention_prompt_tag(room)
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur" 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
end end
+3 -3
View File
@@ -73,12 +73,12 @@ module RoomsHelper
def composer_data_actions def composer_data_actions
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles" drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
trix_attachment_actions = attachment_actions =
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online" "lexxy:file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
remaining_actions = remaining_actions =
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline" "typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
[ drop_target_actions, drag_and_drop_actions, trix_attachment_actions, remaining_actions ].join(" ") [ drop_target_actions, drag_and_drop_actions, attachment_actions, remaining_actions ].join(" ")
end end
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 // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails" import "@hotwired/turbo-rails"
import "trix"
import "@rails/actiontext"
import "initializers" import "initializers"
import "controllers" import "controllers"
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
import FileUploader from "models/file_uploader" import FileUploader from "models/file_uploader"
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers" import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
import { escapeHTML } from "helpers/dom_helpers" import { escapeHTML } from "helpers/string_helpers"
export default class extends Controller { export default class extends Controller {
static classes = ["toolbar"] static classes = ["toolbar"]
@@ -12,11 +12,21 @@ export default class extends Controller {
#files = [] #files = []
connect() { connect() {
this.#restoreDraft()
if (!this.#usingTouchDevice) { if (!this.#usingTouchDevice) {
onNextEventLoopTick(() => this.textTarget.focus()) onNextEventLoopTick(() => this.textTarget.focus())
} }
} }
saveDraft() {
if (this.textTarget.isBlank) {
localStorage.removeItem(this.#draftKey)
} else {
localStorage.setItem(this.#draftKey, this.textTarget.value)
}
}
submit(event) { submit(event) {
event.preventDefault() event.preventDefault()
@@ -44,21 +54,20 @@ export default class extends Controller {
} }
replaceMessageContent(content) { replaceMessageContent(content) {
const editor = this.textTarget.editor this.textTarget.value = content
this.textTarget.focus()
editor.recordUndoEntry("Format reply") this.textTarget.selection.placeCursorAtTheEnd()
editor.setSelectedRange([0, editor.getDocument().toString().length])
editor.deleteInDirection("forward")
editor.insertHTML(content)
editor.setSelectedRange([editor.getDocument().toString().length - 1])
} }
submitByKeyboard(event) { submitByKeyboard(event) {
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
const toolbarVisible = this.element.classList.contains(this.toolbarClass) const toolbarVisible = this.element.classList.contains(this.toolbarClass)
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey) const metaEnter = event.metaKey || event.ctrlKey
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing const plainEnter = !event.shiftKey && !event.isComposing
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) { if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
event.stopPropagation()
this.submit(event) this.submit(event)
} }
} }
@@ -108,6 +117,19 @@ export default class extends Controller {
this.fieldsTarget.disabled = true this.fieldsTarget.disabled = true
} }
#restoreDraft() {
const draft = localStorage.getItem(this.#draftKey)
if (draft) {
this.textTarget.value = draft
this.textTarget.selection.placeCursorAtTheEnd()
}
}
get #draftKey() {
return `composer-draft-${this.roomIdValue}`
}
get #usingTouchDevice() { get #usingTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
} }
@@ -126,7 +148,7 @@ export default class extends Controller {
} }
#validInput() { #validInput() {
return this.textTarget.textContent.trim().length > 0 return !this.textTarget.isBlank
} }
async #submitFiles() { async #submitFiles() {
@@ -159,6 +181,7 @@ export default class extends Controller {
#reset() { #reset() {
this.textTarget.value = "" this.textTarget.value = ""
localStorage.removeItem(this.#draftKey)
} }
#updateFileList() { #updateFileList() {
@@ -89,7 +89,7 @@ export default class extends Controller {
} }
async editMyLastMessage() { async editMyLastMessage() {
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty") const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
if (editorEmpty && this.#paginator.upToDate) { if (editorEmpty && this.#paginator.upToDate) {
this.#myLastMessage?.querySelector(".message__edit-btn")?.click() this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
@@ -11,7 +11,7 @@ export default class extends Controller {
} }
reply() { 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) this.composerOutlet.replaceMessageContent(content)
} }
@@ -23,7 +23,7 @@ export default class extends Controller {
} }
get #bodyContent() { 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 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 container.scrollTop = container.scrollHeight
} }
export function escapeHTML(html) {
const div = document.createElement("div")
div.textContent = html
return div.innerHTML
}
export function parseHTMLFragment(html) { export function parseHTMLFragment(html) {
const template = document.createElement("template") const template = document.createElement("template")
template.innerHTML = html template.innerHTML = html
+6
View File
@@ -1,3 +1,5 @@
const HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }
export function truncateString(string, length, omission = "…") { export function truncateString(string, length, omission = "…") {
if (string.length <= length) { if (string.length <= length) {
return string return string
@@ -5,3 +7,7 @@ export function truncateString(string, length, omission = "…") {
return string.slice(0, length - omission.length) + omission return string.slice(0, length - omission.length) + omission
} }
} }
export function escapeHTML(string) {
return String(string ?? "").replace(/[&<>"']/g, character => HTML_ESCAPES[character])
}
+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 Lexxy.configure({
Trix.config.blockAttributes.cite = { global: {
tagName: "cite", // Keep the content type of mention attachments (application/vnd.campfire.mention)
inheritable: false, // 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() // Trix offered a single heading level, rendered as h1
unfurler.install() headings: [ "h1" ]
}
})
@@ -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, body,
messageTimestamp: Math.floor(now.getTime()), messageTimestamp: Math.floor(now.getTime()),
messageDatetime: now.toISOString(), 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) { #isPlayCommand(node) {
return this.#matchPlayCommand(node) return this.#matchPlayCommand(node)
} }
#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) { #plainTextFromNode(node) {
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1') if (this.#isRichText(node)) {
return node.toString().trim()
} else {
return node
}
} }
#isRichText(node) { #isRichText(node) {
return typeof(node) != "string" return typeof(node) != "string"
} }
#richTextContent(node) { #richTextContent(node) {
return `<div class="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) { #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) { #isPlainText(element) {
+9 -1
View File
@@ -1,6 +1,14 @@
class Account < ApplicationRecord class Account < ApplicationRecord
include Joinable 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 has_json :settings, restrict_room_creation_to_administrators: false
def logo_variant(size)
logo.variant(size).processed if logo.variable?
end
end end
+10 -1
View File
@@ -1,10 +1,19 @@
module Message::Broadcasts module Message::Broadcasts
def broadcast_create def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ] broadcast_append_to room, :messages, target: [ room, :messages ]
ActionCable.server.broadcast("unread_rooms", { roomId: room.id }) broadcast_unread_room
end end
def broadcast_remove def broadcast_remove
broadcast_remove_to room, :messages broadcast_remove_to room, :messages
end end
private
# Fanned out to the room's members rather than published on one global stream, so
# that the timing of activity in a room only reaches people who are in it.
def broadcast_unread_room
room.memberships.pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
end
end
end end
+55 -1
View File
@@ -1,7 +1,61 @@
require "restricted_http/private_network_guard"
class Push::Subscription < ApplicationRecord class Push::Subscription < ApplicationRecord
PERMITTED_ENDPOINT_HOSTS = %w[
jmt17.google.com
fcm.googleapis.com
updates.push.services.mozilla.com
web.push.apple.com
notify.windows.com
].freeze
belongs_to :user belongs_to :user
validates :endpoint, presence: true
validate :validate_endpoint_url
def notification(**params) 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
end end
+11
View File
@@ -22,6 +22,8 @@ class Room < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :creator, class_name: "User", default: -> { Current.user }
validate :direct_rooms_keep_their_type, on: :update
scope :opens, -> { where(type: "Rooms::Open") } scope :opens, -> { where(type: "Rooms::Open") }
scope :closeds, -> { where(type: "Rooms::Closed") } scope :closeds, -> { where(type: "Rooms::Closed") }
scope :directs, -> { where(type: "Rooms::Direct") } scope :directs, -> { where(type: "Rooms::Direct") }
@@ -65,6 +67,15 @@ class Room < ApplicationRecord
end end
private private
# Open and closed rooms convert into each other freely. A direct room can't become
# either: its participants agreed to a private conversation, not to one whose
# audience someone else gets to widen afterwards.
def direct_rooms_keep_their_type
if type_changed? && type_was == "Rooms::Direct"
errors.add :type, "can't be changed for a direct room"
end
end
def unread_memberships(message) def unread_memberships(message)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current) memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end end
+7 -1
View File
@@ -2,7 +2,9 @@ module User::Avatar
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
has_one_attached :avatar has_one_attached :avatar do |attachable|
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
end
end end
class_methods do class_methods do
@@ -14,4 +16,8 @@ module User::Avatar
def avatar_token def avatar_token
signed_id(purpose: :avatar) signed_id(purpose: :avatar)
end end
def avatar_variant
avatar.variant(:square).processed if avatar.variable?
end
end end
+5 -3
View File
@@ -1,11 +1,13 @@
module User::Mentionable module User::Mentionable
include ActionText::Attachable include ActionText::Attachable
def to_attachable_partial_path MENTION_CONTENT_TYPE = "application/vnd.campfire.mention"
"users/mention"
def attachable_content_type
MENTION_CONTENT_TYPE
end end
def to_trix_content_attachment_partial_path def to_attachable_partial_path
"users/mention" "users/mention"
end 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 } Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
end end
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
# and operators legitimately point bots at their own internal services.
def http def http
Net::HTTP.new(uri.host, uri.port).tap do |http| Net::HTTP.new(uri.host, uri.port).tap do |http|
http.use_ssl = (uri.scheme == "https") http.use_ssl = (uri.scheme == "https")
@@ -1,11 +1,11 @@
<figure class="attachment attachment--content attachment--og"> <figure class="attachment attachment--content attachment--og">
<actiontext-opengraph-embed> <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__content">
<div class="og-embed__title"> <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>
<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> </div>
<% if opengraph_embed.url %> <% if opengraph_embed.url %>
<div class="og-embed__image"> <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 -%> <%= yield -%>
</div> </div>
+2 -1
View File
@@ -1,6 +1,7 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %> <%# 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 %> <%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2> <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
@@ -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 %> <% else %>
<div class="composer--edit composer--rich-text"> <div class="composer--edit composer--rich-text">
<%= form_with model: @message, url: room_message_path(@room, @message), id: dom_id(@message, :form), data: { <%= 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"> <div class="full-width input input--actor min-width fill-white">
<%= form.rich_text_area :body, <%= form.rich_text_area :body,
value: editable_body(@message),
rows: 1, rows: 1,
class: "input", class: "input lexxy-content",
aria: { multiline: "true", label: "Edit message" }, aria: { multiline: "true", label: "Edit message" },
autofocus: true, autofocus: true,
data: { "permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
controller: "rich-autocomplete", data: { action: rich_text_data_actions } do %>
action: rich_text_data_actions, <%= mention_prompt_tag @room %>
rich_autocomplete_url_value: autocompletable_users_path(room_id: @room.id) } %> <% end %>
</div> </div>
<%= link_to "Close editor and discard changes", room_message_path(@room, @message), data: { form_target: "cancel" }, hidden: true %> <%= 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 %> <%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %> <% end %>
<%= turbo_stream_from @room, :messages %> <%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
<%= button_to_jump_to_newest_message %> <%= button_to_jump_to_newest_message %>
<% end %> <% end %>
+7 -6
View File
@@ -19,15 +19,16 @@
<div class="flex flex-column flex-item-grow min-width gap"> <div class="flex flex-column flex-item-grow min-width gap">
<%= form.rich_text_area :body, <%= form.rich_text_area :body,
rows: 1, rows: 1,
class: "input", class: "input lexxy-content",
style: "order: -1", style: "order: -1",
aria: { multiline: "true", label: "Write a message" }, aria: { multiline: "true", label: "Write a message" },
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
data: { data: {
controller: "rich-autocomplete", controller: "unfurl",
action: rich_text_data_actions, action: "#{rich_text_data_actions} lexxy:change->composer#saveDraft lexxy:insert-link->unfurl#unfurl",
rich_autocomplete_url_value: autocompletable_users_path(room_id: room.id), composer_target: "text" } do %>
permitted_attachment_types: "application/vnd.actiontext.opengraph-embed", <%= mention_prompt_tag room %>
composer_target: "text" } %> <% end %>
</div> </div>
<label class="btn btn--borderless txt-small flex-item-no-shrink composer__attachment-btn input--file"> <label class="btn btn--borderless txt-small flex-item-no-shrink composer__attachment-btn input--file">
+4 -4
View File
@@ -1,4 +1,4 @@
<div class="mention" sgid="<%= user.attachable_sgid %>"> <%# A span rather than a div: mentions render inline inside the <p> paragraphs
<%= avatar_tag user %> the editor produces, and block elements would make the browser split the
<%= user.name %> paragraph when parsing %>
</div> <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
+1 -1
View File
@@ -2,6 +2,6 @@
require "rubygems" require "rubygems"
require "bundler/setup" require "bundler/setup"
ARGV.unshift("--ensure-latest") ARGV.unshift("--ensure-latest", "15")
load Gem.bin_path("brakeman", "brakeman") load Gem.bin_path("brakeman", "brakeman")
+9 -2
View File
@@ -15,6 +15,7 @@ include SSHKit::DSL
ARCH = "linux/amd64,linux/arm64" ARCH = "linux/amd64,linux/arm64"
BUILDER_NAME = "campfire-builder" BUILDER_NAME = "campfire-builder"
GIT_SHA = `git rev-parse HEAD`.strip GIT_SHA = `git rev-parse HEAD`.strip
MANUAL_NOTES = ARGV.delete("--notes")
VERSION = ARGV[0] VERSION = ARGV[0]
DOCKER_IMAGE_NAME = "registry.once.com/campfire" DOCKER_IMAGE_NAME = "registry.once.com/campfire"
@@ -28,7 +29,7 @@ EXPORT_REMOTE_USER = "app"
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ] EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/" 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 current_branch = `git rev-parse --abbrev-ref HEAD`.strip
if current_branch != "main" if current_branch != "main"
@@ -57,9 +58,10 @@ on(:local) do
end end
announcing "Creating GitHub release v#{VERSION}..." do announcing "Creating GitHub release v#{VERSION}..." do
on(:local) do
if MANUAL_NOTES
notes_file = "tmp/release_notes.md" notes_file = "tmp/release_notes.md"
on(:local) do
execute :mkdir, "-p", "tmp" execute :mkdir, "-p", "tmp"
File.write(notes_file, "# What's Changed\n\n- \n") File.write(notes_file, "# What's Changed\n\n- \n")
@@ -70,6 +72,11 @@ announcing "Creating GitHub release v#{VERSION}..." do
end end
execute :rm, notes_file 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
end end
end end
+1 -1
View File
@@ -1,6 +1,6 @@
default: &default default: &default
adapter: redis adapter: redis
url: redis://localhost:6379 url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379" } %>
development: development:
<<: *default <<: *default
+2 -1
View File
@@ -1,5 +1,6 @@
require "active_support/core_ext/integer/time" require "active_support/core_ext/integer/time"
require "active_support/core_ext/numeric/bytes" require "active_support/core_ext/numeric/bytes"
require_relative "../../lib/rails_ext/log_scrubbing_formatter"
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # 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 # Log to STDOUT by default
config.logger = ActiveSupport::Logger.new(STDOUT) 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) } .then { |logger| ActiveSupport::TaggedLogging.new(logger) }
# Prepend all log lines with the following tags. # Prepend all log lines with the following tags.
+1 -2
View File
@@ -5,8 +5,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@hotwired/turbo-rails", to: "turbo.js" pin "@hotwired/turbo-rails", to: "turbo.js"
pin "@rails/actioncable", to: "actioncable.esm.js" pin "@rails/actioncable", to: "actioncable.esm.js"
pin "@rails/request.js", to: "@rails--request.js" # @0.0.8 pin "@rails/request.js", to: "@rails--request.js" # @0.0.8
pin "trix", to: "trix.esm.min.js" # @2.1.19 pin "lexxy", to: "lexxy.js"
pin "@rails/actiontext", to: "actiontext.js"
pin "highlight.js", to: "highlight.js/core.js" pin "highlight.js", to: "highlight.js/core.js"
pin_all_from "app/javascript/initializers", under: "initializers" pin_all_from "app/javascript/initializers", under: "initializers"
@@ -0,0 +1,15 @@
# Active Storage mounts its direct-upload write endpoints
# (POST /rails/active_storage/direct_uploads and the disk-service PUT) on
# framework controllers that inherit from ActiveStorage::BaseController, so
# they never pass through ApplicationController's require_authentication.
# Campfire uploads attachments through MessagesController instead and does not
# use direct uploads at all, leaving these endpoints reachable by anyone who
# can read the public login page. Require a valid Campfire session before an
# anonymous caller can allocate a Blob or persist bytes to disk.
Rails.application.config.to_prepare do
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
ActiveStorage::DiskController.include ActiveStorageAuthentication
ActiveStorage::DiskController.before_action :require_active_storage_authentication, only: :update
end
+1
View File
@@ -0,0 +1 @@
Resque.redis = ENV.fetch("REDIS_URL") { "redis://localhost:6379" }
@@ -0,0 +1,3 @@
Rails.application.config.to_prepare do
Turbo::StreamsChannel.prepend RoomStreamsAreAuthorized
end
+1 -1
View File
@@ -1,2 +1,2 @@
Rails.application.config.app_version = ENV.fetch("APP_VERSION", "0") Rails.application.config.app_version = ENV["APP_VERSION"].presence || ENV["GIT_REVISION"].presence || "0"
Rails.application.config.git_revision = ENV["GIT_REVISION"] Rails.application.config.git_revision = ENV["GIT_REVISION"]
+9
View File
@@ -0,0 +1,9 @@
# Disable unfuzzed libvips operations.
#
# To block loaders we need to call `Vips.block` after Rails and image_processing set their
# defaults. Force the order of operations by autoloading the file now.
ActiveStorage::Transformers::Vips
Vips.block_untrusted(true)
Vips.block("VipsForeignLoadOpenslide", true) # prevent sqlite segfault in forked parallel workers
Rails.application.config.active_storage.variable_content_types -=
%w[ image/bmp image/vnd.microsoft.icon image/vnd.adobe.photoshop ]
+19 -1
View File
@@ -17,7 +17,25 @@ end
module WebPush::PersistentRequest module WebPush::PersistentRequest
def perform def perform
if @options[:connection] if endpoint_ip = @options[:endpoint_ip]
# Pin the connection to the public IP resolved (and guarded) by
# Push::Subscription so delivery can't be rebound to a private address
# between resolution and connect. Bypasses the shared persistent pool,
# which would re-resolve the host itself.
#
# The explicit nil proxy address disables proxy discovery from
# http_proxy/https_proxy. An egress proxy would open the TCP connection
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
# pin the destination and the DNS-rebinding guarantee would be lost. This
# path is already committed to a direct connection (it bypasses the pool);
# push delivery to public vendor endpoints goes direct.
http = Net::HTTP.new(uri.host, uri.port, nil)
http.ipaddr = endpoint_ip
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
elsif @options[:connection]
http = @options[:connection] http = @options[:connection]
else else
http = Net::HTTP.new(uri.host, uri.port, *proxy_options) http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
+7 -1
View File
@@ -62,7 +62,13 @@ Rails.application.routes.draw do
resources :rooms do resources :rooms do
resources :messages resources :messages
post ":bot_key/messages", to: "messages/by_bots#create", as: :bot_messages nested do
scope path: ":bot_key", as: :bot, defaults: { format: :json } do
resources :messages, controller: "messages/by_bots", only: %i[ index create update destroy ] do
resources :boosts, controller: "messages/boosts/by_bots", only: %i[ create destroy ]
end
end
end
scope module: "rooms" do scope module: "rooms" do
resource :refresh, only: :show resource :refresh, only: :show
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -euo pipefail
db_name="${RAILS_ENV:-production}.sqlite3"
snapshot="/rails/storage/backups/$db_name"
db="/rails/storage/db/$db_name"
if [ -f "$snapshot" ]; then
mkdir -p "$(dirname "$db")"
cp "$snapshot" "$db"
rm -f "$db-wal" "$db-shm"
fi
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exec /rails/script/admin/prepare-backup
+11
View File
@@ -0,0 +1,11 @@
# The rich text editor produces formatting that Rails' default Action Text
# sanitizer strips on render. The additions are declared once in
# ContentFilters, shared by every sanitization layer.
Rails.application.config.to_prepare do
defaults = Class.new.include(ActionText::ContentHelper).new
ActionText::ContentHelper.allowed_tags =
(ActionText::ContentHelper.allowed_tags || defaults.sanitizer_allowed_tags) | ContentFilters::EDITOR_FORMATTING_TAGS
ActionText::ContentHelper.allowed_attributes =
(ActionText::ContentHelper.allowed_attributes || defaults.sanitizer_allowed_attributes) | ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
end
+70 -6
View File
@@ -2,6 +2,7 @@ class ActionText::Attachment::OpengraphEmbed
include ActiveModel::Model include ActiveModel::Model
OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed" OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
class << self class << self
def from_node(node) def from_node(node)
@@ -14,18 +15,85 @@ class ActionText::Attachment::OpengraphEmbed
end end
private private
# Trix serialized the embed's details as attributes of the
# <action-text-attachment> node. Lexxy only serializes the sgid, content
# and content-type, so newer attachments carry the details in their
# content markup instead.
def attributes_from_node(node) def attributes_from_node(node)
if node["filename"].present?
{ {
href: node["href"], href: web_url(node["href"]),
url: node["url"], url: web_url(node["url"]),
filename: node["filename"], filename: node["filename"],
description: node["caption"] description: node["caption"]
} }
else
attributes_from_content(node["content"].to_s)
end
end
def attributes_from_content(content)
fragment = Nokogiri::HTML.fragment(content)
title = fragment.at_css(".og-embed__title")
link = title&.at_css("a")
{
href: web_url(link&.[]("href")),
url: web_url(fragment.at_css(".og-embed__image img")&.[]("src")),
filename: (link || title)&.text&.strip,
description: fragment.at_css(".og-embed__description")&.text&.strip
}
end
# A link preview points at what we unfurled: an absolute http or https URL
# on some other host. Drop anything else a message body asks for, so it
# can't aim the preview's link or its image at this Campfire and have every
# reader's browser fetch it with their session attached.
def web_url(value)
return if value.blank?
parsed = URI.parse(value)
value if parsed.is_a?(URI::HTTP) && elsewhere?(parsed.host)
rescue URI::InvalidURIError
nil
end
# "https:/rooms/1" parses as HTTPS with no host at all, and a browser
# resolves both that and our own hostname against the origin Campfire is
# served from. A percent-escape hides our hostname from this comparison
# while a browser still unescapes it back to us, so an escaped host is out
# too, and neither case is anything an unfurl could have produced.
def elsewhere?(host)
return false unless named_host?(host)
canonical_host(host) != canonical_host(Current.request_host.to_s)
end
# A preview names a page on the public internet, so its host is a domain
# name, written plainly. A bare address is not one, and a browser rewrites
# the many spellings of an address ("2130706433", "0x7f.0.0.1") into a
# single one before it fetches, which is a race a comparison here loses.
def named_host?(host)
host.present? && host.exclude?("%") && host.include?(".") && domain_ending?(host.split(".").last)
end
# What keeps a name from reading as an address is its last label, which is
# a word: never a number, and never the hexadecimal spelling of one.
def domain_ending?(label)
label.match?(/[a-z]/i) && !label.match?(/\A0x/i)
end
def canonical_host(host)
host.downcase.delete_suffix(".")
end end
end end
attr_accessor :href, :url, :filename, :description attr_accessor :href, :url, :filename, :description
def twitter_avatar?
url.to_s.start_with?(TWITTER_AVATAR_URL_PREFIX)
end
def attachable_content_type def attachable_content_type
OPENGRAPH_EMBED_CONTENT_TYPE OPENGRAPH_EMBED_CONTENT_TYPE
end end
@@ -37,8 +105,4 @@ class ActionText::Attachment::OpengraphEmbed
def to_partial_path def to_partial_path
"action_text/attachables/opengraph_embed" "action_text/attachables/opengraph_embed"
end end
def to_trix_content_attachment_partial_path
"action_text/attachables/opengraph_embed"
end
end end
+16
View File
@@ -0,0 +1,16 @@
# Bot requests carry the bot key as a URL path segment (/rooms/:room_id/:bot_key/...).
# config.filter_parameters redacts query and form parameters but never path segments,
# so the key would otherwise be written verbatim to the request log. Redact it wherever
# it appears in a formatted log line.
class LogScrubbingFormatter < ::Logger::Formatter
BOT_KEY_IN_PATH = %r{(/rooms/\d+/)\d+-[A-Za-z0-9]+}
def call(severity, time, progname, message)
scrub(super)
end
private
def scrub(line)
line.gsub(BOT_KEY_IN_PATH, '\1[FILTERED]')
end
end
+15 -11
View File
@@ -1,25 +1,29 @@
require "resolv" require "surfguard"
module RestrictedHTTP module RestrictedHTTP
class Violation < StandardError; end class Violation < StandardError; end
# The address policy lives in the surfguard gem so this app, Basecamp, HEY and
# Fizzy classify "internal" the same way instead of each keeping a copy that
# drifts. The callers here hand a bare hostname in and pin the address that
# comes back, so this stays a hostname-in, address-out shim.
module PrivateNetworkGuard module PrivateNetworkGuard
extend self extend self
LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network # A hostname that resolves to nothing (NXDOMAIN, timeout, empty answer)
# raises Surfguard::Unresolvable, which we let propagate as a lookup failure
# -- the same way the old Resolv.getaddress guard raised Resolv::ResolvError,
# and the callers here already treat it as a fetch failure. The Violation is
# reserved for a host that resolves to a blocked address (an empty list back
# from resolve_public_ips), so a transient DNS miss is never misreported as
# an SSRF attempt.
def resolve(hostname) def resolve(hostname)
Resolv.getaddress(hostname).tap do |ip| Surfguard.resolve_public_ips(hostname).first or
raise Violation.new("Attempt to access private IP via #{hostname}") if ip && private_ip?(ip) raise Violation.new("Attempt to access private IP via #{hostname}")
end
end end
def private_ip?(ip) def private_ip?(ip)
IPAddr.new(ip).then do |ipaddr| Surfguard.blocked_address?(ip)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr)
end
rescue IPAddr::InvalidAddressError
true
end end
end end
end end
+5 -3
View File
@@ -1,17 +1,19 @@
class WebPush::Notification class WebPush::Notification
def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:) def initialize(title:, body:, path:, badge:, endpoint:, endpoint_ip_resolver:, p256dh_key:, auth_key:)
@title, @body, @path, @badge = title, body, path, badge @title, @body, @path, @badge = title, body, path, badge
@endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key @endpoint, @endpoint_ip_resolver, @p256dh_key, @auth_key = endpoint, endpoint_ip_resolver, p256dh_key, auth_key
end end
def deliver(connection: nil) def deliver(connection: nil)
if endpoint_ip = @endpoint_ip_resolver.call
WebPush.payload_send \ WebPush.payload_send \
message: encoded_message, message: encoded_message,
endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key, endpoint: @endpoint, endpoint_ip: endpoint_ip, p256dh: @p256dh_key, auth: @auth_key,
vapid: vapid_identification, vapid: vapid_identification,
connection: connection, connection: connection,
urgency: "high" urgency: "high"
end end
end
private private
def vapid_identification def vapid_identification
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more