mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 08:26:48 +09:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
094ef7b462 |
No files matched your search
@@ -50,6 +50,3 @@
|
|||||||
|
|
||||||
# Ignore test files
|
# Ignore test files
|
||||||
/test/
|
/test/
|
||||||
|
|
||||||
# Agent session state — worktrees under .claude/ are whole extra checkouts of this repo
|
|
||||||
.claude
|
|
||||||
@@ -20,12 +20,13 @@ updates:
|
|||||||
semver-minor-days: 3
|
semver-minor-days: 3
|
||||||
semver-patch-days: 2
|
semver-patch-days: 2
|
||||||
default-days: 7
|
default-days: 7
|
||||||
exclude:
|
|
||||||
- "brakeman"
|
|
||||||
|
|
||||||
# No docker ecosystem. Our base image tag is a build arg
|
- package-ecosystem: docker
|
||||||
# (`FROM ruby:$RUBY_VERSION-slim`), which the updater cannot read, so the
|
directory: "/"
|
||||||
# entry only ever matched nothing — worse than no entry, because it looked
|
schedule:
|
||||||
# like coverage. Ruby base image bumps are a manual, reviewed step here, as
|
interval: weekly
|
||||||
# they are across the rest of the fleet. Keep Dockerfile, Dockerfile-export
|
cooldown:
|
||||||
# and .ruby-version on the same version by hand.
|
semver-major-days: 7
|
||||||
|
semver-minor-days: 3
|
||||||
|
semver-patch-days: 2
|
||||||
|
default-days: 7
|
||||||
+11
-27
@@ -16,12 +16,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -35,12 +35,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -55,15 +55,15 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run actionlint
|
- name: Run actionlint
|
||||||
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
|
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||||
with:
|
with:
|
||||||
advanced-security: false
|
advanced-security: false
|
||||||
|
|
||||||
@@ -71,25 +71,17 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
env:
|
|
||||||
REDIS_URL: redis://localhost:6379/0
|
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -101,25 +93,17 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
env:
|
|
||||||
REDIS_URL: redis://localhost:6379/0
|
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ jobs:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -67,20 +67,9 @@ 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@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.vars.outputs.canonical }}
|
images: ${{ steps.vars.outputs.canonical }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -98,15 +87,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push (${{ matrix.platform }})
|
- name: Build and push (${{ matrix.platform }})
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
OCI_SOURCE=${{ env.SOURCE_URL }}
|
OCI_SOURCE=${{ env.SOURCE_URL }}
|
||||||
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
|
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
|
||||||
APP_VERSION=${{ steps.version.outputs.version }}
|
|
||||||
GIT_REVISION=${{ github.sha }}
|
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -118,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Attest image provenance (per-arch)
|
- name: Attest image provenance (per-arch)
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ steps.vars.outputs.canonical }}
|
subject-name: ${{ steps.vars.outputs.canonical }}
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
subject-digest: ${{ steps.build.outputs.digest }}
|
||||||
@@ -139,10 +126,10 @@ jobs:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Docker Buildx (for imagetools)
|
- name: Set up Docker Buildx (for imagetools)
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -159,7 +146,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Compute base tags (no suffix)
|
- name: Compute base tags (no suffix)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.vars.outputs.canonical }}
|
images: ${{ steps.vars.outputs.canonical }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -207,7 +194,7 @@ jobs:
|
|||||||
done <<< "$tags"
|
done <<< "$tags"
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
|
||||||
|
|
||||||
- name: Cosign sign all tags (keyless OIDC)
|
- name: Cosign sign all tags (keyless OIDC)
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.4.10
|
3.4.5
|
||||||
+2
-5
@@ -1,7 +1,7 @@
|
|||||||
# syntax = docker/dockerfile:1
|
# syntax = docker/dockerfile:1
|
||||||
|
|
||||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||||
ARG RUBY_VERSION=3.4.10
|
ARG RUBY_VERSION=3.4.5
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
# Rails app lives here
|
# Rails app lives here
|
||||||
@@ -9,7 +9,7 @@ WORKDIR /rails
|
|||||||
|
|
||||||
# Install base packages
|
# Install base packages
|
||||||
RUN apt-get update -qq && \
|
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 && \
|
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
|
rm -rf /var/lib/apt/lists /var/cache/apt/archive
|
||||||
|
|
||||||
@@ -66,9 +66,6 @@ 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
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# syntax = docker/dockerfile:1
|
# syntax = docker/dockerfile:1
|
||||||
|
|
||||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||||
ARG RUBY_VERSION=3.4.10
|
ARG RUBY_VERSION=3.4.7
|
||||||
FROM ruby:$RUBY_VERSION-slim as base
|
FROM ruby:$RUBY_VERSION-slim as base
|
||||||
|
|
||||||
# Install the tools we need
|
# Install the tools we need
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ gem "benchmark"
|
|||||||
|
|
||||||
# Drivers
|
# Drivers
|
||||||
gem "sqlite3"
|
gem "sqlite3"
|
||||||
gem "redis", "~> 5.4"
|
|
||||||
|
|
||||||
# Deployment
|
# Deployment
|
||||||
gem "puma", "~> 7.2", ">= 7.2.1"
|
gem "puma", "~> 7.2", ">= 7.2.1"
|
||||||
|
|
||||||
# Jobs
|
# Jobs, caching, WebSockets
|
||||||
gem "resque", "~> 2.7.0"
|
gem "solid_queue"
|
||||||
gem "resque-pool", "~> 0.7.1"
|
gem "solid_cache"
|
||||||
|
gem "solid_cable"
|
||||||
|
|
||||||
# Assets
|
# Assets
|
||||||
gem "propshaft", github: "rails/propshaft"
|
gem "propshaft", github: "rails/propshaft"
|
||||||
@@ -26,9 +26,6 @@ gem "importmap-rails", github: "rails/importmap-rails"
|
|||||||
gem "turbo-rails", github: "hotwired/turbo-rails"
|
gem "turbo-rails", github: "hotwired/turbo-rails"
|
||||||
gem "stimulus-rails"
|
gem "stimulus-rails"
|
||||||
|
|
||||||
# Rich text
|
|
||||||
gem "lexxy", "~> 0.9.24"
|
|
||||||
|
|
||||||
# Media handling
|
# Media handling
|
||||||
gem "image_processing", ">= 1.2"
|
gem "image_processing", ">= 1.2"
|
||||||
|
|
||||||
@@ -44,8 +41,6 @@ gem "rails_autolink"
|
|||||||
gem "geared_pagination"
|
gem "geared_pagination"
|
||||||
gem "jbuilder"
|
gem "jbuilder"
|
||||||
gem "net-http-persistent"
|
gem "net-http-persistent"
|
||||||
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
|
|
||||||
gem "kredis"
|
|
||||||
gem "platform_agent"
|
gem "platform_agent"
|
||||||
gem "thruster"
|
gem "thruster"
|
||||||
|
|
||||||
|
|||||||
+41
-65
@@ -1,9 +1,3 @@
|
|||||||
GIT
|
|
||||||
remote: https://github.com/basecamp/surfguard.git
|
|
||||||
revision: 59e278c01a537755f22791429051891949231ead
|
|
||||||
specs:
|
|
||||||
surfguard (0.2.0)
|
|
||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/hotwired/turbo-rails.git
|
remote: https://github.com/hotwired/turbo-rails.git
|
||||||
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
|
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
|
||||||
@@ -143,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.6)
|
brakeman (8.0.5)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -172,13 +166,17 @@ GEM
|
|||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
erb (6.0.4)
|
erb (6.0.4)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
|
et-orbi (1.4.0)
|
||||||
|
tzinfo
|
||||||
faker (3.5.2)
|
faker (3.5.2)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
ffi (1.17.2)
|
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-darwin)
|
ffi (1.17.2-x86_64-darwin)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
|
fugit (1.13.0)
|
||||||
|
et-orbi (~> 1.4)
|
||||||
|
raabro (~> 1.4)
|
||||||
geared_pagination (1.2.0)
|
geared_pagination (1.2.0)
|
||||||
activesupport (>= 5.0)
|
activesupport (>= 5.0)
|
||||||
addressable (>= 2.5.0)
|
addressable (>= 2.5.0)
|
||||||
@@ -198,22 +196,16 @@ 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.21.2)
|
json (2.20.0)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
kredis (1.8.0)
|
|
||||||
activemodel (>= 6.0.0)
|
|
||||||
activesupport (>= 6.0.0)
|
|
||||||
redis (>= 4.2, < 6)
|
|
||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
lexxy (0.9.24)
|
|
||||||
rails (>= 8.0.2)
|
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.25.2)
|
loofah (2.25.2)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.1)
|
mail (2.9.0)
|
||||||
logger
|
logger
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
@@ -225,12 +217,8 @@ GEM
|
|||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.26.2)
|
minitest (5.26.2)
|
||||||
mocha (3.1.0)
|
mocha (2.7.1)
|
||||||
ruby2_keywords (>= 0.0.5)
|
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)
|
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.1)
|
net-imap (0.6.4.1)
|
||||||
@@ -251,7 +239,7 @@ GEM
|
|||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (4.0.2)
|
openssl (3.3.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.9.0)
|
parser (3.3.9.0)
|
||||||
@@ -270,12 +258,9 @@ GEM
|
|||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.2.1)
|
puma (7.2.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.6)
|
rack (3.2.6)
|
||||||
rack-protection (4.2.1)
|
|
||||||
base64 (>= 0.1.0)
|
|
||||||
logger (>= 1.6.0)
|
|
||||||
rack (>= 3.0.0, < 4)
|
|
||||||
rack-session (2.1.2)
|
rack-session (2.1.2)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
@@ -300,23 +285,9 @@ GEM
|
|||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
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)
|
regexp_parser (2.11.2)
|
||||||
reline (0.6.3)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
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.4)
|
rexml (3.4.4)
|
||||||
rqrcode (3.2.0)
|
rqrcode (3.2.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
@@ -355,7 +326,7 @@ GEM
|
|||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (3.4.0)
|
rubyzip (3.0.2)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.35.0)
|
selenium-webdriver (4.35.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
@@ -369,26 +340,34 @@ GEM
|
|||||||
sentry-ruby (5.26.0)
|
sentry-ruby (5.26.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
sinatra (4.2.1)
|
solid_cable (4.0.2)
|
||||||
logger (>= 1.6.0)
|
actioncable (>= 7.2)
|
||||||
mustermann (~> 3.0)
|
activejob (>= 7.2)
|
||||||
rack (>= 3.0.0, < 4)
|
activerecord (>= 7.2)
|
||||||
rack-protection (= 4.2.1)
|
railties (>= 7.2)
|
||||||
rack-session (>= 2.0.0, < 3)
|
solid_cache (1.0.10)
|
||||||
tilt (~> 2.0)
|
activejob (>= 7.2)
|
||||||
sqlite3 (2.9.6-aarch64-linux-gnu)
|
activerecord (>= 7.2)
|
||||||
sqlite3 (2.9.6-arm64-darwin)
|
railties (>= 7.2)
|
||||||
sqlite3 (2.9.6-x86_64-darwin)
|
solid_queue (1.4.0)
|
||||||
sqlite3 (2.9.6-x86_64-linux-gnu)
|
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)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.8)
|
stringio (3.1.8)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
thruster (0.1.23-aarch64-linux)
|
thruster (0.1.15-aarch64-linux)
|
||||||
thruster (0.1.23-arm64-darwin)
|
thruster (0.1.15-arm64-darwin)
|
||||||
thruster (0.1.23-x86_64-darwin)
|
thruster (0.1.15-x86_64-darwin)
|
||||||
thruster (0.1.23-x86_64-linux)
|
thruster (0.1.15-x86_64-linux)
|
||||||
tilt (2.6.1)
|
|
||||||
timeout (0.6.1)
|
timeout (0.6.1)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
@@ -398,9 +377,9 @@ GEM
|
|||||||
unicode-emoji (4.2.0)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-push (3.1.0)
|
web-push (3.0.2)
|
||||||
jwt (~> 3.0)
|
jwt (~> 3.0)
|
||||||
openssl (>= 3.0)
|
openssl (~> 3.0)
|
||||||
webmock (3.25.1)
|
webmock (3.25.1)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
@@ -432,8 +411,6 @@ DEPENDENCIES
|
|||||||
image_processing (>= 1.2)
|
image_processing (>= 1.2)
|
||||||
importmap-rails!
|
importmap-rails!
|
||||||
jbuilder
|
jbuilder
|
||||||
kredis
|
|
||||||
lexxy (~> 0.9.24)
|
|
||||||
mocha
|
mocha
|
||||||
net-http-persistent
|
net-http-persistent
|
||||||
ostruct
|
ostruct
|
||||||
@@ -442,17 +419,16 @@ DEPENDENCIES
|
|||||||
puma (~> 7.2, >= 7.2.1)
|
puma (~> 7.2, >= 7.2.1)
|
||||||
rails!
|
rails!
|
||||||
rails_autolink
|
rails_autolink
|
||||||
redis (~> 5.4)
|
|
||||||
resque (~> 2.7.0)
|
|
||||||
resque-pool (~> 0.7.1)
|
|
||||||
rqrcode
|
rqrcode
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
sentry-rails
|
sentry-rails
|
||||||
sentry-ruby
|
sentry-ruby
|
||||||
|
solid_cable
|
||||||
|
solid_cache
|
||||||
|
solid_queue
|
||||||
sqlite3
|
sqlite3
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
surfguard!
|
|
||||||
thruster
|
thruster
|
||||||
turbo-rails!
|
turbo-rails!
|
||||||
web-push
|
web-push
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
web: bundle exec thrust bin/start-app
|
web: bundle exec thrust bin/start-app
|
||||||
redis: redis-server config/redis.conf
|
workers: bundle exec bin/jobs
|
||||||
workers: FORK_PER_JOB=false INTERVAL=0.1 bundle exec resque-pool
|
|
||||||
@@ -53,7 +53,3 @@ If you'd rather run the Docker image yourself, you can read more about that in t
|
|||||||
|
|
||||||
You are welcome - and encouraged - to modify Campfire to your liking.
|
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.
|
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
@@ -1,22 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,160 +1,165 @@
|
|||||||
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
|
trix-toolbar {
|
||||||
so everything follows Campfire's light/dark color scheme */
|
display: none;
|
||||||
.lexxy-content {
|
inset: auto 0 0 0;
|
||||||
--lexxy-color-canvas: transparent;
|
min-width: 0;
|
||||||
--lexxy-color-text: var(--color-text);
|
position: sticky;
|
||||||
--lexxy-color-text-subtle: var(--color-text);
|
z-index: 3;
|
||||||
--lexxy-color-link: var(--color-link);
|
|
||||||
--lexxy-color-ink: var(--color-text);
|
|
||||||
--lexxy-color-ink-inverted: var(--color-text-reversed);
|
|
||||||
--lexxy-color-ink-medium: var(--color-text);
|
|
||||||
--lexxy-color-ink-lighter: var(--color-border-darker);
|
|
||||||
--lexxy-color-ink-lightest: var(--color-bg);
|
|
||||||
--lexxy-color-selected: var(--color-selected, var(--color-bg));
|
|
||||||
--lexxy-color-code-bg: var(--color-bg);
|
|
||||||
|
|
||||||
/* Campfire's message lines stack without extra block margins */
|
.trix-dialog {
|
||||||
--lexxy-content-margin: 0;
|
bottom: 3rem !important;
|
||||||
|
top: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-button--icon {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-dialog {
|
||||||
|
background-color: var(--color-bg) !important;
|
||||||
|
border-radius: 1rem !important;
|
||||||
|
border: 2px solid var(--color-text) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
gap: var(--inline-space);
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: clip;
|
||||||
|
|
||||||
|
.trix-input {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
font-size: max(16px, 1em) !important;
|
||||||
|
padding: var(--input-padding, 0.5em 0.8em) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-button {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-dialog--link {
|
||||||
|
.trix-dialog__link-fields {
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[value="Link"],
|
||||||
|
[value="Unlink"] {
|
||||||
|
width: 2.6em;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
text-indent: -9999%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[value="Link"] { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.948 5.258a4.337 4.337 0 0 0-6.108 0L11.217 6.87a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l1.623-1.613a2.323 2.323 0 0 1 3.271 0 2.29 2.29 0 0 1 0 3.251l-2.393 2.38a3.021 3.021 0 0 1-4.255 0l-.05-.049a1.007 1.007 0 0 0-1.418 0 .993.993 0 0 0 0 1.41l.05.049a5.036 5.036 0 0 0 7.091 0l2.394-2.38a4.275 4.275 0 0 0 0-6.072Zm-13.683 13.6a4.337 4.337 0 0 0 6.108 0l1.262-1.255a.993.993 0 0 0 0-1.41 1.007 1.007 0 0 0-1.418 0L9.954 17.45a2.323 2.323 0 0 1-3.27 0 2.29 2.29 0 0 1 0-3.251l2.344-2.331a2.579 2.579 0 0 1 3.631 0c.392.39 1.027.39 1.419 0a.993.993 0 0 0 0-1.41 4.593 4.593 0 0 0-6.468 0l-2.345 2.33a4.275 4.275 0 0 0 0 6.072Z' clip-rule='evenodd'/%3E%3C/svg%3E"); }
|
||||||
|
|
||||||
|
[value="Unlink"] {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.954 5.264a3.58 3.58 0 0 0-5.04 0l-2.157 2.144a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l2.157-2.144a1.565 1.565 0 0 1 2.204 0c.607.603.609 1.58.005 2.186l-2.348 2.355a.993.993 0 0 0 .006 1.41 1.007 1.007 0 0 0 1.419-.007l2.347-2.354a3.528 3.528 0 0 0-.01-5ZM5.257 18.852a3.59 3.59 0 0 0 5.041.015l2.6-2.554a.993.993 0 0 0 .01-1.41 1.007 1.007 0 0 0-1.419-.009l-2.6 2.554c-.615.603-1.603.6-2.214-.006a1.554 1.554 0 0 1-.012-2.195l2.539-2.58a.993.993 0 0 0-.016-1.41 1.007 1.007 0 0 0-1.418.015l-2.539 2.58a3.54 3.54 0 0 0 .028 5Z' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-width='2' d='M10.233 5.78V3.769M5.968 9.802H4.21M7.343 7.107 5.968 5.781M14.348 17.523v2.013M18.613 13.502h1.758M17.238 16.196l1.375 1.327'/%3E%3C/svg%3E%0A");
|
||||||
|
border-top-right-radius: 1em !important;
|
||||||
|
border-bottom-right-radius: 1em !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lexxy-editor {
|
trix-editor {
|
||||||
&.input {
|
&.input {
|
||||||
--lexxy-editor-padding: 0;
|
|
||||||
|
|
||||||
/* One line high when empty. Lexxy derives this from attr(rows lh, 8lh),
|
|
||||||
whose resolution differs between Chrome versions — pin it instead of
|
|
||||||
risking the 8-line fallback */
|
|
||||||
--lexxy-editor-rows: 1lh;
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment--content {
|
.attachment--content {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
||||||
|
.attachment__toolbar {
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The mentions menu needs a solid surface — the editor's transparent canvas
|
.trix-button-group--file-tools,
|
||||||
would let messages show through it */
|
.trix-button-group--history-tools {
|
||||||
.lexxy-prompt-menu {
|
|
||||||
--lexxy-color-canvas: var(--color-bg);
|
|
||||||
|
|
||||||
border: 1px solid var(--color-border-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Same for the code block language picker: with a transparent canvas the
|
|
||||||
native dropdown renders white-on-white in dark mode */
|
|
||||||
lexxy-editor lexxy-code-language-picker select {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
|
|
||||||
option {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The toolbar only shows when rich text mode is toggled on (see composer.css) */
|
|
||||||
lexxy-toolbar {
|
|
||||||
/* Wide, airy button spacing like the Trix toolbar */
|
|
||||||
--lexxy-toolbar-gap: 1.3em;
|
|
||||||
|
|
||||||
/* Match the compact size of the Trix toolbar buttons */
|
|
||||||
--lexxy-toolbar-button-size: 1.7em;
|
|
||||||
--lexxy-toolbar-icon-size: 1.125em;
|
|
||||||
|
|
||||||
display: none;
|
|
||||||
border-block-end: 0;
|
|
||||||
min-width: 0;
|
|
||||||
z-index: 3;
|
|
||||||
|
|
||||||
/* Tools Campfire doesn't offer, matching the Trix toolbar: underline,
|
|
||||||
color highlights, tables, dividers and undo/redo (history tools were
|
|
||||||
hidden under Trix as well) */
|
|
||||||
button[name="underline"],
|
|
||||||
button[name="table"],
|
|
||||||
button[name="divider"],
|
|
||||||
button[name="undo"],
|
|
||||||
button[name="redo"],
|
|
||||||
lexxy-highlight-dropdown {
|
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-button-group,
|
||||||
|
.trix-button {
|
||||||
|
border: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 0.3em !important;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* No group separator pipes, like the Trix toolbar */
|
@media (prefers-color-scheme: dark) {
|
||||||
.lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after {
|
&::before {
|
||||||
display: none;
|
filter: invert(100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropdown panels: a solid surface with a uniform radius. Lexxy derives
|
&.trix-active {
|
||||||
the panel's radius and gap from --lexxy-toolbar-gap, which Campfire
|
background-color: var(--color-text) !important;
|
||||||
repurposes for button spacing */
|
|
||||||
.lexxy-editor__toolbar-dropdown [data-dropdown-panel] {
|
|
||||||
--lexxy-color-canvas: var(--color-bg);
|
|
||||||
--lexxy-color-ink-lightest: var(--color-message-bg);
|
|
||||||
--lexxy-color-selected: var(--color-selected);
|
|
||||||
--lexxy-color-selected-hover: var(--color-message-bg);
|
|
||||||
|
|
||||||
border: 1px solid var(--color-border-dark);
|
&::before {
|
||||||
border-radius: 0.5em;
|
filter: invert(100);
|
||||||
gap: 0.25em;
|
|
||||||
padding: 0.25em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lexxy-editor__toolbar-dropdown > .lexxy-editor__toolbar-button[aria-expanded="true"] {
|
@media (prefers-color-scheme: dark) {
|
||||||
border-radius: var(--lexxy-radius);
|
&::before {
|
||||||
|
filter: invert(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.trix-button--remove {
|
||||||
|
background-color: var(--color-negative);
|
||||||
|
border-radius: 1.8em !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
filter: invert(100);
|
||||||
|
background-size: 70%;
|
||||||
|
background-image: url("minus.svg");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Attachment markup contains formatting whitespace that must not turn into
|
[data-trix-content-type~='application/vnd.campfire.mention'] {
|
||||||
line breaks under the editor's white-space: pre-wrap */
|
.trix-button-group {
|
||||||
lexxy-editor action-text-attachment {
|
display: inline;
|
||||||
white-space: normal;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
action-text-attachment[content-type~="application/vnd.campfire.mention"] {
|
[data-trix-content-type~="application/vnd.actiontext.opengraph-embed"] {
|
||||||
display: inline-flex;
|
* {
|
||||||
margin: 0;
|
text-decoration: none;
|
||||||
padding: 0;
|
}
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed"] {
|
trix-editor & {
|
||||||
lexxy-editor & {
|
|
||||||
display: block;
|
display: block;
|
||||||
inline-size: fit-content;
|
inline-size: fit-content;
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* No selection background — it peeks through the card's rounded corners.
|
.trix-button:not(.trix-button--remove) {
|
||||||
The lexxy-editor prefix out-specifies Lexxy's own selection rule. */
|
@media (any-hover: hover) {
|
||||||
lexxy-editor &.node--selected {
|
&:where(:not(:active):hover) {
|
||||||
--lexxy-attachment-bg-color: transparent;
|
filter: brightness(0.9);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A prominent remove button on the card's top edge, horizontally
|
&:disabled {
|
||||||
centered, like the Trix one. The lexxy-editor prefix out-specifies
|
opacity: 0.33;
|
||||||
Lexxy's own placement rules. */
|
|
||||||
lexxy-editor & lexxy-node-delete-button {
|
|
||||||
inset-block-start: 0;
|
|
||||||
inset-inline-start: 50%;
|
|
||||||
inset-inline-end: auto;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
.lexxy-floating-controls__group {
|
|
||||||
--button-size: 2.2em;
|
|
||||||
|
|
||||||
background-color: var(--color-negative);
|
|
||||||
border-radius: 50%;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lexxy-content {
|
trix-editor,
|
||||||
|
.trix-content {
|
||||||
pre {
|
pre {
|
||||||
background-color: var(--color-bg) !important;
|
background-color: var(--color-bg) !important;
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5em;
|
||||||
@@ -174,13 +179,11 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
|
|||||||
blockquote {
|
blockquote {
|
||||||
border: 0 solid var(--color-border-darker) !important;
|
border: 0 solid var(--color-border-darker) !important;
|
||||||
border-inline-start-width: var(--blockquote-border) !important;
|
border-inline-start-width: var(--blockquote-border) !important;
|
||||||
font-style: normal;
|
|
||||||
margin: 0 var(--blockquote-margin) !important;
|
margin: 0 var(--blockquote-margin) !important;
|
||||||
padding-inline-start: var(--blockquote-padding) !important;
|
padding-inline-start: var(--blockquote-padding) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
cite {
|
cite {
|
||||||
display: block;
|
|
||||||
margin-inline-start: calc(var(--blockquote-padding));
|
margin-inline-start: calc(var(--blockquote-padding));
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -196,14 +199,13 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
|
|||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */
|
ul,
|
||||||
ul:not(.lexxy-prompt-menu),
|
|
||||||
ol {
|
ol {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
li:not(.lexxy-prompt-menu__item) {
|
li {
|
||||||
margin: 0 0 0 var(--inline-space-double) !important;
|
margin: 0 0 0 var(--inline-space-double) !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
action-text-attachment[content-type~='application/vnd.campfire.mention'] {
|
[data-trix-content-type~='application/vnd.campfire.mention'] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -100,42 +100,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer--rich-text {
|
.composer--rich-text {
|
||||||
lexxy-toolbar {
|
trix-toolbar {
|
||||||
display: flex;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
lexxy-editor.input {
|
trix-editor.input {
|
||||||
margin-block-start: var(--block-space-half);
|
margin-block-start: var(--block-space-half);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* In the message composer the toolbar goes below the text, on the same
|
.composer--edit trix-toolbar {
|
||||||
row as the surrounding action buttons, like the Trix toolbar did.
|
|
||||||
The edit composer keeps it on top. */
|
|
||||||
#composer.composer--rich-text lexxy-editor.input {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
/* Sit the toolbar row on the same baseline as the composer's round
|
|
||||||
buttons, which pull themselves down with negative margins */
|
|
||||||
margin-block-end: -0.25em;
|
|
||||||
|
|
||||||
.lexxy-editor__content {
|
|
||||||
order: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
lexxy-toolbar {
|
|
||||||
margin-block-start: 0.4em;
|
|
||||||
|
|
||||||
/* Dropdowns open upward — the toolbar sits at the bottom of the screen */
|
|
||||||
[data-dropdown-panel] {
|
|
||||||
inset-block-start: auto;
|
|
||||||
inset-block-end: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer--edit lexxy-toolbar {
|
|
||||||
border-block-end: 1px solid var(--color-border);
|
border-block-end: 1px solid var(--color-border);
|
||||||
margin-inline: -0.8em;
|
margin-inline: -0.8em;
|
||||||
padding-inline: 0.3em;
|
padding-inline: 0.3em;
|
||||||
@@ -143,16 +117,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The editor's container-type prevents it from sizing the message bubble by
|
|
||||||
its content, so give the bubble the full available width while editing */
|
|
||||||
.message__body:has(.composer--edit) {
|
|
||||||
inline-size: 100%;
|
|
||||||
|
|
||||||
.message__body-content--editing {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typing indicator */
|
/* Typing indicator */
|
||||||
.typing-indicator {
|
.typing-indicator {
|
||||||
inset-block-start: var(--indicator-position, 0.15rem);
|
inset-block-start: var(--indicator-position, 0.15rem);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
trix-editor,
|
||||||
.lexxy-content {
|
.trix-content {
|
||||||
.og-embed {
|
.og-embed {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
border: 1px solid var(--color-border-dark);
|
border: 1px solid var(--color-border-dark);
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.og-embed--twitter-avatar {
|
.cf-twitter-avatar & {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,11 +35,10 @@
|
|||||||
|
|
||||||
.og-embed__title {
|
.og-embed__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
/* Underline the link itself so the line is link-colored */
|
|
||||||
a {
|
a {
|
||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +53,7 @@
|
|||||||
inline-size: 15dvw;
|
inline-size: 15dvw;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* block-size/border-radius guard against Lexxy's 1em round avatar
|
|
||||||
styling for custom attachment images */
|
|
||||||
> * {
|
> * {
|
||||||
block-size: auto;
|
|
||||||
border-radius: 0;
|
|
||||||
object-position: top right;
|
object-position: top right;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -68,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.og-embed--twitter-avatar & {
|
.cf-twitter-avatar & {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
inline-size: 2lh;
|
inline-size: 2lh;
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ img.message__attachment {
|
|||||||
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
|
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
|
||||||
text-shadow: 0 0 3px var(--color-text-reversed);
|
text-shadow: 0 0 3px var(--color-text-reversed);
|
||||||
|
|
||||||
.lexxy-content {
|
.trix-content {
|
||||||
font-size: 2.4em;
|
font-size: 2.4em;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# Prepended onto Turbo::StreamsChannel. The subscriber names the channel it wants, so
|
|
||||||
# authorizing room messages only in RoomMessagesChannel would leave the stock channel as
|
|
||||||
# a way around it: same signed stream name, no membership check. Turn those names away
|
|
||||||
# here and RoomMessagesChannel becomes the only door.
|
|
||||||
module RoomStreamsAreAuthorized
|
|
||||||
def subscribed
|
|
||||||
if RoomMessagesChannel.guarded_stream?(verified_stream_name_from_params)
|
|
||||||
reject
|
|
||||||
else
|
|
||||||
super
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Authorizes the room message stream when the subscription is made, so that revoking a
|
|
||||||
# membership actually stops delivery.
|
|
||||||
#
|
|
||||||
# Turbo's stock channel verifies only the signature on the stream name. That name carries
|
|
||||||
# no expiry and no binding to a user, so one harvested while a member keeps working after
|
|
||||||
# the membership is gone. Reconnecting makes it worse rather than better: revoking a
|
|
||||||
# membership disconnects the user with reconnect: true, and the client then replays its
|
|
||||||
# subscriptions on the fresh socket.
|
|
||||||
#
|
|
||||||
# The room is derived from the verified stream name rather than taken as a parameter, so
|
|
||||||
# there's nothing for a subscriber to point somewhere else. The subscriber also doesn't
|
|
||||||
# get to choose the channel: Turbo::StreamsChannel turns these stream names away, so this
|
|
||||||
# is the only way onto them. See config/initializers/turbo_streams_authorization.rb.
|
|
||||||
class RoomMessagesChannel < ApplicationCable::Channel
|
|
||||||
extend Turbo::Streams::StreamName
|
|
||||||
include Turbo::Streams::StreamName::ClassMethods
|
|
||||||
|
|
||||||
STREAM_SUFFIX = "messages"
|
|
||||||
|
|
||||||
class << self
|
|
||||||
# True for the stream names this channel exists to guard, whoever is asking.
|
|
||||||
def guarded_stream?(stream_name)
|
|
||||||
stream_name.to_s.split(":", 2).second == STREAM_SUFFIX
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribable_room(user, stream_name)
|
|
||||||
gid_param, suffix = stream_name.to_s.split(":", 2)
|
|
||||||
|
|
||||||
if suffix == STREAM_SUFFIX && room = room_from(gid_param)
|
|
||||||
user.rooms.find_by(id: room.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def room_from(gid_param)
|
|
||||||
GlobalID::Locator.locate gid_param, only: Room
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribed
|
|
||||||
if stream_name = authorized_stream_name
|
|
||||||
stream_from stream_name
|
|
||||||
else
|
|
||||||
reject
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def authorized_stream_name
|
|
||||||
stream_name = verified_stream_name_from_params
|
|
||||||
stream_name if stream_name.present? && self.class.subscribable_room(current_user, stream_name)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
class UnreadRoomsChannel < ApplicationCable::Channel
|
class UnreadRoomsChannel < ApplicationCable::Channel
|
||||||
# Scoped per user, like ReadRoomsChannel. A single global stream would tell every
|
|
||||||
# authenticated connection when any room on the account is active, including closed
|
|
||||||
# rooms and direct conversations they're not part of.
|
|
||||||
def self.stream_name_for(user_id)
|
|
||||||
"user_#{user_id}_unreads"
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_from self.class.stream_name_for(current_user.id)
|
stream_from "unread_rooms"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -8,8 +8,9 @@ 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 (logo_variant = Current.account&.logo_variant(logo_size))
|
if Current.account&.logo&.attached?
|
||||||
send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
|
logo = Current.account.logo.variant(logo_variant).processed
|
||||||
|
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
|
||||||
else
|
else
|
||||||
send_stock_icon
|
send_stock_icon
|
||||||
end
|
end
|
||||||
@@ -22,6 +23,9 @@ 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
|
||||||
@@ -34,8 +38,8 @@ class Accounts::LogosController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def logo_size
|
def logo_variant
|
||||||
small_logo? ? :small : :large
|
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
|
||||||
end
|
end
|
||||||
|
|
||||||
def small_logo?
|
def small_logo?
|
||||||
|
|||||||
@@ -1,26 +1,11 @@
|
|||||||
class Autocompletable::UsersController < ApplicationController
|
class Autocompletable::UsersController < ApplicationController
|
||||||
def index
|
def index
|
||||||
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
|
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
format.html { render layout: false } # <lexxy-prompt-item> elements for the mentions prompt
|
|
||||||
format.json
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def find_autocompletable_users
|
def find_autocompletable_users
|
||||||
if query.present?
|
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
|
||||||
users_scope.active.filtered_by(query)
|
|
||||||
else
|
|
||||||
users_scope.active
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# The rich text editor's mentions prompt filters with `filter`, the
|
|
||||||
# autocomplete inputs with `query`
|
|
||||||
def query
|
|
||||||
params[:filter].presence || params[:query].presence
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def users_scope
|
def users_scope
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
module ActiveStorageAuthentication
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
include Authentication::SessionLookup
|
|
||||||
|
|
||||||
private
|
|
||||||
def require_active_storage_authentication
|
|
||||||
head :unauthorized unless find_session_by_cookie
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -71,13 +71,6 @@ module Authentication
|
|||||||
Current.session&.destroy!
|
Current.session&.destroy!
|
||||||
reset_session
|
reset_session
|
||||||
remove_authentication_cookie
|
remove_authentication_cookie
|
||||||
disconnect_remote_connections
|
|
||||||
end
|
|
||||||
|
|
||||||
def disconnect_remote_connections
|
|
||||||
Current.user&.reset_remote_connections
|
|
||||||
rescue => error
|
|
||||||
Rails.logger.warn "Could not disconnect remote connections on sign out: #{error.class}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticated_as(session)
|
def authenticated_as(session)
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
module RawRequestBody
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def raw_request_body
|
|
||||||
request.body.rewind
|
|
||||||
request.body.read.force_encoding("UTF-8")
|
|
||||||
ensure
|
|
||||||
request.body.rewind
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
class Messages::Boosts::ByBotsController < Messages::BoostsController
|
|
||||||
include RawRequestBody
|
|
||||||
|
|
||||||
allow_bot_access only: %i[ create destroy ]
|
|
||||||
|
|
||||||
before_action :ensure_content_present, only: :create
|
|
||||||
|
|
||||||
def create
|
|
||||||
@boost = @message.boosts.create!(boost_params)
|
|
||||||
|
|
||||||
broadcast_create
|
|
||||||
render :show, status: :created
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def set_message
|
|
||||||
if room = Current.user.rooms.find_by(id: params[:room_id])
|
|
||||||
@message = room.messages.find_by(id: params[:message_id])
|
|
||||||
end
|
|
||||||
|
|
||||||
head :not_found unless @message
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_boost
|
|
||||||
super
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
head :not_found
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_content_present
|
|
||||||
if raw_request_body.blank?
|
|
||||||
head :unprocessable_content
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def boost_params
|
|
||||||
{ content: raw_request_body }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
class Messages::BoostsController < ApplicationController
|
class Messages::BoostsController < ApplicationController
|
||||||
before_action :set_message
|
before_action :set_message
|
||||||
before_action :set_boost, only: :destroy
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
end
|
end
|
||||||
@@ -16,6 +15,7 @@ class Messages::BoostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
@boost = Current.user.boosts.find(params[:id])
|
||||||
@boost.destroy!
|
@boost.destroy!
|
||||||
|
|
||||||
broadcast_remove
|
broadcast_remove
|
||||||
@@ -26,10 +26,6 @@ class Messages::BoostsController < ApplicationController
|
|||||||
@message = Current.user.reachable_messages.find(params[:message_id])
|
@message = Current.user.reachable_messages.find(params[:message_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_boost
|
|
||||||
@boost = @message.boosts.find_by!(id: params[:id], booster: Current.user)
|
|
||||||
end
|
|
||||||
|
|
||||||
def boost_params
|
def boost_params
|
||||||
params.require(:boost).permit(:content)
|
params.require(:boost).permit(:content)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,64 +1,24 @@
|
|||||||
class Messages::ByBotsController < MessagesController
|
class Messages::ByBotsController < MessagesController
|
||||||
include RawRequestBody
|
allow_bot_access only: :create
|
||||||
|
|
||||||
allow_bot_access only: %i[ index create update destroy ]
|
|
||||||
|
|
||||||
before_action :set_room
|
|
||||||
before_action :set_message, only: %i[ update destroy ]
|
|
||||||
before_action :ensure_can_administer, only: %i[ update destroy ]
|
|
||||||
before_action :ensure_body_or_attachment_present, only: :create
|
|
||||||
|
|
||||||
def index
|
|
||||||
@messages = find_paged_messages
|
|
||||||
set_pagination_headers
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
def create
|
||||||
super
|
super
|
||||||
head :created, location: message_url(@message)
|
head :created, location: message_url(@message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
|
||||||
super
|
|
||||||
head :no_content
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
|
||||||
@room = Current.user.rooms.find_by(id: params[:room_id])
|
|
||||||
|
|
||||||
head :not_found unless @room
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_body_or_attachment_present
|
|
||||||
if params[:attachment].blank? && raw_request_body.blank?
|
|
||||||
head :unprocessable_content
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_pagination_headers
|
|
||||||
headers["X-Total-Count"] = @room.messages.count.to_s
|
|
||||||
|
|
||||||
if next_page = next_page_params
|
|
||||||
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def next_page_params
|
|
||||||
if @messages.any?
|
|
||||||
if params[:after].present?
|
|
||||||
{ after: @messages.last.id } if @room.messages.after(@messages.last).exists?
|
|
||||||
else
|
|
||||||
{ before: @messages.first.id } if @room.messages.before(@messages.first).exists?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_params
|
def message_params
|
||||||
if params[:attachment]
|
if params[:attachment]
|
||||||
params.permit(:attachment)
|
params.permit(:attachment)
|
||||||
else
|
else
|
||||||
{ body: raw_request_body }
|
reading(request.body) { |body| { body: body } }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reading(io)
|
||||||
|
io.rewind
|
||||||
|
yield io.read.force_encoding("UTF-8")
|
||||||
|
ensure
|
||||||
|
io.rewind
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -37,11 +37,7 @@ class MessagesController < ApplicationController
|
|||||||
@message.update!(message_params)
|
@message.update!(message_params)
|
||||||
|
|
||||||
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
|
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
|
||||||
|
redirect_to room_message_url(@room, @message)
|
||||||
respond_to do |format|
|
|
||||||
format.html { redirect_to room_message_url(@room, @message) }
|
|
||||||
format.json { render :show }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|||||||
@@ -42,12 +42,6 @@ class Rooms::ClosedsController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Closed)
|
@room = @room.becomes!(Rooms::Closed)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
|
||||||
# rooms never are: converting one would let its creator revise who's in it.
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms.without_directs
|
|
||||||
end
|
|
||||||
|
|
||||||
def grantees
|
def grantees
|
||||||
User.where(id: grantee_ids)
|
User.where(id: grantee_ids)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,13 +29,8 @@ class Rooms::DirectsController < RoomsController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# All users in a direct room can administer it. Only direct rooms, though: this
|
# All users in a direct room can administer it
|
||||||
# relaxation is why room_scope below has to keep every other type out of reach.
|
|
||||||
def ensure_can_administer
|
def ensure_can_administer
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms.directs
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -40,12 +40,6 @@ class Rooms::OpensController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Open)
|
@room = @room.becomes!(Rooms::Open)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
|
||||||
# rooms never are: promoting one would republish its history to the whole account.
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms.without_directs
|
|
||||||
end
|
|
||||||
|
|
||||||
def broadcast_create_room(room)
|
def broadcast_create_room(room)
|
||||||
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,19 +20,13 @@ class RoomsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
def set_room
|
||||||
if room = room_scope.find_by(id: params[:room_id] || params[:id])
|
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
|
||||||
@room = room
|
@room = room
|
||||||
else
|
else
|
||||||
redirect_to root_url, alert: "Room not found or inaccessible"
|
redirect_to root_url, alert: "Room not found or inaccessible"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Subclasses narrow this to the room types they're allowed to act on, so that one
|
|
||||||
# room namespace can't be used to reach another's rooms.
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_can_administer
|
def ensure_can_administer
|
||||||
head :forbidden unless Current.user.can_administer?(@room)
|
head :forbidden unless Current.user.can_administer?(@room)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ 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 (avatar_variant = @user.avatar_variant)
|
if @user.avatar.attached?
|
||||||
|
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
|
||||||
@@ -25,6 +26,8 @@ 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
|
||||||
|
|||||||
@@ -6,17 +6,12 @@ class Users::PushSubscriptionsController < ApplicationController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
if subscription = @push_subscriptions.find_by(push_subscription_params)
|
if subscription = @push_subscriptions.find_by(push_subscription_params)
|
||||||
# Existing endpoints must pass current validations
|
|
||||||
if subscription.valid?
|
|
||||||
subscription.touch
|
subscription.touch
|
||||||
|
else
|
||||||
|
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
|
||||||
|
end
|
||||||
|
|
||||||
head :ok
|
head :ok
|
||||||
else
|
|
||||||
head :unprocessable_entity
|
|
||||||
end
|
|
||||||
else
|
|
||||||
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
|
|
||||||
head subscription.persisted? ? :ok : :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
module ContentFilters
|
module ContentFilters
|
||||||
# Formatting the rich text editor produces that Rails' sanitizers don't
|
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
|
||||||
# allow by default. A message passes through three sanitization layers on
|
|
||||||
# its way to the screen, and each builds on this list:
|
|
||||||
#
|
|
||||||
# * SanitizeTags strips disallowed markup from the message body
|
|
||||||
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
|
|
||||||
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
|
|
||||||
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
|
|
||||||
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
|
|
||||||
|
|
||||||
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, SanitizeTags, SanitizeAttributes)
|
|
||||||
end
|
end
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
||||||
TWITTER_DOMAINS = %w[ x.com twitter.com ]
|
|
||||||
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
|
|
||||||
|
|
||||||
def applicable?
|
def applicable?
|
||||||
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
|
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply
|
def apply
|
||||||
if trix_body?
|
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
||||||
remove_link_text_from_wrapping_div
|
|
||||||
else
|
|
||||||
remove_link_paragraphs
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
TWITTER_DOMAINS = %w[ x.com twitter.com ]
|
||||||
|
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
|
||||||
|
|
||||||
def solo_unfurled_url
|
def solo_unfurled_url
|
||||||
ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1
|
unfurled_links.first["href"] if unfurled_links.size == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
def unfurled_links
|
def unfurled_links
|
||||||
@@ -39,20 +35,4 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
|||||||
def twitter_url?(url)
|
def twitter_url?(url)
|
||||||
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
|
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def trix_body?
|
|
||||||
fragment.find_all("div").any?
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_link_text_from_wrapping_div
|
|
||||||
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_link_paragraphs
|
|
||||||
fragment.replace("p") do |node|
|
|
||||||
if node.at_css("action-text-attachment")
|
|
||||||
node
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
class ContentFilters::SanitizeAttributes < ActionText::Content::Filter
|
|
||||||
def applicable?
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
# Scrub attributes on the tags that SanitizeTags allows, using Rails' safe-list
|
|
||||||
# sanitizer so unsafe URI schemes and event-handler attributes are stripped.
|
|
||||||
# Runs after SanitizeTags, so passing the same allowed tags makes the tag pass
|
|
||||||
# a no-op and only attributes are scrubbed.
|
|
||||||
def apply
|
|
||||||
sanitizer.sanitize fragment.to_html, tags: allowed_tags, attributes: allowed_attributes
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
# Presentation styling relies on class attributes (e.g. unfurled link embeds),
|
|
||||||
# which the standard ActionText set doesn't include.
|
|
||||||
EXTRA_ALLOWED_ATTRIBUTES = %w[ class ]
|
|
||||||
|
|
||||||
# ActionText::ContentHelper.sanitizer is a single process-wide instance, and
|
|
||||||
# rails-html-sanitizer reuses one mutable permit scrubber that it reconfigures
|
|
||||||
# from the tags/attributes on every #sanitize call. Sharing it here would race
|
|
||||||
# our stricter tag set against ActionText's default rendering on concurrent
|
|
||||||
# requests, so use a dedicated instance of the same sanitizer class to keep the
|
|
||||||
# scrubber isolated.
|
|
||||||
def sanitizer
|
|
||||||
sanitizer_class.new
|
|
||||||
end
|
|
||||||
|
|
||||||
def sanitizer_class
|
|
||||||
ActionText::ContentHelper.sanitizer.class
|
|
||||||
end
|
|
||||||
|
|
||||||
def allowed_tags
|
|
||||||
ContentFilters::SanitizeTags::ALLOWED_TAGS
|
|
||||||
end
|
|
||||||
|
|
||||||
def allowed_attributes
|
|
||||||
standard_allowed_attributes + EXTRA_ALLOWED_ATTRIBUTES
|
|
||||||
end
|
|
||||||
|
|
||||||
# Mirrors ActionText::ContentHelper#sanitizer_allowed_attributes, which isn't
|
|
||||||
# exposed at the module level.
|
|
||||||
def standard_allowed_attributes
|
|
||||||
ActionText::ContentHelper.allowed_attributes ||
|
|
||||||
(sanitizer_class.allowed_attributes + ActionText::Attachment::ATTRIBUTES).to_a
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
||||||
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
|
|
||||||
p pre samp small span strong sub sup time tt ul var ] + ContentFilters::EDITOR_FORMATTING_TAGS +
|
|
||||||
[ ActionText::Attachment.tag_name, "figure", "figcaption" ]
|
|
||||||
|
|
||||||
def applicable?
|
def applicable?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
@@ -12,6 +8,9 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
|
||||||
|
p pre samp small span strong sub sup time tt ul var ] + [ ActionText::Attachment.tag_name, "figure", "figcaption" ]
|
||||||
|
|
||||||
def not_allowed_tags_css_selector
|
def not_allowed_tags_css_selector
|
||||||
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
|
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
class ContentFilters::StyleUnfurledTwitterAvatars < ActionText::Content::Filter
|
||||||
|
def applicable?
|
||||||
|
unfurled_twitter_avatars.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply
|
||||||
|
fragment.update do |source|
|
||||||
|
div = source.at_css("div")
|
||||||
|
div["class"] = UNFURLED_TWITTER_AVATAR_CSS_CLASS
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
|
||||||
|
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
||||||
|
|
||||||
|
def unfurled_twitter_avatars
|
||||||
|
fragment.find_all("#{opengraph_css_selector}[url*='#{TWITTER_AVATAR_URL_PREFIX}']")
|
||||||
|
end
|
||||||
|
|
||||||
|
def opengraph_css_selector
|
||||||
|
"action-text-attachment[@content-type='#{ActionText::Attachment::OpengraphEmbed::OPENGRAPH_EMBED_CONTENT_TYPE}']"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
module MessagesHelper
|
module MessagesHelper
|
||||||
# auto_link re-sanitizes with Rails' default safe list, which lacks the
|
|
||||||
# formatting the rich text editor produces
|
|
||||||
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
|
|
||||||
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
|
|
||||||
|
|
||||||
def message_area_tag(room, &)
|
def message_area_tag(room, &)
|
||||||
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
||||||
controller: "messages presence drop-target",
|
controller: "messages presence drop-target",
|
||||||
@@ -62,8 +57,7 @@ module MessagesHelper
|
|||||||
when "sound"
|
when "sound"
|
||||||
message_sound_presentation(message)
|
message_sound_presentation(message)
|
||||||
else
|
else
|
||||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
|
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
|
||||||
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
|
|
||||||
end
|
end
|
||||||
rescue Exception => e
|
rescue Exception => e
|
||||||
Sentry.capture_exception(e, extra: { message: message })
|
Sentry.capture_exception(e, extra: { message: message })
|
||||||
|
|||||||
@@ -1,33 +1,11 @@
|
|||||||
module RichTextHelper
|
module RichTextHelper
|
||||||
def rich_text_data_actions
|
def rich_text_data_actions
|
||||||
# submitByKeyboard runs in the capture phase so it can submit on Enter
|
default_actions =
|
||||||
# before the editor turns the keystroke into a newline
|
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
|
||||||
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
|
|
||||||
end
|
|
||||||
|
|
||||||
def mention_prompt_tag(room)
|
autocomplete_actions =
|
||||||
tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id),
|
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
|
||||||
"remote-filtering": true, "empty-results": "No matches"
|
|
||||||
end
|
|
||||||
|
|
||||||
# The editor keeps an attachment's content as it finds it, so every
|
[ default_actions, autocomplete_actions ].join(" ")
|
||||||
# attachment is rebuilt from its attachable before editing: a Trix-era
|
|
||||||
# embed carries its details as node attributes the editor doesn't
|
|
||||||
# round-trip, a mention edited under Trix carries the generic content type
|
|
||||||
# the editor doesn't permit, and a hand-written embed carries whatever
|
|
||||||
# markup the author put there.
|
|
||||||
def editable_body(message)
|
|
||||||
fragment = ActionText::Fragment.wrap(message.body.body_before_type_cast)
|
|
||||||
|
|
||||||
transformed = fragment.replace(ActionText::Attachment.tag_name) do |node|
|
|
||||||
attachment = ActionText::Attachment.from_node(node)
|
|
||||||
|
|
||||||
node.tap do |n|
|
|
||||||
n["content-type"] = attachment.attachable.attachable_content_type
|
|
||||||
n["content"] = render_action_text_attachment(attachment)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
ActionText::RichText.new(body: transformed.to_html)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -73,12 +73,12 @@ module RoomsHelper
|
|||||||
def composer_data_actions
|
def composer_data_actions
|
||||||
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
|
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
|
||||||
|
|
||||||
attachment_actions =
|
trix_attachment_actions =
|
||||||
"lexxy:file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
||||||
|
|
||||||
remaining_actions =
|
remaining_actions =
|
||||||
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
|
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
|
||||||
|
|
||||||
[ drop_target_actions, drag_and_drop_actions, attachment_actions, remaining_actions ].join(" ")
|
[ drop_target_actions, drag_and_drop_actions, trix_attachment_actions, remaining_actions ].join(" ")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||||
import "@hotwired/turbo-rails"
|
import "@hotwired/turbo-rails"
|
||||||
|
import "trix"
|
||||||
|
import "@rails/actiontext"
|
||||||
import "initializers"
|
import "initializers"
|
||||||
import "controllers"
|
import "controllers"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
import FileUploader from "models/file_uploader"
|
import FileUploader from "models/file_uploader"
|
||||||
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
|
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
|
||||||
import { escapeHTML } from "helpers/string_helpers"
|
import { escapeHTML } from "helpers/dom_helpers"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static classes = ["toolbar"]
|
static classes = ["toolbar"]
|
||||||
@@ -12,21 +12,11 @@ export default class extends Controller {
|
|||||||
#files = []
|
#files = []
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.#restoreDraft()
|
|
||||||
|
|
||||||
if (!this.#usingTouchDevice) {
|
if (!this.#usingTouchDevice) {
|
||||||
onNextEventLoopTick(() => this.textTarget.focus())
|
onNextEventLoopTick(() => this.textTarget.focus())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDraft() {
|
|
||||||
if (this.textTarget.isBlank) {
|
|
||||||
localStorage.removeItem(this.#draftKey)
|
|
||||||
} else {
|
|
||||||
localStorage.setItem(this.#draftKey, this.textTarget.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(event) {
|
submit(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
@@ -54,20 +44,21 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceMessageContent(content) {
|
replaceMessageContent(content) {
|
||||||
this.textTarget.value = content
|
const editor = this.textTarget.editor
|
||||||
this.textTarget.focus()
|
|
||||||
this.textTarget.selection.placeCursorAtTheEnd()
|
editor.recordUndoEntry("Format reply")
|
||||||
|
editor.setSelectedRange([0, editor.getDocument().toString().length])
|
||||||
|
editor.deleteInDirection("forward")
|
||||||
|
editor.insertHTML(content)
|
||||||
|
editor.setSelectedRange([editor.getDocument().toString().length - 1])
|
||||||
}
|
}
|
||||||
|
|
||||||
submitByKeyboard(event) {
|
submitByKeyboard(event) {
|
||||||
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
|
|
||||||
|
|
||||||
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
|
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
|
||||||
const metaEnter = event.metaKey || event.ctrlKey
|
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
|
||||||
const plainEnter = !event.shiftKey && !event.isComposing
|
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
|
||||||
|
|
||||||
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
||||||
event.stopPropagation()
|
|
||||||
this.submit(event)
|
this.submit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,19 +108,6 @@ export default class extends Controller {
|
|||||||
this.fieldsTarget.disabled = true
|
this.fieldsTarget.disabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
#restoreDraft() {
|
|
||||||
const draft = localStorage.getItem(this.#draftKey)
|
|
||||||
|
|
||||||
if (draft) {
|
|
||||||
this.textTarget.value = draft
|
|
||||||
this.textTarget.selection.placeCursorAtTheEnd()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get #draftKey() {
|
|
||||||
return `composer-draft-${this.roomIdValue}`
|
|
||||||
}
|
|
||||||
|
|
||||||
get #usingTouchDevice() {
|
get #usingTouchDevice() {
|
||||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||||||
}
|
}
|
||||||
@@ -148,7 +126,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#validInput() {
|
#validInput() {
|
||||||
return !this.textTarget.isBlank
|
return this.textTarget.textContent.trim().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async #submitFiles() {
|
async #submitFiles() {
|
||||||
@@ -181,7 +159,6 @@ export default class extends Controller {
|
|||||||
|
|
||||||
#reset() {
|
#reset() {
|
||||||
this.textTarget.value = ""
|
this.textTarget.value = ""
|
||||||
localStorage.removeItem(this.#draftKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateFileList() {
|
#updateFileList() {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async editMyLastMessage() {
|
async editMyLastMessage() {
|
||||||
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
|
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
|
||||||
|
|
||||||
if (editorEmpty && this.#paginator.upToDate) {
|
if (editorEmpty && this.#paginator.upToDate) {
|
||||||
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reply() {
|
reply() {
|
||||||
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><p><br></p>`
|
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><br>`
|
||||||
this.composerOutlet.replaceMessageContent(content)
|
this.composerOutlet.replaceMessageContent(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get #bodyContent() {
|
get #bodyContent() {
|
||||||
const body = this.bodyTarget.querySelector(".lexxy-content").cloneNode(true)
|
const body = this.bodyTarget.querySelector(".trix-content").cloneNode(true)
|
||||||
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
|
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import MentionsAutocompleteHandler from "lib/autocomplete/mentions_autocomplete_handler"
|
||||||
|
import { debounce } from "helpers/timing_helpers"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { url: String }
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
this.handlers = []
|
||||||
|
this.search = debounce(this.search.bind(this), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.element == document.activeElement) {
|
||||||
|
this.#installHandlers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(event) {
|
||||||
|
this.#installHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
search(event) {
|
||||||
|
const content = this.editor.getDocument().toString()
|
||||||
|
const position = this.editor.getPosition()
|
||||||
|
this.handlers.forEach(handler => handler.updateWithContentAndPosition(content, position))
|
||||||
|
}
|
||||||
|
|
||||||
|
blur(event) {
|
||||||
|
this.#uninstallHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
#installHandlers() {
|
||||||
|
this.#uninstallHandlers()
|
||||||
|
this.handlers = [ new MentionsAutocompleteHandler(this.element, this.urlValue) ]
|
||||||
|
}
|
||||||
|
|
||||||
|
#uninstallHandlers() {
|
||||||
|
this.handlers.forEach(handler => handler.destroy())
|
||||||
|
this.handlers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
get editor() {
|
||||||
|
return this.element.editor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
import { post } from "@rails/request.js"
|
|
||||||
import { escapeHTML, truncateString } from "helpers/string_helpers"
|
|
||||||
|
|
||||||
const OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
|
|
||||||
|
|
||||||
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "og-embed--twitter-avatar"
|
|
||||||
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
#abortController
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.#abortController?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
async unfurl(event) {
|
|
||||||
if (!this.element.permitsAttachmentContentType(OPENGRAPH_EMBED_CONTENT_TYPE)) return
|
|
||||||
|
|
||||||
const { url, insertBelowLink } = event.detail
|
|
||||||
|
|
||||||
const metadata = await this.#fetchOpengraphMetadata(url)
|
|
||||||
|
|
||||||
if (metadata) {
|
|
||||||
insertBelowLink(this.#opengraphEmbedHTML(metadata), { attachment: { contentType: OPENGRAPH_EMBED_CONTENT_TYPE } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #fetchOpengraphMetadata(url) {
|
|
||||||
this.#abortController?.abort()
|
|
||||||
this.#abortController = new AbortController()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await post("/unfurl_link", {
|
|
||||||
body: { url },
|
|
||||||
contentType: "application/json",
|
|
||||||
signal: this.#abortController.signal
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok && response.statusCode !== 204) {
|
|
||||||
const { title, url: href, image, description } = await response.json
|
|
||||||
if (title && href) return { title, href, image, description }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore aborted and failed requests
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
#opengraphEmbedHTML({ title, href, image, description }) {
|
|
||||||
return `<actiontext-opengraph-embed>
|
|
||||||
<div class="og-embed gap ${this.#embedClass(image)}">
|
|
||||||
<div class="og-embed__content">
|
|
||||||
<div class="og-embed__title">
|
|
||||||
<a href="${escapeHTML(href)}" rel="noreferrer" target="_blank">${escapeHTML(truncateString(title, 280))}</a>
|
|
||||||
</div>
|
|
||||||
<div class="og-embed__description">${escapeHTML(truncateString(description, 560))}</div>
|
|
||||||
</div>
|
|
||||||
${this.#imageHTML(image)}
|
|
||||||
</div>
|
|
||||||
</actiontext-opengraph-embed>`
|
|
||||||
}
|
|
||||||
|
|
||||||
#embedClass(image) {
|
|
||||||
if (this.#isTwitterAvatar(image)) {
|
|
||||||
return UNFURLED_TWITTER_AVATAR_CSS_CLASS
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#isTwitterAvatar(image) {
|
|
||||||
return Boolean(image) && image.startsWith(TWITTER_AVATAR_URL_PREFIX)
|
|
||||||
}
|
|
||||||
|
|
||||||
#imageHTML(image) {
|
|
||||||
if (image) {
|
|
||||||
return `<div class="og-embed__image"><img src="${escapeHTML(image)}" class="image center" alt="" /></div>`
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,12 @@ export function scrollToBottom(container) {
|
|||||||
container.scrollTop = container.scrollHeight
|
container.scrollTop = container.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function escapeHTML(html) {
|
||||||
|
const div = document.createElement("div")
|
||||||
|
div.textContent = html
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
export function parseHTMLFragment(html) {
|
export function parseHTMLFragment(html) {
|
||||||
const template = document.createElement("template")
|
const template = document.createElement("template")
|
||||||
template.innerHTML = html
|
template.innerHTML = html
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
const HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }
|
|
||||||
|
|
||||||
export function truncateString(string, length, omission = "…") {
|
export function truncateString(string, length, omission = "…") {
|
||||||
if (string.length <= length) {
|
if (string.length <= length) {
|
||||||
return string
|
return string
|
||||||
@@ -7,7 +5,3 @@ export function truncateString(string, length, omission = "…") {
|
|||||||
return string.slice(0, length - omission.length) + omission
|
return string.slice(0, length - omission.length) + omission
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeHTML(string) {
|
|
||||||
return String(string ?? "").replace(/[&<>"']/g, character => HTML_ESCAPES[character])
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
import * as Lexxy from "lexxy"
|
import Unfurler from "lib/rich_text/unfurl/unfurler"
|
||||||
import CampfireRichTextExtension from "lib/rich_text/campfire_extension"
|
|
||||||
|
|
||||||
Lexxy.configure({
|
// Support a `cite` block for attribution links
|
||||||
global: {
|
Trix.config.blockAttributes.cite = {
|
||||||
// Keep the content type of mention attachments (application/vnd.campfire.mention)
|
tagName: "cite",
|
||||||
// that Campfire has used since its Trix days
|
inheritable: false,
|
||||||
attachmentContentTypeNamespace: "campfire",
|
}
|
||||||
extensions: [ CampfireRichTextExtension ]
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
// Campfire sends files as separate messages via the composer's own
|
|
||||||
// attach button, never through the editor
|
|
||||||
toolbar: { attachments: false },
|
|
||||||
|
|
||||||
// Trix offered a single heading level, rendered as h1
|
const unfurler = new Unfurler()
|
||||||
headings: [ "h1" ]
|
unfurler.install()
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
|
||||||
|
import { PUNCTUATION_PATTERN } from "lib/autocomplete/constants"
|
||||||
|
|
||||||
|
export default class extends BaseAutocompleteHandler {
|
||||||
|
get pattern() {
|
||||||
|
return new RegExp(`^@(.*?)(${PUNCTUATION_PATTERN.source}*)$`)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAutocompletable(autocompletable, range, terminator, options = {}) {
|
||||||
|
const attachment = this.#createAttachmentForAutocompletable(autocompletable)
|
||||||
|
this.#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override to set selector's position relative to the cursor in the editor
|
||||||
|
getOffsetsAtPosition(position) {
|
||||||
|
return this.#getOffsetsFromEditorAtPosition(this.#editor, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
#createAttachmentForAutocompletable(mentionable) {
|
||||||
|
const mention = `
|
||||||
|
<span class="mention" sgid=${mentionable.sgid}>
|
||||||
|
<img src="${mentionable.avatar_url}" class="avatar" alt="${mentionable.name}">
|
||||||
|
${mentionable.name}
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
|
||||||
|
return new Trix.Attachment({
|
||||||
|
content: mention,
|
||||||
|
contentType: "application/vnd.campfire.mention",
|
||||||
|
sgid: mentionable.sgid
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range) {
|
||||||
|
if (range) { this.#editor.setSelectedRange(range) }
|
||||||
|
this.#editor.insertAttachment(attachment)
|
||||||
|
this.#editor.insertString(terminator)
|
||||||
|
}
|
||||||
|
|
||||||
|
get #editor() {
|
||||||
|
return this.element.editor
|
||||||
|
}
|
||||||
|
|
||||||
|
#getOffsetsFromEditorAtPosition(editor, position) {
|
||||||
|
const rect = this.#editor.getClientRectAtPosition(position)
|
||||||
|
return rect ? rect : {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import * as Lexxy from "lexxy"
|
|
||||||
import CiteNode from "lib/rich_text/cite_node"
|
|
||||||
|
|
||||||
export default class CampfireRichTextExtension extends Lexxy.Extension {
|
|
||||||
get allowedElements() {
|
|
||||||
return [
|
|
||||||
"cite",
|
|
||||||
"figure",
|
|
||||||
"figcaption",
|
|
||||||
"actiontext-opengraph-embed",
|
|
||||||
{ tag: "div", attributes: [ "sgid" ] },
|
|
||||||
{ tag: "span", attributes: [ "sgid" ] },
|
|
||||||
{ tag: "img", attributes: [ "alt" ] },
|
|
||||||
{ tag: "a", attributes: [ "rel", "target" ] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
get lexicalExtension() {
|
|
||||||
return this.defineExtension({
|
|
||||||
name: "campfire/rich-text",
|
|
||||||
nodes: [ CiteNode ]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import * as Lexxy from "lexxy"
|
|
||||||
|
|
||||||
const { ElementNode } = Lexxy.Lexical
|
|
||||||
|
|
||||||
export default class CiteNode extends ElementNode {
|
|
||||||
static getType() {
|
|
||||||
return "cite"
|
|
||||||
}
|
|
||||||
|
|
||||||
static clone(node) {
|
|
||||||
return new CiteNode(node.__key)
|
|
||||||
}
|
|
||||||
|
|
||||||
static importJSON(serializedNode) {
|
|
||||||
return new CiteNode().updateFromJSON(serializedNode)
|
|
||||||
}
|
|
||||||
|
|
||||||
static importDOM() {
|
|
||||||
return {
|
|
||||||
cite: () => ({ conversion: () => ({ node: new CiteNode() }), priority: 1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exportJSON() {
|
|
||||||
return { ...super.exportJSON(), type: "cite" }
|
|
||||||
}
|
|
||||||
|
|
||||||
createDOM() {
|
|
||||||
return document.createElement("cite")
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDOM() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
exportDOM() {
|
|
||||||
return { element: document.createElement("cite") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { post } from "@rails/request.js"
|
||||||
|
import { truncateString } from "helpers/string_helpers"
|
||||||
|
|
||||||
|
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
|
||||||
|
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
||||||
|
|
||||||
|
export default class OpengraphEmbedOperation {
|
||||||
|
constructor(paste) {
|
||||||
|
this.paste = paste
|
||||||
|
this.editor = this.paste.editor
|
||||||
|
this.url = this.paste.string
|
||||||
|
|
||||||
|
this.abortController = new AbortController()
|
||||||
|
}
|
||||||
|
|
||||||
|
perform() {
|
||||||
|
return this.#createOpenGraphMetadataRequest()
|
||||||
|
.then(response => response.json)
|
||||||
|
.then(this.#insertOpengraphAttachment.bind(this))
|
||||||
|
.catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
this.abortController.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
#createOpenGraphMetadataRequest() {
|
||||||
|
return post("/unfurl_link", {
|
||||||
|
body: { url: this.url },
|
||||||
|
contentType: "application/json",
|
||||||
|
signal: this.abortController.signal
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#insertOpengraphAttachment(response) {
|
||||||
|
if (this.#shouldInsertOpengraphPreview) {
|
||||||
|
const currentRange = this.editor.getSelectedRange()
|
||||||
|
this.editor.setSelectedRange(this.editor.getSelectedRange())
|
||||||
|
this.editor.recordUndoEntry("Insert Opengraph preview for Pasted URL")
|
||||||
|
this.editor.insertAttachment(this.#createOpengraphAttachment(response))
|
||||||
|
this.editor.setSelectedRange(currentRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get #shouldInsertOpengraphPreview() {
|
||||||
|
return this.editor.getDocument().toString().includes(this.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#createOpengraphAttachment(response) {
|
||||||
|
const { title, url, image, description } = response
|
||||||
|
const html = this.#generateOpengraphEmbedHTML({ title, url, image, description })
|
||||||
|
|
||||||
|
return new Trix.Attachment({
|
||||||
|
contentType: "application/vnd.actiontext.opengraph-embed",
|
||||||
|
content: html,
|
||||||
|
filename: title,
|
||||||
|
href: url,
|
||||||
|
url: image,
|
||||||
|
caption: description
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#generateOpengraphEmbedHTML(embed) {
|
||||||
|
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(embed) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ''}">
|
||||||
|
<div class="og-embed">
|
||||||
|
<div class="og-embed__content">
|
||||||
|
<div class="og-embed__title">${truncateString(embed.title, 560)}</div>
|
||||||
|
<div class="og-embed__description">${truncateString(embed.description, 560)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="og-embed__image">
|
||||||
|
<img src="${embed.image}" class="image" alt="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</actiontext-opengraph-embed>`
|
||||||
|
}
|
||||||
|
|
||||||
|
#isTwitterAvatar(embed) {
|
||||||
|
return embed.image.startsWith(TWITTER_AVATAR_URL_PREFIX)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export default class Paste {
|
||||||
|
constructor(range, editor, document) {
|
||||||
|
this.range = range
|
||||||
|
this.editor = editor
|
||||||
|
this.document = document
|
||||||
|
if (this.document == null) { this.document = this.editor.getDocument() }
|
||||||
|
this.string = this.document.getStringAtRange(this.range)
|
||||||
|
}
|
||||||
|
|
||||||
|
isURL() {
|
||||||
|
return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/.test(this.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPathname() {
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = this.string
|
||||||
|
return a.pathname
|
||||||
|
}
|
||||||
|
|
||||||
|
isLinked() {
|
||||||
|
const {href} = this.getCommonAttributes()
|
||||||
|
return (href != null) && (href !== this.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommonAttributes() {
|
||||||
|
return this.document.getCommonAttributesAtRange(this.range)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignificantPaste() {
|
||||||
|
return new this.constructor(this.getSignificantRange(), this.editor, this.document)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignificantRange() {
|
||||||
|
const significantString = this.string.trim()
|
||||||
|
const startOffset = this.range[0] + this.string.indexOf(significantString)
|
||||||
|
const endOffset = startOffset + significantString.length
|
||||||
|
return [startOffset, endOffset]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import OpengraphEmbedOperation from "lib/rich_text/unfurl/lib/opengraph_embed_operation"
|
||||||
|
import Paste from "lib/rich_text/unfurl/lib/paste"
|
||||||
|
|
||||||
|
const performOperation = (function() {
|
||||||
|
let operation = null
|
||||||
|
let requestId = null
|
||||||
|
|
||||||
|
return function(operationToPerform) {
|
||||||
|
operation?.abort()
|
||||||
|
cancelAnimationFrame(requestId)
|
||||||
|
|
||||||
|
requestId = requestAnimationFrame(function() {
|
||||||
|
operation = operationToPerform
|
||||||
|
operation.perform().then(() => operation = null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
export default class Unfurler {
|
||||||
|
install() {
|
||||||
|
this.#addEventListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
#addEventListeners() {
|
||||||
|
addEventListener("trix-initialize", function(event) {
|
||||||
|
if (this.#editorElementPermitsAttribute(event.target, "href")) {
|
||||||
|
return event.target.addEventListener("trix-paste", this.#didPaste.bind(this))
|
||||||
|
}
|
||||||
|
}.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
#didPaste(event) {
|
||||||
|
const {range} = event.paste
|
||||||
|
const {editor} = event.target
|
||||||
|
|
||||||
|
if (range != null) {
|
||||||
|
const paste = new Paste(range, editor).getSignificantPaste()
|
||||||
|
|
||||||
|
if (paste.isURL()) {
|
||||||
|
if (this.#editorElementPermitsOpengraphAttachment(event.target)) {
|
||||||
|
performOperation(new OpengraphEmbedOperation(paste))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#editorElementPermitsAttribute(element, attributeName) {
|
||||||
|
if (element.hasAttribute("data-permitted-attributes")) {
|
||||||
|
return Array.from(element.getAttribute("data-permitted-attributes").split(" ")).includes(attributeName)
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#editorElementPermitsOpengraphAttachment(element) {
|
||||||
|
const permittedAttachmentTypes = element.getAttribute("data-permitted-attachment-types")
|
||||||
|
return permittedAttachmentTypes && permittedAttachmentTypes.includes("application/vnd.actiontext.opengraph-embed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export default class ClientMessage {
|
|||||||
body,
|
body,
|
||||||
messageTimestamp: Math.floor(now.getTime()),
|
messageTimestamp: Math.floor(now.getTime()),
|
||||||
messageDatetime: now.toISOString(),
|
messageDatetime: now.toISOString(),
|
||||||
messageClasses: this.#messageClassesFromNode(node),
|
messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,37 +52,26 @@ export default class ClientMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#messageClassesFromNode(node) {
|
|
||||||
if (this.#containsOnlyEmoji(this.#plainTextFromNode(node))) {
|
|
||||||
return "message--emoji"
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#isPlayCommand(node) {
|
#isPlayCommand(node) {
|
||||||
return this.#matchPlayCommand(node)
|
return this.#matchPlayCommand(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
#matchPlayCommand(node) {
|
#matchPlayCommand(node) {
|
||||||
return this.#plainTextFromNode(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
return this.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
#plainTextFromNode(node) {
|
#stripWrapperElement(node) {
|
||||||
if (this.#isRichText(node)) {
|
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
|
||||||
return node.toString().trim()
|
|
||||||
} else {
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#isRichText(node) {
|
#isRichText(node) {
|
||||||
return typeof(node) != "string"
|
return typeof(node) != "string"
|
||||||
}
|
}
|
||||||
|
|
||||||
#richTextContent(node) {
|
#richTextContent(node) {
|
||||||
return `<div class="lexxy-content">${node.value}</div>`
|
return `<div class="trix-content">${node.innerHTML}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,21 +76,7 @@ export default class MessageFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#highlightCodeBlock(block) {
|
#highlightCodeBlock(block) {
|
||||||
this.#normalizeLineBreaks(block)
|
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
|
||||||
|
|
||||||
if (this.#isPlainText(block)) {
|
|
||||||
const language = block.dataset.language
|
|
||||||
if (language && window.hljs.getLanguage(language)) {
|
|
||||||
block.classList.add(`language-${language}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.hljs.highlightElement(block)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lexxy breaks code block lines with <br>, Trix-era blocks used newlines
|
|
||||||
#normalizeLineBreaks(block) {
|
|
||||||
block.querySelectorAll("br").forEach(br => br.replaceWith("\n"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#isPlainText(element) {
|
#isPlainText(element) {
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
include Joinable
|
include Joinable
|
||||||
|
|
||||||
has_one_attached :logo do |attachable|
|
has_one_attached :logo
|
||||||
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
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
module Message::Broadcasts
|
module Message::Broadcasts
|
||||||
def broadcast_create
|
def broadcast_create
|
||||||
broadcast_append_to room, :messages, target: [ room, :messages ]
|
broadcast_append_to room, :messages, target: [ room, :messages ]
|
||||||
broadcast_unread_room
|
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_remove
|
def broadcast_remove
|
||||||
broadcast_remove_to room, :messages
|
broadcast_remove_to room, :messages
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
# Fanned out to the room's members rather than published on one global stream, so
|
|
||||||
# that the timing of activity in a room only reaches people who are in it.
|
|
||||||
def broadcast_unread_room
|
|
||||||
room.memberships.pluck(:user_id).each do |user_id|
|
|
||||||
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -1,61 +1,7 @@
|
|||||||
require "restricted_http/private_network_guard"
|
|
||||||
|
|
||||||
class Push::Subscription < ApplicationRecord
|
class Push::Subscription < ApplicationRecord
|
||||||
PERMITTED_ENDPOINT_HOSTS = %w[
|
|
||||||
jmt17.google.com
|
|
||||||
fcm.googleapis.com
|
|
||||||
updates.push.services.mozilla.com
|
|
||||||
web.push.apple.com
|
|
||||||
notify.windows.com
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
validates :endpoint, presence: true
|
|
||||||
validate :validate_endpoint_url
|
|
||||||
|
|
||||||
def notification(**params)
|
def notification(**params)
|
||||||
# Defer DNS lookup to the delivery worker to prevent rebinding
|
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
|
||||||
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Validate at point of use, not just when saved.
|
|
||||||
def resolved_endpoint_ip
|
|
||||||
RestrictedHTTP::PrivateNetworkGuard.resolve(endpoint_uri.host) if permitted_endpoint_uri?
|
|
||||||
rescue RestrictedHTTP::Violation, Surfguard::Unresolvable
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
# Validate endpoint shape. Belt & suspenders.
|
|
||||||
def permitted_endpoint_uri?
|
|
||||||
endpoint_uri&.scheme == "https" && endpoint_uri.port == 443 && permitted_endpoint_host?
|
|
||||||
end
|
|
||||||
|
|
||||||
def endpoint_uri
|
|
||||||
URI.parse(endpoint) if endpoint.present?
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_endpoint_url
|
|
||||||
if endpoint_uri.nil?
|
|
||||||
errors.add(:endpoint, "is not a valid URL")
|
|
||||||
elsif endpoint_uri.scheme != "https"
|
|
||||||
errors.add(:endpoint, "must use HTTPS")
|
|
||||||
elsif endpoint_uri.port != 443
|
|
||||||
errors.add(:endpoint, "must use the default HTTPS port")
|
|
||||||
elsif !permitted_endpoint_host?
|
|
||||||
errors.add(:endpoint, "is not a permitted push service")
|
|
||||||
elsif resolved_endpoint_ip.nil?
|
|
||||||
errors.add(:endpoint, "resolves to a private or invalid IP address")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_endpoint_host?
|
|
||||||
host = endpoint_uri&.host&.downcase
|
|
||||||
host.present? && PERMITTED_ENDPOINT_HOSTS.any? do |permitted|
|
|
||||||
host == permitted || host.end_with?(".#{permitted}")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -22,8 +22,6 @@ class Room < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||||
|
|
||||||
validate :direct_rooms_keep_their_type, on: :update
|
|
||||||
|
|
||||||
scope :opens, -> { where(type: "Rooms::Open") }
|
scope :opens, -> { where(type: "Rooms::Open") }
|
||||||
scope :closeds, -> { where(type: "Rooms::Closed") }
|
scope :closeds, -> { where(type: "Rooms::Closed") }
|
||||||
scope :directs, -> { where(type: "Rooms::Direct") }
|
scope :directs, -> { where(type: "Rooms::Direct") }
|
||||||
@@ -67,15 +65,6 @@ class Room < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# Open and closed rooms convert into each other freely. A direct room can't become
|
|
||||||
# either: its participants agreed to a private conversation, not to one whose
|
|
||||||
# audience someone else gets to widen afterwards.
|
|
||||||
def direct_rooms_keep_their_type
|
|
||||||
if type_changed? && type_was == "Rooms::Direct"
|
|
||||||
errors.add :type, "can't be changed for a direct room"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def unread_memberships(message)
|
def unread_memberships(message)
|
||||||
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ module User::Avatar
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
has_one_attached :avatar do |attachable|
|
has_one_attached :avatar
|
||||||
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
@@ -16,8 +14,4 @@ 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
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
module User::Mentionable
|
module User::Mentionable
|
||||||
include ActionText::Attachable
|
include ActionText::Attachable
|
||||||
|
|
||||||
MENTION_CONTENT_TYPE = "application/vnd.campfire.mention"
|
def to_attachable_partial_path
|
||||||
|
"users/mention"
|
||||||
def attachable_content_type
|
|
||||||
MENTION_CONTENT_TYPE
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_attachable_partial_path
|
def to_trix_content_attachment_partial_path
|
||||||
"users/mention"
|
"users/mention"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ 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,11 +1,11 @@
|
|||||||
<figure class="attachment attachment--content attachment--og">
|
<figure class="attachment attachment--content attachment--og">
|
||||||
<actiontext-opengraph-embed>
|
<actiontext-opengraph-embed>
|
||||||
<div class="og-embed gap <%= "og-embed--twitter-avatar" if opengraph_embed.twitter_avatar? %>">
|
<div class="og-embed gap">
|
||||||
<div class="og-embed__content">
|
<div class="og-embed__content">
|
||||||
<div class="og-embed__title">
|
<div class="og-embed__title">
|
||||||
<%= link_to_if opengraph_embed.href.present?, truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
||||||
</div>
|
</div>
|
||||||
<div class="og-embed__description"><%= truncate(opengraph_embed.description.to_s, length: 560, omission: "…") %></div>
|
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %></div>
|
||||||
</div>
|
</div>
|
||||||
<% if opengraph_embed.url %>
|
<% if opengraph_embed.url %>
|
||||||
<div class="og-embed__image">
|
<div class="og-embed__image">
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
<lexxy-prompt-item search="<%= user.name %>" sgid="<%= user.attachable_sgid %>">
|
|
||||||
<template type="menu">
|
|
||||||
<span class="autocomplete__item flex align-center gap unpad">
|
|
||||||
<%= avatar_tag user %>
|
|
||||||
<span class="autocompletable__name"><%= user.name %></span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template type="editor">
|
|
||||||
<%= render "users/mention", user: user %>
|
|
||||||
</template>
|
|
||||||
</lexxy-prompt-item>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<%= render partial: "autocompletable/users/prompt_item", collection: @page.records, as: :user %>
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
<div class="lexxy-content">
|
<div class="trix-content">
|
||||||
<%= yield -%>
|
<%= yield -%>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
||||||
|
|
||||||
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
|
<% cache message do %>
|
||||||
<% cache [ message, "presentation-v3" ] do %>
|
|
||||||
<%= message_tag message do %>
|
<%= message_tag message do %>
|
||||||
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
|
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
json.cache! message do
|
|
||||||
json.(message, :id)
|
|
||||||
|
|
||||||
json.created_at message.created_at.utc
|
|
||||||
|
|
||||||
json.body do
|
|
||||||
json.plain_text message.plain_text_body
|
|
||||||
json.html message.body.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
json.creator message.creator, partial: "users/user", as: :user
|
|
||||||
|
|
||||||
json.room do
|
|
||||||
json.id message.room_id
|
|
||||||
end
|
|
||||||
|
|
||||||
json.url room_message_url(message.room, message)
|
|
||||||
end
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
json.cache! boost do
|
|
||||||
json.(boost, :id, :content)
|
|
||||||
|
|
||||||
json.created_at boost.created_at.utc
|
|
||||||
|
|
||||||
json.booster boost.booster, partial: "users/user", as: :user
|
|
||||||
|
|
||||||
json.message do
|
|
||||||
json.id boost.message_id
|
|
||||||
json.url room_message_url(boost.message.room, boost.message)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
json.partial! "messages/boosts/boost", boost: @boost
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
json.array! @messages, partial: "messages/message", as: :message
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
json.partial! "messages/message", message: @message
|
|
||||||
@@ -14,18 +14,17 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<div class="composer--edit composer--rich-text">
|
<div class="composer--edit composer--rich-text">
|
||||||
<%= form_with model: @message, url: room_message_path(@room, @message), id: dom_id(@message, :form), data: {
|
<%= form_with model: @message, url: room_message_path(@room, @message), id: dom_id(@message, :form), data: {
|
||||||
controller: "form", action: "lexxy:file-accept->form#preventAttachment keydown.esc->form#cancel keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent" } do |form| %>
|
controller: "form", action: "trix-file-accept->form#preventAttachment keydown.esc->form#cancel keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent" } do |form| %>
|
||||||
<div class="full-width input input--actor min-width fill-white">
|
<div class="full-width input input--actor min-width fill-white">
|
||||||
<%= form.rich_text_area :body,
|
<%= form.rich_text_area :body,
|
||||||
value: editable_body(@message),
|
|
||||||
rows: 1,
|
rows: 1,
|
||||||
class: "input lexxy-content",
|
class: "input",
|
||||||
aria: { multiline: "true", label: "Edit message" },
|
aria: { multiline: "true", label: "Edit message" },
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
|
data: {
|
||||||
data: { action: rich_text_data_actions } do %>
|
controller: "rich-autocomplete",
|
||||||
<%= mention_prompt_tag @room %>
|
action: rich_text_data_actions,
|
||||||
<% end %>
|
rich_autocomplete_url_value: autocompletable_users_path(room_id: @room.id) } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= link_to "Close editor and discard changes", room_message_path(@room, @message), data: { form_target: "cancel" }, hidden: true %>
|
<%= link_to "Close editor and discard changes", room_message_path(@room, @message), data: { form_target: "cancel" }, hidden: true %>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
|
<%= turbo_stream_from @room, :messages %>
|
||||||
<%= button_to_jump_to_newest_message %>
|
<%= button_to_jump_to_newest_message %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
@@ -19,16 +19,15 @@
|
|||||||
<div class="flex flex-column flex-item-grow min-width gap">
|
<div class="flex flex-column flex-item-grow min-width gap">
|
||||||
<%= form.rich_text_area :body,
|
<%= form.rich_text_area :body,
|
||||||
rows: 1,
|
rows: 1,
|
||||||
class: "input lexxy-content",
|
class: "input",
|
||||||
style: "order: -1",
|
style: "order: -1",
|
||||||
aria: { multiline: "true", label: "Write a message" },
|
aria: { multiline: "true", label: "Write a message" },
|
||||||
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
|
|
||||||
data: {
|
data: {
|
||||||
controller: "unfurl",
|
controller: "rich-autocomplete",
|
||||||
action: "#{rich_text_data_actions} lexxy:change->composer#saveDraft lexxy:insert-link->unfurl#unfurl",
|
action: rich_text_data_actions,
|
||||||
composer_target: "text" } do %>
|
rich_autocomplete_url_value: autocompletable_users_path(room_id: room.id),
|
||||||
<%= mention_prompt_tag room %>
|
permitted_attachment_types: "application/vnd.actiontext.opengraph-embed",
|
||||||
<% end %>
|
composer_target: "text" } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="btn btn--borderless txt-small flex-item-no-shrink composer__attachment-btn input--file">
|
<label class="btn btn--borderless txt-small flex-item-no-shrink composer__attachment-btn input--file">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%# A span rather than a div: mentions render inline inside the <p> paragraphs
|
<div class="mention" sgid="<%= user.attachable_sgid %>">
|
||||||
the editor produces, and block elements would make the browser split the
|
<%= avatar_tag user %>
|
||||||
paragraph when parsing %>
|
<%= user.name %>
|
||||||
<span class="mention" sgid="<%= user.attachable_sgid %>"><%= avatar_tag user %> <%= user.name %></span>
|
</div>
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
json.cache! user do
|
|
||||||
json.(user, :id, :name, :role)
|
|
||||||
|
|
||||||
json.avatar_url fresh_user_avatar_url(user)
|
|
||||||
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 }
|
||||||
+1
-1
@@ -2,6 +2,6 @@
|
|||||||
require "rubygems"
|
require "rubygems"
|
||||||
require "bundler/setup"
|
require "bundler/setup"
|
||||||
|
|
||||||
ARGV.unshift("--ensure-latest", "15")
|
ARGV.unshift("--ensure-latest")
|
||||||
|
|
||||||
load Gem.bin_path("brakeman", "brakeman")
|
load Gem.bin_path("brakeman", "brakeman")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
require_relative "../config/environment"
|
||||||
|
require "solid_queue/cli"
|
||||||
|
|
||||||
|
SolidQueue::Cli.start(ARGV)
|
||||||
+3
-23
@@ -15,11 +15,9 @@ include SSHKit::DSL
|
|||||||
ARCH = "linux/amd64,linux/arm64"
|
ARCH = "linux/amd64,linux/arm64"
|
||||||
BUILDER_NAME = "campfire-builder"
|
BUILDER_NAME = "campfire-builder"
|
||||||
GIT_SHA = `git rev-parse HEAD`.strip
|
GIT_SHA = `git rev-parse HEAD`.strip
|
||||||
MANUAL_NOTES = ARGV.delete("--notes")
|
|
||||||
VERSION = ARGV[0]
|
VERSION = ARGV[0]
|
||||||
|
|
||||||
DOCKER_REGISTRY = "registry.once.com"
|
DOCKER_IMAGE_NAME = "registry.once.com/campfire"
|
||||||
DOCKER_IMAGE_NAME = "#{DOCKER_REGISTRY}/campfire"
|
|
||||||
DOCKER_LATEST_IMAGE_NAME = [ DOCKER_IMAGE_NAME, :latest ].join(":")
|
DOCKER_LATEST_IMAGE_NAME = [ DOCKER_IMAGE_NAME, :latest ].join(":")
|
||||||
DOCKER_TAGGED_IMAGE_NAME = [ DOCKER_IMAGE_NAME, GIT_SHA ].join(":")
|
DOCKER_TAGGED_IMAGE_NAME = [ DOCKER_IMAGE_NAME, GIT_SHA ].join(":")
|
||||||
|
|
||||||
@@ -30,7 +28,7 @@ EXPORT_REMOTE_USER = "app"
|
|||||||
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
|
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
|
||||||
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
|
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
|
||||||
|
|
||||||
abort "You must specify a version, e.g. `bin/release 1.0.0` (pass --notes to write release notes manually)" unless VERSION
|
abort "You must specify a version, e.g. `bin/release 1.0.0`" unless VERSION
|
||||||
|
|
||||||
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
|
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
|
||||||
if current_branch != "main"
|
if current_branch != "main"
|
||||||
@@ -48,18 +46,6 @@ def announcing(message)
|
|||||||
yield
|
yield
|
||||||
end
|
end
|
||||||
|
|
||||||
announcing "Checking #{DOCKER_REGISTRY} login..." do
|
|
||||||
unless system "docker", "manifest", "inspect", DOCKER_LATEST_IMAGE_NAME, out: File::NULL, err: File::NULL
|
|
||||||
abort <<~MESSAGE
|
|
||||||
Not logged in to #{DOCKER_REGISTRY}. Run:
|
|
||||||
|
|
||||||
docker login #{DOCKER_REGISTRY}
|
|
||||||
|
|
||||||
The credentials are in the "chatinabox" entry in 1Password.
|
|
||||||
MESSAGE
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
on(:local) do
|
on(:local) do
|
||||||
builder_found = test :docker, :buildx, :inspect, BUILDER_NAME
|
builder_found = test :docker, :buildx, :inspect, BUILDER_NAME
|
||||||
|
|
||||||
@@ -71,10 +57,9 @@ on(:local) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
announcing "Creating GitHub release v#{VERSION}..." do
|
announcing "Creating GitHub release v#{VERSION}..." do
|
||||||
on(:local) do
|
|
||||||
if MANUAL_NOTES
|
|
||||||
notes_file = "tmp/release_notes.md"
|
notes_file = "tmp/release_notes.md"
|
||||||
|
|
||||||
|
on(:local) do
|
||||||
execute :mkdir, "-p", "tmp"
|
execute :mkdir, "-p", "tmp"
|
||||||
|
|
||||||
File.write(notes_file, "# What's Changed\n\n- \n")
|
File.write(notes_file, "# What's Changed\n\n- \n")
|
||||||
@@ -85,11 +70,6 @@ announcing "Creating GitHub release v#{VERSION}..." do
|
|||||||
end
|
end
|
||||||
|
|
||||||
execute :rm, notes_file
|
execute :rm, notes_file
|
||||||
else
|
|
||||||
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--generate-notes"
|
|
||||||
abort "Failed to create release; check that the version doesn't already exist"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ app_root="$(
|
|||||||
)"
|
)"
|
||||||
export PATH="$app_root/bin:$PATH"
|
export PATH="$app_root/bin:$PATH"
|
||||||
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
|
|
||||||
if [ "$RAILS_ENV" = "production" ]; then
|
if [ "$RAILS_ENV" = "production" ]; then
|
||||||
echo "RAILS_ENV is production; bailing out"
|
echo "RAILS_ENV is production; bailing out"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -45,10 +42,6 @@ step() {
|
|||||||
return $exit_code
|
return $exit_code
|
||||||
}
|
}
|
||||||
|
|
||||||
redis_running() {
|
|
||||||
nc -z "$REDIS_HOST" "$REDIS_PORT" 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
gum style --foreground 214 " ) "
|
gum style --foreground 214 " ) "
|
||||||
gum style --foreground 208 " ) \\ campfire"
|
gum style --foreground 208 " ) \\ campfire"
|
||||||
@@ -88,21 +81,6 @@ if [[ $* == *--reset* ]]; then
|
|||||||
fi
|
fi
|
||||||
step "Preparing the database" rails db:prepare
|
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
|
|
||||||
else
|
|
||||||
step "Setting up Redis" docker run -d --name campfire-redis -p "$REDIS_PORT:$REDIS_PORT" redis:7
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Couldn't start Redis"
|
|
||||||
echo "Install either docker or redis and then run this command again"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install GitHub Actions linting tools
|
# Install GitHub Actions linting tools
|
||||||
for tool in actionlint shellcheck zizmor; do
|
for tool in actionlint shellcheck zizmor; do
|
||||||
if ! command -v "$tool" &> /dev/null; then
|
if ! command -v "$tool" &> /dev/null; then
|
||||||
|
|||||||
+17
-10
@@ -1,18 +1,25 @@
|
|||||||
default: &default
|
# Async adapter only works within the same process, so for manually triggering cable updates from a console,
|
||||||
adapter: redis
|
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
|
||||||
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379" } %>
|
# 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:
|
development:
|
||||||
<<: *default
|
adapter: async
|
||||||
channel_prefix: campfire_development
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
adapter: test
|
adapter: test
|
||||||
|
|
||||||
performance:
|
performance:
|
||||||
<<: *default
|
adapter: solid_cable
|
||||||
channel_prefix: campfire_performance
|
connects_to:
|
||||||
|
database:
|
||||||
|
writing: cable
|
||||||
|
polling_interval: 0.05.seconds
|
||||||
|
message_retention: 1.day
|
||||||
|
|
||||||
production:
|
production:
|
||||||
<<: *default
|
adapter: solid_cable
|
||||||
channel_prefix: campfire_production
|
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
|
||||||
@@ -14,6 +14,10 @@ development:
|
|||||||
primary:
|
primary:
|
||||||
<<: *default
|
<<: *default
|
||||||
database: storage/db/development.sqlite3
|
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
|
# Warning: The database defined as "test" will be erased and
|
||||||
# re-generated from your development database when you run "rake".
|
# re-generated from your development database when you run "rake".
|
||||||
@@ -27,8 +31,32 @@ performance:
|
|||||||
primary:
|
primary:
|
||||||
<<: *default
|
<<: *default
|
||||||
database: storage/db/performance.sqlite3
|
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:
|
production:
|
||||||
primary:
|
primary:
|
||||||
<<: *default
|
<<: *default
|
||||||
database: storage/db/production.sqlite3
|
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.perform_caching = true
|
||||||
config.action_controller.enable_fragment_cache_logging = 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 = {
|
config.public_file_server.headers = {
|
||||||
"cache-control" => "public, max-age=#{2.days.to_i}"
|
"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
|
# config.action_cable.disable_request_forgery_protection = true
|
||||||
|
|
||||||
# Uncomment to test with production class job queue
|
# 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.
|
# Highlight code that enqueued background job in logs.
|
||||||
config.active_job.verbose_enqueue_logs = true
|
config.active_job.verbose_enqueue_logs = true
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
require "active_support/core_ext/integer/time"
|
require "active_support/core_ext/integer/time"
|
||||||
require "active_support/core_ext/numeric/bytes"
|
require "active_support/core_ext/numeric/bytes"
|
||||||
require_relative "../../lib/rails_ext/log_scrubbing_formatter"
|
|
||||||
|
|
||||||
Rails.application.configure do
|
Rails.application.configure do
|
||||||
# Settings specified here will take precedence over those in config/application.rb.
|
# Settings specified here will take precedence over those in config/application.rb.
|
||||||
@@ -51,7 +50,7 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Log to STDOUT by default
|
# Log to STDOUT by default
|
||||||
config.logger = ActiveSupport::Logger.new(STDOUT)
|
config.logger = ActiveSupport::Logger.new(STDOUT)
|
||||||
.tap { |logger| logger.formatter = LogScrubbingFormatter.new }
|
.tap { |logger| logger.formatter = ::Logger::Formatter.new }
|
||||||
.then { |logger| ActiveSupport::TaggedLogging.new(logger) }
|
.then { |logger| ActiveSupport::TaggedLogging.new(logger) }
|
||||||
|
|
||||||
# Prepend all log lines with the following tags.
|
# Prepend all log lines with the following tags.
|
||||||
@@ -69,7 +68,7 @@ Rails.application.configure do
|
|||||||
config.active_support.report_deprecations = false
|
config.active_support.report_deprecations = false
|
||||||
|
|
||||||
# Cache in memory for now
|
# Cache in memory for now
|
||||||
config.cache_store = :redis_cache_store
|
config.cache_store = :solid_cache_store
|
||||||
|
|
||||||
# Assets are cacheable
|
# Assets are cacheable
|
||||||
config.public_file_server.headers = {
|
config.public_file_server.headers = {
|
||||||
@@ -93,5 +92,6 @@ Rails.application.configure do
|
|||||||
# Only use :id for inspections in production.
|
# Only use :id for inspections in production.
|
||||||
config.active_record.attributes_for_inspect = [ :id ]
|
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
|
end
|
||||||
+2
-1
@@ -5,7 +5,8 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
|||||||
pin "@hotwired/turbo-rails", to: "turbo.js"
|
pin "@hotwired/turbo-rails", to: "turbo.js"
|
||||||
pin "@rails/actioncable", to: "actioncable.esm.js"
|
pin "@rails/actioncable", to: "actioncable.esm.js"
|
||||||
pin "@rails/request.js", to: "@rails--request.js" # @0.0.8
|
pin "@rails/request.js", to: "@rails--request.js" # @0.0.8
|
||||||
pin "lexxy", to: "lexxy.js"
|
pin "trix", to: "trix.esm.min.js" # @2.1.19
|
||||||
|
pin "@rails/actiontext", to: "actiontext.js"
|
||||||
pin "highlight.js", to: "highlight.js/core.js"
|
pin "highlight.js", to: "highlight.js/core.js"
|
||||||
|
|
||||||
pin_all_from "app/javascript/initializers", under: "initializers"
|
pin_all_from "app/javascript/initializers", under: "initializers"
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# Active Storage mounts its direct-upload write endpoints
|
|
||||||
# (POST /rails/active_storage/direct_uploads and the disk-service PUT) on
|
|
||||||
# framework controllers that inherit from ActiveStorage::BaseController, so
|
|
||||||
# they never pass through ApplicationController's require_authentication.
|
|
||||||
# Campfire uploads attachments through MessagesController instead and does not
|
|
||||||
# use direct uploads at all, leaving these endpoints reachable by anyone who
|
|
||||||
# can read the public login page. Require a valid Campfire session before an
|
|
||||||
# anonymous caller can allocate a Blob or persist bytes to disk.
|
|
||||||
Rails.application.config.to_prepare do
|
|
||||||
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
|
|
||||||
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
|
|
||||||
|
|
||||||
ActiveStorage::DiskController.include ActiveStorageAuthentication
|
|
||||||
ActiveStorage::DiskController.before_action :require_active_storage_authentication, only: :update
|
|
||||||
end
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Resque.redis = ENV.fetch("REDIS_URL") { "redis://localhost:6379" }
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Rails.application.config.to_prepare do
|
|
||||||
Turbo::StreamsChannel.prepend RoomStreamsAreAuthorized
|
|
||||||
end
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
Rails.application.config.app_version = ENV["APP_VERSION"].presence || ENV["GIT_REVISION"].presence || "0"
|
Rails.application.config.app_version = ENV.fetch("APP_VERSION", "0")
|
||||||
Rails.application.config.git_revision = ENV["GIT_REVISION"]
|
Rails.application.config.git_revision = ENV["GIT_REVISION"]
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# 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 ]
|
|
||||||
@@ -17,25 +17,7 @@ end
|
|||||||
|
|
||||||
module WebPush::PersistentRequest
|
module WebPush::PersistentRequest
|
||||||
def perform
|
def perform
|
||||||
if endpoint_ip = @options[:endpoint_ip]
|
if @options[:connection]
|
||||||
# Pin the connection to the public IP resolved (and guarded) by
|
|
||||||
# Push::Subscription so delivery can't be rebound to a private address
|
|
||||||
# between resolution and connect. Bypasses the shared persistent pool,
|
|
||||||
# which would re-resolve the host itself.
|
|
||||||
#
|
|
||||||
# The explicit nil proxy address disables proxy discovery from
|
|
||||||
# http_proxy/https_proxy. An egress proxy would open the TCP connection
|
|
||||||
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
|
|
||||||
# pin the destination and the DNS-rebinding guarantee would be lost. This
|
|
||||||
# path is already committed to a direct connection (it bypasses the pool);
|
|
||||||
# push delivery to public vendor endpoints goes direct.
|
|
||||||
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
||||||
http.ipaddr = endpoint_ip
|
|
||||||
http.use_ssl = true
|
|
||||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
|
||||||
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
|
||||||
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
|
|
||||||
elsif @options[:connection]
|
|
||||||
http = @options[:connection]
|
http = @options[:connection]
|
||||||
else
|
else
|
||||||
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
|
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
|
||||||
|
|||||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user