mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
63
Commits
v1.4.0
...
solid-trifecta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
094ef7b462 | ||
|
|
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
|
||||
+45
-24
@@ -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,25 +48,40 @@ 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
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Install packages
|
||||
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
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
@@ -66,23 +91,19 @@ jobs:
|
||||
|
||||
test_system:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Install packages
|
||||
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
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
|
||||
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg && \
|
||||
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archive
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,14 +9,14 @@ gem "benchmark"
|
||||
|
||||
# Drivers
|
||||
gem "sqlite3"
|
||||
gem "redis", "~> 5.4"
|
||||
|
||||
# Deployment
|
||||
gem "puma", "~> 6.6"
|
||||
gem "puma", "~> 7.2", ">= 7.2.1"
|
||||
|
||||
# Jobs
|
||||
gem "resque", "~> 2.7.0"
|
||||
gem "resque-pool", "~> 0.7.1"
|
||||
# Jobs, caching, WebSockets
|
||||
gem "solid_queue"
|
||||
gem "solid_cache"
|
||||
gem "solid_cable"
|
||||
|
||||
# Assets
|
||||
gem "propshaft", github: "rails/propshaft"
|
||||
@@ -41,13 +41,13 @@ gem "rails_autolink"
|
||||
gem "geared_pagination"
|
||||
gem "jbuilder"
|
||||
gem "net-http-persistent"
|
||||
gem "kredis"
|
||||
gem "platform_agent"
|
||||
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
|
||||
|
||||
+66
-75
@@ -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,25 +153,30 @@ 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)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
fugit (1.13.0)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
geared_pagination (1.2.0)
|
||||
activesupport (>= 5.0)
|
||||
addressable (>= 2.5.0)
|
||||
@@ -188,17 +196,13 @@ 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)
|
||||
activesupport (>= 6.0.0)
|
||||
redis (>= 4.2, < 6)
|
||||
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)
|
||||
@@ -215,13 +219,9 @@ GEM
|
||||
minitest (5.26.2)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
mono_logger (1.1.2)
|
||||
multi_json (1.17.0)
|
||||
mustermann (3.0.4)
|
||||
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 +231,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 +256,12 @@ GEM
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (6.6.1)
|
||||
puma (7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack-protection (4.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-session (2.1.1)
|
||||
rack (3.2.6)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
@@ -275,8 +272,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)
|
||||
@@ -288,29 +285,15 @@ GEM
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redis (5.4.1)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.25.2)
|
||||
connection_pool
|
||||
redis-namespace (1.11.0)
|
||||
redis (>= 4)
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
resque (2.7.0)
|
||||
mono_logger (~> 1)
|
||||
multi_json (~> 1.0)
|
||||
redis-namespace (~> 1.6)
|
||||
sinatra (>= 0.9.2)
|
||||
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 +340,26 @@ GEM
|
||||
sentry-ruby (5.26.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sinatra (4.1.1)
|
||||
logger (>= 1.6.0)
|
||||
mustermann (~> 3.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-protection (= 4.1.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)
|
||||
solid_cable (4.0.2)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_cache (1.0.10)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.4.0)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
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)
|
||||
@@ -376,14 +368,13 @@ GEM
|
||||
thruster (0.1.15-arm64-darwin)
|
||||
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 +385,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 +403,7 @@ DEPENDENCIES
|
||||
bcrypt
|
||||
benchmark
|
||||
brakeman
|
||||
bundler-audit
|
||||
capybara
|
||||
debug
|
||||
faker
|
||||
@@ -419,23 +411,22 @@ DEPENDENCIES
|
||||
image_processing (>= 1.2)
|
||||
importmap-rails!
|
||||
jbuilder
|
||||
kredis
|
||||
mocha
|
||||
net-http-persistent
|
||||
ostruct
|
||||
platform_agent
|
||||
propshaft!
|
||||
puma (~> 6.6)
|
||||
puma (~> 7.2, >= 7.2.1)
|
||||
rails!
|
||||
rails_autolink
|
||||
redis (~> 5.4)
|
||||
resque (~> 2.7.0)
|
||||
resque-pool (~> 0.7.1)
|
||||
rqrcode
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
sqlite3
|
||||
stimulus-rails
|
||||
thruster
|
||||
@@ -444,4 +435,4 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.9
|
||||
4.0.13
|
||||
@@ -1,3 +1,2 @@
|
||||
web: bundle exec thrust bin/start-app
|
||||
redis: redis-server config/redis.conf
|
||||
workers: FORK_PER_JOB=false INTERVAL=0.1 bundle exec resque-pool
|
||||
workers: bundle exec bin/jobs
|
||||
@@ -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 %>
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env ruby
|
||||
require "optparse"
|
||||
require "json"
|
||||
require "net/http"
|
||||
require "fileutils"
|
||||
require "etc"
|
||||
require "time"
|
||||
|
||||
options = {
|
||||
users: 3000, ramp: 480, hold: 120, cpus: 2, memory: "2g",
|
||||
send_interval: 1, sample: 25, build: true, port: 3000, image: "campfire-bench"
|
||||
}
|
||||
|
||||
OptionParser.new do |parser|
|
||||
parser.on("--label LABEL", "Run label (required), e.g. redis-2cpu-2g") { |v| options[:label] = v }
|
||||
parser.on("--users N", Integer, "Ramp target (default 3000, max 10000)") { |v| options[:users] = v }
|
||||
parser.on("--ramp SECONDS", Integer, "Ramp duration (default 480)") { |v| options[:ramp] = v }
|
||||
parser.on("--hold SECONDS", Integer, "Hold duration at target (default 120)") { |v| options[:hold] = v }
|
||||
parser.on("--cpus N", Integer, "App container CPU count via cpuset (default 2)") { |v| options[:cpus] = v }
|
||||
parser.on("--memory SIZE", "App container memory limit (default 2g)") { |v| options[:memory] = v }
|
||||
parser.on("--send-interval S", Float, "Seconds between messages (default 1)") { |v| options[:send_interval] = v }
|
||||
parser.on("--sample N", Integer, "1-in-N receivers log latency lines (default 25)") { |v| options[:sample] = v }
|
||||
parser.on("--port PORT", Integer, "Host port for the app (default 3000)") { |v| options[:port] = v }
|
||||
parser.on("--image TAG", "Image tag (default campfire-bench)") { |v| options[:image] = v }
|
||||
parser.on("--no-build", "Skip docker build") { options[:build] = false }
|
||||
end.parse!
|
||||
|
||||
abort "--label is required" unless options[:label]
|
||||
|
||||
class Bench
|
||||
APP_CONTAINER = "campfire-bench-app"
|
||||
K6_CONTAINER = "campfire-bench-k6"
|
||||
|
||||
attr_reader :opts, :results_dir
|
||||
|
||||
def initialize(opts)
|
||||
@opts = opts
|
||||
@root = File.expand_path("..", __dir__)
|
||||
@perf_dir = File.join(@root, "test/performance")
|
||||
@results_dir = File.join(@root, "tmp/bench", opts[:label])
|
||||
end
|
||||
|
||||
def run
|
||||
FileUtils.mkdir_p(results_dir)
|
||||
build if opts[:build]
|
||||
write_meta
|
||||
start_app
|
||||
wait_for_app
|
||||
sampler = start_stats_sampler
|
||||
run_k6
|
||||
ensure
|
||||
stop(sampler)
|
||||
capture_app_state
|
||||
cleanup
|
||||
end
|
||||
|
||||
private
|
||||
def build
|
||||
sh "docker", "build", "-t", opts[:image], @root
|
||||
end
|
||||
|
||||
def write_meta
|
||||
meta = opts.merge(
|
||||
git_revision: `git rev-parse HEAD`.strip,
|
||||
git_branch: `git rev-parse --abbrev-ref HEAD`.strip,
|
||||
host_cpus: Etc.nprocessors,
|
||||
started_at: Time.now.utc.iso8601
|
||||
)
|
||||
File.write(File.join(results_dir, "meta.json"), JSON.pretty_generate(meta))
|
||||
end
|
||||
|
||||
def start_app
|
||||
system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
|
||||
sh "docker", "run", "-d", "--name", APP_CONTAINER,
|
||||
"--cpuset-cpus", app_cpuset,
|
||||
"--memory", opts[:memory], "--memory-swap", opts[:memory],
|
||||
"-p", "#{opts[:port]}:80",
|
||||
"-e", "SECRET_KEY_BASE=dummy",
|
||||
"-e", "RAILS_ENV=performance",
|
||||
opts[:image]
|
||||
end
|
||||
|
||||
def app_cpuset
|
||||
(0...opts[:cpus]).to_a.join(",")
|
||||
end
|
||||
|
||||
def wait_for_app
|
||||
timeout_at = Time.now + 300
|
||||
uri = URI("http://127.0.0.1:#{opts[:port]}/")
|
||||
loop do
|
||||
raise "App not up after 300s" if Time.now > timeout_at
|
||||
begin
|
||||
break if Net::HTTP.get_response(uri).code.to_i < 400
|
||||
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError
|
||||
end
|
||||
puts "Waiting for app (seeding 10k users on first boot)..."
|
||||
sleep 2
|
||||
end
|
||||
puts "App is up."
|
||||
end
|
||||
|
||||
def start_stats_sampler
|
||||
stats_path = File.join(results_dir, "stats.csv")
|
||||
fork do
|
||||
File.open(stats_path, "w") do |file|
|
||||
file.puts "epoch_ms,cpu_perc,mem_bytes"
|
||||
file.sync = true
|
||||
loop do
|
||||
line = `docker stats --no-stream --format "{{.CPUPerc}} {{.MemUsage}}" #{APP_CONTAINER} 2>/dev/null`.strip
|
||||
if line =~ /([\d.]+)%\s+([\d.]+)(\w+)/
|
||||
file.puts "#{(Time.now.to_f * 1000).to_i},#{$1},#{to_bytes($2.to_f, $3)}"
|
||||
end
|
||||
sleep 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def to_bytes(value, unit)
|
||||
factors = { "B" => 1, "KiB" => 1024, "MiB" => 1024**2, "GiB" => 1024**3,
|
||||
"kB" => 1000, "MB" => 1000**2, "GB" => 1000**3 }
|
||||
(value * factors.fetch(unit, 1)).to_i
|
||||
end
|
||||
|
||||
def run_k6
|
||||
total = opts[:ramp] + opts[:hold]
|
||||
puts "Running k6: ramp to #{opts[:users]} users over #{opts[:ramp]}s, hold #{opts[:hold]}s (total #{total}s)..."
|
||||
system "docker", "rm", "-f", K6_CONTAINER, err: File::NULL, out: File::NULL
|
||||
k6_log = File.join(results_dir, "k6.log")
|
||||
summary = File.join(results_dir, "summary.json")
|
||||
finished = system(
|
||||
"docker", "run", "--rm", "--name", K6_CONTAINER,
|
||||
"--network", "host",
|
||||
"--cpuset-cpus", k6_cpuset,
|
||||
"--ulimit", "nofile=262144:262144",
|
||||
"-u", "#{Process.uid}:#{Process.gid}",
|
||||
"-v", "#{@perf_dir}:/src",
|
||||
"-v", "#{results_dir}:/results",
|
||||
"-e", "HOST=127.0.0.1", "-e", "PORT=#{opts[:port]}",
|
||||
"-e", "USERS=#{opts[:users]}",
|
||||
"-e", "RAMP_S=#{opts[:ramp]}", "-e", "HOLD_S=#{opts[:hold]}",
|
||||
"-e", "SEND_INTERVAL_S=#{opts[:send_interval]}",
|
||||
"-e", "SAMPLE=#{opts[:sample]}",
|
||||
"grafana/k6", "run",
|
||||
"--summary-export", "/results/summary.json",
|
||||
"--quiet",
|
||||
"/src/ramp.js",
|
||||
out: k6_log, err: [ k6_log, "a" ]
|
||||
)
|
||||
if finished
|
||||
puts "k6 finished."
|
||||
else
|
||||
puts "k6 exited non-zero — results still captured in #{k6_log}."
|
||||
end
|
||||
end
|
||||
|
||||
def k6_cpuset
|
||||
(opts[:cpus]...Etc.nprocessors).to_a.join(",")
|
||||
end
|
||||
|
||||
def stop(sampler)
|
||||
Process.kill("TERM", sampler)
|
||||
Process.wait(sampler)
|
||||
rescue Errno::ESRCH, Errno::ECHILD
|
||||
nil
|
||||
end
|
||||
|
||||
def capture_app_state
|
||||
oom = `docker inspect --format '{{.State.OOMKilled}}' #{APP_CONTAINER} 2>/dev/null`.strip
|
||||
File.write(File.join(results_dir, "oom.txt"), oom)
|
||||
system "docker logs --tail 200 #{APP_CONTAINER} > #{File.join(results_dir, 'app.log')} 2>&1"
|
||||
puts "App container was OOM-killed during the run." if oom == "true"
|
||||
end
|
||||
|
||||
def cleanup
|
||||
system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
|
||||
end
|
||||
|
||||
def sh(*cmd)
|
||||
puts "+ #{cmd.join(' ')}"
|
||||
system(*cmd) || abort("Command failed: #{cmd.join(' ')}")
|
||||
end
|
||||
end
|
||||
|
||||
Bench.new(options).run
|
||||
puts "Results in tmp/bench/#{options[:label]}/"
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env ruby
|
||||
require "json"
|
||||
require "csv"
|
||||
|
||||
BIN_MS = 10_000
|
||||
SLO_P95_MS = 500
|
||||
|
||||
class Run
|
||||
attr_reader :label, :dir
|
||||
|
||||
def initialize(label)
|
||||
@label = label
|
||||
@dir = File.expand_path("../tmp/bench/#{label}", __dir__)
|
||||
abort "No results in tmp/bench/#{label}" unless File.exist?(File.join(dir, "k6.log"))
|
||||
end
|
||||
|
||||
def report
|
||||
write_series
|
||||
print_summary
|
||||
end
|
||||
|
||||
def series
|
||||
@series ||= build_series
|
||||
end
|
||||
|
||||
def meta
|
||||
@meta ||= JSON.parse(File.read(File.join(dir, "meta.json")))
|
||||
end
|
||||
|
||||
def summary
|
||||
@summary ||= JSON.parse(File.read(File.join(dir, "summary.json")))
|
||||
rescue Errno::ENOENT
|
||||
{}
|
||||
end
|
||||
|
||||
def capacity
|
||||
breach = first_sustained_breach
|
||||
if breach
|
||||
breach[:connected]
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def build_series
|
||||
events = parse_k6_log
|
||||
stats = parse_stats
|
||||
first_ts = events.map { |e| e[:ts] }.min
|
||||
return [] unless first_ts
|
||||
|
||||
bins = Hash.new { |h, k| h[k] = { lats: [], conns: 0, sents: 0, errs: 0, cpu: [], mem: [] } }
|
||||
events.each do |event|
|
||||
bin = (event[:ts] - first_ts) / BIN_MS
|
||||
case event[:kind]
|
||||
when "CONN" then bins[bin][:conns] += 1
|
||||
when "LAT" then bins[bin][:lats] << event[:value]
|
||||
when "SENT" then bins[bin][:sents] += 1
|
||||
when "ERR" then bins[bin][:errs] += 1
|
||||
end
|
||||
end
|
||||
stats.each do |stat|
|
||||
bin = (stat[:ts] - first_ts) / BIN_MS
|
||||
next if bin < 0
|
||||
bins[bin][:cpu] << stat[:cpu]
|
||||
bins[bin][:mem] << stat[:mem]
|
||||
end
|
||||
|
||||
connected = 0
|
||||
(0..bins.keys.max).map do |bin|
|
||||
data = bins[bin]
|
||||
connected = [ connected + data[:conns], meta["users"] ].min
|
||||
{
|
||||
elapsed_s: bin * BIN_MS / 1000,
|
||||
connected: connected,
|
||||
sent: data[:sents],
|
||||
samples: data[:lats].size,
|
||||
lat_p50: percentile(data[:lats], 50),
|
||||
lat_p95: percentile(data[:lats], 95),
|
||||
lat_max: data[:lats].max,
|
||||
errors: data[:errs],
|
||||
cpu_perc: average(data[:cpu]),
|
||||
mem_mb: average(data[:mem])&.then { |bytes| (bytes / 1024.0 / 1024.0).round(1) }
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_k6_log
|
||||
events = []
|
||||
File.foreach(File.join(dir, "k6.log")) do |line|
|
||||
if line =~ /BENCH (CONN|LAT|SENT|ERR) (\d+)(?: (\S+))?/
|
||||
events << { kind: $1, ts: $2.to_i, value: $3.to_i }
|
||||
end
|
||||
end
|
||||
events
|
||||
end
|
||||
|
||||
def parse_stats
|
||||
path = File.join(dir, "stats.csv")
|
||||
return [] unless File.exist?(path)
|
||||
CSV.read(path, headers: true).map do |row|
|
||||
{ ts: row["epoch_ms"].to_i, cpu: row["cpu_perc"].to_f, mem: row["mem_bytes"].to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def percentile(values, pct)
|
||||
return nil if values.empty?
|
||||
sorted = values.sort
|
||||
sorted[((pct / 100.0) * (sorted.size - 1)).round]
|
||||
end
|
||||
|
||||
def average(values)
|
||||
return nil if values.empty?
|
||||
(values.sum / values.size.to_f).round(1)
|
||||
end
|
||||
|
||||
def write_series
|
||||
CSV.open(File.join(dir, "series.csv"), "w") do |csv|
|
||||
csv << series.first.keys
|
||||
series.each { |row| csv << row.values }
|
||||
end
|
||||
end
|
||||
|
||||
def first_sustained_breach
|
||||
series.each_cons(2) do |a, b|
|
||||
if breached?(a) && breached?(b)
|
||||
return a
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def breached?(bin)
|
||||
if bin[:samples] > 0 && bin[:lat_p95] && bin[:lat_p95] > SLO_P95_MS
|
||||
true
|
||||
else
|
||||
bin[:errors] > 0
|
||||
end
|
||||
end
|
||||
|
||||
def print_summary
|
||||
latency = summary.dig("metrics", "delivery_latency") || {}
|
||||
puts
|
||||
puts "== #{label} (#{meta['cpus']} CPU, #{meta['memory']} RAM, ramp to #{meta['users']}) =="
|
||||
puts " delivery latency: p50=#{latency['med']&.round(1)}ms p95=#{latency['p(95)']&.round(1)}ms max=#{latency['max']&.round(1)}ms"
|
||||
puts " messages received: #{summary.dig('metrics', 'bench_messages_received', 'count')}"
|
||||
puts " socket errors: #{summary.dig('metrics', 'bench_socket_errors', 'count') || 0}"
|
||||
peak = series.map { |bin| bin[:mem_mb] }.compact.max
|
||||
puts " peak container memory: #{peak} MB"
|
||||
if capacity
|
||||
puts " capacity at SLO (p95 <= #{SLO_P95_MS}ms, no errors): ~#{capacity} concurrent users"
|
||||
else
|
||||
puts " capacity at SLO: not reached (>= #{series.last[:connected]} concurrent users)"
|
||||
end
|
||||
puts " series: tmp/bench/#{label}/series.csv"
|
||||
end
|
||||
end
|
||||
|
||||
abort "Usage: bin/bench-report LABEL [LABEL...]" if ARGV.empty?
|
||||
ARGV.each { |label| Run.new(label).report }
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require_relative "../config/environment"
|
||||
require "solid_queue/cli"
|
||||
|
||||
SolidQueue::Cli.start(ARGV)
|
||||
+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
|
||||
|
||||
@@ -8,9 +8,6 @@ app_root="$(
|
||||
)"
|
||||
export PATH="$app_root/bin:$PATH"
|
||||
|
||||
REDIS_PORT=6379
|
||||
REDIS_HOST=localhost
|
||||
|
||||
if [ "$RAILS_ENV" = "production" ]; then
|
||||
echo "RAILS_ENV is production; bailing out"
|
||||
exit 1
|
||||
@@ -45,10 +42,6 @@ step() {
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
redis_running() {
|
||||
nc -z "$REDIS_HOST" "$REDIS_PORT" 2>/dev/null
|
||||
}
|
||||
|
||||
echo
|
||||
gum style --foreground 214 " ) "
|
||||
gum style --foreground 208 " ) \\ campfire"
|
||||
@@ -60,7 +53,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
|
||||
@@ -85,20 +81,19 @@ if [[ $* == *--reset* ]]; then
|
||||
fi
|
||||
step "Preparing the database" rails db:prepare
|
||||
|
||||
# Start Redis if not running
|
||||
if ! redis_running; then
|
||||
if command -v docker &>/dev/null; then
|
||||
if docker ps -aq -f name=campfire-redis | grep -q .; then
|
||||
step "Starting Redis" docker start campfire-redis
|
||||
# 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
|
||||
step "Setting up Redis" docker run -d --name campfire-redis -p "$REDIS_PORT:$REDIS_PORT" redis:7
|
||||
echo "Error: install $tool manually" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Couldn't start Redis"
|
||||
echo "Install either docker or redis and then run this command again"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
step "Cleaning up logs and tempfiles" rails log:clear tmp:clear
|
||||
|
||||
|
||||
+17
-10
@@ -1,18 +1,25 @@
|
||||
default: &default
|
||||
adapter: redis
|
||||
url: redis://localhost:6379
|
||||
|
||||
# Async adapter only works within the same process, so for manually triggering cable updates from a console,
|
||||
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
|
||||
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
|
||||
# to make the web console appear.
|
||||
development:
|
||||
<<: *default
|
||||
channel_prefix: campfire_development
|
||||
adapter: async
|
||||
|
||||
test:
|
||||
adapter: test
|
||||
|
||||
performance:
|
||||
<<: *default
|
||||
channel_prefix: campfire_performance
|
||||
adapter: solid_cable
|
||||
connects_to:
|
||||
database:
|
||||
writing: cable
|
||||
polling_interval: 0.05.seconds
|
||||
message_retention: 1.day
|
||||
|
||||
production:
|
||||
<<: *default
|
||||
channel_prefix: campfire_production
|
||||
adapter: solid_cable
|
||||
connects_to:
|
||||
database:
|
||||
writing: cable
|
||||
polling_interval: 0.05.seconds
|
||||
message_retention: 1.day
|
||||
@@ -0,0 +1,21 @@
|
||||
default: &default
|
||||
store_options:
|
||||
# Cap age of oldest cache entry to fulfill retention policies
|
||||
# max_age: <%= 60.days.to_i %>
|
||||
max_size: <%= 256.megabytes %>
|
||||
namespace: <%= Rails.env %>
|
||||
|
||||
development:
|
||||
database: cache
|
||||
<<: *default
|
||||
|
||||
test:
|
||||
<<: *default
|
||||
|
||||
performance:
|
||||
database: cache
|
||||
<<: *default
|
||||
|
||||
production:
|
||||
database: cache
|
||||
<<: *default
|
||||
@@ -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"
|
||||
|
||||
@@ -14,6 +14,10 @@ development:
|
||||
primary:
|
||||
<<: *default
|
||||
database: storage/db/development.sqlite3
|
||||
cache:
|
||||
<<: *default
|
||||
database: storage/db/development-cache.sqlite3
|
||||
migrations_paths: db/cache_migrate
|
||||
|
||||
# Warning: The database defined as "test" will be erased and
|
||||
# re-generated from your development database when you run "rake".
|
||||
@@ -27,8 +31,32 @@ performance:
|
||||
primary:
|
||||
<<: *default
|
||||
database: storage/db/performance.sqlite3
|
||||
queue:
|
||||
<<: *default
|
||||
database: storage/db/performance-queue.sqlite3
|
||||
migrations_paths: db/queue_migrate
|
||||
cache:
|
||||
<<: *default
|
||||
database: storage/db/performance-cache.sqlite3
|
||||
migrations_paths: db/cache_migrate
|
||||
cable:
|
||||
<<: *default
|
||||
database: storage/db/performance-cable.sqlite3
|
||||
migrations_paths: db/cable_migrate
|
||||
|
||||
production:
|
||||
primary:
|
||||
<<: *default
|
||||
database: storage/db/production.sqlite3
|
||||
queue:
|
||||
<<: *default
|
||||
database: storage/db/production-queue.sqlite3
|
||||
migrations_paths: db/queue_migrate
|
||||
cache:
|
||||
<<: *default
|
||||
database: storage/db/production-cache.sqlite3
|
||||
migrations_paths: db/cache_migrate
|
||||
cable:
|
||||
<<: *default
|
||||
database: storage/db/production-cable.sqlite3
|
||||
migrations_paths: db/cable_migrate
|
||||
@@ -21,7 +21,7 @@ Rails.application.configure do
|
||||
config.action_controller.perform_caching = true
|
||||
config.action_controller.enable_fragment_cache_logging = true
|
||||
|
||||
config.cache_store = :redis_cache_store
|
||||
config.cache_store = :solid_cache_store
|
||||
config.public_file_server.headers = {
|
||||
"cache-control" => "public, max-age=#{2.days.to_i}"
|
||||
}
|
||||
@@ -65,7 +65,7 @@ Rails.application.configure do
|
||||
# config.action_cable.disable_request_forgery_protection = true
|
||||
|
||||
# Uncomment to test with production class job queue
|
||||
# config.active_job.queue_adapter = :resque
|
||||
# config.active_job.queue_adapter = :solid_queue
|
||||
|
||||
# Highlight code that enqueued background job in logs.
|
||||
config.active_job.verbose_enqueue_logs = true
|
||||
|
||||
@@ -68,7 +68,7 @@ Rails.application.configure do
|
||||
config.active_support.report_deprecations = false
|
||||
|
||||
# Cache in memory for now
|
||||
config.cache_store = :redis_cache_store
|
||||
config.cache_store = :solid_cache_store
|
||||
|
||||
# Assets are cacheable
|
||||
config.public_file_server.headers = {
|
||||
@@ -92,5 +92,6 @@ Rails.application.configure do
|
||||
# Only use :id for inspections in production.
|
||||
config.active_record.attributes_for_inspect = [ :id ]
|
||||
|
||||
config.active_job.queue_adapter = :resque
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
end
|
||||
+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"
|
||||
|
||||
|
||||
@@ -35,8 +35,6 @@ pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
|
||||
worker_count = (Concurrent.processor_count * 0.666).ceil
|
||||
workers ENV.fetch("WEB_CONCURRENCY") { worker_count }
|
||||
|
||||
ENV["JOB_CONCURRENCY"] ||= worker_count.to_s
|
||||
|
||||
# Use the `preload_app!` method when specifying a `workers` number.
|
||||
# This directive tells Puma to first boot the application and load code
|
||||
# before forking the application. This takes advantage of Copy On Write
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
default: &default
|
||||
dispatchers:
|
||||
- polling_interval: 1
|
||||
batch_size: 500
|
||||
workers:
|
||||
- queues: "*"
|
||||
threads: 3
|
||||
processes: <%= ENV.fetch("JOB_CONCURRENCY") { (Concurrent.processor_count * 0.5).ceil } %>
|
||||
polling_interval: 0.1
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
|
||||
test:
|
||||
<<: *default
|
||||
|
||||
performance:
|
||||
<<: *default
|
||||
|
||||
production:
|
||||
<<: *default
|
||||
@@ -0,0 +1,15 @@
|
||||
# examples:
|
||||
# periodic_cleanup:
|
||||
# class: CleanSoftDeletedRecordsJob
|
||||
# queue: background
|
||||
# args: [ 1000, { batch_size: 500 } ]
|
||||
# schedule: every hour
|
||||
# periodic_cleanup_with_command:
|
||||
# command: "SoftDeletedRecord.due.delete_all"
|
||||
# priority: 2
|
||||
# schedule: at 5am every day
|
||||
|
||||
production:
|
||||
clear_solid_queue_finished_jobs:
|
||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||
schedule: every hour at minute 12
|
||||
@@ -1,4 +0,0 @@
|
||||
port 6379
|
||||
daemonize no
|
||||
appendonly no
|
||||
save ""
|
||||
@@ -1 +0,0 @@
|
||||
default: <%= (Concurrent.processor_count * 0.5).ceil %>
|
||||
@@ -0,0 +1,11 @@
|
||||
ActiveRecord::Schema[7.1].define(version: 1) do
|
||||
create_table "solid_cable_messages", force: :cascade do |t|
|
||||
t.binary "channel", limit: 1024, null: false
|
||||
t.binary "payload", limit: 536870912, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "channel_hash", limit: 8, null: false
|
||||
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
|
||||
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
|
||||
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
ActiveRecord::Schema[7.2].define(version: 1) do
|
||||
create_table "solid_cache_entries", force: :cascade do |t|
|
||||
t.binary "key", limit: 1024, null: false
|
||||
t.binary "value", limit: 536870912, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "key_hash", limit: 8, null: false
|
||||
t.integer "byte_size", limit: 4, null: false
|
||||
t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
|
||||
t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
|
||||
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,129 @@
|
||||
ActiveRecord::Schema[7.1].define(version: 1) do
|
||||
create_table "solid_queue_blocked_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "concurrency_key", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
|
||||
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
|
||||
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_claimed_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.bigint "process_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
|
||||
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
|
||||
end
|
||||
|
||||
create_table "solid_queue_failed_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.text "error"
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_jobs", force: :cascade do |t|
|
||||
t.string "queue_name", null: false
|
||||
t.string "class_name", null: false
|
||||
t.text "arguments"
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "active_job_id"
|
||||
t.datetime "scheduled_at"
|
||||
t.datetime "finished_at"
|
||||
t.string "concurrency_key"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
|
||||
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
|
||||
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
|
||||
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
|
||||
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
|
||||
end
|
||||
|
||||
create_table "solid_queue_pauses", force: :cascade do |t|
|
||||
t.string "queue_name", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_processes", force: :cascade do |t|
|
||||
t.string "kind", null: false
|
||||
t.datetime "last_heartbeat_at", null: false
|
||||
t.bigint "supervisor_id"
|
||||
t.integer "pid", null: false
|
||||
t.string "hostname"
|
||||
t.text "metadata"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "name", null: false
|
||||
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
|
||||
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
|
||||
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
|
||||
end
|
||||
|
||||
create_table "solid_queue_ready_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
|
||||
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
|
||||
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
|
||||
end
|
||||
|
||||
create_table "solid_queue_recurring_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "task_key", null: false
|
||||
t.datetime "run_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
|
||||
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.string "schedule", null: false
|
||||
t.string "command", limit: 2048
|
||||
t.string "class_name"
|
||||
t.text "arguments"
|
||||
t.string "queue_name"
|
||||
t.integer "priority", default: 0
|
||||
t.boolean "static", default: true, null: false
|
||||
t.text "description"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
|
||||
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
|
||||
end
|
||||
|
||||
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.datetime "scheduled_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
|
||||
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
|
||||
end
|
||||
|
||||
create_table "solid_queue_semaphores", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.integer "value", default: 1, null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
|
||||
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
|
||||
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
|
||||
end
|
||||
|
||||
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
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
|
||||
@@ -16,7 +16,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)
|
||||
end
|
||||
rescue IPAddr::InvalidAddressError
|
||||
true
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
task "resque:setup" do
|
||||
require_relative "../../config/environment"
|
||||
end
|
||||
|
||||
task "resque:pool:setup" do
|
||||
ActiveRecord::Base.connection.disconnect!
|
||||
|
||||
Resque::Pool.after_prefork do |job|
|
||||
ActiveRecord::Base.establish_connection
|
||||
Resque.redis.client.close
|
||||
end
|
||||
end
|
||||
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,89 @@
|
||||
require "test_helper"
|
||||
require "restricted_http/private_network_guard"
|
||||
|
||||
class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase
|
||||
test "private_ip? returns true for 'This' network (RFC1700)" do
|
||||
assert_private_ip "0.0.0.0"
|
||||
assert_private_ip "0.255.255.255"
|
||||
end
|
||||
|
||||
test "private_ip? returns true for loopback addresses" do
|
||||
assert_private_ip "127.0.0.0"
|
||||
assert_private_ip "127.0.0.1"
|
||||
assert_private_ip "127.255.255.255"
|
||||
end
|
||||
|
||||
test "private_ip? returns true for RFC1918 private addresses" do
|
||||
assert_private_ip "10.0.0.0"
|
||||
assert_private_ip "10.255.255.255"
|
||||
assert_private_ip "172.16.0.0"
|
||||
assert_private_ip "172.31.255.255"
|
||||
assert_private_ip "192.168.0.0"
|
||||
assert_private_ip "192.168.255.255"
|
||||
end
|
||||
|
||||
test "private_ip? returns true for link-local addresses" do
|
||||
assert_private_ip "169.254.0.1"
|
||||
assert_private_ip "169.254.169.254" # AWS IMDS
|
||||
assert_private_ip "169.254.255.255"
|
||||
end
|
||||
|
||||
test "private_ip? returns false for public addresses" do
|
||||
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("93.184.216.34")
|
||||
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("8.8.8.8")
|
||||
end
|
||||
|
||||
# IPv6 address format tests (SSRF bypass prevention)
|
||||
|
||||
test "private_ip? returns true for IPv4-mapped IPv6 addresses with private IPs" do
|
||||
assert_private_ip "::ffff:192.168.1.1"
|
||||
assert_private_ip "::ffff:10.0.0.1"
|
||||
assert_private_ip "::ffff:172.16.0.1"
|
||||
end
|
||||
|
||||
test "private_ip? returns true for IPv4-mapped IPv6 addresses with link-local IPs" do
|
||||
assert_private_ip "::ffff:169.254.169.254" # AWS metadata via mapped format
|
||||
end
|
||||
|
||||
test "private_ip? returns true for IPv4-mapped IPv6 addresses even with public IPs" do
|
||||
# Block all ipv4_mapped? since DNS never returns this format legitimately
|
||||
assert_private_ip "::ffff:93.184.216.34"
|
||||
end
|
||||
|
||||
test "private_ip? returns true for IPv4-compatible IPv6 addresses with private IPs" do
|
||||
assert_private_ip "::192.168.1.1"
|
||||
assert_private_ip "::10.0.0.1"
|
||||
end
|
||||
|
||||
test "private_ip? returns true for IPv4-compatible IPv6 addresses with link-local IPs" do
|
||||
assert_private_ip "::169.254.169.254" # AWS metadata via compat format - the reported bypass
|
||||
end
|
||||
|
||||
test "private_ip? returns true for IPv4-compatible IPv6 addresses even with public IPs" do
|
||||
# Block all ipv4_compat? since DNS never returns this format legitimately
|
||||
assert_private_ip "::93.184.216.34"
|
||||
end
|
||||
|
||||
test "private_ip? returns true for 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")
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import ws from 'k6/ws';
|
||||
import http from 'k6/http';
|
||||
import { sleep } from 'k6';
|
||||
import { Trend, Counter } from 'k6/metrics';
|
||||
import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';
|
||||
import { SharedArray } from 'k6/data';
|
||||
|
||||
// Stream names signed with the performance environment's SECRET_KEY_BASE=dummy:
|
||||
// the rooms list, room 1 messages, and user 1 rooms.
|
||||
const turboSignedStreamNames = [
|
||||
"InJvb21zIg==--54acd827f0a7db144c75316a9fc488c0a949f9635b1e47956ce1bd9d1cf2c41d",
|
||||
"IloybGtPaTh2WTJGdGNHWnBjbVV2VW05dmJYTTZPa05zYjNObFpDOHg6bWVzc2FnZXMi--84f0f3dde5d23eb0fdb410746c2fb76813a4ddff1e2798aac4be0c3d969702ba",
|
||||
"IloybGtPaTh2WTJGdGNHWnBjbVV2VlhObGNpOHg6cm9vbXMi--df547a679cd41f7531b53d9e48f9883c02481a4da0d862453106441d8546d084"
|
||||
];
|
||||
|
||||
const dummyCookies = new SharedArray('cookies', function () {
|
||||
return papaparse.parse(open('cookies.txt'), { header: false }).data;
|
||||
});
|
||||
|
||||
const host = __ENV.HOST || "127.0.0.1";
|
||||
let port = ":3000";
|
||||
if (__ENV.PORT) {
|
||||
port = `:${__ENV.PORT}`;
|
||||
}
|
||||
|
||||
const users = parseInt(__ENV.USERS || "3000");
|
||||
const rampS = parseInt(__ENV.RAMP_S || "480");
|
||||
const holdS = parseInt(__ENV.HOLD_S || "120");
|
||||
const sendIntervalS = parseFloat(__ENV.SEND_INTERVAL_S || "1");
|
||||
const sample = parseInt(__ENV.SAMPLE || "25");
|
||||
const totalS = rampS + holdS;
|
||||
|
||||
const deliveryLatency = new Trend('delivery_latency', true);
|
||||
const messagesReceived = new Counter('bench_messages_received');
|
||||
const socketErrors = new Counter('bench_socket_errors');
|
||||
|
||||
export const options = {
|
||||
discardResponseBodies: true,
|
||||
scenarios: {
|
||||
receivers: {
|
||||
executor: 'ramping-vus',
|
||||
exec: 'receiver',
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: `${rampS}s`, target: users },
|
||||
{ duration: `${holdS}s`, target: users },
|
||||
],
|
||||
gracefulRampDown: '0s',
|
||||
gracefulStop: '5s',
|
||||
},
|
||||
sender: {
|
||||
executor: 'constant-vus',
|
||||
exec: 'sender',
|
||||
vus: 1,
|
||||
duration: `${totalS}s`,
|
||||
gracefulStop: '10s',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function receiver() {
|
||||
const cookie = dummyCookies[(__VU - 1) % dummyCookies.length][0];
|
||||
const url = `ws://${host}${port}/cable`;
|
||||
const params = {
|
||||
headers: { 'Origin': 'http://localhost', 'Cookie': `session_token=${cookie}` }
|
||||
};
|
||||
const subscriptions = [
|
||||
'{"channel":"PresenceChannel", "room_id":1}',
|
||||
'{"channel":"UnreadRoomsChannel"}',
|
||||
'{"channel":"HeartbeatChannel"}',
|
||||
...turboSignedStreamNames.map((name) =>
|
||||
`{"channel":"Turbo::StreamsChannel", "signed_stream_name":"${name}"}`)
|
||||
];
|
||||
|
||||
const res = ws.connect(url, params, function (socket) {
|
||||
let confirmed = 0;
|
||||
|
||||
socket.on('open', function () {
|
||||
subscriptions.forEach((identifier) => {
|
||||
socket.send(JSON.stringify({ command: 'subscribe', identifier: identifier }));
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('message', function (message) {
|
||||
if (message.includes('confirm_subscription')) {
|
||||
confirmed++;
|
||||
if (confirmed === subscriptions.length) {
|
||||
console.log(`BENCH CONN ${Date.now()} ${__VU}`);
|
||||
}
|
||||
} else if (message.includes('reject_subscription')) {
|
||||
socketErrors.add(1);
|
||||
console.log(`BENCH ERR ${Date.now()} reject_subscription`);
|
||||
} else if (message.includes('bench:')) {
|
||||
const match = message.match(/bench:(\d+):(\d+)/);
|
||||
if (match) {
|
||||
const latency = Date.now() - parseInt(match[1]);
|
||||
deliveryLatency.add(latency);
|
||||
messagesReceived.add(1);
|
||||
if (__VU % sample === 0) {
|
||||
console.log(`BENCH LAT ${Date.now()} ${latency}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', function (e) {
|
||||
if (e.error() != 'websocket: close sent') {
|
||||
socketErrors.add(1);
|
||||
console.log(`BENCH ERR ${Date.now()} ${e.error()}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.setTimeout(() => socket.close(), totalS * 1000);
|
||||
});
|
||||
|
||||
if (!res || res.status >= 400) {
|
||||
socketErrors.add(1);
|
||||
let status = "nil";
|
||||
if (res) {
|
||||
status = res.status;
|
||||
}
|
||||
console.log(`BENCH ERR ${Date.now()} connect_status_${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function sender() {
|
||||
const cookie = `session_token=${dummyCookies[0][0]}`;
|
||||
|
||||
const page = http.get(`http://${host}${port}/rooms/1`, {
|
||||
headers: { "Cookie": cookie }, responseType: "text"
|
||||
});
|
||||
const csrfToken = page.body.match(/<meta name="csrf-token" content="([^"]*)"/i)[1];
|
||||
|
||||
const postHeaders = {
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
|
||||
};
|
||||
|
||||
let seq = 0;
|
||||
const deadline = Date.now() + totalS * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
seq++;
|
||||
const payload = {
|
||||
"message[body]": `bench:${Date.now()}:${seq}`,
|
||||
"message[client_message_id]": `bench-${seq}-${Math.random().toString(36)}`,
|
||||
"authenticity_token": csrfToken
|
||||
};
|
||||
const res = http.post(`http://${host}${port}/rooms/1/messages`, payload, {
|
||||
headers: postHeaders, responseType: "none", tags: { name: 'send_message' }
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
console.log(`BENCH ERR ${Date.now()} post_status_${res.status}`);
|
||||
} else {
|
||||
console.log(`BENCH SENT ${Date.now()} ${seq}`);
|
||||
}
|
||||
sleep(sendIntervalS);
|
||||
}
|
||||
}
|
||||
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