mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae7a34113f | ||
|
|
df5ed25e14 | ||
|
|
048ef90ae7 | ||
|
|
ea994053ea | ||
|
|
018a0b5dd9 | ||
|
|
38741c53f1 | ||
|
|
cf0a67fcaf | ||
|
|
e945d4217b | ||
|
|
cccfb8f774 | ||
|
|
55fbf16629 | ||
|
|
9ebc47a8f0 | ||
|
|
962c48af13 | ||
|
|
0920ceab6b | ||
|
|
12bb943af8 | ||
|
|
e4f88b425a | ||
|
|
bfa9f66633 | ||
|
|
8d3c2bbd2b | ||
|
|
03fa883f98 | ||
|
|
8e0aa7d636 | ||
|
|
efaae642b0 | ||
|
|
28525bec5d | ||
|
|
52ccd24efa | ||
|
|
c189ddea41 | ||
|
|
7ffb043cf3 | ||
|
|
17f81d4fa0 | ||
|
|
905165b7e2 | ||
|
|
ce62fd0814 | ||
|
|
47cdbb2a0f | ||
|
|
6ec04cbc28 | ||
|
|
e74d192aec | ||
|
|
67ca61da1f | ||
|
|
0febc9f415 | ||
|
|
4a75954080 | ||
|
|
69c94b6594 | ||
|
|
ba11a0a67f | ||
|
|
8c7f49e003 | ||
|
|
3fada3d997 | ||
|
|
dde94b06ed | ||
|
|
1852adb06c | ||
|
|
e983e3f79f | ||
|
|
53e36a16ea | ||
|
|
a05292f548 | ||
|
|
71ffeeea78 | ||
|
|
77bcad65b5 | ||
|
|
238f73c26f | ||
|
|
1feb2d94b9 | ||
|
|
49c0ce496c | ||
|
|
88f3f942f7 | ||
|
|
6f256f5f2d | ||
|
|
089a8b35c0 | ||
|
|
b52c318518 | ||
|
|
de5493d8a9 | ||
|
|
74346342df | ||
|
|
e4a49d52b2 | ||
|
|
b8919161a8 | ||
|
|
80585a9585 | ||
|
|
b3d97ecb0e | ||
|
|
94692026d3 | ||
|
|
0672673916 | ||
|
|
5667262d1c | ||
|
|
1babf3f9ed | ||
|
|
4d04f9beee | ||
|
|
bebe518c74 |
No files matched your search
@@ -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 %>
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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],
|
||||
&[type=submit]:disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -3,7 +3,9 @@ class AccountsController < ApplicationController
|
||||
before_action :set_account
|
||||
|
||||
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
|
||||
|
||||
def update
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ class FirstRunsController < ApplicationController
|
||||
user = FirstRun.create!(user_params)
|
||||
start_new_session_for user
|
||||
|
||||
redirect_to root_url
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
redirect_to root_url
|
||||
end
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class Rooms::InvolvementsController < ApplicationController
|
||||
include RoomScoped
|
||||
|
||||
before_action :ensure_can_administer, only: %i[ update ]
|
||||
|
||||
def show
|
||||
@involvement = @membership.involvement
|
||||
end
|
||||
|
||||
@@ -18,7 +18,7 @@ class SessionsController < ApplicationController
|
||||
|
||||
def destroy
|
||||
remove_push_subscription
|
||||
reset_authentication
|
||||
terminate_current_session
|
||||
redirect_to root_url
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -100,7 +100,13 @@
|
||||
|
||||
<menu class="flex flex-column gap margin-none pad">
|
||||
<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? %>
|
||||
</turbo-frame>
|
||||
</menu>
|
||||
|
||||
@@ -9,23 +9,26 @@
|
||||
|
||||
<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? %>
|
||||
<%= 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) %>">
|
||||
<span class="for-screen-reader">Role: <%= user.administrator? ? "Administrator" : "Member" %></span>
|
||||
<%= 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>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= 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 %>
|
||||
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
|
||||
<span class="for-screen-reader">Delete <%= user.name %></span>
|
||||
<% unless user == Current.user %>
|
||||
<%= 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 %>
|
||||
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
|
||||
<span class="for-screen-reader">Delete <%= user.name %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% elsif user == Current.user %>
|
||||
<% end %>
|
||||
<% if user == Current.user %>
|
||||
<%= 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" } %>
|
||||
<span class="for-screen-reader">My settings</span>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<%= button_to user_ban_path(user), method: :delete,
|
||||
class: "btn btn--negative full-width",
|
||||
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>
|
||||
<% end %>
|
||||
<% end %>
|
||||
+11
@@ -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
|
||||
|
||||
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)
|
||||
puts "\n\e[1;36m#{message}\e[0m"
|
||||
yield
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -10,14 +10,16 @@
|
||||
#
|
||||
# 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|
|
||||
t.datetime "created_at", null: false
|
||||
t.text "custom_styles"
|
||||
t.string "join_code", null: false
|
||||
t.string "name", null: false
|
||||
t.json "settings"
|
||||
t.integer "singleton_guard", default: 0, null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["singleton_guard"], name: "index_accounts_on_singleton_guard", unique: true
|
||||
end
|
||||
|
||||
create_table "action_text_rich_texts", force: :cascade do |t|
|
||||
|
||||
@@ -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.
|
||||
@@ -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,21 +12,32 @@ ActiveSupport.on_load(:action_text_content) do
|
||||
|
||||
def attachable_from_possibly_expired_sgid(sgid)
|
||||
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")
|
||||
data
|
||||
elsif data = encoded_message.dig("_rails", "message")
|
||||
# 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
|
||||
# To work around this, we manually extract the GID from the marshaled data
|
||||
decode_base64(data).match(%r{(gid://campfire/[^/]+/\d+)})&.to_s
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
model = GlobalID.find(decoded_gid)
|
||||
|
||||
model.model_name.to_s.in?(ATTACHABLES_PERMITTED_WITH_INVALID_SIGNATURES) ? model : nil
|
||||
if model = GlobalID.find(decoded_gid)
|
||||
model.model_name.to_s.in?(ATTACHABLES_PERMITTED_WITH_INVALID_SIGNATURES) ? model : nil
|
||||
end
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def decode_base64(message)
|
||||
Base64.strict_decode64(message)
|
||||
rescue => _e
|
||||
Base64.urlsafe_decode64(message)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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? || 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
|
||||
|
||||
Executable
+10
@@ -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}"
|
||||
@@ -10,6 +10,38 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :ok
|
||||
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
|
||||
assert users(:david).administrator?
|
||||
|
||||
|
||||
@@ -30,4 +30,28 @@ class FirstRunsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert parsed_cookies.signed[:session_token]
|
||||
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
|
||||
@@ -43,4 +43,13 @@ class Rooms::InvolvementsControllerTest < ActionDispatch::IntegrationTest
|
||||
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
|
||||
@@ -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,39 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionText::AttachmentTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:david)
|
||||
end
|
||||
|
||||
test "from_node" do
|
||||
html = %Q(<action-text-attachment sgid="#{@user.attachable_sgid}"></action-text-attachment>)
|
||||
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
|
||||
|
||||
attachment = ActionText::Attachment.from_node(node)
|
||||
assert_equal @user, attachment.attachable
|
||||
end
|
||||
|
||||
test "from_node with a Rails 7 SGID" do
|
||||
gid = @user.to_gid.to_s
|
||||
marshaled_gid = Base64.urlsafe_encode64(Marshal.dump(gid))
|
||||
rails7_payload = { "_rails" => { "message" => marshaled_gid, "exp" => nil, "pur" => "attachable" } }
|
||||
rails7_message = Base64.strict_encode64(JSON.generate(rails7_payload))
|
||||
rails7_sgid = "#{rails7_message}--invalidsignature"
|
||||
|
||||
html = %Q(<action-text-attachment sgid="#{rails7_sgid}"></action-text-attachment>)
|
||||
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
|
||||
|
||||
attachment = ActionText::Attachment.from_node(node)
|
||||
assert_equal @user, attachment.attachable
|
||||
end
|
||||
|
||||
test "from_node with an invalid SGID" do
|
||||
room = rooms(:pets).tap { |r| r.extend ActionText::Attachable }
|
||||
|
||||
html = %Q(<action-text-attachment sgid="#{room.attachable_sgid}invalid"></action-text-attachment>)
|
||||
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
|
||||
|
||||
attachment = ActionText::Attachment.from_node(node)
|
||||
assert_kind_of ActionText::Attachables::MissingAttachable, attachment.attachable
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -21,6 +21,28 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
|
||||
assert_equal [ "is not public" ], location.errors[:url]
|
||||
end
|
||||
|
||||
test "link-local addresses are blocked" do
|
||||
Resolv.stubs(:getaddress).with("metadata.internal").returns("169.254.169.254")
|
||||
|
||||
location = Opengraph::Location.new("https://metadata.internal")
|
||||
assert_not location.valid?
|
||||
assert_equal [ "is not public" ], location.errors[:url]
|
||||
end
|
||||
|
||||
test "IPv6 addresses mapped to IPv4 addresses are blocked" do
|
||||
Resolv.stubs(:getaddress).with("metadata.internal").returns("::ffff:192.168.1.1")
|
||||
|
||||
location = Opengraph::Location.new("https://metadata.internal")
|
||||
assert_not location.valid?
|
||||
assert_equal [ "is not public" ], location.errors[:url]
|
||||
|
||||
Resolv.stubs(:getaddress).with("metadata.internal").returns("::ffff:c0a8:0101")
|
||||
|
||||
location = Opengraph::Location.new("https://metadata.internal")
|
||||
assert_not location.valid?
|
||||
assert_equal [ "is not public" ], location.errors[:url]
|
||||
end
|
||||
|
||||
test "avoid reading file urls when expecting HTML" do
|
||||
large_file = Opengraph::Location.new("https://www.example.com/100gb.zip")
|
||||
|
||||
|
||||
Vendored
+4
-3
@@ -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
|
||||
Reference in new issue
Block a user