mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1d304999d | ||
|
|
d28ab55bf4 | ||
|
|
50f25cea80 | ||
|
|
d79225ecd1 | ||
|
|
b065b40a34 | ||
|
|
69e8cd7885 | ||
|
|
c0cade51a1 | ||
|
|
80fdd44622 | ||
|
|
4cfcc2a370 | ||
|
|
9085adcbb3 | ||
|
|
0de302c977 | ||
|
|
9fb419e469 | ||
|
|
df5ed25e14 | ||
|
|
048ef90ae7 | ||
|
|
ea994053ea | ||
|
|
018a0b5dd9 | ||
|
|
38741c53f1 | ||
|
|
cf0a67fcaf | ||
|
|
e945d4217b | ||
|
|
cccfb8f774 | ||
|
|
55fbf16629 | ||
|
|
9ebc47a8f0 | ||
|
|
962c48af13 | ||
|
|
0920ceab6b | ||
|
|
12bb943af8 | ||
|
|
e4f88b425a | ||
|
|
bfa9f66633 |
No files matched your search
@@ -67,6 +67,17 @@ jobs:
|
|||||||
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
|
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
|
||||||
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
|
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Compute app version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||||
|
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Extract Docker metadata (tags, labels) with arch suffix
|
- name: Extract Docker metadata (tags, labels) with arch suffix
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||||
@@ -94,6 +105,8 @@ jobs:
|
|||||||
build-args: |
|
build-args: |
|
||||||
OCI_SOURCE=${{ env.SOURCE_URL }}
|
OCI_SOURCE=${{ env.SOURCE_URL }}
|
||||||
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
|
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
|
||||||
|
APP_VERSION=${{ steps.version.outputs.version }}
|
||||||
|
GIT_REVISION=${{ github.sha }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ ENV HTTP_WRITE_TIMEOUT=300
|
|||||||
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
|
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
|
||||||
COPY --from=build --chown=rails:rails /rails /rails
|
COPY --from=build --chown=rails:rails /rails /rails
|
||||||
|
|
||||||
|
# Install ONCE backup/restore hooks
|
||||||
|
COPY --chmod=755 hooks /hooks
|
||||||
|
|
||||||
# Set version and revision
|
# Set version and revision
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
ENV APP_VERSION=$APP_VERSION
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
|||||||
+19
-19
@@ -137,7 +137,7 @@ GEM
|
|||||||
bcrypt (3.1.22)
|
bcrypt (3.1.22)
|
||||||
benchmark (0.5.0)
|
benchmark (0.5.0)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (3.3.1)
|
||||||
brakeman (8.0.4)
|
brakeman (8.0.5)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -153,13 +153,13 @@ GEM
|
|||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.7)
|
||||||
connection_pool (2.5.5)
|
connection_pool (2.5.5)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.7)
|
||||||
date (3.5.0)
|
date (3.5.1)
|
||||||
debug (1.11.0)
|
debug (1.11.0)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
@@ -191,7 +191,7 @@ GEM
|
|||||||
jbuilder (2.14.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.13.2)
|
json (2.20.0)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
kredis (1.8.0)
|
kredis (1.8.0)
|
||||||
@@ -201,7 +201,7 @@ GEM
|
|||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.24.1)
|
loofah (2.25.2)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.9.0)
|
||||||
@@ -224,7 +224,7 @@ GEM
|
|||||||
ruby2_keywords (~> 0.0.1)
|
ruby2_keywords (~> 0.0.1)
|
||||||
net-http-persistent (4.0.6)
|
net-http-persistent (4.0.6)
|
||||||
connection_pool (~> 2.2, >= 2.2.4)
|
connection_pool (~> 2.2, >= 2.2.4)
|
||||||
net-imap (0.6.4)
|
net-imap (0.6.4.1)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -234,13 +234,13 @@ GEM
|
|||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.3-aarch64-linux-gnu)
|
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.3-arm64-darwin)
|
nokogiri (1.19.4-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.3-x86_64-darwin)
|
nokogiri (1.19.4-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (3.3.0)
|
openssl (3.3.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
@@ -278,8 +278,8 @@ GEM
|
|||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.6.2)
|
rails-html-sanitizer (1.7.1)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.25, >= 2.25.2)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
rails_autolink (1.1.8)
|
rails_autolink (1.1.8)
|
||||||
actionview (> 3.1)
|
actionview (> 3.1)
|
||||||
@@ -367,10 +367,10 @@ GEM
|
|||||||
rack-protection (= 4.2.1)
|
rack-protection (= 4.2.1)
|
||||||
rack-session (>= 2.0.0, < 3)
|
rack-session (>= 2.0.0, < 3)
|
||||||
tilt (~> 2.0)
|
tilt (~> 2.0)
|
||||||
sqlite3 (2.7.3-aarch64-linux-gnu)
|
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||||
sqlite3 (2.7.3-arm64-darwin)
|
sqlite3 (2.9.5-arm64-darwin)
|
||||||
sqlite3 (2.7.3-x86_64-darwin)
|
sqlite3 (2.9.5-x86_64-darwin)
|
||||||
sqlite3 (2.7.3-x86_64-linux-gnu)
|
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.8)
|
stringio (3.1.8)
|
||||||
@@ -380,7 +380,7 @@ GEM
|
|||||||
thruster (0.1.15-x86_64-darwin)
|
thruster (0.1.15-x86_64-darwin)
|
||||||
thruster (0.1.15-x86_64-linux)
|
thruster (0.1.15-x86_64-linux)
|
||||||
tilt (2.6.1)
|
tilt (2.6.1)
|
||||||
timeout (0.4.4)
|
timeout (0.6.1)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
@@ -397,7 +397,7 @@ GEM
|
|||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
websocket (1.2.11)
|
websocket (1.2.11)
|
||||||
websocket-driver (0.8.0)
|
websocket-driver (0.8.2)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
|
|||||||
@@ -11,51 +11,49 @@ expect, including:
|
|||||||
- @mentions
|
- @mentions
|
||||||
- API, with support for bot integrations
|
- API, with support for bot integrations
|
||||||
|
|
||||||
## Deploying with Docker
|
## Running your own Campfire instance
|
||||||
|
|
||||||
Campfire's Docker image contains everything needed for a fully-functional,
|
Campfire's Docker image contains everything needed for a fully-functional,
|
||||||
single-machine deployment. This includes the web app, background jobs, caching,
|
single-machine deployment. This includes the web app, background jobs, caching,
|
||||||
file serving, and SSL.
|
file serving, and SSL. You can use our pre-built image at
|
||||||
|
`ghcr.io/basecamp/once-campfire:latest`, or build your own from this repo.
|
||||||
|
|
||||||
To persist storage of the database and file attachments, map a volume to `/rails/storage`.
|
### Deploying with ONCE
|
||||||
|
|
||||||
To configure additional features, you can set the following environment variables:
|
The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once).
|
||||||
|
It will guide you through the initial set up and then keep your instance up to date automatically.
|
||||||
|
|
||||||
- `SSL_DOMAIN` - enable automatic SSL via Let's Encrypt for the given domain name
|
If you don't already have `once` installed, run this on the machine you want to run Campfire on:
|
||||||
- `DISABLE_SSL` - alternatively, set `DISABLE_SSL` to serve over plain HTTP
|
|
||||||
- `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` - set these to a valid keypair to
|
|
||||||
allow sending Web Push notifications. You can generate a new keypair by running
|
|
||||||
`/script/admin/create-vapid-key`
|
|
||||||
- `SENTRY_DSN` - to enable error reporting to sentry in production, supply your
|
|
||||||
DSN here
|
|
||||||
|
|
||||||
For example:
|
```sh
|
||||||
|
curl https://get.once.com | sh
|
||||||
|
```
|
||||||
|
|
||||||
docker build -t campfire .
|
`once` will launch as soon as the install is finished.
|
||||||
|
|
||||||
docker run \
|
Choose Campfire from the list of applications, follow the instructions, and ONCE will take care of the rest.
|
||||||
--publish 80:80 --publish 443:443 \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--volume campfire:/rails/storage \
|
|
||||||
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
|
|
||||||
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
|
|
||||||
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
|
|
||||||
--env TLS_DOMAIN=chat.example.com \
|
|
||||||
campfire
|
|
||||||
|
|
||||||
## Running in development
|
If you prefer the command line to the dashboard, you can deploy directly:
|
||||||
|
|
||||||
bin/setup
|
```sh
|
||||||
bin/rails server
|
once deploy ghcr.io/basecamp/once-campfire --host chat.example.com
|
||||||
|
```
|
||||||
|
|
||||||
## Worth Noting
|
### Deploying with Docker
|
||||||
|
|
||||||
When you start Campfire for the first time, you’ll be guided through
|
If you'd rather run the Docker image yourself, you can read more about that in the [self-hosting guide](docs/self-hosting.md).
|
||||||
creating an admin account.
|
|
||||||
The email address of this admin account will be shown on the login page
|
|
||||||
so that people who forget their password know who to contact for help.
|
|
||||||
(You can change this email later in the settings)
|
|
||||||
|
|
||||||
Campfire is single-tenant: any rooms designated "public" will be accessible by
|
> [!TIP]
|
||||||
all users in the system. To support entirely distinct groups of customers, you
|
> When you start Campfire for the first time, you'll be guided through a wizard to create an admin account.
|
||||||
would deploy multiple instances of the application.
|
> The email address that you enter for the admin account will be visible on the sign-in page, it's there so
|
||||||
|
> that people have someone to contact if they need help with their account. If that bothers you, put in any
|
||||||
|
> email address you want and create yourself a new admin account.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
You are welcome - and encouraged - to modify Campfire to your liking.
|
||||||
|
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Report vulnerabilities through our [HackerOne program](https://hackerone.com/basecamp) or at
|
||||||
|
https://github.com/basecamp/once-campfire/security. Note that Campfire is in scope for our security
|
||||||
|
program, but is not bounty eligible.
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
Campfire is self-hosted and single-tenant, so the administrator is the server operator,
|
||||||
|
with shell, network, and database access already. Anything requiring the administrator
|
||||||
|
role grants nothing they do not already have, and is not a vulnerability.
|
||||||
|
|
||||||
|
We do want reports of anything a **non-administrator** can reach, and of any credential or
|
||||||
|
network path held by the Campfire process but not by the operator's own shell.
|
||||||
|
|
||||||
|
## Intentional behavior
|
||||||
|
|
||||||
|
Bot webhook URLs are unrestricted: an administrator can point one at any address,
|
||||||
|
including internal ones, because operators legitimately wire bots to their own services.
|
||||||
|
Link unfurling is different because any member can trigger it by pasting a URL, so it
|
||||||
|
validates destinations through `RestrictedHTTP::PrivateNetworkGuard`. The difference is who
|
||||||
|
picks the destination.
|
||||||
@@ -8,9 +8,8 @@ class Accounts::LogosController < ApplicationController
|
|||||||
if stale?(etag: Current.account)
|
if stale?(etag: Current.account)
|
||||||
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
|
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
|
||||||
|
|
||||||
if Current.account&.logo&.attached?
|
if (logo_variant = Current.account&.logo_variant(logo_size))
|
||||||
logo = Current.account.logo.variant(logo_variant).processed
|
send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
|
||||||
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
|
|
||||||
else
|
else
|
||||||
send_stock_icon
|
send_stock_icon
|
||||||
end
|
end
|
||||||
@@ -23,9 +22,6 @@ class Accounts::LogosController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
|
|
||||||
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
|
|
||||||
|
|
||||||
def send_png_file(path)
|
def send_png_file(path)
|
||||||
send_file path, content_type: "image/png", disposition: :inline
|
send_file path, content_type: "image/png", disposition: :inline
|
||||||
end
|
end
|
||||||
@@ -38,8 +34,8 @@ class Accounts::LogosController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def logo_variant
|
def logo_size
|
||||||
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
|
small_logo? ? :small : :large
|
||||||
end
|
end
|
||||||
|
|
||||||
def small_logo?
|
def small_logo?
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ class Users::AvatarsController < ApplicationController
|
|||||||
if stale?(etag: @user)
|
if stale?(etag: @user)
|
||||||
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
|
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
|
||||||
|
|
||||||
if @user.avatar.attached?
|
if (avatar_variant = @user.avatar_variant)
|
||||||
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
|
|
||||||
send_webp_blob_file avatar_variant.key
|
send_webp_blob_file avatar_variant.key
|
||||||
elsif @user.bot?
|
elsif @user.bot?
|
||||||
render_default_bot
|
render_default_bot
|
||||||
@@ -26,8 +25,6 @@ class Users::AvatarsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
|
|
||||||
|
|
||||||
def send_webp_blob_file(key)
|
def send_webp_blob_file(key)
|
||||||
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
|
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
include Joinable
|
include Joinable
|
||||||
|
|
||||||
has_one_attached :logo
|
has_one_attached :logo do |attachable|
|
||||||
|
attachable.variant :large, resize_to_limit: [ 512, 512 ], format: :png
|
||||||
|
attachable.variant :small, resize_to_limit: [ 192, 192 ], format: :png
|
||||||
|
end
|
||||||
|
|
||||||
has_json :settings, restrict_room_creation_to_administrators: false
|
has_json :settings, restrict_room_creation_to_administrators: false
|
||||||
|
|
||||||
|
def logo_variant(size)
|
||||||
|
logo.variant(size).processed if logo.variable?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -2,7 +2,9 @@ module User::Avatar
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
has_one_attached :avatar
|
has_one_attached :avatar do |attachable|
|
||||||
|
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
@@ -14,4 +16,8 @@ module User::Avatar
|
|||||||
def avatar_token
|
def avatar_token
|
||||||
signed_id(purpose: :avatar)
|
signed_id(purpose: :avatar)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def avatar_variant
|
||||||
|
avatar.variant(:square).processed if avatar.variable?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -24,6 +24,8 @@ class Webhook < ApplicationRecord
|
|||||||
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
|
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
|
||||||
|
# and operators legitimately point bots at their own internal services.
|
||||||
def http
|
def http
|
||||||
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
||||||
http.use_ssl = (uri.scheme == "https")
|
http.use_ssl = (uri.scheme == "https")
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
Rails.application.config.app_version = ENV.fetch("APP_VERSION", "0")
|
Rails.application.config.app_version = ENV["APP_VERSION"].presence || ENV["GIT_REVISION"].presence || "0"
|
||||||
Rails.application.config.git_revision = ENV["GIT_REVISION"]
|
Rails.application.config.git_revision = ENV["GIT_REVISION"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Disable unfuzzed libvips operations.
|
||||||
|
#
|
||||||
|
# To block loaders we need to call `Vips.block` after Rails and image_processing set their
|
||||||
|
# defaults. Force the order of operations by autoloading the file now.
|
||||||
|
ActiveStorage::Transformers::Vips
|
||||||
|
Vips.block_untrusted(true)
|
||||||
|
Vips.block("VipsForeignLoadOpenslide", true) # prevent sqlite segfault in forked parallel workers
|
||||||
|
Rails.application.config.active_storage.variable_content_types -=
|
||||||
|
%w[ image/bmp image/vnd.microsoft.icon image/vnd.adobe.photoshop ]
|
||||||
@@ -0,0 +1,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.
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
db_name="${RAILS_ENV:-production}.sqlite3"
|
||||||
|
snapshot="/rails/storage/backups/$db_name"
|
||||||
|
db="/rails/storage/db/$db_name"
|
||||||
|
|
||||||
|
if [ -f "$snapshot" ]; then
|
||||||
|
mkdir -p "$(dirname "$db")"
|
||||||
|
cp "$snapshot" "$db"
|
||||||
|
rm -f "$db-wal" "$db-shm"
|
||||||
|
fi
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exec /rails/script/admin/prepare-backup
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
require "ipaddr"
|
||||||
require "resolv"
|
require "resolv"
|
||||||
|
|
||||||
module RestrictedHTTP
|
module RestrictedHTTP
|
||||||
@@ -6,7 +7,32 @@ module RestrictedHTTP
|
|||||||
module PrivateNetworkGuard
|
module PrivateNetworkGuard
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network
|
# IPv4 special-use ranges (RFC 5735/6890) not already covered by the
|
||||||
|
# private?/loopback?/link_local? predicates in #disallowed_ipv4?.
|
||||||
|
DISALLOWED_IPV4 = %w[
|
||||||
|
0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24
|
||||||
|
198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
|
||||||
|
].map { |cidr| IPAddr.new(cidr) }.freeze
|
||||||
|
|
||||||
|
# IPv6 special-use ranges not caught by the predicates. 6to4 (2002::/16) and
|
||||||
|
# Teredo (2001::/32) are deprecated transition mechanisms with no legitimate
|
||||||
|
# fetch target, so they are blocked outright. ULA (fc00::/7, incl. the AWS
|
||||||
|
# IMDSv6 address fd00:ec2::254), link-local, and loopback are covered by the
|
||||||
|
# predicates in #disallowed_ipv6?.
|
||||||
|
DISALLOWED_IPV6 = %w[
|
||||||
|
::/128 100::/64 2001::/32 2001:2::/48 2001:db8::/32 2002::/16
|
||||||
|
fec0::/10 ff00::/8
|
||||||
|
].map { |cidr| IPAddr.new(cidr) }.freeze
|
||||||
|
|
||||||
|
# NAT64 prefixes: the well-known prefix (RFC 6052/6146) and the local-use
|
||||||
|
# prefix (RFC 8215). An address here embeds an IPv4 target in its low 32
|
||||||
|
# bits; we extract it and re-check against the IPv4 rules so NAT64 to a
|
||||||
|
# public address still resolves while NAT64 to an internal address is
|
||||||
|
# blocked.
|
||||||
|
NAT64_PREFIXES = [
|
||||||
|
IPAddr.new("64:ff9b::/96"),
|
||||||
|
IPAddr.new("64:ff9b:1::/48")
|
||||||
|
].freeze
|
||||||
|
|
||||||
def resolve(hostname)
|
def resolve(hostname)
|
||||||
Resolv.getaddress(hostname).tap do |ip|
|
Resolv.getaddress(hostname).tap do |ip|
|
||||||
@@ -15,11 +41,36 @@ module RestrictedHTTP
|
|||||||
end
|
end
|
||||||
|
|
||||||
def private_ip?(ip)
|
def private_ip?(ip)
|
||||||
IPAddr.new(ip).then do |ipaddr|
|
ipaddr = IPAddr.new(ip)
|
||||||
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr)
|
|
||||||
|
# DNS never legitimately returns these embedded forms, so block them all
|
||||||
|
# regardless of the address they wrap.
|
||||||
|
if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat?
|
||||||
|
true
|
||||||
|
elsif ipaddr.ipv4?
|
||||||
|
disallowed_ipv4?(ipaddr)
|
||||||
|
elsif NAT64_PREFIXES.any? { |prefix| prefix.include?(ipaddr) }
|
||||||
|
disallowed_ipv4?(embedded_ipv4(ipaddr))
|
||||||
|
else
|
||||||
|
disallowed_ipv6?(ipaddr)
|
||||||
end
|
end
|
||||||
rescue IPAddr::InvalidAddressError
|
rescue IPAddr::InvalidAddressError
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def disallowed_ipv4?(ipaddr)
|
||||||
|
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
|
||||||
|
DISALLOWED_IPV4.any? { |range| range.include?(ipaddr) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def disallowed_ipv6?(ipaddr)
|
||||||
|
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
|
||||||
|
DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def embedded_ipv4(ipaddr)
|
||||||
|
IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join("."))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
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}"
|
||||||
@@ -30,6 +30,13 @@ class Accounts::LogosControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_valid_png_response size: 192
|
assert_valid_png_response size: 192
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "show stock when custom logo cannot be resized" do
|
||||||
|
accounts(:signal).update! logo: fixture_file_upload("pixel.bmp", "image/bmp")
|
||||||
|
|
||||||
|
get account_logo_url
|
||||||
|
assert_valid_png_response size: 512
|
||||||
|
end
|
||||||
|
|
||||||
test "destroy" do
|
test "destroy" do
|
||||||
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
|
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal "image/webp", @response.content_type
|
assert_equal "image/webp", @response.content_type
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "show initials when image cannot be resized" do
|
||||||
|
users(:kevin).update! avatar: fixture_file_upload("pixel.bmp", "image/bmp")
|
||||||
|
get user_avatar_url(users(:kevin).avatar_token)
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_select "text", text: "K"
|
||||||
|
end
|
||||||
|
|
||||||
test "show image with invalid token responds 404" do
|
test "show image with invalid token responds 404" do
|
||||||
get user_avatar_url("not-a-valid-token")
|
get user_avatar_url("not-a-valid-token")
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 58 B |
@@ -64,6 +64,50 @@ class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase
|
|||||||
assert_private_ip "::93.184.216.34"
|
assert_private_ip "::93.184.216.34"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "private_ip? returns true for carrier-grade NAT addresses (RFC6598)" do
|
||||||
|
assert_private_ip "100.64.0.1"
|
||||||
|
assert_private_ip "100.127.255.255"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private_ip? returns true for NAT64 addresses embedding a private IPv4" do
|
||||||
|
assert_private_ip "64:ff9b::a9fe:a9fe" # NAT64 -> 169.254.169.254 (AWS metadata)
|
||||||
|
assert_private_ip "64:ff9b::a00:5" # NAT64 -> 10.0.0.5
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private_ip? returns true for local-use NAT64 addresses embedding a private IPv4 (RFC8215)" do
|
||||||
|
assert_private_ip "64:ff9b:1::a00:1" # local-use NAT64 -> 10.0.0.1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private_ip? returns false for local-use NAT64 addresses embedding a public IPv4 (RFC8215)" do
|
||||||
|
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b:1::808:808") # -> 8.8.8.8
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private_ip? returns false for NAT64 addresses embedding a public IPv4" do
|
||||||
|
# DNS64 legitimately synthesizes these for public sites on IPv6-only hosts.
|
||||||
|
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b::808:808") # -> 8.8.8.8
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private_ip? returns true for 6to4 and Teredo transition addresses" do
|
||||||
|
assert_private_ip "2002:a9fe:a9fe::" # 6to4 embedding 169.254.169.254
|
||||||
|
assert_private_ip "2001::1" # Teredo
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private_ip? returns true for IPv6 loopback, ULA, and link-local" do
|
||||||
|
assert_private_ip "::1"
|
||||||
|
assert_private_ip "fd00:ec2::254" # AWS IMDSv6 (ULA)
|
||||||
|
assert_private_ip "fe80::1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private_ip? returns true for IPv6 multicast, documentation, and benchmarking ranges" do
|
||||||
|
assert_private_ip "ff02::1"
|
||||||
|
assert_private_ip "2001:db8::1"
|
||||||
|
assert_private_ip "2001:2::1" # benchmarking (RFC5180), matches 198.18.0.0/15
|
||||||
|
end
|
||||||
|
|
||||||
|
test "private_ip? returns false for public IPv6 addresses" do
|
||||||
|
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("2606:4700:4700::1111")
|
||||||
|
end
|
||||||
|
|
||||||
test "private_ip? returns true for invalid addresses" do
|
test "private_ip? returns true for invalid addresses" do
|
||||||
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip")
|
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip")
|
||||||
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("")
|
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("")
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
require "test_helper"
|
||||||
|
require "vips"
|
||||||
|
require "tempfile"
|
||||||
|
|
||||||
|
# libvips selects a loader from a file's actual bytes, not from its declared content type. These
|
||||||
|
# tests pin which loader is selected for each file type under the app's configured loader policy
|
||||||
|
# (config/initializers/vips.rb).
|
||||||
|
class VipsLoaderPolicyTest < ActiveSupport::TestCase
|
||||||
|
# Header bytes are enough for libvips to identify a format; native types are encoded live, exotic
|
||||||
|
# ones are represented by their magic bytes.
|
||||||
|
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
|
||||||
|
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
|
||||||
|
BMP = "BM" + [ 0, 0, 54 ].pack("V3") + "\x00" * 40
|
||||||
|
PSD = "8BPS" + [ 1 ].pack("n") + "\x00" * 26
|
||||||
|
ICO = "\x00\x00\x01\x00\x01\x00" + "\x00" * 16
|
||||||
|
SVG = %q(<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>)
|
||||||
|
|
||||||
|
test "loads PNG" do
|
||||||
|
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "loads GIF" do
|
||||||
|
assert_equal "VipsForeignLoadNsgifFile", loader_for(encode("gif"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "loads JPEG" do
|
||||||
|
assert_equal "VipsForeignLoadJpegFile", loader_for(encode("jpg"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "loads TIFF" do
|
||||||
|
assert_equal "VipsForeignLoadTiffFile", loader_for(encode("tif"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "loads WebP" do
|
||||||
|
assert_equal "VipsForeignLoadWebpFile", loader_for(encode("webp"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "loads AVIF" do
|
||||||
|
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_AVIF)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "loads HEIC" do
|
||||||
|
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies BMP through magickload" do
|
||||||
|
assert_nil loader_for(BMP)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies PSD through magickload" do
|
||||||
|
assert_nil loader_for(PSD)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies ICO through magickload" do
|
||||||
|
assert_nil loader_for(ICO)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies SVG through svgload" do
|
||||||
|
assert_nil loader_for(SVG)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies OpenSlide files through openslideload" do
|
||||||
|
# OpenSlide files can segfault the embedded sqlite in forked parallel workers
|
||||||
|
assert_loader_blocked :openslideload, ".svs"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies FITS files through fitsload" do
|
||||||
|
assert_loader_blocked :fitsload, ".fits"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies MATLAB files through matload" do
|
||||||
|
assert_loader_blocked :matload, ".mat"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies NIFTI files through niftiload" do
|
||||||
|
assert_loader_blocked :niftiload, ".nii"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies RAW files through dcrawload" do
|
||||||
|
assert_loader_blocked :dcrawload, ".raw"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "denies VIPS files through vipsload" do
|
||||||
|
assert_loader_blocked :vipsload, ".vips"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# Invoke a specific libvips loader directly and assert it is refused because the
|
||||||
|
# operation is blocked (rather than because the bytes are not a valid image).
|
||||||
|
def assert_loader_blocked(operation, extension)
|
||||||
|
Tempfile.create([ "blocked_loader", extension ], binmode: true) do |file|
|
||||||
|
file.write "not an image"
|
||||||
|
file.flush
|
||||||
|
|
||||||
|
error = assert_raises(Vips::Error) { Vips::Image.public_send(operation, file.path) }
|
||||||
|
actual = error.message.chomp
|
||||||
|
|
||||||
|
# note that exception message may include multiple errors on separate lines,
|
||||||
|
# so `^` and `$` anchors are used instead of `\A` and `\z`.
|
||||||
|
if actual =~ /^VipsOperation: class \"#{operation}\" not found$/
|
||||||
|
skip "libvips does not support #{operation} on this system"
|
||||||
|
end
|
||||||
|
assert_match(/^#{operation}: operation is blocked$/, actual)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode(ext)
|
||||||
|
Vips::Image.black(8, 8).add(128).cast("uchar").write_to_buffer(".#{ext}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def loader_for(bytes)
|
||||||
|
Tempfile.create(%w[loader_probe .img], binmode: true) do |file|
|
||||||
|
file.write bytes
|
||||||
|
file.flush
|
||||||
|
Vips.vips_foreign_find_load(file.path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,4 +15,20 @@ class AccountTest < ActiveSupport::TestCase
|
|||||||
accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" })
|
accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" })
|
||||||
assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
|
assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "logo_variant is a resized variant of a variable logo" do
|
||||||
|
accounts(:signal).logo.attach io: file_fixture("moon.jpg").open, filename: "moon.jpg", content_type: "image/jpeg"
|
||||||
|
|
||||||
|
assert_kind_of ActiveStorage::VariantWithRecord, accounts(:signal).logo_variant(:large)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "logo_variant is nil when the logo cannot be resized" do
|
||||||
|
accounts(:signal).logo.attach io: file_fixture("pixel.bmp").open, filename: "pixel.bmp", content_type: "image/bmp"
|
||||||
|
|
||||||
|
assert_nil accounts(:signal).logo_variant(:large)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "logo_variant is nil without a logo" do
|
||||||
|
assert_nil accounts(:signal).logo_variant(:large)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -49,8 +49,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
|
|||||||
# to a resolved IP, not a hostname to re-resolve.
|
# to a resolved IP, not a hostname to re-resolve.
|
||||||
WebMock.disable_net_connect! allow: [ @url.host ]
|
WebMock.disable_net_connect! allow: [ @url.host ]
|
||||||
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
|
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
|
||||||
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never
|
TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
|
||||||
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
|
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
|
||||||
|
|
||||||
assert_throws :dns_not_rebound do
|
assert_throws :dns_not_rebound do
|
||||||
@fetch.fetch_document(@url)
|
@fetch.fetch_document(@url)
|
||||||
@@ -66,8 +66,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
|
|||||||
# to a resolved IP, not a hostname to re-resolve.
|
# to a resolved IP, not a hostname to re-resolve.
|
||||||
WebMock.disable_net_connect! allow: [ @url.host ]
|
WebMock.disable_net_connect! allow: [ @url.host ]
|
||||||
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
|
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
|
||||||
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never
|
TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
|
||||||
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
|
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
|
||||||
|
|
||||||
assert_throws :dns_not_rebound do
|
assert_throws :dns_not_rebound do
|
||||||
@fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")
|
@fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class User::AvatarTest < ActiveSupport::TestCase
|
||||||
|
test "avatar_variant is a resized variant of a variable avatar" do
|
||||||
|
users(:kevin).avatar.attach io: file_fixture("moon.jpg").open, filename: "moon.jpg", content_type: "image/jpeg"
|
||||||
|
|
||||||
|
assert_kind_of ActiveStorage::VariantWithRecord, users(:kevin).avatar_variant
|
||||||
|
end
|
||||||
|
|
||||||
|
test "avatar_variant is nil when the avatar cannot be resized" do
|
||||||
|
users(:kevin).avatar.attach io: file_fixture("pixel.bmp").open, filename: "pixel.bmp", content_type: "image/bmp"
|
||||||
|
|
||||||
|
assert_nil users(:kevin).avatar_variant
|
||||||
|
end
|
||||||
|
|
||||||
|
test "avatar_variant is nil without an avatar" do
|
||||||
|
assert_nil users(:kevin).avatar_variant
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user