Compare commits

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

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

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

- `post-restore` moves our snaphotted file back into place, and removes
  the orphaned sidecar files
2026-08-03 12:30:54 +01:00
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
Stanko K.R. df5ed25e14 Recommend the latest tag over main 2026-07-16 21:17:17 +02:00
Stanko K.R. 048ef90ae7 Add the missing development guide 2026-07-16 21:17:17 +02:00
Mike Dalessio ea994053ea Bump sqlite3 to 2.9.5 (#222)
to resolve security advisories
2026-07-16 02:13:05 -04:00
Mike Dalessio 018a0b5dd9 Bump websocket-driver to 0.8.2
Conservative lockfile-only bump clearing four advisories affecting
websocket-driver < 0.8.2: GHSA-2x63-gw47-w4mm, CVE-2026-54463,
CVE-2026-54464, CVE-2026-54465.

Notes: https://github.com/faye/websocket-driver-ruby/blob/main/CHANGELOG.md
2026-07-16 01:52:48 -04:00
Mike Dalessio 38741c53f1 Upgrade loofah to 2.25.2 and rails-html-sanitizer to 1.7.1 (#221)
Coordinated security releases; 1.7.1 requires loofah >= 2.25.2.

- https://github.com/flavorjones/loofah/releases/tag/v2.25.2
- https://github.com/rails/rails-html-sanitizer/releases/tag/v1.7.1
2026-07-16 00:51:35 -04:00
Stanko K.R. cf0a67fcaf Add a development guide 2026-07-15 13:43:14 +02:00
Stanko K.R. e945d4217b Explain how we tag docker images 2026-07-15 13:39:42 +02:00
Stanko K.R. cccfb8f774 Add backup and restore instructions 2026-07-15 13:34:09 +02:00
Stanko K.R. 55fbf16629 Improve self-hosting instructions 2026-07-15 13:29:13 +02:00
Rosa GutierrezandClaude Opus 4.8 9ebc47a8f0 Bump json to 2.20.0 (CVE-2026-54696)
Fixes a heap buffer overflow in the JSON generator when streaming an
oversized object to an IO (JSON.dump(obj, io) / JSON::State#generate).
Affects json 2.9.0-2.19.8; patched in 2.19.9. Bumps to the current
2.20.0 line via `bundle update json --conservative` (lockfile-only).

GHSA-x2f5-4prf-w687 / https://nvd.nist.gov/vuln/detail/CVE-2026-54696

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:09:20 +02:00
Rosa Gutierrez 962c48af13 Update brakeman 2026-06-29 14:35:42 +02:00
Rosa GutierrezandClaude Opus 4.8 0920ceab6b Upgrade crass to 1.0.7
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:35:42 +02:00
dependabot[bot] 12bb943af8 Bump concurrent-ruby from 1.3.5 to 1.3.7
Bumps [concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby) from 1.3.5 to 1.3.7.
- [Release notes](https://github.com/ruby-concurrency/concurrent-ruby/releases)
- [Changelog](https://github.com/ruby-concurrency/concurrent-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.5...v1.3.7)

---
updated-dependencies:
- dependency-name: concurrent-ruby
  dependency-version: 1.3.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 13:32:23 +02:00
dependabot[bot] e4f88b425a Bump nokogiri from 1.19.3 to 1.19.4
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.19.3 to 1.19.4.
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.19.3...v1.19.4)

---
updated-dependencies:
- dependency-name: nokogiri
  dependency-version: 1.19.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 13:32:14 +02:00
dependabot[bot] bfa9f66633 Bump net-imap from 0.6.4 to 0.6.4.1
Bumps [net-imap](https://github.com/ruby/net-imap) from 0.6.4 to 0.6.4.1.
- [Release notes](https://github.com/ruby/net-imap/releases)
- [Commits](https://github.com/ruby/net-imap/compare/v0.6.4...v0.6.4.1)

---
updated-dependencies:
- dependency-name: net-imap
  dependency-version: 0.6.4.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 13:31:57 +02:00
Mike Dalessio 8d3c2bbd2b Merge pull request #211 from basecamp/clean-202606
Security dependency updates and general maintenance
2026-06-09 12:47:49 -04:00
Mike Dalessio 03fa883f98 dep: update puma 6.6.1 → 7.2.1 (major)
Security bump for CVE-2026-47736 (PROXY Protocol v1 parser memory
exhaustion) and CVE-2026-47737 (PROXY v1 repeated headers). Not exposed:
PROXY protocol is opt-in via set_remote_address proxy_protocol:, which
campfire never calls — default remote_address :socket leaves the parser
unreachable. Thruster/kamal-proxy front campfire over HTTP X-Forwarded-*,
not PROXY protocol.

No 6.x fix exists, so this is a major v6→v7 bump:
  - Gemfile pin "~> 6.6" → "~> 7.2", ">= 7.2.1"
  - config/puma.rb needs NO changes — every DSL method used (threads,
    worker_timeout, bind, environment, pidfile, workers, plugin
    :tmp_restart) is unchanged in v7; tmp_restart.rb is byte-identical.

Behavior notes (no action): preload_app effectively defaults on for
clustered mode (safe — Rails eager-loaded in master, Membership.
disconnect_all handles pre-fork conns); persistent_timeout default
20→65s (internal keep-alive). Min Ruby 3.0; campfire runs 3.4.5.

Direct gem. 137 commits triaged, 0 mitigations. Verified green via full
unit + system suites (system tests boot Puma 7.2.1).

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-puma_v6.6.1..v7.2.1.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio 8e0aa7d636 dep: update nokogiri 1.18.10 → 1.19.3
Security bump clearing 3 advisories: GHSA-c4rq-3m3g-8wgx (High, CSS
selector tokenizer ReDoS), GHSA-v2fc-qm4h-8hqv (XSLT transform memory
leak), GHSA-wx95-c6cv-8532 (unchecked xmlC14NExecute return). None
exposed: loofah/rails-html-sanitizer use DOM/XPath not CSS selectors,
every app CSS selector is a compile-time literal, and campfire uses no
XSLT or XML canonicalization.

The 1.18→1.19 minor bump is a Ruby-4-support/packaging milestone —
bundled libxml2 2.13.9 / libxslt 1.1.43 are unchanged, so HTML parsing
and ActionText sanitization output are identical. Ruby floor 3.2
(campfire runs 3.4.5); all 4 locked platforms still ship.

Transitive dep via Rails/loofah (no Gemfile change). 33 commits analyzed,
0 mitigations. Verified green via full unit + system suites.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-nokogiri_v1.18.10..v1.19.3.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio efaae642b0 dep: update rack 3.2.4 → 3.2.6
Security bump clearing ~11 CVEs (multipart parser differentials/DoS, Host
header validation, byte-range DoS, Forwarded injection, Rack::Static/
Directory/Sendfile disclosures). Reachable fixes (multipart uploads, AS
byte-range streaming, host parsing) apply transparently via corrective
behavior and generous new defaults; the Static/Directory/Sendfile/
Deflater CVEs are not reachable (campfire uses none of those middlewares).

No app changes required. Optional hardening noted in plan: new env knob
RACK_MULTIPART_PARSER_BYTESIZE_LIMIT (default 10 GiB) could be tuned to
campfire's real max attachment size — left at default here.

Transitive dep via Rails (no Gemfile change). 23 commits analyzed,
0 required mitigations. Verified green via full unit + system suites.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-rack_v3.2.4..v3.2.6.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio 28525bec5d dep: update sinatra 4.1.1 → 4.2.1
Security bump for CVE-2025-61921 (ReDoS via ETag header generation). Not
exposed: sinatra reaches campfire only through resque-web, which is never
mounted — config.ru runs plain Rails, no `require "resque/server"`
anywhere, so Sinatra::Base is never defined. Dead code at runtime.

Conservative update also bumped rack-protection 4.1.1 → 4.2.1 (same
monorepo); its shipped lib is byte-identical bar the version string.

Transitive dep (no Gemfile change). 14 commits analyzed, 0 mitigations.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-sinatra_v4.1.1..v4.2.1.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio 52ccd24efa dep: update action_text-trix 2.1.15 → 2.1.19
Security fix for 3 stored/DOM XSS advisories in Trix:
GHSA-g9jg-w8vm-g96v (attachment attribute), GHSA-qmpg-8xg6-ph5q
(serialized attributes), GHSA-53p3-c7vp-4mcc (JSON deserialization
bypass in drag-and-drop).

EXPOSED: campfire's composer/editor use Trix for message bodies, and the
browser actually ran the hand-vendored vendor/javascript/trix.esm.min.js
pinned at 2.0.10 — which lacks the DOMPurify/isValidAttribute sanitizers.
The gem bump alone is a no-op for the browser; the real fix is
re-vendoring the ESM build:

  - bump gem to 2.1.19 (Gemfile.lock)
  - re-vendor vendor/javascript/trix.esm.min.js from Trix 2.1.19
    (2.0.10 → 2.1.19; bundles DOMPurify 3.4.2, rangy 1.3.2)
  - update importmap pin comment @2.0.10 → @2.1.19

89 commits analyzed. Verified green via full unit + system suites
(system tests drive the Trix composer via fill_in_rich_text_area).

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-action_text-trix_v2.1.15..v2.1.19.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio c189ddea41 dep: update net-imap 0.5.12 → 0.6.4
Security bump clearing 5 CVEs (CVE-2026-42245/42246/42256/42257/42258:
literal DoS, STARTTLS stripping, SCRAM DoS, command injection ×2). Not
exposed: all require acting as an IMAP client. net-imap is autoloaded by
mail only when retriever_method :imap is set; campfire configures no
retriever and has no inbound email — net/imap is never required.

Conservative update landed 0.6.4 (minor jump, dormant dep). Ruby floor
raised to >= 3.2.0; campfire runs 3.4.5. 206 commits triaged, 0
mitigations.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-net-imap_v0.5.12..v0.6.4.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio 7ffb043cf3 dep: update erb 6.0.0 → 6.0.4
Security bump for CVE-2026-41316 (High, @_init deserialization guard
bypass via def_module/def_method/def_class). Not exposed: ActionView
renders via Erubi, not Ruby's ERB class; no def_* calls or Marshal of ERB
objects anywhere — vulnerable path absent.

Transitive dep (no Gemfile change). 24 commits analyzed, 0 mitigations.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-erb_v6.0.0..v6.0.4.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio 17f81d4fa0 dep: update jwt 3.1.2 → 3.2.0
Security bump for CVE-2026-45363 (High, empty-key HMAC bypass). Not
exposed: jwt reaches campfire only via web-push VAPID signing, which uses
ES256 (asymmetric ECDSA), not HMAC — the vulnerable JWA::Hmac path is
unreachable. web-push requires jwt ~> 3.0; minor bump, encode API
unchanged.

Transitive dep (no Gemfile change). 15 commits analyzed, 0 mitigations.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-jwt_v3.1.2..v3.2.0.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio 905165b7e2 dep: update bcrypt 3.1.20 → 3.1.22
Security bump for CVE-2026-33306 (integer overflow → zero KDF iterations
at Cost=31). Not exposed: JRuby-only bug; campfire runs MRI (Ruby 3.4.5),
no java platform in lockfile, ActiveModel default cost is 10-12.

Direct gem (no version constraint, unchanged). 33 commits analyzed,
2 no-action mitigations (constant-time password compare hardening on the
has_secure_password path) — auth tests green.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-bcrypt_v3.1.20..v3.1.22.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio ce62fd0814 dep: update addressable 2.8.7 → 2.9.0
Security bump for CVE-2026-35611 (High, ReDoS in Addressable::Template).
Not exposed: campfire never reaches Addressable::Template — runtime use
is geared_pagination's URI.parse; templates appear only in webmock test
stubs (all string/Regexp, never templates).

Transitive dep (no Gemfile change). 49 commits analyzed, 0 mitigations.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-addressable_2.8.7..2.9.0.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio 47cdbb2a0f dep: update rexml 3.4.1 → 3.4.4
Security bump for CVE-2025-58767 (DoS on malformed XML). Conservative
update landed 3.4.4 (latest patch, above advisory floor of 3.4.2).

Test/build-only transitive dep (webmock→crack, selenium-webdriver);
absent from app runtime. 34 commits analyzed, 1 test-only mitigation
(no-root-element parse change) — verified green via full unit + system
test suites.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-rexml_3.4.1..3.4.4.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio 6ec04cbc28 dep: update rack-session 2.1.1 → 2.1.2
Security bump for CVE-2026-39324 (Rack::Session::Cookie secretless
session forgery). Campfire uses Rails ActionDispatch CookieStore, not
Rack::Session::Cookie, so was never exposed — hygiene update.

Transitive dep (no Gemfile change). 10 commits analyzed, 0 mitigations.

https://github.com/basecamp/37signals-hq/blob/main/upgrade-analysis/campfire-20260609-rack-session_2.1.1..2.1.2.md

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio e74d192aec dep: explicitly add bundler-audit 2026-06-09 12:42:51 -04:00
Mike Dalessio 67ca61da1f fix(docker): install libssl-dev to build the openssl gem
The build stage was missing the OpenSSL headers, so bundle install
failed compiling the openssl gem's native extension.

🤖 Assisted by Claude
2026-06-09 12:42:51 -04:00
Mike Dalessio 0febc9f415 dep: update to bundler 4 2026-06-09 12:20:38 -04:00
Mike Dalessio 4a75954080 style(rubocop): Layout/SpaceInsideArrayLiteralBrackets 2026-06-09 12:20:37 -04:00
Mike Dalessio 69c94b6594 bin/setup only asks for pacman install password when needed 2026-06-09 12:20:37 -04:00
Stanko Krtalić ba11a0a67f Merge pull request #192 from ESH13/fix-negative-limit-direct-placeholders
Fix crash when user has 20+ direct conversations
2026-04-13 16:47:16 +02:00
Eric Hiler 8c7f49e003 Fix crash when user has 20+ direct conversations 2026-04-10 09:23:30 -04:00
Mike DalessioandClaude Opus 4.6 3fada3d997 ci: harden GitHub Actions workflows (#185)
* Add GitHub Actions audit job (actionlint + zizmor) to CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Configure dependabot for GitHub Actions, bundler, and Docker

Batches all action updates into a single weekly PR. Adds cooldown
periods to all ecosystems.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add local GitHub Actions linting (actionlint + zizmor) to bin/setup and bin/ci

Install actionlint, shellcheck, and zizmor in bin/setup. Run both
linters as CI steps in config/ci.rb alongside existing style checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Pin all GitHub Actions to SHA hashes

Run pinact to pin action versions to specific commit SHAs,
preventing supply chain attacks from tag mutation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix high severity zizmor findings

- Suppress unpinned-images for redis service containers (digest
  pinning is nontrivial for service containers)
- Move workflow-level permissions to job-level in publish-image.yml
  (build gets full set, manifest gets only what it needs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix medium severity zizmor findings

- Add persist-credentials: false to all checkout steps
- Add permissions: {} at workflow level in ci.yml
- Add job-level permissions (contents: read) to all CI jobs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix informational template-injection findings in publish-image.yml

Move steps.meta.outputs.tags from inline ${{ }} expressions to env
vars in both the manifest creation and cosign signing steps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update brakeman to 8.0.4

bin/brakeman uses --ensure-latest which fails if not on the newest version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:26:25 -04:00
Rosa GutierrezandClaude Opus 4.5 dde94b06ed Delete server-side session on logout
When it's set. Also, store it in current attributes for convenience.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 09:31:22 +01:00
Jeremy Daer 1852adb06c Fix 1Password account ID (was user UUID) (#156) 2025-12-31 13:59:12 -08:00
Jeremy Daer e983e3f79f Block IPv6 SSRF bypass via ipv4_compat addresses (#153)
Adds ipv4_mapped? and ipv4_compat? checks to PrivateNetworkGuard.private_ip?
to block SSRF bypass attempts using IPv6 address formats like:
- ::ffff:169.254.169.254 (IPv4-mapped)
- ::169.254.169.254 (IPv4-compatible)

These formats could previously bypass the link_local? check since Ruby
treats them as IPv6 addresses, not IPv4.

Ref: HackerOne #3481701
2025-12-31 13:01:43 -08:00
Jeremy Daer 53e36a16ea Latest Brakeman 2025-12-31 12:56:01 -08:00
Jeremy Daer a05292f548 Switch 1Password account to 37signals.1password.com (#154) 2025-12-31 11:54:27 -08:00
Stanko Krtalić 71ffeeea78 Merge pull request #147 from basecamp/fix-sgid-decoding
Try to decodde SGIDs in multiple ways
2025-12-15 17:05:01 +01:00
Stanko K.R. 77bcad65b5 Try to decode SGIDs in multiple ways
This should avoid message decoding failures between different versions
of sgids
2025-12-15 17:00:50 +01:00
Mike Dalessio 238f73c26f Merge pull request #146 from basecamp/flavorjones/fix-account-creation-race
Address race condition during "first run" account creation
2025-12-12 11:14:52 -05:00
Mike Dalessio 1feb2d94b9 Address race condition during "first run" account creation 2025-12-12 10:51:28 -05:00
Jason Zimdars 49c0ce496c Merge pull request #144 from basecamp/user-self
User self
2025-12-08 23:03:07 -06:00
Jason Zimdars 88f3f942f7 Ensure edit (not trash) is displayed
Fix conditional
2025-12-08 22:57:38 -06:00
Jason Zimdars 6f256f5f2d Show admin toggle, but disable for current user
You shouldn't be able to demote yourself but displaying the disabled
toggle helps explain the group of admins at the top of the list. Builds
upon #140
2025-12-08 22:53:11 -06:00
Jason Zimdars 089a8b35c0 Merge pull request #140 from ashwin47/admin-ordering
Sort users with administrators appearing first in ordered scope
2025-12-08 22:33:49 -06:00
Ashwin M b52c318518 Group administrators separately from members with visual divider 2025-12-09 08:42:00 +05:30
Stanko Krtalić de5493d8a9 Merge pull request #124 from ashwin47/ban_typo
Fix button label to 'UnBan' for clarity
2025-12-06 11:42:50 +01:00
Ashwin M 74346342df Rewrite label 2025-12-06 16:09:13 +05:30
Stanko Krtalić e4a49d52b2 Merge pull request #125 from mphalliday/involvement-fix
Allow non-admins to update their room involvements
2025-12-04 21:43:31 +01:00
Michael Halliday b8919161a8 Allow non-admins to update their room involvements 2025-12-03 09:56:15 -05:00
Ashwin M 80585a9585 Fix button label to 'UnBan' for clarity 2025-12-03 17:50:16 +05:30
Stanko K.R. b3d97ecb0e Add safety checks to release script 2025-12-03 08:24:04 +01:00
53 changed files with 1097 additions and 148 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
<% if (session_token = `op signin --account basecamp --raw`.strip) != "" %># Generated by mrsk envify <% if (session_token = `op signin --account 23QPQDKZC5BKBIIG7UGT5GR5RM --raw`.strip) != "" %># Generated by mrsk envify
MRSK_REGISTRY_PASSWORD=<%= `op read "op://Deploy/Docker Hub MRSK/password" -n --session #{session_token}` %> MRSK_REGISTRY_PASSWORD=<%= `op read "op://Deploy/Docker Hub MRSK/password" -n --session #{session_token}` %>
RAILS_MASTER_KEY=<%= `op read "op://Development/Chat-in-a-box/RAILS_MASTER_KEY" -n --session #{session_token}` %> RAILS_MASTER_KEY=<%= `op read "op://Development/Chat-in-a-box/RAILS_MASTER_KEY" -n --session #{session_token}` %>
<% else raise ArgumentError, "Session token missing" end %> <% else raise ArgumentError, "Session token missing" end %>
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
groups:
github-actions:
patterns:
- "*"
schedule:
interval: weekly
cooldown:
default-days: 7
- package-ecosystem: bundler
directory: "/"
schedule:
interval: weekly
cooldown:
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
cooldown:
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
+47 -10
View File
@@ -6,16 +6,22 @@ on:
pull_request: pull_request:
branches: [ main ] branches: [ main ]
permissions: {}
jobs: jobs:
scan: scan:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with: with:
ruby-version: .ruby-version ruby-version: .ruby-version
bundler-cache: true bundler-cache: true
@@ -25,12 +31,16 @@ jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with: with:
ruby-version: .ruby-version ruby-version: .ruby-version
bundler-cache: true bundler-cache: true
@@ -38,11 +48,32 @@ jobs:
- name: Lint code for consistent style - name: Lint code for consistent style
run: bin/rubocop run: bin/rubocop
lint-actions:
name: GitHub Actions audit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Run actionlint
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
- name: Run zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
with:
advanced-security: false
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
services: services:
redis: redis:
image: redis image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
ports: ports:
- 6379:6379 - 6379:6379
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
@@ -51,10 +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@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env: env:
REDIS_URL: redis://localhost:6379/0 REDIS_URL: redis://localhost:6379/0
with: with:
@@ -66,9 +99,11 @@ jobs:
test_system: test_system:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
services: services:
redis: redis:
image: redis image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
ports: ports:
- 6379:6379 - 6379:6379
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
@@ -77,10 +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@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env: env:
REDIS_URL: redis://localhost:6379/0 REDIS_URL: redis://localhost:6379/0
with: with:
+41 -17
View File
@@ -13,11 +13,7 @@ concurrency:
group: publish-${{ github.workflow }}-${{ github.ref }} group: publish-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: permissions: {}
contents: read
packages: write
id-token: write
attestations: write
env: env:
IMAGE_DESCRIPTION: Campfire is a web-based chat application with multiple rooms, direct messages, file attachments with previews, search, web push notifications, @mentions, and bot integrations. Single-tenant; production-ready image with web app, background jobs, caching, file serving, and SSL. IMAGE_DESCRIPTION: Campfire is a web-based chat application with multiple rooms, direct messages, file attachments with previews, search, web push notifications, @mentions, and bot integrations. Single-tenant; production-ready image with web app, background jobs, caching, file serving, and SSL.
@@ -27,6 +23,11 @@ jobs:
build: build:
name: Build and push image (${{ matrix.arch }}) name: Build and push image (${{ matrix.arch }})
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
timeout-minutes: 45 timeout-minutes: 45
strategy: strategy:
fail-fast: false fail-fast: false
@@ -43,13 +44,15 @@ jobs:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.11.1 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@v3.5.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -64,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@v5.8.0 uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with: with:
images: ${{ steps.vars.outputs.canonical }} images: ${{ steps.vars.outputs.canonical }}
tags: | tags: |
@@ -84,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@v6.18.0 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.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 }}
@@ -102,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@v3.0.0 uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
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 }}
@@ -113,16 +129,20 @@ jobs:
needs: build needs: build
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
timeout-minutes: 20 timeout-minutes: 20
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
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@v3.11.1 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@v3.5.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -139,7 +159,7 @@ jobs:
- name: Compute base tags (no suffix) - name: Compute base tags (no suffix)
id: meta id: meta
uses: docker/metadata-action@v5.8.0 uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with: with:
images: ${{ steps.vars.outputs.canonical }} images: ${{ steps.vars.outputs.canonical }}
tags: | tags: |
@@ -157,9 +177,11 @@ jobs:
- name: Create multi-arch manifests - name: Create multi-arch manifests
shell: bash shell: bash
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: | run: |
set -eu set -eu
tags="${{ steps.meta.outputs.tags }}" tags="$TAGS"
echo "Creating manifests for tags:" echo "Creating manifests for tags:"
printf '%s\n' "$tags" printf '%s\n' "$tags"
while IFS= read -r tag; do while IFS= read -r tag; do
@@ -185,13 +207,15 @@ jobs:
done <<< "$tags" done <<< "$tags"
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@v3.9.2 uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
- name: Cosign sign all tags (keyless OIDC) - name: Cosign sign all tags (keyless OIDC)
shell: bash shell: bash
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: | run: |
set -eu set -eu
tags="${{ steps.meta.outputs.tags }}" tags="$TAGS"
printf '%s\n' "$tags" printf '%s\n' "$tags"
while IFS= read -r tag; do while IFS= read -r tag; do
[ -z "$tag" ] && continue [ -z "$tag" ] && continue
+4 -1
View File
@@ -26,7 +26,7 @@ FROM base AS build
# Install packages need to build gems # Install packages need to build gems
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install -y build-essential git pkg-config libyaml-dev && \ apt-get install -y build-essential git pkg-config libyaml-dev libssl-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems # Install application gems
@@ -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
+2 -1
View File
@@ -12,7 +12,7 @@ gem "sqlite3"
gem "redis", "~> 5.4" gem "redis", "~> 5.4"
# Deployment # Deployment
gem "puma", "~> 6.6" gem "puma", "~> 7.2", ">= 7.2.1"
# Jobs # Jobs
gem "resque", "~> 2.7.0" gem "resque", "~> 2.7.0"
@@ -48,6 +48,7 @@ gem "thruster"
group :development, :test do group :development, :test do
gem "debug" gem "debug"
gem "rubocop-rails-omakase", require: false gem "rubocop-rails-omakase", require: false
gem "bundler-audit", require: false
gem "faker", require: false gem "faker", require: false
gem "brakeman", require: false gem "brakeman", require: false
end end
+44 -40
View File
@@ -128,18 +128,21 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.15) action_text-trix (2.1.19)
railties railties
addressable (2.8.7) addressable (2.9.0)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3) ast (2.4.3)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.22)
benchmark (0.5.0) benchmark (0.5.0)
bigdecimal (3.3.1) bigdecimal (3.3.1)
brakeman (7.1.1) brakeman (8.0.5)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0) capybara (3.40.0)
addressable addressable
matrix matrix
@@ -150,18 +153,18 @@ GEM
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
chunky_png (1.4.0) chunky_png (1.4.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.7)
connection_pool (2.5.5) connection_pool (2.5.5)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.7)
date (3.5.0) date (3.5.1)
debug (1.11.0) debug (1.11.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
drb (2.2.3) drb (2.2.3)
erb (6.0.0) erb (6.0.4)
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)
@@ -188,8 +191,8 @@ 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.13.2) json (2.20.0)
jwt (3.1.2) jwt (3.2.0)
base64 base64
kredis (1.8.0) kredis (1.8.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
@@ -198,7 +201,7 @@ GEM
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.24.1) 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.0)
@@ -221,7 +224,7 @@ GEM
ruby2_keywords (~> 0.0.1) ruby2_keywords (~> 0.0.1)
net-http-persistent (4.0.6) net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4) connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.5.12) net-imap (0.6.4.1)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -231,13 +234,13 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin) nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin) nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
openssl (3.3.0) openssl (3.3.0)
ostruct (0.6.3) ostruct (0.6.3)
@@ -256,15 +259,15 @@ GEM
date date
stringio stringio
public_suffix (6.0.2) public_suffix (6.0.2)
puma (6.6.1) puma (7.2.1)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.4) rack (3.2.6)
rack-protection (4.1.1) rack-protection (4.2.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-session (2.1.1) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
@@ -275,8 +278,8 @@ GEM
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.1)
loofah (~> 2.21) loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails_autolink (1.1.8) rails_autolink (1.1.8)
actionview (> 3.1) actionview (> 3.1)
@@ -305,12 +308,12 @@ GEM
resque-pool (0.7.1) resque-pool (0.7.1)
rake (>= 10.0, < 14.0) rake (>= 10.0, < 14.0)
resque (>= 1.22, < 3) resque (>= 1.22, < 3)
rexml (3.4.1) rexml (3.4.4)
rqrcode (3.1.0) rqrcode (3.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.0.0) rqrcode_core (2.1.0)
rubocop (1.80.0) rubocop (1.80.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@@ -357,17 +360,17 @@ GEM
sentry-ruby (5.26.0) sentry-ruby (5.26.0)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sinatra (4.1.1) sinatra (4.2.1)
logger (>= 1.6.0) logger (>= 1.6.0)
mustermann (~> 3.0) mustermann (~> 3.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-protection (= 4.1.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.7.3-aarch64-linux-gnu) sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.7.3-arm64-darwin) sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.7.3-x86_64-darwin) sqlite3 (2.9.5-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu) sqlite3 (2.9.5-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)
@@ -377,13 +380,13 @@ GEM
thruster (0.1.15-x86_64-darwin) thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux) thruster (0.1.15-x86_64-linux)
tilt (2.6.1) tilt (2.6.1)
timeout (0.4.4) timeout (0.6.1)
tsort (0.2.0) tsort (0.2.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (3.1.5) unicode-display_width (3.2.0)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.1)
unicode-emoji (4.0.4) 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.0.2)
@@ -394,7 +397,7 @@ GEM
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.0) websocket-driver (0.8.2)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
@@ -412,6 +415,7 @@ DEPENDENCIES
bcrypt bcrypt
benchmark benchmark
brakeman brakeman
bundler-audit
capybara capybara
debug debug
faker faker
@@ -425,7 +429,7 @@ DEPENDENCIES
ostruct ostruct
platform_agent platform_agent
propshaft! propshaft!
puma (~> 6.6) puma (~> 7.2, >= 7.2.1)
rails! rails!
rails_autolink rails_autolink
redis (~> 5.4) redis (~> 5.4)
@@ -444,4 +448,4 @@ DEPENDENCIES
webmock webmock
BUNDLED WITH BUNDLED WITH
2.5.9 4.0.13
+32 -34
View File
@@ -11,51 +11,49 @@ expect, including:
- @mentions - @mentions
- API, with support for bot integrations - API, with support for bot integrations
## Deploying with Docker ## Running your own Campfire instance
Campfire's Docker image contains everything needed for a fully-functional, Campfire's Docker image contains everything needed for a fully-functional,
single-machine deployment. This includes the web app, background jobs, caching, single-machine deployment. This includes the web app, background jobs, caching,
file serving, and SSL. file serving, and SSL. You can use our pre-built image at
`ghcr.io/basecamp/once-campfire:latest`, or build your own from this repo.
To persist storage of the database and file attachments, map a volume to `/rails/storage`. ### Deploying with ONCE
To configure additional features, you can set the following environment variables: The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once).
It will guide you through the initial set up and then keep your instance up to date automatically.
- `SSL_DOMAIN` - enable automatic SSL via Let's Encrypt for the given domain name If you don't already have `once` installed, run this on the machine you want to run Campfire on:
- `DISABLE_SSL` - alternatively, set `DISABLE_SSL` to serve over plain HTTP
- `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` - set these to a valid keypair to
allow sending Web Push notifications. You can generate a new keypair by running
`/script/admin/create-vapid-key`
- `SENTRY_DSN` - to enable error reporting to sentry in production, supply your
DSN here
For example: ```sh
curl https://get.once.com | sh
```
docker build -t campfire . `once` will launch as soon as the install is finished.
docker run \ Choose Campfire from the list of applications, follow the instructions, and ONCE will take care of the rest.
--publish 80:80 --publish 443:443 \
--restart unless-stopped \
--volume campfire:/rails/storage \
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
--env TLS_DOMAIN=chat.example.com \
campfire
## Running in development If you prefer the command line to the dashboard, you can deploy directly:
bin/setup ```sh
bin/rails server once deploy ghcr.io/basecamp/once-campfire --host chat.example.com
```
## Worth Noting ### Deploying with Docker
When you start Campfire for the first time, you’ll be guided through If you'd rather run the Docker image yourself, you can read more about that in the [self-hosting guide](docs/self-hosting.md).
creating an admin account.
The email address of this admin account will be shown on the login page
so that people who forget their password know who to contact for help.
(You can change this email later in the settings)
Campfire is single-tenant: any rooms designated "public" will be accessible by > [!TIP]
all users in the system. To support entirely distinct groups of customers, you > When you start Campfire for the first time, you'll be guided through a wizard to create an admin account.
would deploy multiple instances of the application. > The email address that you enter for the admin account will be visible on the sign-in page, it's there so
> that people have someone to contact if they need help with their account. If that bothers you, put in any
> email address you want and create yourself a new admin account.
## Development
You are welcome - and encouraged - to modify Campfire to your liking.
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
## Security
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
+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.
+7
View File
@@ -127,6 +127,13 @@
} }
} }
&:has(input[type=checkbox][disabled]) {
cursor: not-allowed;
filter: grayscale(100%) contrast(0.5);
opacity: 0.5;
pointer-events: none;
}
[disabled] &[type=submit], [disabled] &[type=submit],
&[type=submit]:disabled { &[type=submit]:disabled {
cursor: not-allowed; cursor: not-allowed;
+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?
+3 -1
View File
@@ -3,7 +3,9 @@ class AccountsController < ApplicationController
before_action :set_account before_action :set_account
def edit def edit
set_page_and_extract_portion_from account_users.ordered, per_page: 500 users = account_users.ordered.without_bots
@administrators, @members = users.partition(&:administrator?)
set_page_and_extract_portion_from users, per_page: 500
end end
def update def update
+13 -3
View File
@@ -67,17 +67,27 @@ module Authentication
authenticated_as session authenticated_as session
end end
def terminate_current_session
Current.session&.destroy!
reset_session
remove_authentication_cookie
end
def authenticated_as(session) def authenticated_as(session)
Current.user = session.user Current.session = session
set_authenticated_by(:session) set_authenticated_by(:session)
cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax } set_authentication_cookie(session)
end end
def post_authenticating_url def post_authenticating_url
session.delete(:return_to_after_authenticating) || root_url session.delete(:return_to_after_authenticating) || root_url
end end
def reset_authentication def set_authentication_cookie(session)
cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax }
end
def remove_authentication_cookie
cookies.delete(:session_token) cookies.delete(:session_token)
end end
+2
View File
@@ -11,6 +11,8 @@ class FirstRunsController < ApplicationController
user = FirstRun.create!(user_params) user = FirstRun.create!(user_params)
start_new_session_for user start_new_session_for user
redirect_to root_url
rescue ActiveRecord::RecordNotUnique
redirect_to root_url redirect_to root_url
end end
@@ -1,8 +1,6 @@
class Rooms::InvolvementsController < ApplicationController class Rooms::InvolvementsController < ApplicationController
include RoomScoped include RoomScoped
before_action :ensure_can_administer, only: %i[ update ]
def show def show
@involvement = @membership.involvement @involvement = @membership.involvement
end end
+1 -1
View File
@@ -18,7 +18,7 @@ class SessionsController < ApplicationController
def destroy def destroy
remove_push_subscription remove_push_subscription
reset_authentication terminate_current_session
redirect_to root_url redirect_to root_url
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
+1 -1
View File
@@ -16,7 +16,7 @@ class Users::SidebarsController < ApplicationController
def find_direct_placeholder_users def find_direct_placeholder_users
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id) exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
User.active.where.not(id: exclude_user_ids).order(:created_at).limit(DIRECT_PLACEHOLDERS - exclude_user_ids.count) User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
end end
def user_ids_already_in_direct_rooms_with_current_user def user_ids_already_in_direct_rooms_with_current_user
+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
+9 -1
View File
@@ -1,8 +1,16 @@
class Current < ActiveSupport::CurrentAttributes class Current < ActiveSupport::CurrentAttributes
attribute :user, :request attribute :session, :user, :request
delegate :host, :protocol, to: :request, prefix: true, allow_nil: true delegate :host, :protocol, to: :request, prefix: true, allow_nil: true
def session=(value)
super(value)
if value.present?
self.user = session.user
end
end
def account def account
Account.first Account.first
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
+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")
+7 -1
View File
@@ -100,7 +100,13 @@
<menu class="flex flex-column gap margin-none pad"> <menu class="flex flex-column gap margin-none pad">
<turbo-frame id="account_users"> <turbo-frame id="account_users">
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %> <%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
<% if @administrators.any? && @members.any? %>
<hr class="separator full-width" style="--border-style: solid">
<% end %>
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %> <%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
</turbo-frame> </turbo-frame>
</menu> </menu>
+6 -3
View File
@@ -9,23 +9,26 @@
<hr class="separator" aria-hidden="true"> <hr class="separator" aria-hidden="true">
<% if Current.user.can_administer? && user != Current.user && user.active? %> <% if Current.user.can_administer? && user.active? %>
<% unless user.bot? %> <% unless user.bot? %>
<%= form_with model: user, url: account_user_path(user), data: { controller: "form" }, method: :patch do | form | %> <%= form_with model: user, url: account_user_path(user), data: { controller: "form" }, method: :patch do | form | %>
<label class="btn txt-small flex-item-no-shrink" for="<%= dom_id(user, :role) %>"> <label class="btn txt-small flex-item-no-shrink" for="<%= dom_id(user, :role) %>">
<span class="for-screen-reader">Role: <%= user.administrator? ? "Administrator" : "Member" %></span> <span class="for-screen-reader">Role: <%= user.administrator? ? "Administrator" : "Member" %></span>
<%= image_tag "crown.svg", size: 20, aria: { hidden: "true" } %> <%= image_tag "crown.svg", size: 20, aria: { hidden: "true" } %>
<%= form.check_box :role, { data: { action: "form#submit" }, hidden: true, id: dom_id(user, :role) }, "administrator", "member" %> <%= form.check_box :role, { data: { action: "form#submit" }, hidden: true, id: dom_id(user, :role), disabled: user == Current.user }, "administrator", "member" %>
</label> </label>
<% end %> <% end %>
<% end %> <% end %>
<% unless user == Current.user %>
<%= button_to account_user_path(user), method: :delete, class: "btn txt-small flex-item-no-shrink btn--negative", data: { <%= button_to account_user_path(user), method: :delete, class: "btn txt-small flex-item-no-shrink btn--negative", data: {
turbo_confirm: "Are you sure you want to permanently remove this person from the account? This can’t be undone."} do %> turbo_confirm: "Are you sure you want to permanently remove this person from the account? This can’t be undone."} do %>
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %> <%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Delete <%= user.name %></span> <span class="for-screen-reader">Delete <%= user.name %></span>
<% end %> <% end %>
<% elsif user == Current.user %> <% end %>
<% end %>
<% if user == Current.user %>
<%= link_to user_profile_path, class: "btn txt-small flex-item-no-shrink", target: "_top" do %> <%= link_to user_profile_path, class: "btn txt-small flex-item-no-shrink", target: "_top" do %>
<%= image_tag "pencil.svg", size: 20, aria: { hidden: "true" } %> <%= image_tag "pencil.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">My settings</span> <span class="for-screen-reader">My settings</span>
+1 -1
View File
@@ -9,7 +9,7 @@
<%= button_to user_ban_path(user), method: :delete, <%= button_to user_ban_path(user), method: :delete,
class: "btn btn--negative full-width", class: "btn btn--negative full-width",
data: { turbo_confirm: "Are you sure you want to remove the ban on this user?" } do %> data: { turbo_confirm: "Are you sure you want to remove the ban on this user?" } do %>
<%= image_tag "cancel.svg", aria: { hidden: "true", label: "Ban #{@user.name}" } %> <%= image_tag "cancel.svg", aria: { hidden: "true", label: "Remove Ban #{@user.name}" } %>
<span>Remove ban</span> <span>Remove ban</span>
<% end %> <% end %>
<% end %> <% end %>
+11
View File
@@ -30,6 +30,17 @@ 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`" unless VERSION
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
if current_branch != "main"
print "\e[1;33mWarning: You are on branch '#{current_branch}', not 'main'. Continue? [y/N] \e[0m"
abort "Release aborted." unless $stdin.gets.strip.downcase.chars.first == "y"
end
unless `git status --porcelain`.strip.empty?
print "\e[1;33mWarning: You have uncommitted changes. Continue? [y/N] \e[0m"
abort "Release aborted." unless $stdin.gets.strip.downcase.chars.first == "y"
end
def announcing(message) def announcing(message)
puts "\n\e[1;36m#{message}\e[0m" puts "\n\e[1;36m#{message}\e[0m"
yield yield
+18 -1
View File
@@ -60,7 +60,10 @@ echo
if command -v brew &>/dev/null; then if command -v brew &>/dev/null; then
step "Installing packages" brew install sqlite ffmpeg mise step "Installing packages" brew install sqlite ffmpeg mise
elif command -v pacman &>/dev/null; then elif command -v pacman &>/dev/null; then
step "Installing packages" sudo pacman -S --noconfirm --needed sqlite ffmpeg mise packages=(sqlite ffmpeg mise)
if ! pacman -Q "${packages[@]}" >/dev/null 2>&1; then
step "Installing packages" sudo pacman -S --noconfirm --needed "${packages[@]}"
fi
elif command -v apt &>/dev/null; then elif command -v apt &>/dev/null; then
step "Installing packages" sudo apt-get install --no-install-recommends -y libsqlite3-0 ffmpeg step "Installing packages" sudo apt-get install --no-install-recommends -y libsqlite3-0 ffmpeg
fi fi
@@ -100,6 +103,20 @@ if ! redis_running; then
fi fi
fi fi
# Install GitHub Actions linting tools
for tool in actionlint shellcheck zizmor; do
if ! command -v "$tool" &> /dev/null; then
if command -v brew &> /dev/null; then
step "Installing $tool" brew install "$tool"
elif command -v pacman &> /dev/null; then
step "Installing $tool" sudo pacman -S --noconfirm "$tool"
else
echo "Error: install $tool manually" >&2
exit 1
fi
fi
done
step "Cleaning up logs and tempfiles" rails log:clear tmp:clear step "Cleaning up logs and tempfiles" rails log:clear tmp:clear
step "Restarting services" rails restart step "Restarting services" rails restart
+2
View File
@@ -4,6 +4,8 @@ CI.run do
step "Setup", "bin/setup --skip-server" step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop" step "Style: Ruby", "bin/rubocop"
step "Style: GitHub Actions (actionlint)", "actionlint"
step "Style: GitHub Actions (zizmor)", "zizmor ."
step "Security: Gem audit", "bin/bundler-audit" step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit" step "Security: Importmap vulnerability audit", "bin/importmap audit"
+1 -1
View File
@@ -5,7 +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.0.10 pin "trix", to: "trix.esm.min.js" # @2.1.19
pin "@rails/actiontext", to: "actiontext.js" pin "@rails/actiontext", to: "actiontext.js"
pin "highlight.js", to: "highlight.js/core.js" pin "highlight.js", to: "highlight.js/core.js"
+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 ]
@@ -0,0 +1,6 @@
class AddSingletonConstraintToAccounts < ActiveRecord::Migration[8.2]
def change
add_column :accounts, :singleton_guard, :integer, default: 0, null: false
add_index :accounts, :singleton_guard, unique: true
end
end
Generated
+3 -1
View File
@@ -10,14 +10,16 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_11_26_130131) do ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
create_table "accounts", force: :cascade do |t| create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.text "custom_styles" t.text "custom_styles"
t.string "join_code", null: false t.string "join_code", null: false
t.string "name", null: false t.string "name", null: false
t.json "settings" t.json "settings"
t.integer "singleton_guard", default: 0, null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["singleton_guard"], name: "index_accounts_on_singleton_guard", unique: true
end end
create_table "action_text_rich_texts", force: :cascade do |t| create_table "action_text_rich_texts", force: :cascade do |t|
+69
View File
@@ -0,0 +1,69 @@
## Development
### Setting up
First, get everything installed and configured with:
```sh
bin/setup
```
This installs the system packages Campfire needs (SQLite, ffmpeg), the right Ruby version (via [mise](https://mise.jdx.dev)), and the app's gems; prepares the database; and starts Redis (in a Docker container called `campfire-redis`, if it isn't already running locally).
If you want to start over at any point, run:
```sh
bin/setup --reset
```
### Running the server
Start the development server with:
```sh
bin/dev
```
You'll be able to access the app at http://localhost:3000.
On first run you'll be guided through creating your admin account, and you can sign in with that account from then on.
Note that Campfire needs Redis (for Action Cable, caching, and background jobs), so if you've restarted your machine or stopped the container, `docker start campfire-redis` will bring it back.
### Web Push notifications
Campfire uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables:
- `VAPID_PRIVATE_KEY`
- `VAPID_PUBLIC_KEY`
You can generate a fresh pair (along with a secret key base, which you can ignore in development) by running:
```sh
script/admin/generate-secrets
```
### Running tests
Run the unit tests with:
```sh
bin/rails test
```
And the browser-based system tests with:
```sh
bin/rails test:system
```
Before pushing your changes, you can run the full CI suite locally - style checks, security audits, and all the tests - with a single command:
```sh
bin/ci
```
### Contributing
You are welcome - and encouraged - to modify Campfire to your liking.
If you'd like to contribute your changes back, please read our [contributing guide](../CONTRIBUTING.md) first.
+213
View File
@@ -0,0 +1,213 @@
## Self-hosting Campfire
Campfire's Docker image contains everything needed for a fully-functional, single-machine deployment.
This includes the web app, background jobs, caching, file serving, and SSL.
> [!TIP]
> The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once), which handles installation, updates, and backups for you. See the [README](../README.md#deploying-with-once) for details. This guide covers running the Docker image by hand.
We recommend using `ghcr.io/basecamp/once-campfire:latest`, which always points to the most recent tagged release - the most stable and battle-tested version of Campfire.
We provide a tagged release for every major, minor and patch version of Campfire, so you can also pin your deployment to a specific version if you want to avoid unexpected changes. For example:
```bash
# exactly version 1.4.4
ghcr.io/basecamp/once-campfire:1.4.4
# any 1.4.x version
# e.g. 1.4.4 or 1.4.5
# the last number is usually changed for bug fixes
ghcr.io/basecamp/once-campfire:1.4
# any 1.x version
# e.g. 1.4.4 or 1.5.0
# the middle number is usually changed for changes to, or addition of, features
ghcr.io/basecamp/once-campfire:1
```
If you want to live on the bleeding edge, the `main` tag tracks the main release branch instead.
It changes with every merged pull request, so it's the newest - but least battle-tested - version of Campfire.
To run it you'll need three things:
1. a machine that runs Docker
2. a mounted volume (so that your database and file attachments are kept around between restarts)
3. some environment variables for configuration
If you'd rather build the image yourself from your own copy of the source, you can do that too:
```sh
docker build -t campfire .
```
### Mounting a storage volume
Campfire keeps all of its storage - the database and uploaded file attachments - inside the path `/rails/storage`.
By default Docker containers don't persist storage between runs, so you'll want to mount a persistent volume into that location.
The simplest way to do this is with the `--volume` flag with `docker run`. For example:
```sh
docker run --volume campfire:/rails/storage ghcr.io/basecamp/once-campfire:latest
```
That will create a named volume (called `campfire`) and mount it into the correct path.
Docker will manage where that volume is actually stored on your server.
You can also specify the data location yourself, mount a network drive, and more.
Check the Docker documentation to find out more about what's available.
### Configuring with environment variables
To configure your Campfire installation, you can use environment variables.
At a minimum you'll want to configure your secret key and your SSL domain.
#### Secrets
Campfire needs a few secret values that are specific to your instance:
- `SECRET_KEY_BASE` - the basis for cryptographic features like signed cookies. This should be a long, unguessable random string.
- `VAPID_PRIVATE_KEY`/`VAPID_PUBLIC_KEY` - a key pair used for sending Web Push notifications.
You can generate them by running:
```sh
docker run --rm ghcr.io/basecamp/once-campfire:latest script/admin/generate-secrets
```
It prints a fresh set of values ready to set as environment variables:
```
SECRET_KEY_BASE=...
VAPID_PRIVATE_KEY=...
VAPID_PUBLIC_KEY=...
```
Keep them safe and reuse the same values across restarts and upgrades - changing them later will invalidate sessions and push notification subscriptions.
#### SSL
If you want the Campfire container to handle its own SSL (HTTPS) automatically (via Let's Encrypt), you just need to specify the domain name that you're running it on.
You can do that with the `TLS_DOMAIN` environment variable.
> [!NOTE]
> If you're using SSL, you'll want to allow traffic on ports 80 and 443.
So if you were running on `chat.example.com` you could enable SSL like this:
```sh
docker run --publish 80:80 --publish 443:443 --env TLS_DOMAIN=chat.example.com ...
```
If you are terminating SSL in some other proxy in front of Campfire, or aren't using SSL at all (for example, if you want to run it locally on your laptop), then you should set `DISABLE_SSL=true` instead and just publish port 80:
```sh
docker run --publish 80:80 --env DISABLE_SSL=true ...
```
#### Error reporting (optional)
To enable error reporting to Sentry in production, supply your DSN in the `SENTRY_DSN` environment variable.
To disable Sentry initialization entirely, set `SKIP_TELEMETRY=true`.
### Example
Putting it all together, here's a complete `docker run` invocation:
```sh
docker run \
--name campfire \
--publish 80:80 --publish 443:443 \
--restart unless-stopped \
--volume campfire:/rails/storage \
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
--env TLS_DOMAIN=chat.example.com \
ghcr.io/basecamp/once-campfire:latest
```
And here's an equivalent `docker-compose.yml` that you could use to run Campfire via `docker compose up`:
```yaml
services:
web:
image: ghcr.io/basecamp/once-campfire:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- SECRET_KEY_BASE=abcdefabcdef
- TLS_DOMAIN=chat.example.com
- VAPID_PRIVATE_KEY=myvapidprivatekey
- VAPID_PUBLIC_KEY=myvapidpublickey
volumes:
- campfire:/rails/storage
volumes:
campfire:
```
### First run
When you start Campfire for the first time, you'll be guided through creating an admin account.
> [!TIP]
> The email address of this admin account will be shown on the login page so that people who forget their password know who to contact for help.
> (You can change this email later in the settings.)
Campfire is single-tenant: any rooms designated "public" will be accessible by all users in the system.
To support entirely distinct groups of customers, you would deploy multiple instances of the application.
### Upgrading
All of Campfire's state lives in the mounted volume, so upgrading is a matter of pulling a newer image and recreating the container:
```sh
docker pull ghcr.io/basecamp/once-campfire:latest
```
Any pending database migrations run automatically when the container boots.
### Backups
To back up your instance, back up the contents of the `/rails/storage` volume.
Because the SQLite database may be written to at any moment, you shouldn't copy its files directly while Campfire is running.
Instead, first run `script/admin/prepare-backup` inside the running container to produce a consistent snapshot of the database (it's written to `storage/backups/` inside the volume):
```sh
docker exec campfire script/admin/prepare-backup
```
(If you're using Docker Compose, replace `docker exec campfire` with `docker compose exec web`)
Then archive the whole storage volume to a file on the host:
```sh
docker run --rm \
--user root \
--volume campfire:/rails/storage \
--volume "$PWD":/backup \
ghcr.io/basecamp/once-campfire:latest \
tar czf "/backup/campfire-backup.tar.gz" -C /rails storage
```
This gives you a `campfire-backup.tar.gz` in your current directory containing the database snapshot and all uploaded files.
Copy it somewhere safe, ideally off the machine.
To restore, extract the archive back into a (stopped) instance's volume, and replace the live database with the snapshot:
```sh
docker run --rm \
--user root \
--volume campfire:/rails/storage \
--volume "$PWD":/backup \
ghcr.io/basecamp/once-campfire:latest \
bash -c "tar xzf /backup/campfire-backup.tar.gz -C /rails &&
cp /rails/storage/backups/production.sqlite3 /rails/storage/db/production.sqlite3 &&
rm -f /rails/storage/db/production.sqlite3-wal /rails/storage/db/production.sqlite3-shm &&
chown -R rails:rails /rails/storage"
```
Then start Campfire again.
+12
View File
@@ -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
+8 -2
View File
@@ -12,7 +12,7 @@ ActiveSupport.on_load(:action_text_content) do
def attachable_from_possibly_expired_sgid(sgid) def attachable_from_possibly_expired_sgid(sgid)
if message = sgid&.split("--")&.first if message = sgid&.split("--")&.first
encoded_message = JSON.parse Base64.strict_decode64(message) encoded_message = JSON.parse(decode_base64(message))
decoded_gid = if data = encoded_message.dig("_rails", "data") decoded_gid = if data = encoded_message.dig("_rails", "data")
data data
@@ -20,7 +20,7 @@ ActiveSupport.on_load(:action_text_content) do
# Rails 7 used an older format of GID that serialized the payload using Marshall # Rails 7 used an older format of GID that serialized the payload using Marshall
# Since we intentionally skip signature verification, we can't safely unmarshal the data # Since we intentionally skip signature verification, we can't safely unmarshal the data
# To work around this, we manually extract the GID from the marshaled data # To work around this, we manually extract the GID from the marshaled data
Base64.urlsafe_decode64(data).match(%r{(gid://campfire/[^/]+/\d+)})&.to_s decode_base64(data).match(%r{(gid://campfire/[^/]+/\d+)})&.to_s
else else
nil nil
end end
@@ -32,6 +32,12 @@ ActiveSupport.on_load(:action_text_content) do
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
nil nil
end end
def decode_base64(message)
Base64.strict_decode64(message)
rescue => _e
Base64.urlsafe_decode64(message)
end
end end
end end
end end
+54 -3
View File
@@ -1,3 +1,4 @@
require "ipaddr"
require "resolv" require "resolv"
module RestrictedHTTP module RestrictedHTTP
@@ -6,7 +7,32 @@ module RestrictedHTTP
module PrivateNetworkGuard module PrivateNetworkGuard
extend self extend self
LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network # IPv4 special-use ranges (RFC 5735/6890) not already covered by the
# private?/loopback?/link_local? predicates in #disallowed_ipv4?.
DISALLOWED_IPV4 = %w[
0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24
198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
].map { |cidr| IPAddr.new(cidr) }.freeze
# IPv6 special-use ranges not caught by the predicates. 6to4 (2002::/16) and
# Teredo (2001::/32) are deprecated transition mechanisms with no legitimate
# fetch target, so they are blocked outright. ULA (fc00::/7, incl. the AWS
# IMDSv6 address fd00:ec2::254), link-local, and loopback are covered by the
# predicates in #disallowed_ipv6?.
DISALLOWED_IPV6 = %w[
::/128 100::/64 2001::/32 2001:2::/48 2001:db8::/32 2002::/16
fec0::/10 ff00::/8
].map { |cidr| IPAddr.new(cidr) }.freeze
# NAT64 prefixes: the well-known prefix (RFC 6052/6146) and the local-use
# prefix (RFC 8215). An address here embeds an IPv4 target in its low 32
# bits; we extract it and re-check against the IPv4 rules so NAT64 to a
# public address still resolves while NAT64 to an internal address is
# blocked.
NAT64_PREFIXES = [
IPAddr.new("64:ff9b::/96"),
IPAddr.new("64:ff9b:1::/48")
].freeze
def resolve(hostname) def resolve(hostname)
Resolv.getaddress(hostname).tap do |ip| Resolv.getaddress(hostname).tap do |ip|
@@ -15,11 +41,36 @@ module RestrictedHTTP
end end
def private_ip?(ip) def private_ip?(ip)
IPAddr.new(ip).then do |ipaddr| ipaddr = IPAddr.new(ip)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || LOCAL_IP.include?(ipaddr)
# DNS never legitimately returns these embedded forms, so block them all
# regardless of the address they wrap.
if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat?
true
elsif ipaddr.ipv4?
disallowed_ipv4?(ipaddr)
elsif NAT64_PREFIXES.any? { |prefix| prefix.include?(ipaddr) }
disallowed_ipv4?(embedded_ipv4(ipaddr))
else
disallowed_ipv6?(ipaddr)
end end
rescue IPAddr::InvalidAddressError rescue IPAddr::InvalidAddressError
true true
end end
private
def disallowed_ipv4?(ipaddr)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
DISALLOWED_IPV4.any? { |range| range.include?(ipaddr) }
end
def disallowed_ipv6?(ipaddr)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) }
end
def embedded_ipv4(ipaddr)
IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join("."))
end
end end
end end
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env ruby
require "bundler/setup"
require "securerandom"
require "web_push"
vapid_key = WebPush.generate_key
puts "SECRET_KEY_BASE=#{SecureRandom.hex(64)}"
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
@@ -30,6 +30,13 @@ class Accounts::LogosControllerTest < ActionDispatch::IntegrationTest
assert_valid_png_response size: 192 assert_valid_png_response size: 192
end end
test "show stock when custom logo cannot be resized" do
accounts(:signal).update! logo: fixture_file_upload("pixel.bmp", "image/bmp")
get account_logo_url
assert_valid_png_response size: 512
end
test "destroy" do test "destroy" do
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg") accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
@@ -10,6 +10,38 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_response :ok assert_response :ok
end end
test "edit groups administrators separately from members with a divider" do
get edit_account_url
assert_response :ok
# Verify the divider exists between administrator and member sections
assert_select "turbo-frame#account_users hr.separator.full-width"
# Verify administrators appear before the divider and members appear after
# by checking the order of user names in the response body
administrators = users(:david, :jason).map(&:name)
members = users(:jz, :kevin).map(&:name)
response_body = response.body
# Find positions of divider and user names
divider_position = response_body.index('hr class="separator full-width"')
assert divider_position, "Divider should exist in the response"
administrators.each do |name|
name_position = response_body.index("<strong>#{name}</strong>")
assert name_position, "Administrator #{name} should appear in the response"
assert name_position < divider_position, "Administrator #{name} should appear before the divider"
end
members.each do |name|
name_position = response_body.index("<strong>#{name}</strong>")
assert name_position, "Member #{name} should appear in the response"
assert name_position > divider_position, "Member #{name} should appear after the divider"
end
end
test "update" do test "update" do
assert users(:david).administrator? assert users(:david).administrator?
@@ -30,4 +30,28 @@ class FirstRunsControllerTest < ActionDispatch::IntegrationTest
assert parsed_cookies.signed[:session_token] assert parsed_cookies.signed[:session_token]
end end
test "create is not vulnerable to race conditions" do
num_attackers = 5
url = first_run_url
barrier = Concurrent::CyclicBarrier.new(num_attackers)
num_attackers.times.map do |i|
Thread.new do
session = ActionDispatch::Integration::Session.new(Rails.application)
barrier.wait # All threads wait here, then fire simultaneously
session.post url, params: {
user: {
name: "Attacker#{i}",
email_address: "attacker#{i}@example.com",
password: "password123"
}
}
end
end.each(&:join)
assert_equal 1, Account.count, "Race condition allowed #{Account.count} accounts to be created!"
assert_equal 1, User.where(role: :administrator).count, "Race condition allowed multiple admin users!"
end
end end
@@ -43,4 +43,13 @@ class Rooms::InvolvementsControllerTest < ActionDispatch::IntegrationTest
end end
end end
end end
test "a non-admin can update their room involvement" do
sign_in :jz
assert_changes -> { memberships(:jz_designers).reload.involvement }, from: "everything", to: "mentions" do
put room_involvement_url(rooms(:designers)), params: { involvement: "mentions" }
assert_redirected_to room_involvement_url(rooms(:designers))
end
end
end end
@@ -28,7 +28,9 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end end
test "create with valid credentials" do test "create with valid credentials" do
assert_difference -> { Session.count }, +1 do
post session_url, params: { email_address: "david@37signals.com", password: "secret123456" } post session_url, params: { email_address: "david@37signals.com", password: "secret123456" }
end
assert_redirected_to root_url assert_redirected_to root_url
assert parsed_cookies.signed[:session_token] assert parsed_cookies.signed[:session_token]
@@ -43,11 +45,15 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
test "destroy" do test "destroy" do
sign_in :david sign_in :david
session = users(:david).sessions.last
assert_difference -> { Session.count }, -1 do
delete session_url delete session_url
end
assert_redirected_to root_url assert_redirected_to root_url
assert_not cookies[:session_token].present? assert_not cookies[:session_token].present?
assert_nil Session.find_by(id: session.id)
end end
test "destroy removes the push subscription for the device" do test "destroy removes the push subscription for the device" do
@@ -18,6 +18,14 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
assert_equal "image/webp", @response.content_type assert_equal "image/webp", @response.content_type
end end
test "show initials when image cannot be resized" do
users(:kevin).update! avatar: fixture_file_upload("pixel.bmp", "image/bmp")
get user_avatar_url(users(:kevin).avatar_token)
assert_response :success
assert_select "text", text: "K"
end
test "show image with invalid token responds 404" do test "show image with invalid token responds 404" do
get user_avatar_url("not-a-valid-token") get user_avatar_url("not-a-valid-token")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 B

@@ -0,0 +1,133 @@
require "test_helper"
require "restricted_http/private_network_guard"
class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase
test "private_ip? returns true for 'This' network (RFC1700)" do
assert_private_ip "0.0.0.0"
assert_private_ip "0.255.255.255"
end
test "private_ip? returns true for loopback addresses" do
assert_private_ip "127.0.0.0"
assert_private_ip "127.0.0.1"
assert_private_ip "127.255.255.255"
end
test "private_ip? returns true for RFC1918 private addresses" do
assert_private_ip "10.0.0.0"
assert_private_ip "10.255.255.255"
assert_private_ip "172.16.0.0"
assert_private_ip "172.31.255.255"
assert_private_ip "192.168.0.0"
assert_private_ip "192.168.255.255"
end
test "private_ip? returns true for link-local addresses" do
assert_private_ip "169.254.0.1"
assert_private_ip "169.254.169.254" # AWS IMDS
assert_private_ip "169.254.255.255"
end
test "private_ip? returns false for public addresses" do
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("93.184.216.34")
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("8.8.8.8")
end
# IPv6 address format tests (SSRF bypass prevention)
test "private_ip? returns true for IPv4-mapped IPv6 addresses with private IPs" do
assert_private_ip "::ffff:192.168.1.1"
assert_private_ip "::ffff:10.0.0.1"
assert_private_ip "::ffff:172.16.0.1"
end
test "private_ip? returns true for IPv4-mapped IPv6 addresses with link-local IPs" do
assert_private_ip "::ffff:169.254.169.254" # AWS metadata via mapped format
end
test "private_ip? returns true for IPv4-mapped IPv6 addresses even with public IPs" do
# Block all ipv4_mapped? since DNS never returns this format legitimately
assert_private_ip "::ffff:93.184.216.34"
end
test "private_ip? returns true for IPv4-compatible IPv6 addresses with private IPs" do
assert_private_ip "::192.168.1.1"
assert_private_ip "::10.0.0.1"
end
test "private_ip? returns true for IPv4-compatible IPv6 addresses with link-local IPs" do
assert_private_ip "::169.254.169.254" # AWS metadata via compat format - the reported bypass
end
test "private_ip? returns true for IPv4-compatible IPv6 addresses even with public IPs" do
# Block all ipv4_compat? since DNS never returns this format legitimately
assert_private_ip "::93.184.216.34"
end
test "private_ip? returns true for carrier-grade NAT addresses (RFC6598)" do
assert_private_ip "100.64.0.1"
assert_private_ip "100.127.255.255"
end
test "private_ip? returns true for NAT64 addresses embedding a private IPv4" do
assert_private_ip "64:ff9b::a9fe:a9fe" # NAT64 -> 169.254.169.254 (AWS metadata)
assert_private_ip "64:ff9b::a00:5" # NAT64 -> 10.0.0.5
end
test "private_ip? returns true for local-use NAT64 addresses embedding a private IPv4 (RFC8215)" do
assert_private_ip "64:ff9b:1::a00:1" # local-use NAT64 -> 10.0.0.1
end
test "private_ip? returns false for local-use NAT64 addresses embedding a public IPv4 (RFC8215)" do
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b:1::808:808") # -> 8.8.8.8
end
test "private_ip? returns false for NAT64 addresses embedding a public IPv4" do
# DNS64 legitimately synthesizes these for public sites on IPv6-only hosts.
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b::808:808") # -> 8.8.8.8
end
test "private_ip? returns true for 6to4 and Teredo transition addresses" do
assert_private_ip "2002:a9fe:a9fe::" # 6to4 embedding 169.254.169.254
assert_private_ip "2001::1" # Teredo
end
test "private_ip? returns true for IPv6 loopback, ULA, and link-local" do
assert_private_ip "::1"
assert_private_ip "fd00:ec2::254" # AWS IMDSv6 (ULA)
assert_private_ip "fe80::1"
end
test "private_ip? returns true for IPv6 multicast, documentation, and benchmarking ranges" do
assert_private_ip "ff02::1"
assert_private_ip "2001:db8::1"
assert_private_ip "2001:2::1" # benchmarking (RFC5180), matches 198.18.0.0/15
end
test "private_ip? returns false for public IPv6 addresses" do
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("2606:4700:4700::1111")
end
test "private_ip? returns true for invalid addresses" do
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip")
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("")
end
test "resolve raises Violation for private hostname" do
Resolv.stubs(:getaddress).returns("192.168.1.1")
assert_raises RestrictedHTTP::Violation do
RestrictedHTTP::PrivateNetworkGuard.resolve("private.example.com")
end
end
test "resolve returns IP for public hostname" do
Resolv.stubs(:getaddress).returns("93.184.216.34")
assert_equal "93.184.216.34", RestrictedHTTP::PrivateNetworkGuard.resolve("example.com")
end
private
def assert_private_ip(address)
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?(address),
"Expected #{address} to be classified as private"
end
end
+118
View File
@@ -0,0 +1,118 @@
require "test_helper"
require "vips"
require "tempfile"
# libvips selects a loader from a file's actual bytes, not from its declared content type. These
# tests pin which loader is selected for each file type under the app's configured loader policy
# (config/initializers/vips.rb).
class VipsLoaderPolicyTest < ActiveSupport::TestCase
# Header bytes are enough for libvips to identify a format; native types are encoded live, exotic
# ones are represented by their magic bytes.
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
BMP = "BM" + [ 0, 0, 54 ].pack("V3") + "\x00" * 40
PSD = "8BPS" + [ 1 ].pack("n") + "\x00" * 26
ICO = "\x00\x00\x01\x00\x01\x00" + "\x00" * 16
SVG = %q(<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>)
test "loads PNG" do
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
end
test "loads GIF" do
assert_equal "VipsForeignLoadNsgifFile", loader_for(encode("gif"))
end
test "loads JPEG" do
assert_equal "VipsForeignLoadJpegFile", loader_for(encode("jpg"))
end
test "loads TIFF" do
assert_equal "VipsForeignLoadTiffFile", loader_for(encode("tif"))
end
test "loads WebP" do
assert_equal "VipsForeignLoadWebpFile", loader_for(encode("webp"))
end
test "loads AVIF" do
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_AVIF)
end
test "loads HEIC" do
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
end
test "denies BMP through magickload" do
assert_nil loader_for(BMP)
end
test "denies PSD through magickload" do
assert_nil loader_for(PSD)
end
test "denies ICO through magickload" do
assert_nil loader_for(ICO)
end
test "denies SVG through svgload" do
assert_nil loader_for(SVG)
end
test "denies OpenSlide files through openslideload" do
# OpenSlide files can segfault the embedded sqlite in forked parallel workers
assert_loader_blocked :openslideload, ".svs"
end
test "denies FITS files through fitsload" do
assert_loader_blocked :fitsload, ".fits"
end
test "denies MATLAB files through matload" do
assert_loader_blocked :matload, ".mat"
end
test "denies NIFTI files through niftiload" do
assert_loader_blocked :niftiload, ".nii"
end
test "denies RAW files through dcrawload" do
assert_loader_blocked :dcrawload, ".raw"
end
test "denies VIPS files through vipsload" do
assert_loader_blocked :vipsload, ".vips"
end
private
# Invoke a specific libvips loader directly and assert it is refused because the
# operation is blocked (rather than because the bytes are not a valid image).
def assert_loader_blocked(operation, extension)
Tempfile.create([ "blocked_loader", extension ], binmode: true) do |file|
file.write "not an image"
file.flush
error = assert_raises(Vips::Error) { Vips::Image.public_send(operation, file.path) }
actual = error.message.chomp
# note that exception message may include multiple errors on separate lines,
# so `^` and `$` anchors are used instead of `\A` and `\z`.
if actual =~ /^VipsOperation: class \"#{operation}\" not found$/
skip "libvips does not support #{operation} on this system"
end
assert_match(/^#{operation}: operation is blocked$/, actual)
end
end
def encode(ext)
Vips::Image.black(8, 8).add(128).cast("uchar").write_to_buffer(".#{ext}")
end
def loader_for(bytes)
Tempfile.create(%w[loader_probe .img], binmode: true) do |file|
file.write bytes
file.flush
Vips.vips_foreign_find_load(file.path)
end
end
end
+16
View File
@@ -15,4 +15,20 @@ class AccountTest < ActiveSupport::TestCase
accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" }) accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" })
assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators? assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
end end
test "logo_variant is a resized variant of a variable logo" do
accounts(:signal).logo.attach io: file_fixture("moon.jpg").open, filename: "moon.jpg", content_type: "image/jpeg"
assert_kind_of ActiveStorage::VariantWithRecord, accounts(:signal).logo_variant(:large)
end
test "logo_variant is nil when the logo cannot be resized" do
accounts(:signal).logo.attach io: file_fixture("pixel.bmp").open, filename: "pixel.bmp", content_type: "image/bmp"
assert_nil accounts(:signal).logo_variant(:large)
end
test "logo_variant is nil without a logo" do
assert_nil accounts(:signal).logo_variant(:large)
end
end end
+4 -4
View File
@@ -49,8 +49,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
# to a resolved IP, not a hostname to re-resolve. # to a resolved IP, not a hostname to re-resolve.
WebMock.disable_net_connect! allow: [ @url.host ] WebMock.disable_net_connect! allow: [ @url.host ]
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1") Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound) TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
assert_throws :dns_not_rebound do assert_throws :dns_not_rebound do
@fetch.fetch_document(@url) @fetch.fetch_document(@url)
@@ -66,8 +66,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
# to a resolved IP, not a hostname to re-resolve. # to a resolved IP, not a hostname to re-resolve.
WebMock.disable_net_connect! allow: [ @url.host ] WebMock.disable_net_connect! allow: [ @url.host ]
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1") Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound) TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
assert_throws :dns_not_rebound do assert_throws :dns_not_rebound do
@fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4") @fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")
+19
View File
@@ -0,0 +1,19 @@
require "test_helper"
class User::AvatarTest < ActiveSupport::TestCase
test "avatar_variant is a resized variant of a variable avatar" do
users(:kevin).avatar.attach io: file_fixture("moon.jpg").open, filename: "moon.jpg", content_type: "image/jpeg"
assert_kind_of ActiveStorage::VariantWithRecord, users(:kevin).avatar_variant
end
test "avatar_variant is nil when the avatar cannot be resized" do
users(:kevin).avatar.attach io: file_fixture("pixel.bmp").open, filename: "pixel.bmp", content_type: "image/bmp"
assert_nil users(:kevin).avatar_variant
end
test "avatar_variant is nil without an avatar" do
assert_nil users(:kevin).avatar_variant
end
end
+4 -3
View File
@@ -1,6 +1,7 @@
/* /*
Trix 2.0.10 Trix 2.1.19
Copyright © 2024 37signals, LLC Copyright © 2026 37signals, LLC
*/ */
var t="2.0.10";const e="[data-trix-attachment]",i={preview:{presentation:"gallery",caption:{name:!0,size:!0}},file:{caption:{size:!0}}},n={default:{tagName:"div",parse:!1},quote:{tagName:"blockquote",nestable:!0},heading1:{tagName:"h1",terminal:!0,breakOnReturn:!0,group:!1},code:{tagName:"pre",terminal:!0,text:{plaintext:!0}},bulletList:{tagName:"ul",parse:!1},bullet:{tagName:"li",listAttribute:"bulletList",group:!1,nestable:!0,test(t){return r(t.parentNode)===n[this.listAttribute].tagName}},numberList:{tagName:"ol",parse:!1},number:{tagName:"li",listAttribute:"numberList",group:!1,nestable:!0,test(t){return r(t.parentNode)===n[this.listAttribute].tagName}},attachmentGallery:{tagName:"div",exclusive:!0,terminal:!0,parse:!1,group:!1}},r=t=>{var e;return null==t||null===(e=t.tagName)||void 0===e?void 0:e.toLowerCase()},o=navigator.userAgent.match(/android\s([0-9]+.*Chrome)/i),s=o&&parseInt(o[1]);var a={composesExistingText:/Android.*Chrome/.test(navigator.userAgent),recentAndroid:s&&s>12,samsungAndroid:s&&navigator.userAgent.match(/Android.*SM-/),forcesObjectResizing:/Trident.*rv:11/.test(navigator.userAgent),supportsInputEvents:"undefined"!=typeof InputEvent&&["data","getTargetRanges","inputType"].every((t=>t in InputEvent.prototype))},l={attachFiles:"Attach Files",bold:"Bold",bullets:"Bullets",byte:"Byte",bytes:"Bytes",captionPlaceholder:"Add a caption…",code:"Code",heading1:"Heading",indent:"Increase Level",italic:"Italic",link:"Link",numbers:"Numbers",outdent:"Decrease Level",quote:"Quote",redo:"Redo",remove:"Remove",strike:"Strikethrough",undo:"Undo",unlink:"Unlink",url:"URL",urlPlaceholder:"Enter a URL…",GB:"GB",KB:"KB",MB:"MB",PB:"PB",TB:"TB"};const c=[l.bytes,l.KB,l.MB,l.GB,l.TB,l.PB];var h={prefix:"IEC",precision:2,formatter(t){switch(t){case 0:return"0 ".concat(l.bytes);case 1:return"1 ".concat(l.byte);default:let e;"SI"===this.prefix?e=1e3:"IEC"===this.prefix&&(e=1024);const i=Math.floor(Math.log(t)/Math.log(e)),n=(t/Math.pow(e,i)).toFixed(this.precision).replace(/0*$/,"").replace(/\.$/,"");return"".concat(n," ").concat(c[i])}}};const u="\ufeff",d=" ",g=function(t){for(const e in t){const i=t[e];this[e]=i}return this},m=document.documentElement,p=m.matches,f=function(t){let{onElement:e,matchingSelector:i,withCallback:n,inPhase:r,preventDefault:o,times:s}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const a=e||m,l=i,c="capturing"===r,h=function(t){null!=s&&0==--s&&h.destroy();const e=A(t.target,{matchingSelector:l});null!=e&&(null==n||n.call(e,t,e),o&&t.preventDefault())};return h.destroy=()=>a.removeEventListener(t,h,c),a.addEventListener(t,h,c),h},b=function(t){let{onElement:e,bubbles:i,cancelable:n,attributes:r}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const o=null!=e?e:m;i=!1!==i,n=!1!==n;const s=document.createEvent("Events");return s.initEvent(t,i,n),null!=r&&g.call(s,r),o.dispatchEvent(s)},v=function(t,e){if(1===(null==t?void 0:t.nodeType))return p.call(t,e)},A=function(t){let{matchingSelector:e,untilNode:i}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(;t&&t.nodeType!==Node.ELEMENT_NODE;)t=t.parentNode;if(null!=t){if(null==e)return t;if(t.closest&&null==i)return t.closest(e);for(;t&&t!==i;){if(v(t,e))return t;t=t.parentNode}}},x=t=>document.activeElement!==t&&y(t,document.activeElement),y=function(t,e){if(t&&e)for(;e;){if(e===t)return!0;e=e.parentNode}},C=function(t){var e;if(null===(e=t)||void 0===e||!e.parentNode)return;let i=0;for(t=t.previousSibling;t;)i++,t=t.previousSibling;return i},R=t=>{var e;return null==t||null===(e=t.parentNode)||void 0===e?void 0:e.removeChild(t)},S=function(t){let{onlyNodesOfType:e,usingFilter:i,expandEntityReferences:n}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const r=(()=>{switch(e){case"element":return NodeFilter.SHOW_ELEMENT;case"text":return NodeFilter.SHOW_TEXT;case"comment":return NodeFilter.SHOW_COMMENT;default:return NodeFilter.SHOW_ALL}})();return document.createTreeWalker(t,r,null!=i?i:null,!0===n)},E=t=>{var e;return null==t||null===(e=t.tagName)||void 0===e?void 0:e.toLowerCase()},k=function(t){let e,i,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};"object"==typeof t?(n=t,t=n.tagName):n={attributes:n};const r=document.createElement(t);if(null!=n.editable&&(null==n.attributes&&(n.attributes={}),n.attributes.contenteditable=n.editable),n.attributes)for(e in n.attributes)i=n.attributes[e],r.setAttribute(e,i);if(n.style)for(e in n.style)i=n.style[e],r.style[e]=i;if(n.data)for(e in n.data)i=n.data[e],r.dataset[e]=i;return n.className&&n.className.split(" ").forEach((t=>{r.classList.add(t)})),n.textContent&&(r.textContent=n.textContent),n.childNodes&&[].concat(n.childNodes).forEach((t=>{r.appendChild(t)})),r};let L;const D=function(){if(null!=L)return L;L=[];for(const t in n){const e=n[t];e.tagName&&L.push(e.tagName)}return L},w=t=>B(null==t?void 0:t.firstChild),T=function(t){let{strict:e}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{strict:!0};return e?B(t):B(t)Line truncated var t="2.1.19";const e="[data-trix-attachment]",i={preview:{presentation:"gallery",caption:{name:!0,size:!0}},file:{caption:{size:!0}}},n={default:{tagName:"div",parse:!1},quote:{tagName:"blockquote",nestable:!0},heading1:{tagName:"h1",terminal:!0,breakOnReturn:!0,group:!1},code:{tagName:"pre",terminal:!0,htmlAttributes:["language"],text:{plaintext:!0}},bulletList:{tagName:"ul",parse:!1},bullet:{tagName:"li",listAttribute:"bulletList",group:!1,nestable:!0,test(t){return r(t.parentNode)===n[this.listAttribute].tagName}},numberList:{tagName:"ol",parse:!1},number:{tagName:"li",listAttribute:"numberList",group:!1,nestable:!0,test(t){return r(t.parentNode)===n[this.listAttribute].tagName}},attachmentGallery:{tagName:"div",exclusive:!0,terminal:!0,parse:!1,group:!1}},r=t=>{var e;return null==t||null===(e=t.tagName)||void 0===e?void 0:e.toLowerCase()},o=navigator.userAgent.match(/android\s([0-9]+.*Chrome)/i),s=o&&parseInt(o[1]);var a={composesExistingText:/Android.*Chrome/.test(navigator.userAgent),recentAndroid:s&&s>12,samsungAndroid:s&&navigator.userAgent.match(/Android.*SM-/),forcesObjectResizing:/Trident.*rv:11/.test(navigator.userAgent),supportsInputEvents:"undefined"!=typeof InputEvent&&["data","getTargetRanges","inputType"].every((t=>t in InputEvent.prototype))},l={ADD_ATTR:["language"],SAFE_FOR_XML:!1,RETURN_DOM:!0},c={attachFiles:"Attach Files",bold:"Bold",bullets:"Bullets",byte:"Byte",bytes:"Bytes",captionPlaceholder:"Add a caption…",code:"Code",heading1:"Heading",indent:"Increase Level",italic:"Italic",link:"Link",numbers:"Numbers",outdent:"Decrease Level",quote:"Quote",redo:"Redo",remove:"Remove",strike:"Strikethrough",undo:"Undo",unlink:"Unlink",url:"URL",urlPlaceholder:"Enter a URL…",GB:"GB",KB:"KB",MB:"MB",PB:"PB",TB:"TB"};const u=[c.bytes,c.KB,c.MB,c.GB,c.TB,c.PB];var h={prefix:"IEC",precision:2,formatter(t){switch(t){case 0:return"0 ".concat(c.bytes);case 1:return"1 ".concat(c.byte);default:let e;"SI"===this.prefix?e=1e3:"IEC"===this.prefix&&(e=1024);const i=Math.floor(Math.log(t)/Math.log(e)),n=(t/Math.pow(e,i)).toFixed(this.precision).replace(/0*$/,"").replace(/\.$/,"");return"".concat(n," ").concat(u[i])}}};const d="\ufeff",g=" ",m=function(t){for(const e in t){const i=t[e];this[e]=i}return this},p=document.documentElement,f=p.matches,b=function(t){let{onElement:e,matchingSelector:i,withCallback:n,inPhase:r,preventDefault:o,times:s}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const a=e||p,l=i,c="capturing"===r,u=function(t){null!=s&&0==--s&&u.destroy();const e=x(t.target,{matchingSelector:l});null!=e&&(null==n||n.call(e,t,e),o&&t.preventDefault())};return u.destroy=()=>a.removeEventListener(t,u,c),a.addEventListener(t,u,c),u},v=function(t){let{bubbles:e,cancelable:i,attributes:n}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};e=!1!==e,i=!1!==i;const r=document.createEvent("Events");return r.initEvent(t,e,i),null!=n&&m.call(r,n),r},A=function(t){let{onElement:e,bubbles:i,cancelable:n,attributes:r}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const o=null!=e?e:p,s=v(t,{bubbles:i,cancelable:n,attributes:r});return o.dispatchEvent(s)},y=function(t,e){if(1===(null==t?void 0:t.nodeType))return f.call(t,e)},x=function(t){let{matchingSelector:e,untilNode:i}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};for(;t&&t.nodeType!==Node.ELEMENT_NODE;)t=t.parentNode;if(null!=t){if(null==e)return t;if(t.closest&&null==i)return t.closest(e);for(;t&&t!==i;){if(y(t,e))return t;t=t.parentNode}}},C=t=>document.activeElement!==t&&E(t,document.activeElement),E=function(t,e){if(t&&e)for(;e;){if(e===t)return!0;e=e.parentNode}},S=function(t){var e;if(null===(e=t)||void 0===e||!e.parentNode)return;let i=0;for(t=t.previousSibling;t;)i++,t=t.previousSibling;return i},R=t=>{var e;return null==t||null===(e=t.parentNode)||void 0===e?void 0:e.removeChild(t)},T=function(t){let{onlyNodesOfType:e,usingFilter:i,expandEntityReferences:n}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const r=(()=>{switch(e){case"element":return NodeFilter.SHOW_ELEMENT;case"text":return NodeFilter.SHOW_TEXT;case"comment":return NodeFilter.SHOW_COMMENT;default:return NodeFilter.SHOW_ALL}})();return document.createTreeWalker(t,r,null!=i?i:null,!0===n)},k=t=>{var e;return null==t||null===(e=t.tagName)||void 0===e?void 0:e.toLowerCase()},w=function(t){let e,i,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};"object"==typeof t?(n=t,t=n.tagName):n={attributes:n};const r=document.createElement(t);if(null!=n.editable&&(null==n.attributes&&(n.attributes={}),n.attributes.contenteditable=n.editable),n.attributes)for(e in n.attributes)i=n.attributes[e],r.setAttribute(e,i);if(n.style)for(e in n.style)i=n.style[e],r.style[e]=i;if(n.data)for(e in n.data)i=n.data[e],r.dataset[e]=i;return n.className&&n.className.split(" ").forEach((t=>{r.classList.add(t)})),n.textContent&&(r.textContent=n.textContent),n.childNodes&&[].concat(n.childNodes).forEach((t=>{r.appendChild(t)})),r};let D;const L=funLine truncated
/*! @license DOMPurify 3.4.2 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.2/LICENSE */const{entries:oe,setPrototypeOf:se,isFrozen:ae,getPrototypeOf:le,getOwnPropertyDescriptor:ce}=Object;let{freeze:ue,seal:he,create:de}=Object,{apply:ge,construct:me}="undefined"!=typeof Reflect&&Reflect;ue||(ue=function(t){return t}),he||(he=function(t){return t}),ge||(ge=function(t,e){for(var i=arguments.length,n=new Array(i>2?i-2:0),r=2;r<i;r++)n[r-2]=arguments[r];return t.apply(e,n)}),me||(me=function(t){for(var e=arguments.length,i=new Array(e>1?e-1:0),n=1;n<e;n++)i[n-1]=arguments[n];return new t(...i)});const pe=Me(Array.prototype.forEach),fe=Me(Array.prototype.lastIndexOf),be=Me(Array.prototype.pop),ve=Me(Array.prototype.push),Ae=Me(Array.prototype.splice),ye=Array.isArray,xe=Me(String.prototype.toLowerCase),Ce=Me(String.prototype.toString),Ee=Me(String.prototype.match),Se=Me(String.prototype.replace),Re=Me(String.prototype.indexOf),Te=Me(String.prototype.trim),ke=Me(Number.prototype.toString),we=Me(Boolean.prototype.toString),De="undefined"==typeof BigInt?null:Me(BigInt.prototype.toString),Le="undefined"==typeof Symbol?null:Me(Symbol.prototype.toString),Ne=Me(Object.prototype.hasOwnProperty),Ie=Me(Object.prototype.toString),Oe=Me(RegExp.prototype.test),Fe=(Pe=TypeError,function(){for(var t=arguments.length,e=new Array(t),i=0;i<t;i++)e[i]=arguments[i];return me(Pe,e)});var Pe;function Me(t){return function(e){e instanceof RegExp&&(e.lastIndex=0);for(var i=arguments.length,n=new Array(i>1?i-1:0),r=1;r<i;r++)n[r-1]=arguments[r];return ge(t,e,n)}}function Be(t,e){let i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:xe;if(se&&se(t,null),!ye(e))return t;let n=e.length;for(;n--;){let r=e[n];if("string"==typeof r){const t=i(r);t!==r&&(ae(e)||(e[n]=t),r=t)}t[r]=!0}return t}function _e(t){for(let e=0;e<t.length;e++){Ne(t,e)||(t[e]=null)}return t}function je(t){const e=de(null);for(const[i,n]of oe(t)){Ne(t,i)&&(ye(n)?e[i]=_e(n):n&&"object"==typeof n&&n.constructor===Object?e[i]=je(n):e[i]=n)}return e}function We(t,e){for(;null!==t;){const i=ce(t,e);if(i){if(i.get)return Me(i.get);if("function"==typeof i.value)return Me(i.value)}t=le(t)}return function(){return null}}const Ue=ue(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","search","section","select","shadow","slot","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),Ve=ue(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","enterkeyhint","exportparts","filter","font","g","glyph","glyphref","hkern","image","inputmode","line","lineargradient","marker","mask","metadata","mpath","part","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),ze=ue(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),He=ue(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),qe=ue(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),Je=ue(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),Ke=ue(["#text"]),Ge=ue(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","contLine truncated
//# sourceMappingURL=trix.esm.min.js.map //# sourceMappingURL=trix.esm.min.js.map