Compare commits

...
Author SHA1 Message Date
Jeremy Daer ae7a34113f Block IPv6 encapsulation and CGNAT ranges in SSRF guard
Add NAT64 (64:ff9b::/96), 6to4 (2002::/16), and CGNAT shared address
space (100.64.0.0/10) to the private-network guard. These ranges can
smuggle requests toward internal networks and were not covered by the
existing predicates, leaving an SSRF bypass. Introduce DISALLOWED_RANGES
and OR it into private_ip?, preserving LOCAL_IP behavior.

Ref: weekend run #3873122 (IPv6 encapsulation SSRF ranges)
2026-07-20 02:17:12 -07: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
22 changed files with 650 additions and 118 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}` %>
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 %>
+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:
branches: [ main ]
permissions: {}
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -25,12 +31,16 @@ jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -38,11 +48,32 @@ jobs:
- name: Lint code for consistent style
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:
runs-on: ubuntu-latest
permissions:
contents: read
services:
redis:
image: redis
image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
ports:
- 6379:6379
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
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
@@ -66,9 +99,11 @@ jobs:
test_system:
runs-on: ubuntu-latest
permissions:
contents: read
services:
redis:
image: redis
image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
ports:
- 6379:6379
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
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
+28 -17
View File
@@ -13,11 +13,7 @@ concurrency:
group: publish-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
id-token: write
attestations: write
permissions: {}
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.
@@ -27,6 +23,11 @@ jobs:
build:
name: Build and push image (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
timeout-minutes: 45
strategy:
fail-fast: false
@@ -43,13 +44,15 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- 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
uses: docker/login-action@v3.5.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -66,7 +69,7 @@ jobs:
- name: Extract Docker metadata (tags, labels) with arch suffix
id: meta
uses: docker/metadata-action@v5.8.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -84,7 +87,7 @@ jobs:
- name: Build and push (${{ matrix.platform }})
id: build
uses: docker/build-push-action@v6.18.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: Dockerfile
@@ -102,7 +105,7 @@ jobs:
- name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@v3.0.0
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }}
@@ -113,16 +116,20 @@ jobs:
needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
timeout-minutes: 20
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
steps:
- 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
uses: docker/login-action@v3.5.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -139,7 +146,7 @@ jobs:
- name: Compute base tags (no suffix)
id: meta
uses: docker/metadata-action@v5.8.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -157,9 +164,11 @@ jobs:
- name: Create multi-arch manifests
shell: bash
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -eu
tags="${{ steps.meta.outputs.tags }}"
tags="$TAGS"
echo "Creating manifests for tags:"
printf '%s\n' "$tags"
while IFS= read -r tag; do
@@ -185,13 +194,15 @@ jobs:
done <<< "$tags"
- 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)
shell: bash
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -eu
tags="${{ steps.meta.outputs.tags }}"
tags="$TAGS"
printf '%s\n' "$tags"
while IFS= read -r tag; do
[ -z "$tag" ] && continue
+1 -1
View File
@@ -26,7 +26,7 @@ FROM base AS build
# Install packages need to build gems
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
# Install application gems
+2 -1
View File
@@ -12,7 +12,7 @@ gem "sqlite3"
gem "redis", "~> 5.4"
# Deployment
gem "puma", "~> 6.6"
gem "puma", "~> 7.2", ">= 7.2.1"
# Jobs
gem "resque", "~> 2.7.0"
@@ -48,6 +48,7 @@ gem "thruster"
group :development, :test do
gem "debug"
gem "rubocop-rails-omakase", require: false
gem "bundler-audit", require: false
gem "faker", require: false
gem "brakeman", require: false
end
+44 -40
View File
@@ -128,18 +128,21 @@ GIT
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.15)
action_text-trix (2.1.19)
railties
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (3.3.1)
brakeman (7.1.1)
brakeman (8.0.5)
racc
builder (3.3.0)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
matrix
@@ -150,18 +153,18 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
concurrent-ruby (1.3.7)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
date (3.5.0)
crass (1.0.7)
date (3.5.1)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.3)
erb (6.0.0)
erb (6.0.4)
erubi (1.13.1)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
@@ -188,8 +191,8 @@ GEM
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.13.2)
jwt (3.1.2)
json (2.20.0)
jwt (3.2.0)
base64
kredis (1.8.0)
activemodel (>= 6.0.0)
@@ -198,7 +201,7 @@ GEM
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -221,7 +224,7 @@ GEM
ruby2_keywords (~> 0.0.1)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.5.12)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
@@ -231,13 +234,13 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin)
nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
openssl (3.3.0)
ostruct (0.6.3)
@@ -256,15 +259,15 @@ GEM
date
stringio
public_suffix (6.0.2)
puma (6.6.1)
puma (7.2.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.4)
rack-protection (4.1.1)
rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.1)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
@@ -275,8 +278,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.1)
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)
rails_autolink (1.1.8)
actionview (> 3.1)
@@ -305,12 +308,12 @@ GEM
resque-pool (0.7.1)
rake (>= 10.0, < 14.0)
resque (>= 1.22, < 3)
rexml (3.4.1)
rqrcode (3.1.0)
rexml (3.4.4)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rubocop (1.80.0)
rqrcode_core (2.1.0)
rubocop (1.80.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -357,17 +360,17 @@ GEM
sentry-ruby (5.26.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sinatra (4.1.1)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.1.1)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sqlite3 (2.7.3-aarch64-linux-gnu)
sqlite3 (2.7.3-arm64-darwin)
sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu)
sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.5-x86_64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
@@ -377,13 +380,13 @@ GEM
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
tilt (2.6.1)
timeout (0.4.4)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.5)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
web-push (3.0.2)
@@ -394,7 +397,7 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.8.0)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -412,6 +415,7 @@ DEPENDENCIES
bcrypt
benchmark
brakeman
bundler-audit
capybara
debug
faker
@@ -425,7 +429,7 @@ DEPENDENCIES
ostruct
platform_agent
propshaft!
puma (~> 6.6)
puma (~> 7.2, >= 7.2.1)
rails!
rails_autolink
redis (~> 5.4)
@@ -444,4 +448,4 @@ DEPENDENCIES
webmock
BUNDLED WITH
2.5.9
4.0.13
+28 -34
View File
@@ -11,51 +11,45 @@ expect, including:
- @mentions
- 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,
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
- `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
If you don't already have `once` installed, run this on the machine you want to run Campfire on:
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 \
--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
Choose Campfire from the list of applications, follow the instructions, and ONCE will take care of the rest.
## Running in development
If you prefer the command line to the dashboard, you can deploy directly:
bin/setup
bin/rails server
```sh
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
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)
If you'd rather run the Docker image yourself, you can read more about that in the [self-hosting guide](docs/self-hosting.md).
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.
> [!TIP]
> When you start Campfire for the first time, you'll be guided through a wizard to create an admin account.
> The email address that you enter for the admin account will be visible on the sign-in page, it's there so
> that people have someone to contact if they need help with their account. If that bothers you, put in any
> email address you want and create yourself a new admin account.
## Development
You are welcome - and encouraged - to modify Campfire to your liking.
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
+13 -3
View File
@@ -67,17 +67,27 @@ module Authentication
authenticated_as session
end
def terminate_current_session
Current.session&.destroy!
reset_session
remove_authentication_cookie
end
def authenticated_as(session)
Current.user = session.user
Current.session = session
set_authenticated_by(:session)
cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax }
set_authentication_cookie(session)
end
def post_authenticating_url
session.delete(:return_to_after_authenticating) || root_url
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)
end
+1 -1
View File
@@ -18,7 +18,7 @@ class SessionsController < ApplicationController
def destroy
remove_push_subscription
reset_authentication
terminate_current_session
redirect_to root_url
end
+1 -1
View File
@@ -16,7 +16,7 @@ class Users::SidebarsController < ApplicationController
def find_direct_placeholder_users
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
User.active.where.not(id: exclude_user_ids).order(:created_at).limit(DIRECT_PLACEHOLDERS - exclude_user_ids.count)
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
end
def user_ids_already_in_direct_rooms_with_current_user
+9 -1
View File
@@ -1,8 +1,16 @@
class Current < ActiveSupport::CurrentAttributes
attribute :user, :request
attribute :session, :user, :request
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
Account.first
end
+18 -1
View File
@@ -60,7 +60,10 @@ echo
if command -v brew &>/dev/null; then
step "Installing packages" brew install sqlite ffmpeg mise
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
step "Installing packages" sudo apt-get install --no-install-recommends -y libsqlite3-0 ffmpeg
fi
@@ -100,6 +103,20 @@ if ! redis_running; then
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 "Restarting services" rails restart
+2
View File
@@ -4,6 +4,8 @@ CI.run do
step "Setup", "bin/setup --skip-server"
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: 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 "@rails/actioncable", to: "actioncable.esm.js"
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 "highlight.js", to: "highlight.js/core.js"
+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.
+9 -1
View File
@@ -8,6 +8,14 @@ module RestrictedHTTP
LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network
# IPv6 transition/encapsulation and shared-address ranges that can smuggle
# requests toward internal networks and must never be reachable.
DISALLOWED_RANGES = [
IPAddr.new("64:ff9b::/96"), # NAT64 (RFC 6052) — embeds IPv4 in IPv6
IPAddr.new("2002::/16"), # 6to4 (RFC 3056) — embeds IPv4 in IPv6
IPAddr.new("100.64.0.0/10") # CGNAT / shared address space (RFC 6598)
]
def resolve(hostname)
Resolv.getaddress(hostname).tap do |ip|
raise Violation.new("Attempt to access private IP via #{hostname}") if ip && private_ip?(ip)
@@ -16,7 +24,7 @@ module RestrictedHTTP
def private_ip?(ip)
IPAddr.new(ip).then do |ipaddr|
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || LOCAL_IP.include?(ipaddr)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr) || DISALLOWED_RANGES.any? { |range| range.include?(ipaddr) }
end
rescue IPAddr::InvalidAddressError
true
+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}"
+8 -2
View File
@@ -28,7 +28,9 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end
test "create with valid credentials" do
post session_url, params: { email_address: "david@37signals.com", password: "secret123456" }
assert_difference -> { Session.count }, +1 do
post session_url, params: { email_address: "david@37signals.com", password: "secret123456" }
end
assert_redirected_to root_url
assert parsed_cookies.signed[:session_token]
@@ -43,11 +45,15 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
test "destroy" do
sign_in :david
session = users(:david).sessions.last
delete session_url
assert_difference -> { Session.count }, -1 do
delete session_url
end
assert_redirected_to root_url
assert_not cookies[:session_token].present?
assert_nil Session.find_by(id: session.id)
end
test "destroy removes the push subscription for the device" do
@@ -0,0 +1,109 @@
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
# IPv6/encapsulation and shared-address ranges (SSRF bypass prevention)
test "private_ip? returns true for NAT64 addresses" do
assert_private_ip "64:ff9b::a00:1" # NAT64-embedded 10.0.0.1
end
test "private_ip? returns true for 6to4 addresses" do
assert_private_ip "2002::1"
assert_private_ip "2002:0a00:0001::" # 6to4-embedded 10.0.0.1
end
test "private_ip? returns true for CGNAT / shared address space" do
assert_private_ip "100.64.0.1"
assert_private_ip "100.127.255.255"
end
test "private_ip? returns false for public addresses outside the new ranges" do
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("93.184.216.34")
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
+4 -3
View File
@@ -1,6 +1,7 @@
/*
Trix 2.0.10
Copyright © 2024 37signals, LLC
Trix 2.1.19
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