mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 08:26:48 +09:00
Compare commits
215
Commits
No files matched your search
@@ -50,3 +50,6 @@
|
|||||||
|
|
||||||
# Ignore test files
|
# Ignore test files
|
||||||
/test/
|
/test/
|
||||||
|
|
||||||
|
# Agent session state — worktrees under .claude/ are whole extra checkouts of this repo
|
||||||
|
.claude
|
||||||
@@ -20,13 +20,12 @@ 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"
|
||||||
|
|
||||||
- package-ecosystem: docker
|
# No docker ecosystem. Our base image tag is a build arg
|
||||||
directory: "/"
|
# (`FROM ruby:$RUBY_VERSION-slim`), which the updater cannot read, so the
|
||||||
schedule:
|
# entry only ever matched nothing — worse than no entry, because it looked
|
||||||
interval: weekly
|
# like coverage. Ruby base image bumps are a manual, reviewed step here, as
|
||||||
cooldown:
|
# they are across the rest of the fleet. Keep Dockerfile, Dockerfile-export
|
||||||
semver-major-days: 7
|
# and .ruby-version on the same version by hand.
|
||||||
semver-minor-days: 3
|
|
||||||
semver-patch-days: 2
|
|
||||||
default-days: 7
|
|
||||||
+39
-13
@@ -16,12 +16,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -48,6 +48,29 @@ jobs:
|
|||||||
- name: Lint code for consistent style
|
- name: Lint code for consistent style
|
||||||
run: bin/rubocop
|
run: bin/rubocop
|
||||||
|
|
||||||
|
herb:
|
||||||
|
name: Herb check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Install packages
|
||||||
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Ruby
|
||||||
|
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||||
|
with:
|
||||||
|
ruby-version: .ruby-version
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
|
- name: Check that every HTML template compiles through Herb
|
||||||
|
run: bin/rails herb:check
|
||||||
|
|
||||||
lint-actions:
|
lint-actions:
|
||||||
name: GitHub Actions audit
|
name: GitHub Actions audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -55,15 +78,15 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run actionlint
|
- name: Run actionlint
|
||||||
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
|
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||||
with:
|
with:
|
||||||
advanced-security: false
|
advanced-security: false
|
||||||
|
|
||||||
@@ -79,15 +102,15 @@ jobs:
|
|||||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
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 sqlite3 libvips curl ffmpeg
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||||
env:
|
env:
|
||||||
REDIS_URL: redis://localhost:6379/0
|
REDIS_URL: redis://localhost:6379/0
|
||||||
with:
|
with:
|
||||||
@@ -97,6 +120,9 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bin/rails db:setup test
|
run: bin/rails db:setup test
|
||||||
|
|
||||||
|
- name: Test benchmark response validation
|
||||||
|
run: bundle exec ruby bench/test_http_client.rb
|
||||||
|
|
||||||
test_system:
|
test_system:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -109,15 +135,15 @@ jobs:
|
|||||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
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 sqlite3 libvips curl ffmpeg
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||||
env:
|
env:
|
||||||
REDIS_URL: redis://localhost:6379/0
|
REDIS_URL: redis://localhost:6379/0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -41,18 +41,19 @@ jobs:
|
|||||||
arch: arm64
|
arch: arm64
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
# Everything goes to <repo>-edge, with this repository's own token.
|
||||||
|
IMAGE_NAME: ${{ github.repository }}-edge
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -67,18 +68,26 @@ jobs:
|
|||||||
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
|
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
|
||||||
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
|
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Compute app version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||||
|
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Extract Docker metadata (tags, labels) with arch suffix
|
- name: Extract Docker metadata (tags, labels) with arch suffix
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.vars.outputs.canonical }}
|
images: ${{ steps.vars.outputs.canonical }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
type=sha,format=short,prefix=sha-
|
type=sha,format=short,prefix=sha-
|
||||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=false
|
latest=false
|
||||||
suffix=-${{ matrix.arch }}
|
suffix=-${{ matrix.arch }}
|
||||||
@@ -87,13 +96,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push (${{ matrix.platform }})
|
- name: Build and push (${{ matrix.platform }})
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.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 }}
|
||||||
@@ -105,7 +116,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@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||||
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 }}
|
||||||
@@ -115,6 +126,8 @@ jobs:
|
|||||||
name: Create multi-arch manifest and sign
|
name: Create multi-arch manifest and sign
|
||||||
needs: build
|
needs: build
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
|
# Multi-platform tags, signed, in <repo>-edge only. basecamp/release-images
|
||||||
|
# promotes v* tags from there into the release package after a reviewer approves.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -123,13 +136,13 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}-edge
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Docker Buildx (for imagetools)
|
- name: Set up Docker Buildx (for imagetools)
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -146,17 +159,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Compute base tags (no suffix)
|
- name: Compute base tags (no suffix)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.vars.outputs.canonical }}
|
images: ${{ steps.vars.outputs.canonical }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
type=sha,format=short,prefix=sha-
|
type=sha,format=short,prefix=sha-
|
||||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=false
|
latest=false
|
||||||
labels: |
|
labels: |
|
||||||
@@ -175,10 +184,6 @@ jobs:
|
|||||||
[ -z "$tag" ] && continue
|
[ -z "$tag" ] && continue
|
||||||
echo "Creating manifest for $tag"
|
echo "Creating manifest for $tag"
|
||||||
src_tag="$tag"
|
src_tag="$tag"
|
||||||
if [[ "$tag" == *:latest && "${GITHUB_REF}" == refs/tags/* ]]; then
|
|
||||||
ref="${GITHUB_REF#refs/tags/}"
|
|
||||||
src_tag="${tag%:latest}:$ref"
|
|
||||||
fi
|
|
||||||
if [ -n "${IMAGE_DESCRIPTION:-}" ]; then
|
if [ -n "${IMAGE_DESCRIPTION:-}" ]; then
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--tag "$tag" \
|
--tag "$tag" \
|
||||||
@@ -194,7 +199,7 @@ jobs:
|
|||||||
done <<< "$tags"
|
done <<< "$tags"
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.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.5
|
3.4.10
|
||||||
+4
-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.5
|
ARG RUBY_VERSION=3.4.10
|
||||||
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
|
||||||
@@ -66,6 +66,9 @@ ENV HTTP_WRITE_TIMEOUT=300
|
|||||||
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
|
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
|
||||||
COPY --from=build --chown=rails:rails /rails /rails
|
COPY --from=build --chown=rails:rails /rails /rails
|
||||||
|
|
||||||
|
# Install ONCE backup/restore hooks
|
||||||
|
COPY --chmod=755 hooks /hooks
|
||||||
|
|
||||||
# Set version and revision
|
# Set version and revision
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
ENV APP_VERSION=$APP_VERSION
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
|||||||
+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.7
|
ARG RUBY_VERSION=3.4.10
|
||||||
FROM ruby:$RUBY_VERSION-slim as base
|
FROM ruby:$RUBY_VERSION-slim as base
|
||||||
|
|
||||||
# Install the tools we need
|
# Install the tools we need
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ 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"
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ gem "rqrcode"
|
|||||||
gem "rails_autolink"
|
gem "rails_autolink"
|
||||||
gem "geared_pagination"
|
gem "geared_pagination"
|
||||||
gem "jbuilder"
|
gem "jbuilder"
|
||||||
gem "net-http-persistent"
|
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
|
||||||
gem "kredis"
|
gem "kredis"
|
||||||
gem "platform_agent"
|
gem "platform_agent"
|
||||||
gem "thruster"
|
gem "thruster"
|
||||||
|
|||||||
+78
-54
@@ -1,3 +1,9 @@
|
|||||||
|
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
|
||||||
@@ -17,16 +23,16 @@ GIT
|
|||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/rails/propshaft.git
|
remote: https://github.com/rails/propshaft.git
|
||||||
revision: e49a9de659ff27462015e54dd832e86e762a6ddc
|
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623
|
||||||
specs:
|
specs:
|
||||||
propshaft (1.2.1)
|
propshaft (1.3.2)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/rails/rails.git
|
remote: https://github.com/rails/rails.git
|
||||||
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
|
revision: e3d5c569d217c56c022b04acb127a8c24dbe9685
|
||||||
branch: main
|
branch: main
|
||||||
specs:
|
specs:
|
||||||
actioncable (8.2.0.alpha)
|
actioncable (8.2.0.alpha)
|
||||||
@@ -57,10 +63,10 @@ GIT
|
|||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.7)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.2.0.alpha)
|
actiontext (8.2.0.alpha)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.16)
|
||||||
actionpack (= 8.2.0.alpha)
|
actionpack (= 8.2.0.alpha)
|
||||||
activerecord (= 8.2.0.alpha)
|
activerecord (= 8.2.0.alpha)
|
||||||
activestorage (= 8.2.0.alpha)
|
activestorage (= 8.2.0.alpha)
|
||||||
@@ -71,11 +77,12 @@ GIT
|
|||||||
activesupport (= 8.2.0.alpha)
|
activesupport (= 8.2.0.alpha)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
|
herb (>= 0.10)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.7)
|
||||||
activejob (8.2.0.alpha)
|
activejob (8.2.0.alpha)
|
||||||
activesupport (= 8.2.0.alpha)
|
activesupport (= 8.2.0.alpha)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 1.4.0)
|
||||||
activemodel (8.2.0.alpha)
|
activemodel (8.2.0.alpha)
|
||||||
activesupport (= 8.2.0.alpha)
|
activesupport (= 8.2.0.alpha)
|
||||||
activerecord (8.2.0.alpha)
|
activerecord (8.2.0.alpha)
|
||||||
@@ -87,7 +94,7 @@ GIT
|
|||||||
activejob (= 8.2.0.alpha)
|
activejob (= 8.2.0.alpha)
|
||||||
activerecord (= 8.2.0.alpha)
|
activerecord (= 8.2.0.alpha)
|
||||||
activesupport (= 8.2.0.alpha)
|
activesupport (= 8.2.0.alpha)
|
||||||
marcel (~> 1.0)
|
marcel (~> 2.0)
|
||||||
activesupport (8.2.0.alpha)
|
activesupport (8.2.0.alpha)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -98,6 +105,8 @@ GIT
|
|||||||
json
|
json
|
||||||
logger (>= 1.4.2)
|
logger (>= 1.4.2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
|
psych (>= 4)
|
||||||
|
ractor-dispatch (>= 0.3.0)
|
||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
@@ -136,8 +145,8 @@ GEM
|
|||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.22)
|
bcrypt (3.1.22)
|
||||||
benchmark (0.5.0)
|
benchmark (0.5.0)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (4.1.3)
|
||||||
brakeman (8.0.5)
|
brakeman (8.0.6)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -153,7 +162,7 @@ GEM
|
|||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.7)
|
concurrent-ruby (1.3.8)
|
||||||
connection_pool (2.5.5)
|
connection_pool (2.5.5)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -164,10 +173,11 @@ GEM
|
|||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
erb (6.0.4)
|
erb (6.0.7)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
faker (3.5.2)
|
faker (3.8.0)
|
||||||
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)
|
||||||
@@ -175,23 +185,28 @@ GEM
|
|||||||
geared_pagination (1.2.0)
|
geared_pagination (1.2.0)
|
||||||
activesupport (>= 5.0)
|
activesupport (>= 5.0)
|
||||||
addressable (>= 2.5.0)
|
addressable (>= 2.5.0)
|
||||||
globalid (1.3.0)
|
globalid (1.4.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
hashdiff (1.2.0)
|
hashdiff (1.2.0)
|
||||||
i18n (1.14.7)
|
herb (0.11.0-aarch64-linux-gnu)
|
||||||
|
herb (0.11.0-arm64-darwin)
|
||||||
|
herb (0.11.0-x86_64-darwin)
|
||||||
|
herb (0.11.0-x86_64-linux-gnu)
|
||||||
|
i18n (1.15.2)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
io-console (0.8.1)
|
io-console (0.9.4)
|
||||||
irb (1.15.3)
|
irb (1.18.0)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
|
prism (>= 1.3.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
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.20.0)
|
json (2.21.2)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
kredis (1.8.0)
|
kredis (1.8.0)
|
||||||
@@ -199,39 +214,41 @@ GEM
|
|||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
redis (>= 4.2, < 6)
|
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.0)
|
mail (2.9.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.1.0)
|
marcel (2.1.0)
|
||||||
matrix (0.4.3)
|
matrix (0.4.3)
|
||||||
mini_magick (5.3.1)
|
mini_magick (5.3.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.26.2)
|
minitest (6.0.6)
|
||||||
mocha (2.7.1)
|
drb (~> 2.0)
|
||||||
|
prism (~> 1.5)
|
||||||
|
mocha (3.1.0)
|
||||||
ruby2_keywords (>= 0.0.5)
|
ruby2_keywords (>= 0.0.5)
|
||||||
mono_logger (1.1.2)
|
mono_logger (1.1.2)
|
||||||
multi_json (1.17.0)
|
multi_json (1.17.0)
|
||||||
mustermann (3.0.4)
|
mustermann (3.0.4)
|
||||||
ruby2_keywords (~> 0.0.1)
|
ruby2_keywords (~> 0.0.1)
|
||||||
net-http-persistent (4.0.6)
|
net-imap (0.6.7)
|
||||||
connection_pool (~> 2.2, >= 2.2.4)
|
|
||||||
net-imap (0.6.4.1)
|
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-protocol (0.2.2)
|
net-protocol (0.4.0)
|
||||||
timeout
|
timeout
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.2)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||||
@@ -242,7 +259,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 (3.3.0)
|
openssl (4.0.2)
|
||||||
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)
|
||||||
@@ -251,18 +268,18 @@ GEM
|
|||||||
platform_agent (1.0.1)
|
platform_agent (1.0.1)
|
||||||
activesupport (>= 5.2.0)
|
activesupport (>= 5.2.0)
|
||||||
useragent (~> 0.16.3)
|
useragent (~> 0.16.3)
|
||||||
pp (0.6.3)
|
pp (0.6.4)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.4.0)
|
prism (1.9.0)
|
||||||
psych (5.2.6)
|
psych (5.5.0)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.2.1)
|
puma (7.2.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.6)
|
rack (3.2.7)
|
||||||
rack-protection (4.2.1)
|
rack-protection (4.2.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
logger (>= 1.6.0)
|
logger (>= 1.6.0)
|
||||||
@@ -272,8 +289,9 @@ GEM
|
|||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.2.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.3.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
|
ractor-dispatch (0.3.0)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -286,19 +304,24 @@ GEM
|
|||||||
activesupport (> 3.1)
|
activesupport (> 3.1)
|
||||||
railties (> 3.1)
|
railties (> 3.1)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
rdoc (6.16.1)
|
rbs (4.2.0)
|
||||||
|
logger
|
||||||
|
prism (>= 1.6.0)
|
||||||
|
tsort
|
||||||
|
rdoc (8.1.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
prism (>= 1.6.0)
|
||||||
|
rbs (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
redis (5.4.1)
|
redis (5.4.1)
|
||||||
redis-client (>= 0.22.0)
|
redis-client (>= 0.22.0)
|
||||||
redis-client (0.25.2)
|
redis-client (0.30.1)
|
||||||
connection_pool
|
connection_pool
|
||||||
redis-namespace (1.11.0)
|
redis-namespace (1.11.0)
|
||||||
redis (>= 4)
|
redis (>= 4)
|
||||||
regexp_parser (2.11.2)
|
regexp_parser (2.11.2)
|
||||||
reline (0.6.3)
|
reline (0.7.0)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
resque (2.7.0)
|
resque (2.7.0)
|
||||||
mono_logger (~> 1)
|
mono_logger (~> 1)
|
||||||
@@ -346,9 +369,9 @@ GEM
|
|||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (3.0.2)
|
rubyzip (3.7.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.35.0)
|
selenium-webdriver (4.49.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
@@ -367,18 +390,18 @@ GEM
|
|||||||
rack-protection (= 4.2.1)
|
rack-protection (= 4.2.1)
|
||||||
rack-session (>= 2.0.0, < 3)
|
rack-session (>= 2.0.0, < 3)
|
||||||
tilt (~> 2.0)
|
tilt (~> 2.0)
|
||||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
sqlite3 (2.9.6-aarch64-linux-gnu)
|
||||||
sqlite3 (2.9.5-arm64-darwin)
|
sqlite3 (2.9.6-arm64-darwin)
|
||||||
sqlite3 (2.9.5-x86_64-darwin)
|
sqlite3 (2.9.6-x86_64-darwin)
|
||||||
sqlite3 (2.9.5-x86_64-linux-gnu)
|
sqlite3 (2.9.6-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.2.0)
|
||||||
thor (1.4.0)
|
thor (1.5.0)
|
||||||
thruster (0.1.15-aarch64-linux)
|
thruster (0.1.23-aarch64-linux)
|
||||||
thruster (0.1.15-arm64-darwin)
|
thruster (0.1.23-arm64-darwin)
|
||||||
thruster (0.1.15-x86_64-darwin)
|
thruster (0.1.23-x86_64-darwin)
|
||||||
thruster (0.1.15-x86_64-linux)
|
thruster (0.1.23-x86_64-linux)
|
||||||
tilt (2.6.1)
|
tilt (2.6.1)
|
||||||
timeout (0.6.1)
|
timeout (0.6.1)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
@@ -389,9 +412,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.0.2)
|
web-push (3.1.0)
|
||||||
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)
|
||||||
@@ -403,7 +426,7 @@ GEM
|
|||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.7.3)
|
zeitwerk (2.8.3)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
@@ -424,8 +447,8 @@ DEPENDENCIES
|
|||||||
importmap-rails!
|
importmap-rails!
|
||||||
jbuilder
|
jbuilder
|
||||||
kredis
|
kredis
|
||||||
|
lexxy (~> 0.9.24)
|
||||||
mocha
|
mocha
|
||||||
net-http-persistent
|
|
||||||
ostruct
|
ostruct
|
||||||
platform_agent
|
platform_agent
|
||||||
propshaft!
|
propshaft!
|
||||||
@@ -442,6 +465,7 @@ DEPENDENCIES
|
|||||||
sentry-ruby
|
sentry-ruby
|
||||||
sqlite3
|
sqlite3
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
|
surfguard!
|
||||||
thruster
|
thruster
|
||||||
turbo-rails!
|
turbo-rails!
|
||||||
web-push
|
web-push
|
||||||
|
|||||||
@@ -49,7 +49,34 @@ If you'd rather run the Docker image yourself, you can read more about that in t
|
|||||||
> that people have someone to contact if they need help with their account. If that bothers you, put in any
|
> that people have someone to contact if they need help with their account. If that bothers you, put in any
|
||||||
> email address you want and create yourself a new admin account.
|
> email address you want and create yourself a new admin account.
|
||||||
|
|
||||||
|
Authenticated room, message, sidebar and search pages use a bounded 64 MiB cache per worker.
|
||||||
|
Set `CAMPFIRE_RESPONSE_CACHE_MB=0` to disable it. Every request still checks authentication
|
||||||
|
and room access; commits from any SQLite writer invalidate pages, and CSRF masks stay fresh.
|
||||||
|
Native HTML, JSON and stream fragments have a separate 64 MiB memory limit per worker;
|
||||||
|
shared rate limits retain their existing store.
|
||||||
|
|
||||||
|
## Other implementations
|
||||||
|
|
||||||
|
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
|
||||||
|
|
||||||
|
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| Room page | 710 | 414 | 1,696 | 42,481 | 1,126 | 52,512 | 105,909 | 137,505 |
|
||||||
|
| Messages page | 1,113 | 454 | 1,890 | 74,779 | 1,407 | 54,100 | 103,301 | 142,669 |
|
||||||
|
| Sidebar | 1,901 | 576 | 3,364 | 94,460 | 3,621 | 58,714 | 120,930 | 151,001 |
|
||||||
|
| Search | 1,332 | 549 | 2,615 | 83,493 | 2,127 | 60,444 | 121,502 | 148,766 |
|
||||||
|
| Post a message | 226 | 113 | 567 | 2,121 | 1,392 | 9,000 | 8,004 | 7,486 |
|
||||||
|
|
||||||
|
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
|
||||||
|
|
||||||
|
Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395 with 32 GB RAM,
|
||||||
|
with four hardware cores allocated to each app.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
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
@@ -0,0 +1,22 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Report vulnerabilities through our [HackerOne program](https://hackerone.com/basecamp) or at
|
||||||
|
https://github.com/basecamp/once-campfire/security. Note that Campfire is in scope for our security
|
||||||
|
program, but is not bounty eligible.
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
Campfire is self-hosted and single-tenant, so the administrator is the server operator,
|
||||||
|
with shell, network, and database access already. Anything requiring the administrator
|
||||||
|
role grants nothing they do not already have, and is not a vulnerability.
|
||||||
|
|
||||||
|
We do want reports of anything a **non-administrator** can reach, and of any credential or
|
||||||
|
network path held by the Campfire process but not by the operator's own shell.
|
||||||
|
|
||||||
|
## Intentional behavior
|
||||||
|
|
||||||
|
Bot webhook URLs are unrestricted: an administrator can point one at any address,
|
||||||
|
including internal ones, because operators legitimately wire bots to their own services.
|
||||||
|
Link unfurling is different because any member can trigger it by pasting a URL, so it
|
||||||
|
validates destinations through `RestrictedHTTP::PrivateNetworkGuard`. The difference is who
|
||||||
|
picks the destination.
|
||||||
@@ -1,165 +1,160 @@
|
|||||||
trix-toolbar {
|
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
|
||||||
display: none;
|
so everything follows Campfire's light/dark color scheme */
|
||||||
inset: auto 0 0 0;
|
.lexxy-content {
|
||||||
min-width: 0;
|
--lexxy-color-canvas: transparent;
|
||||||
position: sticky;
|
--lexxy-color-text: var(--color-text);
|
||||||
z-index: 3;
|
--lexxy-color-text-subtle: var(--color-text);
|
||||||
|
--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);
|
||||||
|
|
||||||
.trix-dialog {
|
/* Campfire's message lines stack without extra block margins */
|
||||||
bottom: 3rem !important;
|
--lexxy-content-margin: 0;
|
||||||
top: auto !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trix-button--icon {
|
lexxy-editor {
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.trix-button-group--file-tools,
|
/* The mentions menu needs a solid surface — the editor's transparent canvas
|
||||||
.trix-button-group--history-tools {
|
would let messages show through it */
|
||||||
|
.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,
|
/* No group separator pipes, like the Trix toolbar */
|
||||||
.trix-button {
|
.lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after {
|
||||||
border: 0 !important;
|
display: none;
|
||||||
margin: 0 !important;
|
|
||||||
border-radius: 0.3em !important;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* Dropdown panels: a solid surface with a uniform radius. Lexxy derives
|
||||||
&::before {
|
the panel's radius and gap from --lexxy-toolbar-gap, which Campfire
|
||||||
filter: invert(100);
|
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);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
gap: 0.25em;
|
||||||
|
padding: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lexxy-editor__toolbar-dropdown > .lexxy-editor__toolbar-button[aria-expanded="true"] {
|
||||||
|
border-radius: var(--lexxy-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.trix-active {
|
/* Attachment markup contains formatting whitespace that must not turn into
|
||||||
background-color: var(--color-text) !important;
|
line breaks under the editor's white-space: pre-wrap */
|
||||||
|
lexxy-editor action-text-attachment {
|
||||||
&::before {
|
white-space: normal;
|
||||||
filter: invert(100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
action-text-attachment[content-type~="application/vnd.campfire.mention"] {
|
||||||
&::before {
|
display: inline-flex;
|
||||||
filter: invert(0);
|
margin: 0;
|
||||||
}
|
padding: 0;
|
||||||
}
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.trix-button--remove {
|
action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed"] {
|
||||||
background-color: var(--color-negative);
|
lexxy-editor & {
|
||||||
border-radius: 1.8em !important;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
filter: invert(100);
|
|
||||||
background-size: 70%;
|
|
||||||
background-image: url("minus.svg");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-trix-content-type~='application/vnd.campfire.mention'] {
|
|
||||||
.trix-button-group {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-trix-content-type~="application/vnd.actiontext.opengraph-embed"] {
|
|
||||||
* {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
trix-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.
|
||||||
|
The lexxy-editor prefix out-specifies Lexxy's own selection rule. */
|
||||||
|
lexxy-editor &.node--selected {
|
||||||
|
--lexxy-attachment-bg-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trix-button:not(.trix-button--remove) {
|
/* A prominent remove button on the card's top edge, horizontally
|
||||||
@media (any-hover: hover) {
|
centered, like the Trix one. The lexxy-editor prefix out-specifies
|
||||||
&:where(:not(:active):hover) {
|
Lexxy's own placement rules. */
|
||||||
filter: brightness(0.9);
|
lexxy-editor & lexxy-node-delete-button {
|
||||||
background-color: var(--color-bg);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
.lexxy-content {
|
||||||
opacity: 0.33;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -179,11 +174,13 @@ trix-editor,
|
|||||||
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;
|
||||||
@@ -199,13 +196,14 @@ trix-editor,
|
|||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
ul,
|
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */
|
||||||
|
ul:not(.lexxy-prompt-menu),
|
||||||
ol {
|
ol {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li:not(.lexxy-prompt-menu__item) {
|
||||||
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 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-trix-content-type~='application/vnd.campfire.mention'] {
|
action-text-attachment[content-type~='application/vnd.campfire.mention'] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -100,16 +100,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer--rich-text {
|
.composer--rich-text {
|
||||||
trix-toolbar {
|
lexxy-toolbar {
|
||||||
display: block;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
trix-editor.input {
|
lexxy-editor.input {
|
||||||
margin-block-start: var(--block-space-half);
|
margin-block-start: var(--block-space-half);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer--edit trix-toolbar {
|
/* In the message composer the toolbar goes below the text, on the same
|
||||||
|
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;
|
||||||
@@ -117,6 +143,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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,
|
|
||||||
.trix-content {
|
.lexxy-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 @@ trix-editor,
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cf-twitter-avatar & {
|
&.og-embed--twitter-avatar {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,10 +35,11 @@ trix-editor,
|
|||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +54,11 @@ trix-editor,
|
|||||||
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;
|
||||||
@@ -63,7 +68,7 @@ trix-editor,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cf-twitter-avatar & {
|
.og-embed--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);
|
||||||
|
|
||||||
.trix-content {
|
.lexxy-content {
|
||||||
font-size: 2.4em;
|
font-size: 2.4em;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 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
|
||||||
@@ -22,6 +22,6 @@ class PresenceChannel < RoomChannel
|
|||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_read_room
|
def broadcast_read_room
|
||||||
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
|
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id, at: Time.current.to_f }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 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,5 +1,12 @@
|
|||||||
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 "unread_rooms"
|
stream_from self.class.stream_name_for(current_user.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -8,9 +8,8 @@ class Accounts::LogosController < ApplicationController
|
|||||||
if stale?(etag: Current.account)
|
if stale?(etag: Current.account)
|
||||||
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
|
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
|
||||||
|
|
||||||
if Current.account&.logo&.attached?
|
if (logo_variant = Current.account&.logo_variant(logo_size))
|
||||||
logo = Current.account.logo.variant(logo_variant).processed
|
send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
|
||||||
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
|
|
||||||
else
|
else
|
||||||
send_stock_icon
|
send_stock_icon
|
||||||
end
|
end
|
||||||
@@ -23,9 +22,6 @@ class Accounts::LogosController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
|
|
||||||
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
|
|
||||||
|
|
||||||
def send_png_file(path)
|
def send_png_file(path)
|
||||||
send_file path, content_type: "image/png", disposition: :inline
|
send_file path, content_type: "image/png", disposition: :inline
|
||||||
end
|
end
|
||||||
@@ -38,8 +34,8 @@ class Accounts::LogosController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def logo_variant
|
def logo_size
|
||||||
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
|
small_logo? ? :small : :large
|
||||||
end
|
end
|
||||||
|
|
||||||
def small_logo?
|
def small_logo?
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class Accounts::UsersController < ApplicationController
|
|||||||
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
|
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
set_page_and_extract_portion_from User.active.ordered.without_bots, per_page: 500
|
set_page_and_extract_portion_from User.visible_to(Current.user).ordered.member, per_page: 500
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ class AccountsController < ApplicationController
|
|||||||
before_action :set_account
|
before_action :set_account
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
users = account_users.ordered.without_bots
|
users = User.visible_to(Current.user).ordered
|
||||||
@administrators, @members = users.partition(&:administrator?)
|
@administrators = users.administrator
|
||||||
set_page_and_extract_portion_from users, per_page: 500
|
set_page_and_extract_portion_from users.member, per_page: 500
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -21,12 +21,4 @@ class AccountsController < ApplicationController
|
|||||||
def account_params
|
def account_params
|
||||||
params.require(:account).permit(:name, :logo, settings: {})
|
params.require(:account).permit(:name, :logo, settings: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_users
|
|
||||||
if Current.user.can_administer?
|
|
||||||
User.where(status: [ :active, :banned ])
|
|
||||||
else
|
|
||||||
User.active
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
|
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses
|
||||||
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
||||||
end
|
end
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
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
|
||||||
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
|
if query.present?
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module ActiveStorageAuthentication
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
include Authentication::SessionLookup
|
||||||
|
|
||||||
|
private
|
||||||
|
def require_active_storage_authentication
|
||||||
|
head :unauthorized unless find_session_by_cookie
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -71,6 +71,13 @@ 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)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
module CachedResponses
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
CACHE_HEADERS = %w[ content-type cache-control etag last-modified vary ].freeze
|
||||||
|
CSRF_TAG = /<meta\b[^>]*\bname="csrf-token"[^>]*>|<input\b[^>]*\bname="authenticity_token"[^>]*>/
|
||||||
|
|
||||||
|
included do
|
||||||
|
prepend_before_action :capture_response_cache_version
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_caching
|
||||||
|
return false unless super && @response_cache_version.present? && !ActiveRecord::Base.connection.transaction_open?
|
||||||
|
|
||||||
|
# Check again after authentication, before the first native fragment lookup.
|
||||||
|
# Later renders retain this immutable namespace and cannot poison a new epoch.
|
||||||
|
unless defined?(@fragment_cache_snapshot_valid)
|
||||||
|
@fragment_cache_snapshot_valid = @response_cache_version == ResponseCache.instance.version
|
||||||
|
end
|
||||||
|
@fragment_cache_snapshot_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
# Keep the class store (including shared rate limits) and Rails.cache unchanged.
|
||||||
|
def cache_store
|
||||||
|
FragmentCache.store
|
||||||
|
end
|
||||||
|
|
||||||
|
def combined_fragment_cache_key(key)
|
||||||
|
@fragment_cache_namespace ||= [
|
||||||
|
@response_cache_version, request.base_url, request.script_name, request.format.to_s, I18n.locale,
|
||||||
|
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session),
|
||||||
|
(Digest::SHA256.hexdigest(real_csrf_token) if request.format.html? || request.format.turbo_stream?)
|
||||||
|
].freeze
|
||||||
|
super([ @fragment_cache_namespace, key ])
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def capture_response_cache_version
|
||||||
|
# Capture for native HTML/JSON/stream renders too, even with page reuse off.
|
||||||
|
# Detached renderers do not run callbacks and therefore render uncached.
|
||||||
|
@response_cache_version = ResponseCache.instance.version
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register after room authorization, but before presentation queries.
|
||||||
|
def cache_read_response
|
||||||
|
if cacheable_read_request?
|
||||||
|
token = form_authenticity_token
|
||||||
|
key = response_cache_key
|
||||||
|
original_session = session.to_hash.deep_dup
|
||||||
|
|
||||||
|
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
|
||||||
|
|
||||||
|
entry = ResponseCache.instance.read(key, @response_cache_version)
|
||||||
|
rendered = false
|
||||||
|
unless entry
|
||||||
|
ResponseCache.instance.synchronize_render(key, @response_cache_version) do
|
||||||
|
entry = ResponseCache.instance.read(key, @response_cache_version)
|
||||||
|
if !entry && ResponseCache.instance.version == @response_cache_version
|
||||||
|
yield
|
||||||
|
rendered = true
|
||||||
|
cache_completed_response(key, original_session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if entry
|
||||||
|
response.headers.merge!(entry[:headers])
|
||||||
|
self.response_body = entry[:body].gsub(entry[:marker], token)
|
||||||
|
elsif !rendered
|
||||||
|
# A queued request retains its pre-auth snapshot. If it has expired,
|
||||||
|
# render outside the stripe instead of blocking the next generation.
|
||||||
|
yield
|
||||||
|
end
|
||||||
|
else
|
||||||
|
yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cache_completed_response(key, original_session)
|
||||||
|
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session
|
||||||
|
marker = "campfire-csrf-#{SecureRandom.hex(32)}"
|
||||||
|
# Replace only framework token attributes, never a matching token in
|
||||||
|
# message text. Postprocessing also leaves fragment caches untouched.
|
||||||
|
body = csrf_neutral_body(response.body, marker)
|
||||||
|
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
|
||||||
|
ResponseCache.instance.write(key, @response_cache_version, { body: body.freeze, marker: marker.freeze, headers: headers }.freeze)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cacheable_read_request?
|
||||||
|
ResponseCache.instance.budget.positive? && @response_cache_version && request.get? && request.format.html? && Current.session &&
|
||||||
|
!authenticated_by.bot_key? && flash.empty? &&
|
||||||
|
!request.headers["If-None-Match"] && !request.headers["If-Modified-Since"] &&
|
||||||
|
!Rails.application.config.content_security_policy_nonce_generator &&
|
||||||
|
!ActiveRecord::Base.connection.transaction_open?
|
||||||
|
end
|
||||||
|
|
||||||
|
def response_cache_key
|
||||||
|
ActiveSupport::JSON.encode([
|
||||||
|
controller_path, request.fullpath, request.base_url, request.user_agent,
|
||||||
|
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
|
||||||
|
# Tokens are hydrated per request, including clients that replay an old
|
||||||
|
# cookie. Their raw CSRF secret does not select a presentation variant.
|
||||||
|
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
|
||||||
|
cookies.to_h.except("_campfire_session", "session_token")
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
def csrf_neutral_body(body, marker)
|
||||||
|
body.gsub(CSRF_TAG) do |tag|
|
||||||
|
attribute = tag.start_with?("<meta") ? "content" : "value"
|
||||||
|
tag.sub(/\b#{attribute}="[^"]*"/, %(#{attribute}="#{marker}"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
@@ -8,6 +8,6 @@ module SetCurrentRequest
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_url_options
|
def default_url_options
|
||||||
{ host: Current.request_host, protocol: Current.request_protocol }.compact_blank
|
{ host: Current.request_host, port: Current.request&.optional_port, protocol: Current.request_protocol }.compact_blank
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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,5 +1,6 @@
|
|||||||
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
|
||||||
@@ -15,7 +16,6 @@ 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,13 +26,17 @@ 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
|
||||||
|
|
||||||
def broadcast_create
|
def broadcast_create
|
||||||
@boost.broadcast_append_to @boost.message.room, :messages,
|
@boost.broadcast_append_to @boost.message.room, :messages,
|
||||||
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
target: helpers.dom_id(@boost.message, :boosts), partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_remove
|
def broadcast_remove
|
||||||
|
|||||||
@@ -1,24 +1,64 @@
|
|||||||
class Messages::ByBotsController < MessagesController
|
class Messages::ByBotsController < MessagesController
|
||||||
allow_bot_access only: :create
|
include RawRequestBody
|
||||||
|
|
||||||
|
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
|
||||||
reading(request.body) { |body| { body: body } }
|
{ body: raw_request_body }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def reading(io)
|
|
||||||
io.rewind
|
|
||||||
yield io.read.force_encoding("UTF-8")
|
|
||||||
ensure
|
|
||||||
io.rewind
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -4,6 +4,7 @@ class MessagesController < ApplicationController
|
|||||||
before_action :set_room, except: :create
|
before_action :set_room, except: :create
|
||||||
before_action :set_message, only: %i[ show edit update destroy ]
|
before_action :set_message, only: %i[ show edit update destroy ]
|
||||||
before_action :ensure_can_administer, only: %i[ edit update destroy ]
|
before_action :ensure_can_administer, only: %i[ edit update destroy ]
|
||||||
|
around_action :cache_read_response, only: :index
|
||||||
|
|
||||||
layout false, only: :index
|
layout false, only: :index
|
||||||
|
|
||||||
@@ -11,7 +12,14 @@ class MessagesController < ApplicationController
|
|||||||
@messages = find_paged_messages
|
@messages = find_paged_messages
|
||||||
|
|
||||||
if @messages.any?
|
if @messages.any?
|
||||||
fresh_when @messages
|
body = render_to_string(:index)
|
||||||
|
# Creator, body and boost edits can change HTML without touching messages.
|
||||||
|
# Masked CSRF tokens remain fresh while the presentation validator stays stable.
|
||||||
|
fresh_when etag: Digest::SHA256.hexdigest(csrf_neutral_body(body, "")), template: false
|
||||||
|
unless performed?
|
||||||
|
response.content_type = "text/html"
|
||||||
|
self.response_body = body
|
||||||
|
end
|
||||||
else
|
else
|
||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
@@ -37,7 +45,11 @@ 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
|
||||||
@@ -58,11 +70,11 @@ class MessagesController < ApplicationController
|
|||||||
def find_paged_messages
|
def find_paged_messages
|
||||||
case
|
case
|
||||||
when params[:before].present?
|
when params[:before].present?
|
||||||
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
|
@room.messages.with_presentation.page_before(@room.messages.find(params[:before]))
|
||||||
when params[:after].present?
|
when params[:after].present?
|
||||||
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
|
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
|
||||||
else
|
else
|
||||||
@room.messages.with_creator.last_page
|
@room.messages.with_presentation.last_page
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ 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,8 +29,13 @@ class Rooms::DirectsController < RoomsController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# All users in a direct room can administer it
|
# All users in a direct room can administer it. Only direct rooms, though: this
|
||||||
|
# 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,6 +40,12 @@ 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
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
|
|||||||
before_action :set_last_updated_at
|
before_action :set_last_updated_at
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
|
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at)
|
||||||
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
|
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ class RoomsController < ApplicationController
|
|||||||
before_action :set_room, only: %i[ show destroy ]
|
before_action :set_room, only: %i[ show destroy ]
|
||||||
before_action :ensure_can_administer, only: %i[ destroy ]
|
before_action :ensure_can_administer, only: %i[ destroy ]
|
||||||
before_action :remember_last_room_visited, only: :show
|
before_action :remember_last_room_visited, only: :show
|
||||||
|
around_action :cache_read_response, only: :show
|
||||||
|
|
||||||
def index
|
def index
|
||||||
redirect_to room_url(Current.user.rooms.last)
|
redirect_to room_url(Current.user.rooms.last)
|
||||||
@@ -12,7 +13,7 @@ class RoomsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@room.destroy
|
@room.destroy_later
|
||||||
|
|
||||||
broadcast_remove_room
|
broadcast_remove_room
|
||||||
redirect_to root_url
|
redirect_to root_url
|
||||||
@@ -20,13 +21,19 @@ class RoomsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
def set_room
|
||||||
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
|
if room = room_scope.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
|
||||||
@@ -38,9 +45,9 @@ class RoomsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_messages
|
def find_messages
|
||||||
messages = @room.messages.with_creator.with_attachment_details.with_boosts
|
messages = @room.messages.with_presentation
|
||||||
|
|
||||||
if show_first_message = messages.find_by(id: params[:message_id])
|
if params[:message_id].present? && (show_first_message = messages.find_by(id: params[:message_id]))
|
||||||
@messages = messages.page_around(show_first_message)
|
@messages = messages.page_around(show_first_message)
|
||||||
else
|
else
|
||||||
@messages = messages.last_page
|
@messages = messages.last_page
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
class SearchesController < ApplicationController
|
class SearchesController < ApplicationController
|
||||||
|
around_action :cache_read_response, only: :index
|
||||||
before_action :set_messages
|
before_action :set_messages
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@@ -20,7 +21,7 @@ class SearchesController < ApplicationController
|
|||||||
private
|
private
|
||||||
def set_messages
|
def set_messages
|
||||||
if query.present?
|
if query.present?
|
||||||
@messages = Current.user.reachable_messages.search(query).last(100)
|
@messages = Message.search_reachable(Current.user, query)
|
||||||
else
|
else
|
||||||
@messages = Message.none
|
@messages = Message.none
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
class UnfurlLinksController < ApplicationController
|
class UnfurlLinksController < ApplicationController
|
||||||
def create
|
# The pasted link decides which hosts an unfurl looks up and fetches from, and how quickly they answer.
|
||||||
opengraph = Opengraph::Metadata.from_url(url_param)
|
DEADLINE = 10.seconds
|
||||||
|
|
||||||
if opengraph.valid?
|
def create
|
||||||
|
if opengraph = unfurl(url_param)
|
||||||
render json: opengraph
|
render json: opengraph
|
||||||
else
|
else
|
||||||
head :no_content
|
head :no_content
|
||||||
@@ -13,4 +14,14 @@ class UnfurlLinksController < ApplicationController
|
|||||||
def url_param
|
def url_param
|
||||||
params.require(:url)
|
params.require(:url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# One deadline for everything the link leads to: DNS lookups, redirects, the page, the image check.
|
||||||
|
def unfurl(url)
|
||||||
|
Timeout.timeout(DEADLINE) do
|
||||||
|
Opengraph::Metadata.from_url(url).then { |opengraph| opengraph if opengraph.valid? }
|
||||||
|
end
|
||||||
|
rescue Timeout::Error
|
||||||
|
Rails.logger.warn "Gave up unfurling #{url} after #{DEADLINE.inspect}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -9,8 +9,7 @@ class Users::AvatarsController < ApplicationController
|
|||||||
if stale?(etag: @user)
|
if stale?(etag: @user)
|
||||||
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
|
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
|
||||||
|
|
||||||
if @user.avatar.attached?
|
if (avatar_variant = @user.avatar_variant)
|
||||||
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
|
|
||||||
send_webp_blob_file avatar_variant.key
|
send_webp_blob_file avatar_variant.key
|
||||||
elsif @user.bot?
|
elsif @user.bot?
|
||||||
render_default_bot
|
render_default_bot
|
||||||
@@ -26,8 +25,6 @@ class Users::AvatarsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
|
|
||||||
|
|
||||||
def send_webp_blob_file(key)
|
def send_webp_blob_file(key)
|
||||||
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
|
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ 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,19 +1,16 @@
|
|||||||
class Users::SidebarsController < ApplicationController
|
class Users::SidebarsController < ApplicationController
|
||||||
DIRECT_PLACEHOLDERS = 20
|
DIRECT_PLACEHOLDERS = 20
|
||||||
|
around_action :cache_read_response, only: :show
|
||||||
|
|
||||||
def show
|
def show
|
||||||
all_memberships = Current.user.memberships.visible.with_ordered_room
|
visible_memberships = Current.user.memberships.visible
|
||||||
@direct_memberships = extract_direct_memberships(all_memberships)
|
@direct_memberships = visible_memberships.with_direct_rooms
|
||||||
@other_memberships = all_memberships.without(@direct_memberships)
|
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms
|
||||||
|
|
||||||
@direct_placeholder_users = find_direct_placeholder_users
|
@direct_placeholder_users = find_direct_placeholder_users
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def extract_direct_memberships(all_memberships)
|
|
||||||
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_direct_placeholder_users
|
def find_direct_placeholder_users
|
||||||
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
|
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
|
||||||
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
|
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
module ContentFilters
|
module ContentFilters
|
||||||
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
|
# Formatting the rich text editor produces that Rails' sanitizers don't
|
||||||
|
# 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,18 +1,22 @@
|
|||||||
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
|
||||||
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
if trix_body?
|
||||||
|
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
|
||||||
unfurled_links.first["href"] if unfurled_links.size == 1
|
ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
def unfurled_links
|
def unfurled_links
|
||||||
@@ -35,4 +39,20 @@ 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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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,4 +1,8 @@
|
|||||||
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
|
||||||
@@ -8,9 +12,6 @@ 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
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -17,11 +17,16 @@ class Messages::AttachmentPresentation
|
|||||||
attr_reader :message, :context
|
attr_reader :message, :context
|
||||||
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
|
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
|
||||||
|
|
||||||
|
# Previews are made when the message is posted, within limits. One that wasn't, because the file was too large or
|
||||||
|
# the preview failed, isn't attempted again from here: its URL would make it on every view.
|
||||||
def render_preview
|
def render_preview
|
||||||
if message.attachment.video?
|
case
|
||||||
|
when message.attachment.video?
|
||||||
video_preview_tag
|
video_preview_tag
|
||||||
else
|
when message.attachment.representation(:thumb).processed?
|
||||||
lightboxed_image_preview_tag
|
lightboxed_image_preview_tag
|
||||||
|
else
|
||||||
|
render_link
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -30,11 +35,17 @@ class Messages::AttachmentPresentation
|
|||||||
|
|
||||||
inline_media_dimension_constraints(width, height) do
|
inline_media_dimension_constraints(width, height) do
|
||||||
tag.video \
|
tag.video \
|
||||||
src: rails_blob_path(message.attachment), poster: url_for(message.attachment.preview(format: :webp, resize_to_limit: [ Message::THUMBNAIL_MAX_WIDTH, Message::THUMBNAIL_MAX_HEIGHT ])),
|
src: rails_blob_path(message.attachment), poster: video_poster_url,
|
||||||
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
|
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A preview is processed once the frame is drawn, but the poster is a variant of that frame, which can fail on its own.
|
||||||
|
def video_poster_url
|
||||||
|
poster = message.attachment.preview(:poster)
|
||||||
|
url_for(poster) if poster.processed? && poster.image.variant(poster.variation).processed?
|
||||||
|
end
|
||||||
|
|
||||||
def lightboxed_image_preview_tag
|
def lightboxed_image_preview_tag
|
||||||
width, height = preview_dimensions
|
width, height = preview_dimensions
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
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",
|
||||||
@@ -31,6 +36,7 @@ module MessagesHelper
|
|||||||
controller: "reply",
|
controller: "reply",
|
||||||
user_id: message.creator_id,
|
user_id: message.creator_id,
|
||||||
message_id: message.id,
|
message_id: message.id,
|
||||||
|
client_message_id: message.client_message_id,
|
||||||
message_timestamp: message_timestamp_milliseconds,
|
message_timestamp: message_timestamp_milliseconds,
|
||||||
message_updated_at: message.updated_at.to_fs(:epoch),
|
message_updated_at: message.updated_at.to_fs(:epoch),
|
||||||
sort_value: message_timestamp_milliseconds,
|
sort_value: message_timestamp_milliseconds,
|
||||||
@@ -57,7 +63,8 @@ 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)), html: { target: "_blank" }
|
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
|
||||||
|
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,11 +1,33 @@
|
|||||||
module RichTextHelper
|
module RichTextHelper
|
||||||
def rich_text_data_actions
|
def rich_text_data_actions
|
||||||
default_actions =
|
# submitByKeyboard runs in the capture phase so it can submit on Enter
|
||||||
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
|
# before the editor turns the keystroke into a newline
|
||||||
|
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
|
||||||
|
end
|
||||||
|
|
||||||
autocomplete_actions =
|
def mention_prompt_tag(room)
|
||||||
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
|
tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id),
|
||||||
|
"remote-filtering": true, "empty-results": "No matches"
|
||||||
|
end
|
||||||
|
|
||||||
[ default_actions, autocomplete_actions ].join(" ")
|
# The editor keeps an attachment's content as it finds it, so every
|
||||||
|
# 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"
|
||||||
|
|
||||||
trix_attachment_actions =
|
attachment_actions =
|
||||||
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
"lexxy: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, trix_attachment_actions, remaining_actions ].join(" ")
|
[ drop_target_actions, drag_and_drop_actions, attachment_actions, remaining_actions ].join(" ")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
// 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/dom_helpers"
|
import { escapeHTML } from "helpers/string_helpers"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static classes = ["toolbar"]
|
static classes = ["toolbar"]
|
||||||
@@ -12,11 +12,21 @@ 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()
|
||||||
|
|
||||||
@@ -44,21 +54,20 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceMessageContent(content) {
|
replaceMessageContent(content) {
|
||||||
const editor = this.textTarget.editor
|
this.textTarget.value = content
|
||||||
|
this.textTarget.focus()
|
||||||
editor.recordUndoEntry("Format reply")
|
this.textTarget.selection.placeCursorAtTheEnd()
|
||||||
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.key == "Enter" && (event.metaKey || event.ctrlKey)
|
const metaEnter = event.metaKey || event.ctrlKey
|
||||||
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
|
const plainEnter = !event.shiftKey && !event.isComposing
|
||||||
|
|
||||||
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
||||||
|
event.stopPropagation()
|
||||||
this.submit(event)
|
this.submit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +117,19 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -126,7 +148,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#validInput() {
|
#validInput() {
|
||||||
return this.textTarget.textContent.trim().length > 0
|
return !this.textTarget.isBlank
|
||||||
}
|
}
|
||||||
|
|
||||||
async #submitFiles() {
|
async #submitFiles() {
|
||||||
@@ -159,6 +181,7 @@ export default class extends Controller {
|
|||||||
|
|
||||||
#reset() {
|
#reset() {
|
||||||
this.textTarget.value = ""
|
this.textTarget.value = ""
|
||||||
|
localStorage.removeItem(this.#draftKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateFileList() {
|
#updateFileList() {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
messageTargetConnected(target) {
|
messageTargetConnected(target) {
|
||||||
|
this.#reconcilePendingMessage(target)
|
||||||
this.#formatter.format(target, ThreadStyle.thread)
|
this.#formatter.format(target, ThreadStyle.thread)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async editMyLastMessage() {
|
async editMyLastMessage() {
|
||||||
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
|
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
|
||||||
|
|
||||||
if (editorEmpty && this.#paginator.upToDate) {
|
if (editorEmpty && this.#paginator.upToDate) {
|
||||||
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
||||||
@@ -125,6 +126,25 @@ export default class extends Controller {
|
|||||||
|
|
||||||
// Internal
|
// Internal
|
||||||
|
|
||||||
|
// The composer inserts a pending message client-side using the client_message_id
|
||||||
|
// as its DOM id. The server broadcast now renders the real message with a DOM id
|
||||||
|
// derived from the record's primary key, so it no longer replaces the pending
|
||||||
|
// element by id. Reconcile by matching the server message's client_message_id and
|
||||||
|
// dropping our own pending placeholder. Only elements flagged data-pending-message
|
||||||
|
// (client-side placeholders, never broadcast) are removed, so a message another
|
||||||
|
// member posted reusing this client_message_id can never displace a real one.
|
||||||
|
#reconcilePendingMessage(target) {
|
||||||
|
const clientMessageId = target.dataset.clientMessageId
|
||||||
|
|
||||||
|
if (target.dataset.messageId && clientMessageId) {
|
||||||
|
const selector = `[data-pending-message][data-client-message-id="${CSS.escape(clientMessageId)}"]`
|
||||||
|
|
||||||
|
this.messagesTarget.querySelectorAll(selector).forEach((pending) => {
|
||||||
|
if (pending !== target) pending.remove()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #ensureUpToDate() {
|
async #ensureUpToDate() {
|
||||||
if (!this.#paginator.upToDate) {
|
if (!this.#paginator.upToDate) {
|
||||||
await this.#paginator.resetToLastPage()
|
await this.#paginator.resetToLastPage()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default class extends Controller {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#read = ({ room_id }) => {
|
#read = ({ room_id, at }) => {
|
||||||
this.dispatch("read", { detail: { roomId: room_id } })
|
this.dispatch("read", { detail: { roomId: room_id, at } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reply() {
|
reply() {
|
||||||
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><br>`
|
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><p><br></p>`
|
||||||
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(".trix-content").cloneNode(true)
|
const body = this.bodyTarget.querySelector(".lexxy-content").cloneNode(true)
|
||||||
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
|
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,13 @@ export default class extends Controller {
|
|||||||
static classes = [ "unread" ]
|
static classes = [ "unread" ]
|
||||||
|
|
||||||
#disconnected = true
|
#disconnected = true
|
||||||
|
#connection = 0
|
||||||
|
#discardDirectEditor = false
|
||||||
|
#readAt = new Map()
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
|
this.element.addEventListener("turbo:click", this.#directNavigation)
|
||||||
|
this.element.addEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
|
||||||
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
|
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
|
||||||
connected: this.#channelConnected.bind(this),
|
connected: this.#channelConnected.bind(this),
|
||||||
disconnected: this.#channelDisconnected.bind(this),
|
disconnected: this.#channelDisconnected.bind(this),
|
||||||
@@ -17,7 +22,11 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
this.element.removeEventListener("turbo:click", this.#directNavigation)
|
||||||
|
this.element.removeEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
|
||||||
|
this.#discardDirectEditor = false
|
||||||
ignoringBriefDisconnects(this.element, () => {
|
ignoringBriefDisconnects(this.element, () => {
|
||||||
|
this.#channelDisconnected()
|
||||||
this.channel?.unsubscribe()
|
this.channel?.unsubscribe()
|
||||||
this.channel = null
|
this.channel = null
|
||||||
})
|
})
|
||||||
@@ -27,31 +36,70 @@ export default class extends Controller {
|
|||||||
this.read({ detail: { roomId: Current.room.id } })
|
this.read({ detail: { roomId: Current.room.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
read({ detail: { roomId } }) {
|
read({ detail: { roomId, at } }) {
|
||||||
const room = this.#findRoomTarget(roomId)
|
const room = this.#findRoomTarget(roomId)
|
||||||
|
|
||||||
|
if (at) {
|
||||||
|
this.#readAt.set(Number(roomId), Math.max(Number(at), this.#readAt.get(Number(roomId)) ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
room.classList.remove(this.unreadClass)
|
room.classList.remove(this.unreadClass)
|
||||||
this.dispatch("read", { detail: { targetId: roomId } })
|
this.dispatch("read", { detail: { targetId: roomId } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#channelConnected() {
|
// Preserve the live picker through background refreshes, but allow deliberate navigation.
|
||||||
|
#directNavigation = event => {
|
||||||
|
if (event.target.closest("a")?.dataset.turboFrame === this.element.id) {
|
||||||
|
this.#discardDirectEditor = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#preserveDirectEditor = event => {
|
||||||
|
if (event.target !== this.element) return
|
||||||
|
const discard = this.#discardDirectEditor
|
||||||
|
this.#discardDirectEditor = false
|
||||||
|
if (discard) return
|
||||||
|
|
||||||
|
const current = this.element.querySelector("#direct_rooms_control")
|
||||||
|
const incoming = event.detail.newFrame.querySelector("#direct_rooms_control")
|
||||||
|
if (!current?.querySelector('[data-autocomplete-target="input"]') || !incoming) return
|
||||||
|
|
||||||
|
// Turbo's Bardo moves the actual element, keeping input, selections and controllers.
|
||||||
|
const controls = [ current, incoming ].map(element => [ element, element.hasAttribute("data-turbo-permanent") ])
|
||||||
|
for (const [ element ] of controls) element.setAttribute("data-turbo-permanent", "")
|
||||||
|
const render = event.detail.render
|
||||||
|
event.detail.render = (...args) => {
|
||||||
|
try {
|
||||||
|
return render(...args)
|
||||||
|
} finally {
|
||||||
|
for (const [ element, permanent ] of controls) element.toggleAttribute("data-turbo-permanent", permanent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #channelConnected() {
|
||||||
if (this.#disconnected) {
|
if (this.#disconnected) {
|
||||||
this.#disconnected = false
|
this.#disconnected = false
|
||||||
|
const connection = ++this.#connection
|
||||||
|
// Reloading an unfinished frame aborts its response body reader.
|
||||||
|
await this.element.loaded
|
||||||
|
if (this.element.isConnected && !this.#disconnected && connection === this.#connection) {
|
||||||
this.element.reload()
|
this.element.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#channelDisconnected() {
|
#channelDisconnected() {
|
||||||
this.#disconnected = true
|
this.#disconnected = true
|
||||||
}
|
}
|
||||||
|
|
||||||
#unread({ roomId }) {
|
#unread({ roomId, at }) {
|
||||||
const unreadRoom = this.#findRoomTarget(roomId)
|
const unreadRoom = this.#findRoomTarget(roomId)
|
||||||
|
|
||||||
if (unreadRoom) {
|
if (unreadRoom) {
|
||||||
if (Current.room.id != roomId) {
|
if (Current.room.id != roomId && !this.#readSince(roomId, at)) {
|
||||||
unreadRoom.classList.add(this.unreadClass)
|
unreadRoom.classList.add(this.unreadClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +107,12 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notices fan out one member at a time, so one can arrive after the member has already
|
||||||
|
// read the room in another tab. It still reorders the room, but doesn't mark it unread.
|
||||||
|
#readSince(roomId, at) {
|
||||||
|
return Number(at) <= this.#readAt.get(Number(roomId))
|
||||||
|
}
|
||||||
|
|
||||||
#findRoomTarget(roomId) {
|
#findRoomTarget(roomId) {
|
||||||
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
|
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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,12 +4,6 @@ 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,3 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -5,3 +7,7 @@ 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,10 +1,19 @@
|
|||||||
import Unfurler from "lib/rich_text/unfurl/unfurler"
|
import * as Lexxy from "lexxy"
|
||||||
|
import CampfireRichTextExtension from "lib/rich_text/campfire_extension"
|
||||||
|
|
||||||
// Support a `cite` block for attribution links
|
Lexxy.configure({
|
||||||
Trix.config.blockAttributes.cite = {
|
global: {
|
||||||
tagName: "cite",
|
// Keep the content type of mention attachments (application/vnd.campfire.mention)
|
||||||
inheritable: false,
|
// that Campfire has used since its Trix days
|
||||||
|
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
|
||||||
|
headings: [ "h1" ]
|
||||||
}
|
}
|
||||||
|
})
|
||||||
const unfurler = new Unfurler()
|
|
||||||
unfurler.install()
|
|
||||||
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
|
|||||||
|
|
||||||
#fetchAutocompletables(url) {
|
#fetchAutocompletables(url) {
|
||||||
if (url) {
|
if (url) {
|
||||||
return fetch(url, { as: "json" }).then(response => response.json())
|
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
|
||||||
} else {
|
} else {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
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 : {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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 ]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
|
messageClasses: this.#messageClassesFromNode(node),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,26 +52,37 @@ 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.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
return this.#plainTextFromNode(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
#stripWrapperElement(node) {
|
#plainTextFromNode(node) {
|
||||||
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
|
if (this.#isRichText(node)) {
|
||||||
|
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="trix-content">${node.innerHTML}</div>`
|
return `<div class="lexxy-content">${node.value}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,21 @@ export default class MessageFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#highlightCodeBlock(block) {
|
#highlightCodeBlock(block) {
|
||||||
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
|
this.#normalizeLineBreaks(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) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class Message::BroadcastUnreadRoomJob < ApplicationJob
|
||||||
|
def perform(message)
|
||||||
|
message.broadcast_unread_room
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
class Room::DestroyJob < ApplicationJob
|
||||||
|
discard_on ActiveJob::DeserializationError
|
||||||
|
|
||||||
|
def perform(room, former_member_ids)
|
||||||
|
room.reset_remote_connections_of(former_member_ids)
|
||||||
|
room.destroy_one_message_at_a_time
|
||||||
|
end
|
||||||
|
end
|
||||||
+10
-2
@@ -1,6 +1,14 @@
|
|||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
include Joinable
|
include Joinable
|
||||||
|
|
||||||
has_one_attached :logo
|
has_one_attached :logo do |attachable|
|
||||||
has_json :settings, restrict_room_creation_to_administrators: false
|
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
|
||||||
|
|
||||||
|
def logo_variant(size)
|
||||||
|
logo.variant(size).processed if logo.variable?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Native view caches share one byte budget across every database generation.
|
||||||
|
class FragmentCache
|
||||||
|
STORE = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes)
|
||||||
|
private_constant :STORE
|
||||||
|
|
||||||
|
def self.store
|
||||||
|
STORE
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,8 @@ class Membership < ApplicationRecord
|
|||||||
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
|
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
|
||||||
|
|
||||||
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
|
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
|
||||||
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
|
scope :with_direct_rooms, -> { includes(:room).joins(:room).merge(Room.directs).order(rooms: { updated_at: :desc }) }
|
||||||
|
scope :without_direct_rooms, -> { joins(:room).merge(Room.without_directs) }
|
||||||
|
|
||||||
scope :visible, -> { where.not(involvement: :invisible) }
|
scope :visible, -> { where.not(involvement: :invisible) }
|
||||||
scope :unread, -> { where.not(unread_at: nil) }
|
scope :unread, -> { where.not(unread_at: nil) }
|
||||||
|
|||||||
@@ -14,20 +14,16 @@ class Message < ApplicationRecord
|
|||||||
scope :ordered, -> { order(:created_at) }
|
scope :ordered, -> { order(:created_at) }
|
||||||
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
||||||
scope :with_attachment_details, -> {
|
scope :with_attachment_details, -> {
|
||||||
with_rich_text_body_and_embeds
|
with_rich_text_body_and_embeds.with_attached_attachment
|
||||||
with_attached_attachment
|
|
||||||
.includes(attachment_blob: :variant_records)
|
.includes(attachment_blob: :variant_records)
|
||||||
}
|
}
|
||||||
scope :with_boosts, -> { includes(boosts: :booster) }
|
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
|
||||||
|
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
|
||||||
|
|
||||||
def plain_text_body
|
def plain_text_body
|
||||||
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_key
|
|
||||||
[ client_message_id ]
|
|
||||||
end
|
|
||||||
|
|
||||||
def content_type
|
def content_type
|
||||||
case
|
case
|
||||||
when attachment? then "attachment"
|
when attachment? then "attachment"
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ module Message::Attachment
|
|||||||
THUMBNAIL_MAX_WIDTH = 1200
|
THUMBNAIL_MAX_WIDTH = 1200
|
||||||
THUMBNAIL_MAX_HEIGHT = 800
|
THUMBNAIL_MAX_HEIGHT = 800
|
||||||
|
|
||||||
|
# Decoding a picture, or a video's frame, costs in proportion to its pixels, however small the file. The largest
|
||||||
|
# phone photos have 200 million.
|
||||||
|
THUMBNAIL_MAX_PIXELS = 250_000_000
|
||||||
|
|
||||||
included do
|
included do
|
||||||
has_one_attached :attachment do |attachable|
|
has_one_attached :attachment do |attachable|
|
||||||
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
||||||
|
attachable.variant :poster, format: :webp, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -30,12 +35,25 @@ module Message::Attachment
|
|||||||
attachment&.analyze
|
attachment&.analyze
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A file that ffmpeg or libvips can't decode is still the message: post it without a preview.
|
||||||
def process_attachment_thumbnail
|
def process_attachment_thumbnail
|
||||||
|
return if too_many_pixels_to_preview?
|
||||||
|
|
||||||
case
|
case
|
||||||
when attachment.video?
|
when attachment.video?
|
||||||
attachment.preview(format: :webp).processed
|
attachment.preview(:poster).processed
|
||||||
when attachment.representable?
|
when attachment.representable?
|
||||||
attachment.representation(:thumb).processed
|
attachment.representation(:thumb).processed
|
||||||
end
|
end
|
||||||
|
rescue ActiveStorage::PreviewError, Vips::Error => error
|
||||||
|
Rails.logger.warn "Posted #{attachment.filename} without a preview: #{error.class}: #{error.message.lines.first&.chomp}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Without a size, the analyzer couldn't read the file's header, and the previewer wouldn't either.
|
||||||
|
def too_many_pixels_to_preview?
|
||||||
|
if attachment.image? || attachment.video?
|
||||||
|
width, height = attachment.metadata.values_at(:width, :height)
|
||||||
|
width.nil? || height.nil? || width * height > THUMBNAIL_MAX_PIXELS
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
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 ]
|
||||||
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
|
broadcast_unread_room_later
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_remove
|
def broadcast_remove
|
||||||
broadcast_remove_to room, :messages
|
broadcast_remove_to room, :messages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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. Of those,
|
||||||
|
# only the ones who still have it unread or are in it now: by the time this runs,
|
||||||
|
# someone may have opened the room and moved on, and would see it marked unread again.
|
||||||
|
def broadcast_unread_room
|
||||||
|
payload = ActiveSupport::JSON.encode(roomId: room.id, at: created_at.to_f)
|
||||||
|
|
||||||
|
room.memberships.unread.or(room.memberships.connected).pluck(:user_id).each do |user_id|
|
||||||
|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# The fanout is one publish per member, which in a big room takes far longer than the
|
||||||
|
# rest of posting a message, so it runs in a job rather than while the poster waits.
|
||||||
|
def broadcast_unread_room_later
|
||||||
|
Message::BroadcastUnreadRoomJob.perform_later(self)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -3,9 +3,31 @@ module Message::Pagination
|
|||||||
|
|
||||||
PAGE_SIZE = 40
|
PAGE_SIZE = 40
|
||||||
|
|
||||||
|
# Expose Rails' collection-preloading hook while preserving Array pagination and
|
||||||
|
# validators. The renderer passes only cache misses to preload_associations.
|
||||||
|
class Page < Array
|
||||||
|
def self.load(relation, direction, size)
|
||||||
|
new(relation.skip_preloading!.public_send(direction, size), relation)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(records, relation)
|
||||||
|
super(records)
|
||||||
|
@relation = relation
|
||||||
|
end
|
||||||
|
|
||||||
|
def loaded?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def preload_associations(records)
|
||||||
|
@relation.preload_associations(records)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
included do
|
included do
|
||||||
scope :last_page, -> { ordered.last(PAGE_SIZE) }
|
# Keep presentation data lazy until the collection cache knows which messages missed.
|
||||||
scope :first_page, -> { ordered.first(PAGE_SIZE) }
|
scope :last_page, -> { last_page_of(PAGE_SIZE) }
|
||||||
|
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
|
||||||
|
|
||||||
scope :before, ->(message) { where("created_at < ?", message.created_at) }
|
scope :before, ->(message) { where("created_at < ?", message.created_at) }
|
||||||
scope :after, ->(message) { where("created_at > ?", message.created_at) }
|
scope :after, ->(message) { where("created_at > ?", message.created_at) }
|
||||||
@@ -14,16 +36,23 @@ module Message::Pagination
|
|||||||
scope :page_after, ->(message) { after(message).first_page }
|
scope :page_after, ->(message) { after(message).first_page }
|
||||||
|
|
||||||
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
|
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
|
||||||
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
|
# Sorting on +created_at, which no index covers, has SQLite find the few messages updated since
|
||||||
|
# through (room_id, updated_at) and sort just those, instead of walking the whole room by
|
||||||
|
# (room_id, created_at) looking for them.
|
||||||
|
scope :page_updated_since, ->(time) { Page.load(where("updated_at > ?", time).reorder(Arel.sql("+messages.created_at")), :last, PAGE_SIZE) }
|
||||||
end
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
|
def last_page_of(size)
|
||||||
|
Page.load(ordered, :last, size)
|
||||||
|
end
|
||||||
|
|
||||||
def page_around(message)
|
def page_around(message)
|
||||||
page_before(message) + [ message ] + page_after(message)
|
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
|
||||||
end
|
end
|
||||||
|
|
||||||
def paged?
|
def paged?
|
||||||
count > PAGE_SIZE
|
offset(PAGE_SIZE).exists?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3,10 +3,45 @@ module Message::Searchable
|
|||||||
|
|
||||||
included do
|
included do
|
||||||
after_create_commit :create_in_index
|
after_create_commit :create_in_index
|
||||||
after_update_commit :update_in_index
|
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
|
||||||
|
after_update_commit :update_in_index, if: :indexed_text_changed?
|
||||||
after_destroy_commit :remove_from_index
|
after_destroy_commit :remove_from_index
|
||||||
|
|
||||||
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
|
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
|
||||||
|
end
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
# Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than
|
||||||
|
# parsing them as operators, which fails on a query like "AND" or "salt AND".
|
||||||
|
def match_terms(query)
|
||||||
|
query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
def search_reachable(user, query, size: 100)
|
||||||
|
relation = user.reachable_messages.search(query).with_presentation
|
||||||
|
probe = connection.select_rows(sanitize_sql([ <<~SQL, user.id, match_terms(query) ]))
|
||||||
|
SELECT m.id, mm.user_id IS NOT NULL FROM message_search_index idx
|
||||||
|
JOIN messages m ON m.id = idx.rowid
|
||||||
|
LEFT JOIN memberships mm ON mm.room_id = m.room_id AND mm.user_id = ?
|
||||||
|
WHERE idx.body MATCH ? ORDER BY idx.rowid DESC LIMIT 1000
|
||||||
|
SQL
|
||||||
|
ids = probe.filter_map { |id, reachable| id if reachable == 1 }.first(size)
|
||||||
|
|
||||||
|
# A scoped fallback preserves older reachable hits behind a large private history.
|
||||||
|
if ids.size < size && probe.size == 1000
|
||||||
|
relation.last_page_of_matches(size)
|
||||||
|
else
|
||||||
|
Message::Pagination::Page.load(relation.where(id: ids).reorder(:id), :last, size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Orders by the index's rowid, which is the message id, so SQLite walks the full-text index
|
||||||
|
# newest first and stops at the page. Ordering by created_at sorted every match before paging.
|
||||||
|
def last_page_of_matches(size)
|
||||||
|
Message::Pagination::Page.load(reorder("idx.rowid"), :last, size)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -14,6 +49,12 @@ module Message::Searchable
|
|||||||
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
|
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Boosts touch their message too: rewrite the index only when the body was saved or the attachment replaced.
|
||||||
|
# Active Storage clears attachment_changes in its own after_commit, before this one runs.
|
||||||
|
def indexed_text_changed?
|
||||||
|
association(:rich_text_body).target&.saved_change_to_body? || @attachment_replaced
|
||||||
|
end
|
||||||
|
|
||||||
def update_in_index
|
def update_in_index
|
||||||
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
|
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class Opengraph::Fetch
|
|||||||
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
|
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
|
||||||
MAX_BODY_SIZE = 5.megabytes
|
MAX_BODY_SIZE = 5.megabytes
|
||||||
MAX_REDIRECTS = 10
|
MAX_REDIRECTS = 10
|
||||||
|
TIMEOUT = 7.seconds # to connect (TLS included), and for each read and write, as Webhook does
|
||||||
|
|
||||||
class TooManyRedirectsError < StandardError; end
|
class TooManyRedirectsError < StandardError; end
|
||||||
class RedirectDeniedError < StandardError; end
|
class RedirectDeniedError < StandardError; end
|
||||||
@@ -22,9 +23,11 @@ class Opengraph::Fetch
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
|
||||||
|
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
|
||||||
def request(url, request_class, ip:)
|
def request(url, request_class, ip:)
|
||||||
MAX_REDIRECTS.times do
|
MAX_REDIRECTS.times do
|
||||||
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") do |http|
|
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
|
||||||
http.request request_class.new(url) do |response|
|
http.request request_class.new(url) do |response|
|
||||||
if response.is_a?(Net::HTTPRedirection)
|
if response.is_a?(Net::HTTPRedirection)
|
||||||
url, ip = resolve_redirect(response["location"])
|
url, ip = resolve_redirect(response["location"])
|
||||||
@@ -38,6 +41,11 @@ class Opengraph::Fetch
|
|||||||
raise TooManyRedirectsError
|
raise TooManyRedirectsError
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Without max_retries: 0, Net::HTTP sends a GET or HEAD that timed out a second time.
|
||||||
|
def timeouts
|
||||||
|
{ open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT, max_retries: 0 }
|
||||||
|
end
|
||||||
|
|
||||||
def resolve_redirect(location)
|
def resolve_redirect(location)
|
||||||
url = URI.parse(location)
|
url = URI.parse(location)
|
||||||
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
|
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
|
||||||
|
|||||||
@@ -1,7 +1,61 @@
|
|||||||
|
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
|
||||||
|
|
||||||
def notification(**params)
|
validates :endpoint, presence: true
|
||||||
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
|
validate :validate_endpoint_url
|
||||||
|
|
||||||
|
def notification(badge: user.memberships.unread.count, **params)
|
||||||
|
# Defer DNS lookup to the delivery worker to prevent rebinding
|
||||||
|
WebPush::Notification.new(**params, badge: badge, 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
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
require "sqlite3"
|
||||||
|
|
||||||
|
# A read-only observer sees commits from every writer, including other runtimes.
|
||||||
|
# PRAGMA data_version can only be compared on the same persistent connection.
|
||||||
|
class ResponseCache
|
||||||
|
MAX_ENTRY_BYTES = 1.megabyte
|
||||||
|
MAX_KEY_BYTES = 2.kilobytes
|
||||||
|
|
||||||
|
def self.instance
|
||||||
|
INSTANCE
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@mutex = Mutex.new
|
||||||
|
@entries = {}
|
||||||
|
@bytes = 0
|
||||||
|
@render_locks = Array.new(16) { Mutex.new }
|
||||||
|
end
|
||||||
|
|
||||||
|
def budget
|
||||||
|
[ ENV.fetch("CAMPFIRE_RESPONSE_CACHE_MB", "64").to_i, 0 ].max.megabytes
|
||||||
|
end
|
||||||
|
|
||||||
|
def version
|
||||||
|
@mutex.synchronize { current_version }
|
||||||
|
rescue SQLite3::Exception
|
||||||
|
clear
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def read(key, version)
|
||||||
|
@mutex.synchronize do
|
||||||
|
@entries[key]&.first if current_version == version
|
||||||
|
end
|
||||||
|
rescue SQLite3::Exception
|
||||||
|
clear
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Collapse cold renders without retaining a mutex for every viewer or URL.
|
||||||
|
# Rendering must never hold the observer/entry mutex: controllers read it too.
|
||||||
|
def synchronize_render(key, version, &block)
|
||||||
|
@render_locks[[ key, version ].hash % @render_locks.length].synchronize(&block)
|
||||||
|
end
|
||||||
|
|
||||||
|
def write(key, version, entry)
|
||||||
|
size = key.bytesize + entry[:body].bytesize + entry[:headers].sum { |name, value| name.bytesize + value.bytesize } + 256
|
||||||
|
return if key.bytesize > MAX_KEY_BYTES || size > [ budget, MAX_ENTRY_BYTES ].min
|
||||||
|
|
||||||
|
@mutex.synchronize do
|
||||||
|
return unless current_version == version
|
||||||
|
return if @entries.key?(key)
|
||||||
|
|
||||||
|
while @entries.any? && @bytes + size > budget
|
||||||
|
_, (_, removed_size) = @entries.shift
|
||||||
|
@bytes -= removed_size
|
||||||
|
end
|
||||||
|
@entries[key] = [ entry, size ]
|
||||||
|
@bytes += size
|
||||||
|
end
|
||||||
|
rescue SQLite3::Exception
|
||||||
|
clear
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear
|
||||||
|
@mutex.synchronize do
|
||||||
|
@entries.clear
|
||||||
|
@bytes = 0
|
||||||
|
@observer&.close
|
||||||
|
@observer = @database = @version = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def current_version
|
||||||
|
database = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||||
|
if @database != database || !@observer
|
||||||
|
@entries.clear
|
||||||
|
@bytes = 0
|
||||||
|
@observer&.close
|
||||||
|
@observer = nil
|
||||||
|
@observer = SQLite3::Database.new(database, readonly: true)
|
||||||
|
@database = database
|
||||||
|
@namespace = SecureRandom.hex(16)
|
||||||
|
@version = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
version = @observer.get_first_value("PRAGMA data_version")
|
||||||
|
if @version != version
|
||||||
|
@entries.clear
|
||||||
|
@bytes = 0
|
||||||
|
@version = version
|
||||||
|
end
|
||||||
|
[ @database, @namespace, @version ]
|
||||||
|
end
|
||||||
|
|
||||||
|
INSTANCE = new
|
||||||
|
end
|
||||||
@@ -22,6 +22,8 @@ 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") }
|
||||||
@@ -43,6 +45,34 @@ class Room < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Takes the room away from its members at once, and leaves its messages to Room::DestroyJob. Destroying them in
|
||||||
|
# the room's own transaction held the database's write lock, and stopped every other write, until the last one.
|
||||||
|
# An open room is closed first, so that no one who joins the account before the job runs is let in.
|
||||||
|
def destroy_later
|
||||||
|
room = open? ? becomes!(Rooms::Closed) : self
|
||||||
|
|
||||||
|
former_member_ids = transaction do
|
||||||
|
room.save!
|
||||||
|
room.memberships.pluck(:user_id).tap { room.memberships.delete_all }
|
||||||
|
end
|
||||||
|
|
||||||
|
Room::DestroyJob.perform_later(room, former_member_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deleting the memberships skips the reset that revoking one does, so the former members get it here, all at once,
|
||||||
|
# before the messages go: their connections stop receiving the room's streams. It's done in the job, not the
|
||||||
|
# request, because it costs a Redis round trip per member.
|
||||||
|
def reset_remote_connections_of(user_ids)
|
||||||
|
User.where(id: user_ids).find_each(&:reset_remote_connections)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Each message is destroyed in its own transaction, so other writes get through in between. Any message posted
|
||||||
|
# meanwhile goes with the room.
|
||||||
|
def destroy_one_message_at_a_time
|
||||||
|
messages.find_each(&:destroy)
|
||||||
|
destroy
|
||||||
|
end
|
||||||
|
|
||||||
def receive(message)
|
def receive(message)
|
||||||
unread_memberships(message)
|
unread_memberships(message)
|
||||||
push_later(message)
|
push_later(message)
|
||||||
@@ -65,6 +95,15 @@ 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
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ class Room::MessagePusher
|
|||||||
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
|
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Banning keeps the user's subscriptions, so that unbanning brings their notifications back,
|
||||||
|
# but nobody who can't sign in should be sent what's said in their rooms meanwhile.
|
||||||
def relevant_subscriptions
|
def relevant_subscriptions
|
||||||
Push::Subscription
|
Push::Subscription
|
||||||
.joins(user: :memberships)
|
.joins(user: :memberships)
|
||||||
|
.merge(User.active)
|
||||||
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
|
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Keeps rooms.messages_count correct for ordinary SQLite writes — Rails, bulk SQL,
|
||||||
|
# and foreign connections — without ActiveRecord counter_cache callbacks that
|
||||||
|
# those paths skip (and that would double-count if combined with triggers).
|
||||||
|
# Foreign REPLACE writes require PRAGMA recursive_triggers=ON so SQLite also
|
||||||
|
# fires the delete trigger. Migrations rebuilding messages must call ensure!
|
||||||
|
# before reading counters inside that migration; database tasks repair afterwards.
|
||||||
|
class Room::MessagesCount
|
||||||
|
INSERT_TRIGGER = "messages_ai_rooms_messages_count"
|
||||||
|
DELETE_TRIGGER = "messages_ad_rooms_messages_count"
|
||||||
|
UPDATE_TRIGGER = "messages_au_rooms_messages_count"
|
||||||
|
TRIGGERS = [ INSERT_TRIGGER, DELETE_TRIGGER, UPDATE_TRIGGER ].freeze
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def install!(connection = ActiveRecord::Base.connection)
|
||||||
|
with_immediate_write(connection) do
|
||||||
|
replace_triggers!(connection)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def uninstall!(connection = ActiveRecord::Base.connection)
|
||||||
|
TRIGGERS.each do |name|
|
||||||
|
connection.execute("DROP TRIGGER IF EXISTS #{name}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def backfill!(connection = ActiveRecord::Base.connection)
|
||||||
|
connection.execute <<~SQL
|
||||||
|
UPDATE rooms SET messages_count = (
|
||||||
|
SELECT COUNT(*) FROM messages WHERE messages.room_id = rooms.id
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
# schema.rb does not dump SQLite triggers; reinstall after schema:load.
|
||||||
|
# Repair rechecks, rebuilds counts, and installs triggers in one write txn
|
||||||
|
# so concurrent writers and concurrent boot repairs cannot observe a gap.
|
||||||
|
def ensure!(connection = ActiveRecord::Base.connection)
|
||||||
|
return unless connection.adapter_name.match?(/sqlite/i)
|
||||||
|
return unless connection.data_source_exists?(:rooms)
|
||||||
|
return unless connection.column_exists?(:rooms, :messages_count)
|
||||||
|
return if triggers_installed?(connection)
|
||||||
|
|
||||||
|
with_immediate_write(connection) do
|
||||||
|
next if triggers_installed?(connection)
|
||||||
|
|
||||||
|
uninstall!(connection)
|
||||||
|
backfill!(connection)
|
||||||
|
create_triggers!(connection)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def trigger_installed?(connection, name)
|
||||||
|
connection.select_value(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = #{connection.quote(name)}"
|
||||||
|
).present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def triggers_installed?(connection = ActiveRecord::Base.connection)
|
||||||
|
TRIGGERS.all? { |name| trigger_installed?(connection, name) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def with_immediate_write(connection)
|
||||||
|
if connection.transaction_open?
|
||||||
|
yield
|
||||||
|
else
|
||||||
|
connection.raw_connection.transaction(:immediate) { yield }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def replace_triggers!(connection)
|
||||||
|
uninstall!(connection)
|
||||||
|
create_triggers!(connection)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_triggers!(connection)
|
||||||
|
connection.execute <<~SQL
|
||||||
|
CREATE TRIGGER #{INSERT_TRIGGER} AFTER INSERT ON messages
|
||||||
|
BEGIN
|
||||||
|
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
connection.execute <<~SQL
|
||||||
|
CREATE TRIGGER #{DELETE_TRIGGER} AFTER DELETE ON messages
|
||||||
|
BEGIN
|
||||||
|
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
connection.execute <<~SQL
|
||||||
|
CREATE TRIGGER #{UPDATE_TRIGGER} AFTER UPDATE OF room_id ON messages
|
||||||
|
WHEN OLD.room_id IS NOT NEW.room_id
|
||||||
|
BEGIN
|
||||||
|
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
|
||||||
|
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,12 +7,15 @@ class Rooms::Direct < Room
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
|
# Among the first user's rooms, the one whose members are exactly these users: as many
|
||||||
# which could be to store the membership id list as a hash on the room, and use that for lookup.
|
# memberships as users, and all of them theirs.
|
||||||
def find_for(users)
|
def find_for(users)
|
||||||
all.joins(:users).detect do |room|
|
user_ids = users.pluck(:id).uniq
|
||||||
Set.new(room.user_ids) == Set.new(users.pluck(:id))
|
|
||||||
end
|
where(id: Membership.where(user_id: user_ids.first).select(:room_id))
|
||||||
|
.joins(:memberships).group(:id)
|
||||||
|
.having("COUNT(*) = :size AND COUNT(CASE WHEN memberships.user_id IN (:user_ids) THEN 1 END) = :size", size: user_ids.size, user_ids: user_ids)
|
||||||
|
.first
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -50,8 +50,18 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
|
||||||
|
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
|
||||||
|
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
|
||||||
|
# as insert_all did.
|
||||||
def grant_membership_to_open_rooms
|
def grant_membership_to_open_rooms
|
||||||
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
|
now = Time.current
|
||||||
|
|
||||||
|
Membership.connection.execute Membership.sanitize_sql([ <<~SQL, id, now, now ])
|
||||||
|
insert into memberships (room_id, user_id, created_at, updated_at)
|
||||||
|
select id, ?, ?, ? from rooms where type = 'Rooms::Open'
|
||||||
|
on conflict do nothing
|
||||||
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
def deactived_email_address
|
def deactived_email_address
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ module User::Avatar
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
has_one_attached :avatar
|
has_one_attached :avatar do |attachable|
|
||||||
|
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
@@ -14,4 +16,8 @@ module User::Avatar
|
|||||||
def avatar_token
|
def avatar_token
|
||||||
signed_id(purpose: :avatar)
|
signed_id(purpose: :avatar)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def avatar_variant
|
||||||
|
avatar.variant(:square).processed if avatar.variable?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
module User::Bannable
|
module User::Bannable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
# Administrators still see banned users, so they can lift the ban.
|
||||||
|
scope :visible_to, ->(user) { user.can_administer? ? where(status: %i[ active banned ]) : active }
|
||||||
|
end
|
||||||
|
|
||||||
def ban
|
def ban
|
||||||
transaction do
|
transaction do
|
||||||
create_bans_from_sessions
|
create_bans_from_sessions
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
module User::Mentionable
|
module User::Mentionable
|
||||||
include ActionText::Attachable
|
include ActionText::Attachable
|
||||||
|
|
||||||
|
MENTION_CONTENT_TYPE = "application/vnd.campfire.mention"
|
||||||
|
|
||||||
|
def attachable_content_type
|
||||||
|
MENTION_CONTENT_TYPE
|
||||||
|
end
|
||||||
|
|
||||||
def to_attachable_partial_path
|
def to_attachable_partial_path
|
||||||
"users/mention"
|
"users/mention"
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_trix_content_attachment_partial_path
|
# How a mention appears inside the editor, matching the prompt's editor template.
|
||||||
|
def to_editor_content_attachment_partial_path
|
||||||
"users/mention"
|
"users/mention"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class Webhook < ApplicationRecord
|
|||||||
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
|
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
|
||||||
|
# and operators legitimately point bots at their own internal services.
|
||||||
def http
|
def http
|
||||||
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
||||||
http.use_ssl = (uri.scheme == "https")
|
http.use_ssl = (uri.scheme == "https")
|
||||||
@@ -61,7 +63,7 @@ class Webhook < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def extract_attachment_from(response)
|
def extract_attachment_from(response)
|
||||||
if response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
if response.code == "200" && response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
||||||
ActiveStorage::Blob.create_and_upload! \
|
ActiveStorage::Blob.create_and_upload! \
|
||||||
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
|
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -76,10 +76,9 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox"
|
<%= tag.input type: "checkbox", class: "switch__input",
|
||||||
class="switch__input"
|
checked: Current.account.settings.restrict_room_creation_to_administrators?,
|
||||||
<%= "checked" if Current.account.settings.restrict_room_creation_to_administrators? %>
|
data: { action: "change->form#submit" } %>
|
||||||
data-action="change->form#submit">
|
|
||||||
<span class="switch__btn round"></span>
|
<span class="switch__btn round"></span>
|
||||||
<span class="for-screen-reader">
|
<span class="for-screen-reader">
|
||||||
Must be admin to create new rooms
|
Must be admin to create new rooms
|
||||||
@@ -102,11 +101,11 @@
|
|||||||
<turbo-frame id="account_users">
|
<turbo-frame id="account_users">
|
||||||
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
|
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
|
||||||
|
|
||||||
<% if @administrators.any? && @members.any? %>
|
<% if @administrators.any? && @page.records.any? %>
|
||||||
<hr class="separator full-width" style="--border-style: solid">
|
<hr class="separator full-width" style="--border-style: solid">
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
|
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
|
||||||
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
|
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
|
||||||
</turbo-frame>
|
</turbo-frame>
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
@@ -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">
|
<div class="og-embed gap <%= "og-embed--twitter-avatar" if opengraph_embed.twitter_avatar? %>">
|
||||||
<div class="og-embed__content">
|
<div class="og-embed__content">
|
||||||
<div class="og-embed__title">
|
<div class="og-embed__title">
|
||||||
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
<%= link_to_if opengraph_embed.href.present?, truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
||||||
</div>
|
</div>
|
||||||
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %></div>
|
<div class="og-embed__description"><%= truncate(opengraph_embed.description.to_s, length: 560, omission: "…") %></div>
|
||||||
</div>
|
</div>
|
||||||
<% if opengraph_embed.url %>
|
<% if opengraph_embed.url %>
|
||||||
<div class="og-embed__image">
|
<div class="og-embed__image">
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<%# Files are posted as a message's own attachment, which gets its preview when it's posted; the composer adds none to
|
||||||
|
the rich text. One that's there anyway is shown by its name, with no preview: Action Text's partial links to a URL
|
||||||
|
that makes one on view, and nothing else in the app would make it. %>
|
||||||
|
<figure class="attachment attachment--file attachment--<%= blob.filename.extension %>">
|
||||||
|
<figcaption class="attachment__caption">
|
||||||
|
<% if caption = blob.try(:caption) %>
|
||||||
|
<%= caption %>
|
||||||
|
<% else %>
|
||||||
|
<span class="attachment__name"><%= blob.filename %></span>
|
||||||
|
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
|
||||||
|
<% end %>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<%= render partial: "autocompletable/users/prompt_item", collection: @page.records, as: :user %>
|
||||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user