mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1d304999d |
No files matched your search
@@ -50,6 +50,3 @@
|
|||||||
|
|
||||||
# Ignore test files
|
# Ignore test files
|
||||||
/test/
|
/test/
|
||||||
|
|
||||||
# Agent session state — worktrees under .claude/ are whole extra checkouts of this repo
|
|
||||||
.claude
|
|
||||||
@@ -20,12 +20,13 @@ updates:
|
|||||||
semver-minor-days: 3
|
semver-minor-days: 3
|
||||||
semver-patch-days: 2
|
semver-patch-days: 2
|
||||||
default-days: 7
|
default-days: 7
|
||||||
exclude:
|
|
||||||
- "brakeman"
|
|
||||||
|
|
||||||
# No docker ecosystem. Our base image tag is a build arg
|
- package-ecosystem: docker
|
||||||
# (`FROM ruby:$RUBY_VERSION-slim`), which the updater cannot read, so the
|
directory: "/"
|
||||||
# entry only ever matched nothing — worse than no entry, because it looked
|
schedule:
|
||||||
# like coverage. Ruby base image bumps are a manual, reviewed step here, as
|
interval: weekly
|
||||||
# they are across the rest of the fleet. Keep Dockerfile, Dockerfile-export
|
cooldown:
|
||||||
# and .ruby-version on the same version by hand.
|
semver-major-days: 7
|
||||||
|
semver-minor-days: 3
|
||||||
|
semver-patch-days: 2
|
||||||
|
default-days: 7
|
||||||
+13
-39
@@ -16,12 +16,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -35,12 +35,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -48,29 +48,6 @@ 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@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.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
|
||||||
@@ -78,15 +55,15 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run actionlint
|
- name: Run actionlint
|
||||||
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
|
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1
|
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||||
with:
|
with:
|
||||||
advanced-security: false
|
advanced-security: false
|
||||||
|
|
||||||
@@ -102,15 +79,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 sqlite3 libvips curl ffmpeg
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
env:
|
env:
|
||||||
REDIS_URL: redis://localhost:6379/0
|
REDIS_URL: redis://localhost:6379/0
|
||||||
with:
|
with:
|
||||||
@@ -120,9 +97,6 @@ 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:
|
||||||
@@ -135,15 +109,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 sqlite3 libvips curl ffmpeg
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
|
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||||
env:
|
env:
|
||||||
REDIS_URL: redis://localhost:6379/0
|
REDIS_URL: redis://localhost:6379/0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ jobs:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract Docker metadata (tags, labels) with arch suffix
|
- name: Extract Docker metadata (tags, labels) with arch suffix
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.vars.outputs.canonical }}
|
images: ${{ steps.vars.outputs.canonical }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push (${{ matrix.platform }})
|
- name: Build and push (${{ matrix.platform }})
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Attest image provenance (per-arch)
|
- name: Attest image provenance (per-arch)
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ steps.vars.outputs.canonical }}
|
subject-name: ${{ steps.vars.outputs.canonical }}
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
subject-digest: ${{ steps.build.outputs.digest }}
|
||||||
@@ -139,10 +139,10 @@ jobs:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Docker Buildx (for imagetools)
|
- name: Set up Docker Buildx (for imagetools)
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Compute base tags (no suffix)
|
- name: Compute base tags (no suffix)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.vars.outputs.canonical }}
|
images: ${{ steps.vars.outputs.canonical }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -207,7 +207,7 @@ jobs:
|
|||||||
done <<< "$tags"
|
done <<< "$tags"
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
|
||||||
|
|
||||||
- name: Cosign sign all tags (keyless OIDC)
|
- name: Cosign sign all tags (keyless OIDC)
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.0.9
|
3.4.5
|
||||||
+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=4.0.9
|
ARG RUBY_VERSION=3.4.5
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
# Rails app lives here
|
# Rails app lives here
|
||||||
|
|||||||
+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=4.0.9
|
ARG RUBY_VERSION=3.4.7
|
||||||
FROM ruby:$RUBY_VERSION-slim as base
|
FROM ruby:$RUBY_VERSION-slim as base
|
||||||
|
|
||||||
# Install the tools we need
|
# Install the tools we need
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ gem "sqlite3"
|
|||||||
gem "redis", "~> 5.4"
|
gem "redis", "~> 5.4"
|
||||||
|
|
||||||
# Deployment
|
# Deployment
|
||||||
gem "puma", "~> 8.0"
|
gem "puma", "~> 7.2", ">= 7.2.1"
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
gem "resque", "~> 2.7.0"
|
gem "resque", "~> 2.7.0"
|
||||||
@@ -26,9 +26,6 @@ gem "importmap-rails", github: "rails/importmap-rails"
|
|||||||
gem "turbo-rails", github: "hotwired/turbo-rails"
|
gem "turbo-rails", github: "hotwired/turbo-rails"
|
||||||
gem "stimulus-rails"
|
gem "stimulus-rails"
|
||||||
|
|
||||||
# Rich text
|
|
||||||
gem "lexxy", "~> 0.9.24"
|
|
||||||
|
|
||||||
# Media handling
|
# Media handling
|
||||||
gem "image_processing", ">= 1.2"
|
gem "image_processing", ">= 1.2"
|
||||||
|
|
||||||
@@ -43,7 +40,7 @@ gem "rqrcode"
|
|||||||
gem "rails_autolink"
|
gem "rails_autolink"
|
||||||
gem "geared_pagination"
|
gem "geared_pagination"
|
||||||
gem "jbuilder"
|
gem "jbuilder"
|
||||||
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
|
gem "net-http-persistent"
|
||||||
gem "kredis"
|
gem "kredis"
|
||||||
gem "platform_agent"
|
gem "platform_agent"
|
||||||
gem "thruster"
|
gem "thruster"
|
||||||
|
|||||||
+67
-91
@@ -1,9 +1,3 @@
|
|||||||
GIT
|
|
||||||
remote: https://github.com/basecamp/surfguard.git
|
|
||||||
revision: 59e278c01a537755f22791429051891949231ead
|
|
||||||
specs:
|
|
||||||
surfguard (0.2.0)
|
|
||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/hotwired/turbo-rails.git
|
remote: https://github.com/hotwired/turbo-rails.git
|
||||||
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
|
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
|
||||||
@@ -23,16 +17,16 @@ GIT
|
|||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/rails/propshaft.git
|
remote: https://github.com/rails/propshaft.git
|
||||||
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623
|
revision: e49a9de659ff27462015e54dd832e86e762a6ddc
|
||||||
specs:
|
specs:
|
||||||
propshaft (1.3.2)
|
propshaft (1.2.1)
|
||||||
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: e3d5c569d217c56c022b04acb127a8c24dbe9685
|
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
|
||||||
branch: main
|
branch: main
|
||||||
specs:
|
specs:
|
||||||
actioncable (8.2.0.alpha)
|
actioncable (8.2.0.alpha)
|
||||||
@@ -63,10 +57,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.7)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.2.0.alpha)
|
actiontext (8.2.0.alpha)
|
||||||
action_text-trix (~> 2.1.16)
|
action_text-trix (~> 2.1.15)
|
||||||
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)
|
||||||
@@ -77,12 +71,11 @@ 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.7)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.2.0.alpha)
|
activejob (8.2.0.alpha)
|
||||||
activesupport (= 8.2.0.alpha)
|
activesupport (= 8.2.0.alpha)
|
||||||
globalid (>= 1.4.0)
|
globalid (>= 0.3.6)
|
||||||
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)
|
||||||
@@ -94,7 +87,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 (~> 2.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.2.0.alpha)
|
activesupport (8.2.0.alpha)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -105,8 +98,6 @@ 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)
|
||||||
@@ -145,8 +136,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 (4.1.3)
|
bigdecimal (3.3.1)
|
||||||
brakeman (8.1.0)
|
brakeman (8.0.5)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -162,7 +153,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.8)
|
concurrent-ruby (1.3.7)
|
||||||
connection_pool (2.5.5)
|
connection_pool (2.5.5)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -173,11 +164,10 @@ 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.7)
|
erb (6.0.4)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
faker (3.8.0)
|
faker (3.5.2)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
ffi (1.17.2)
|
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-darwin)
|
ffi (1.17.2-x86_64-darwin)
|
||||||
@@ -185,28 +175,23 @@ 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.4.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
hashdiff (1.2.0)
|
hashdiff (1.2.0)
|
||||||
herb (0.11.0-aarch64-linux-gnu)
|
i18n (1.14.7)
|
||||||
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.9.4)
|
io-console (0.8.1)
|
||||||
irb (1.18.0)
|
irb (1.15.3)
|
||||||
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.21.2)
|
json (2.20.0)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
kredis (1.8.0)
|
kredis (1.8.0)
|
||||||
@@ -214,41 +199,39 @@ 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.1)
|
mail (2.9.0)
|
||||||
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 (2.1.0)
|
marcel (1.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 (6.0.6)
|
minitest (5.26.2)
|
||||||
drb (~> 2.0)
|
mocha (2.7.1)
|
||||||
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-imap (0.6.7)
|
net-http-persistent (4.0.6)
|
||||||
|
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.4.0)
|
net-protocol (0.2.2)
|
||||||
timeout
|
timeout
|
||||||
net-smtp (0.5.2)
|
net-smtp (0.5.1)
|
||||||
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)
|
||||||
@@ -259,7 +242,7 @@ GEM
|
|||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (4.0.2)
|
openssl (3.3.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.9.0)
|
parser (3.3.9.0)
|
||||||
@@ -268,18 +251,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.4)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.9.0)
|
prism (1.4.0)
|
||||||
psych (5.5.0)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (8.0.2)
|
puma (7.2.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.7)
|
rack (3.2.6)
|
||||||
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)
|
||||||
@@ -289,9 +272,8 @@ 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.3.1)
|
rackup (2.2.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
|
||||||
@@ -304,24 +286,19 @@ GEM
|
|||||||
activesupport (> 3.1)
|
activesupport (> 3.1)
|
||||||
railties (> 3.1)
|
railties (> 3.1)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.4.2)
|
rake (13.3.1)
|
||||||
rbs (4.2.0)
|
rdoc (6.16.1)
|
||||||
logger
|
|
||||||
prism (>= 1.6.0)
|
|
||||||
tsort
|
|
||||||
rdoc (8.1.0)
|
|
||||||
erb
|
erb
|
||||||
prism (>= 1.6.0)
|
psych (>= 4.0.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.30.1)
|
redis-client (0.25.2)
|
||||||
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.7.0)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
resque (2.7.0)
|
resque (2.7.0)
|
||||||
mono_logger (~> 1)
|
mono_logger (~> 1)
|
||||||
@@ -336,29 +313,29 @@ GEM
|
|||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.1.0)
|
rqrcode_core (2.1.0)
|
||||||
rubocop (1.91.0)
|
rubocop (1.80.2)
|
||||||
json (>= 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
parallel (>= 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.49.0, < 2.0)
|
rubocop-ast (>= 1.46.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.50.0)
|
rubocop-ast (1.46.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.7)
|
prism (~> 1.4)
|
||||||
rubocop-performance (1.27.0)
|
rubocop-performance (1.25.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.89.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.38.0, < 2.0)
|
||||||
rubocop-rails (2.38.0)
|
rubocop-rails (2.33.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.89.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.44.0, < 2.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-rails-omakase (1.1.0)
|
rubocop-rails-omakase (1.1.0)
|
||||||
rubocop (>= 1.72)
|
rubocop (>= 1.72)
|
||||||
@@ -369,9 +346,9 @@ GEM
|
|||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (3.7.0)
|
rubyzip (3.0.2)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.49.0)
|
selenium-webdriver (4.35.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)
|
||||||
@@ -390,18 +367,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.6-aarch64-linux-gnu)
|
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||||
sqlite3 (2.9.6-arm64-darwin)
|
sqlite3 (2.9.5-arm64-darwin)
|
||||||
sqlite3 (2.9.6-x86_64-darwin)
|
sqlite3 (2.9.5-x86_64-darwin)
|
||||||
sqlite3 (2.9.6-x86_64-linux-gnu)
|
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.2.0)
|
stringio (3.1.8)
|
||||||
thor (1.5.0)
|
thor (1.4.0)
|
||||||
thruster (0.1.23-aarch64-linux)
|
thruster (0.1.15-aarch64-linux)
|
||||||
thruster (0.1.23-arm64-darwin)
|
thruster (0.1.15-arm64-darwin)
|
||||||
thruster (0.1.23-x86_64-darwin)
|
thruster (0.1.15-x86_64-darwin)
|
||||||
thruster (0.1.23-x86_64-linux)
|
thruster (0.1.15-x86_64-linux)
|
||||||
tilt (2.6.1)
|
tilt (2.6.1)
|
||||||
timeout (0.6.1)
|
timeout (0.6.1)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
@@ -412,9 +389,9 @@ GEM
|
|||||||
unicode-emoji (4.2.0)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-push (3.1.0)
|
web-push (3.0.2)
|
||||||
jwt (~> 3.0)
|
jwt (~> 3.0)
|
||||||
openssl (>= 3.0)
|
openssl (~> 3.0)
|
||||||
webmock (3.25.1)
|
webmock (3.25.1)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
@@ -426,7 +403,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.8.3)
|
zeitwerk (2.7.3)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
@@ -447,12 +424,12 @@ 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!
|
||||||
puma (~> 8.0)
|
puma (~> 7.2, >= 7.2.1)
|
||||||
rails!
|
rails!
|
||||||
rails_autolink
|
rails_autolink
|
||||||
redis (~> 5.4)
|
redis (~> 5.4)
|
||||||
@@ -465,7 +442,6 @@ DEPENDENCIES
|
|||||||
sentry-ruby
|
sentry-ruby
|
||||||
sqlite3
|
sqlite3
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
surfguard!
|
|
||||||
thruster
|
thruster
|
||||||
turbo-rails!
|
turbo-rails!
|
||||||
web-push
|
web-push
|
||||||
|
|||||||
@@ -49,32 +49,6 @@ 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 complete cached HTML and gzip bodies.
|
|
||||||
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 | 4,101 | 1,507 | 3,872 | 42,636 | 5,350 | 53,060 | 106,494 | 137,524 |
|
|
||||||
| Messages page | 4,115 | 1,596 | 3,995 | 74,362 | 5,712 | 54,800 | 102,697 | 144,642 |
|
|
||||||
| Sidebar | 4,333 | 1,873 | 4,493 | 94,329 | 5,949 | 59,144 | 120,294 | 152,002 |
|
|
||||||
| Search | 4,282 | 1,862 | 4,172 | 84,665 | 5,848 | 60,509 | 121,378 | 149,487 |
|
|
||||||
| Post a message | 330 | 262 | 794 | 2,155 | 1,278 | 9,021 | 8,037 | 7,530 |
|
|
||||||
| Backend KLOC | 5.5 | 5.3 | 4.1 | 6.8 | 11.6 | 23.2 | 30.7 | 94.8 |
|
|
||||||
|
|
||||||
KLOC counts backend code plus executable code in templates; excludes plain HTML, frontend, tests, dependencies and generated files.
|
|
||||||
|
|
||||||
[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.
|
||||||
@@ -83,7 +57,3 @@ Please see our [development guide](docs/development.md) for how to get Campfire
|
|||||||
## Security
|
## Security
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
|
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
|
||||||
|
|
||||||
Browser writes use Rails’ `Sec-Fetch-Site` header-only protection and `Origin` check.
|
|
||||||
HTTPS requires browser metadata; plain HTTP retains the missing-header fallback.
|
|
||||||
Forms contain no CSRF tokens. Existing cookies, bot APIs and signed uploads remain compatible.
|
|
||||||
@@ -1,160 +1,165 @@
|
|||||||
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
|
trix-toolbar {
|
||||||
so everything follows Campfire's light/dark color scheme */
|
display: none;
|
||||||
.lexxy-content {
|
inset: auto 0 0 0;
|
||||||
--lexxy-color-canvas: transparent;
|
min-width: 0;
|
||||||
--lexxy-color-text: var(--color-text);
|
position: sticky;
|
||||||
--lexxy-color-text-subtle: var(--color-text);
|
z-index: 3;
|
||||||
--lexxy-color-link: var(--color-link);
|
|
||||||
--lexxy-color-ink: var(--color-text);
|
|
||||||
--lexxy-color-ink-inverted: var(--color-text-reversed);
|
|
||||||
--lexxy-color-ink-medium: var(--color-text);
|
|
||||||
--lexxy-color-ink-lighter: var(--color-border-darker);
|
|
||||||
--lexxy-color-ink-lightest: var(--color-bg);
|
|
||||||
--lexxy-color-selected: var(--color-selected, var(--color-bg));
|
|
||||||
--lexxy-color-code-bg: var(--color-bg);
|
|
||||||
|
|
||||||
/* Campfire's message lines stack without extra block margins */
|
.trix-dialog {
|
||||||
--lexxy-content-margin: 0;
|
bottom: 3rem !important;
|
||||||
|
top: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-button--icon {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-dialog {
|
||||||
|
background-color: var(--color-bg) !important;
|
||||||
|
border-radius: 1rem !important;
|
||||||
|
border: 2px solid var(--color-text) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
gap: var(--inline-space);
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: clip;
|
||||||
|
|
||||||
|
.trix-input {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
font-size: max(16px, 1em) !important;
|
||||||
|
padding: var(--input-padding, 0.5em 0.8em) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-button {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-dialog--link {
|
||||||
|
.trix-dialog__link-fields {
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[value="Link"],
|
||||||
|
[value="Unlink"] {
|
||||||
|
width: 2.6em;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
text-indent: -9999%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[value="Link"] { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.948 5.258a4.337 4.337 0 0 0-6.108 0L11.217 6.87a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l1.623-1.613a2.323 2.323 0 0 1 3.271 0 2.29 2.29 0 0 1 0 3.251l-2.393 2.38a3.021 3.021 0 0 1-4.255 0l-.05-.049a1.007 1.007 0 0 0-1.418 0 .993.993 0 0 0 0 1.41l.05.049a5.036 5.036 0 0 0 7.091 0l2.394-2.38a4.275 4.275 0 0 0 0-6.072Zm-13.683 13.6a4.337 4.337 0 0 0 6.108 0l1.262-1.255a.993.993 0 0 0 0-1.41 1.007 1.007 0 0 0-1.418 0L9.954 17.45a2.323 2.323 0 0 1-3.27 0 2.29 2.29 0 0 1 0-3.251l2.344-2.331a2.579 2.579 0 0 1 3.631 0c.392.39 1.027.39 1.419 0a.993.993 0 0 0 0-1.41 4.593 4.593 0 0 0-6.468 0l-2.345 2.33a4.275 4.275 0 0 0 0 6.072Z' clip-rule='evenodd'/%3E%3C/svg%3E"); }
|
||||||
|
|
||||||
|
[value="Unlink"] {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.954 5.264a3.58 3.58 0 0 0-5.04 0l-2.157 2.144a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l2.157-2.144a1.565 1.565 0 0 1 2.204 0c.607.603.609 1.58.005 2.186l-2.348 2.355a.993.993 0 0 0 .006 1.41 1.007 1.007 0 0 0 1.419-.007l2.347-2.354a3.528 3.528 0 0 0-.01-5ZM5.257 18.852a3.59 3.59 0 0 0 5.041.015l2.6-2.554a.993.993 0 0 0 .01-1.41 1.007 1.007 0 0 0-1.419-.009l-2.6 2.554c-.615.603-1.603.6-2.214-.006a1.554 1.554 0 0 1-.012-2.195l2.539-2.58a.993.993 0 0 0-.016-1.41 1.007 1.007 0 0 0-1.418.015l-2.539 2.58a3.54 3.54 0 0 0 .028 5Z' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-width='2' d='M10.233 5.78V3.769M5.968 9.802H4.21M7.343 7.107 5.968 5.781M14.348 17.523v2.013M18.613 13.502h1.758M17.238 16.196l1.375 1.327'/%3E%3C/svg%3E%0A");
|
||||||
|
border-top-right-radius: 1em !important;
|
||||||
|
border-bottom-right-radius: 1em !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lexxy-editor {
|
trix-editor {
|
||||||
&.input {
|
&.input {
|
||||||
--lexxy-editor-padding: 0;
|
|
||||||
|
|
||||||
/* One line high when empty. Lexxy derives this from attr(rows lh, 8lh),
|
|
||||||
whose resolution differs between Chrome versions — pin it instead of
|
|
||||||
risking the 8-line fallback */
|
|
||||||
--lexxy-editor-rows: 1lh;
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment--content {
|
.attachment--content {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The mentions menu needs a solid surface — the editor's transparent canvas
|
.attachment__toolbar {
|
||||||
would let messages show through it */
|
top: 0;
|
||||||
.lexxy-prompt-menu {
|
z-index: 2;
|
||||||
--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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No group separator pipes, like the Trix toolbar */
|
|
||||||
.lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown panels: a solid surface with a uniform radius. Lexxy derives
|
|
||||||
the panel's radius and gap from --lexxy-toolbar-gap, which Campfire
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Attachment markup contains formatting whitespace that must not turn into
|
|
||||||
line breaks under the editor's white-space: pre-wrap */
|
|
||||||
lexxy-editor action-text-attachment {
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
action-text-attachment[content-type~="application/vnd.campfire.mention"] {
|
|
||||||
display: inline-flex;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed"] {
|
|
||||||
lexxy-editor & {
|
|
||||||
display: block;
|
|
||||||
inline-size: fit-content;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A prominent remove button on the card's top edge, horizontally
|
|
||||||
centered, like the Trix one. The lexxy-editor prefix out-specifies
|
|
||||||
Lexxy's own placement rules. */
|
|
||||||
lexxy-editor & lexxy-node-delete-button {
|
|
||||||
inset-block-start: 0;
|
|
||||||
inset-inline-start: 50%;
|
|
||||||
inset-inline-end: auto;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
.lexxy-floating-controls__group {
|
|
||||||
--button-size: 2.2em;
|
|
||||||
|
|
||||||
background-color: var(--color-negative);
|
|
||||||
border-radius: 50%;
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lexxy-content {
|
.trix-button-group--file-tools,
|
||||||
|
.trix-button-group--history-tools {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-button-group,
|
||||||
|
.trix-button {
|
||||||
|
border: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 0.3em !important;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
&::before {
|
||||||
|
filter: invert(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.trix-active {
|
||||||
|
background-color: var(--color-text) !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
filter: invert(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
&::before {
|
||||||
|
filter: invert(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.trix-button--remove {
|
||||||
|
background-color: var(--color-negative);
|
||||||
|
border-radius: 1.8em !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
filter: invert(100);
|
||||||
|
background-size: 70%;
|
||||||
|
background-image: url("minus.svg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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;
|
||||||
|
inline-size: fit-content;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-button:not(.trix-button--remove) {
|
||||||
|
@media (any-hover: hover) {
|
||||||
|
&:where(:not(:active):hover) {
|
||||||
|
filter: brightness(0.9);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
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;
|
||||||
@@ -174,13 +179,11 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
|
|||||||
blockquote {
|
blockquote {
|
||||||
border: 0 solid var(--color-border-darker) !important;
|
border: 0 solid var(--color-border-darker) !important;
|
||||||
border-inline-start-width: var(--blockquote-border) !important;
|
border-inline-start-width: var(--blockquote-border) !important;
|
||||||
font-style: normal;
|
|
||||||
margin: 0 var(--blockquote-margin) !important;
|
margin: 0 var(--blockquote-margin) !important;
|
||||||
padding-inline-start: var(--blockquote-padding) !important;
|
padding-inline-start: var(--blockquote-padding) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
cite {
|
cite {
|
||||||
display: block;
|
|
||||||
margin-inline-start: calc(var(--blockquote-padding));
|
margin-inline-start: calc(var(--blockquote-padding));
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -196,14 +199,13 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
|
|||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */
|
ul,
|
||||||
ul:not(.lexxy-prompt-menu),
|
|
||||||
ol {
|
ol {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
li:not(.lexxy-prompt-menu__item) {
|
li {
|
||||||
margin: 0 0 0 var(--inline-space-double) !important;
|
margin: 0 0 0 var(--inline-space-double) !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
action-text-attachment[content-type~='application/vnd.campfire.mention'] {
|
[data-trix-content-type~='application/vnd.campfire.mention'] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -100,42 +100,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer--rich-text {
|
.composer--rich-text {
|
||||||
lexxy-toolbar {
|
trix-toolbar {
|
||||||
display: flex;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
lexxy-editor.input {
|
trix-editor.input {
|
||||||
margin-block-start: var(--block-space-half);
|
margin-block-start: var(--block-space-half);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* In the message composer the toolbar goes below the text, on the same
|
.composer--edit trix-toolbar {
|
||||||
row as the surrounding action buttons, like the Trix toolbar did.
|
|
||||||
The edit composer keeps it on top. */
|
|
||||||
#composer.composer--rich-text lexxy-editor.input {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
/* Sit the toolbar row on the same baseline as the composer's round
|
|
||||||
buttons, which pull themselves down with negative margins */
|
|
||||||
margin-block-end: -0.25em;
|
|
||||||
|
|
||||||
.lexxy-editor__content {
|
|
||||||
order: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
lexxy-toolbar {
|
|
||||||
margin-block-start: 0.4em;
|
|
||||||
|
|
||||||
/* Dropdowns open upward — the toolbar sits at the bottom of the screen */
|
|
||||||
[data-dropdown-panel] {
|
|
||||||
inset-block-start: auto;
|
|
||||||
inset-block-end: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer--edit lexxy-toolbar {
|
|
||||||
border-block-end: 1px solid var(--color-border);
|
border-block-end: 1px solid var(--color-border);
|
||||||
margin-inline: -0.8em;
|
margin-inline: -0.8em;
|
||||||
padding-inline: 0.3em;
|
padding-inline: 0.3em;
|
||||||
@@ -143,16 +117,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The editor's container-type prevents it from sizing the message bubble by
|
|
||||||
its content, so give the bubble the full available width while editing */
|
|
||||||
.message__body:has(.composer--edit) {
|
|
||||||
inline-size: 100%;
|
|
||||||
|
|
||||||
.message__body-content--editing {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typing indicator */
|
/* Typing indicator */
|
||||||
.typing-indicator {
|
.typing-indicator {
|
||||||
inset-block-start: var(--indicator-position, 0.15rem);
|
inset-block-start: var(--indicator-position, 0.15rem);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
trix-editor,
|
||||||
.lexxy-content {
|
.trix-content {
|
||||||
.og-embed {
|
.og-embed {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
border: 1px solid var(--color-border-dark);
|
border: 1px solid var(--color-border-dark);
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.og-embed--twitter-avatar {
|
.cf-twitter-avatar & {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,11 +35,10 @@
|
|||||||
|
|
||||||
.og-embed__title {
|
.og-embed__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
/* Underline the link itself so the line is link-colored */
|
|
||||||
a {
|
a {
|
||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +53,7 @@
|
|||||||
inline-size: 15dvw;
|
inline-size: 15dvw;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* block-size/border-radius guard against Lexxy's 1em round avatar
|
|
||||||
styling for custom attachment images */
|
|
||||||
> * {
|
> * {
|
||||||
block-size: auto;
|
|
||||||
border-radius: 0;
|
|
||||||
object-position: top right;
|
object-position: top right;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -68,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.og-embed--twitter-avatar & {
|
.cf-twitter-avatar & {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
inline-size: 2lh;
|
inline-size: 2lh;
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ img.message__attachment {
|
|||||||
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
|
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
|
||||||
text-shadow: 0 0 3px var(--color-text-reversed);
|
text-shadow: 0 0 3px var(--color-text-reversed);
|
||||||
|
|
||||||
.lexxy-content {
|
.trix-content {
|
||||||
font-size: 2.4em;
|
font-size: 2.4em;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,42 +2,19 @@ module ApplicationCable
|
|||||||
class Connection < ActionCable::Connection::Base
|
class Connection < ActionCable::Connection::Base
|
||||||
include Authentication::SessionLookup
|
include Authentication::SessionLookup
|
||||||
|
|
||||||
SESSION_CHECK_INTERVAL = 1.minute
|
|
||||||
|
|
||||||
identified_by :current_user
|
identified_by :current_user
|
||||||
|
|
||||||
def connect
|
def connect
|
||||||
self.current_user = find_verified_user
|
self.current_user = find_verified_user
|
||||||
@session_checked_at = Time.now
|
|
||||||
end
|
|
||||||
|
|
||||||
# Signing out or changing the password ends sessions and then disconnects their connections, but a connection can
|
|
||||||
# miss that: one that found its session just before isn't listening yet. So on the server's heartbeat, which
|
|
||||||
# reaches every open connection, each one looks its session up again now and then, and closes once it's gone. A
|
|
||||||
# rejected connection has no session, and stays registered until its socket closes.
|
|
||||||
def beat
|
|
||||||
super
|
|
||||||
check_session if @session_checked_at&.before?(SESSION_CHECK_INTERVAL.ago)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def find_verified_user
|
def find_verified_user
|
||||||
if @verified_session = find_session_by_cookie
|
if verified_session = find_session_by_cookie
|
||||||
@verified_session.user
|
verified_session.user
|
||||||
else
|
else
|
||||||
reject_unauthorized_connection
|
reject_unauthorized_connection
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_session
|
|
||||||
@session_checked_at = Time.now
|
|
||||||
perform_work -> { close_unless_session_remains }, :call
|
|
||||||
end
|
|
||||||
|
|
||||||
def close_unless_session_remains
|
|
||||||
unless Session.exists?(@verified_session.id)
|
|
||||||
close reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Prepended onto Turbo::StreamsChannel. The subscriber names the channel it wants, so
|
|
||||||
# authorizing room messages only in RoomMessagesChannel would leave the stock channel as
|
|
||||||
# a way around it: same signed stream name, no membership check. Turn those names away
|
|
||||||
# here and RoomMessagesChannel becomes the only door.
|
|
||||||
module RoomStreamsAreAuthorized
|
|
||||||
def subscribed
|
|
||||||
if RoomMessagesChannel.guarded_stream?(verified_stream_name_from_params)
|
|
||||||
reject
|
|
||||||
else
|
|
||||||
super
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -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, at: Time.current.to_f }
|
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Authorizes the room message stream when the subscription is made, so that revoking a
|
|
||||||
# membership actually stops delivery.
|
|
||||||
#
|
|
||||||
# Turbo's stock channel verifies only the signature on the stream name. That name carries
|
|
||||||
# no expiry and no binding to a user, so one harvested while a member keeps working after
|
|
||||||
# the membership is gone. Reconnecting makes it worse rather than better: revoking a
|
|
||||||
# membership disconnects the user with reconnect: true, and the client then replays its
|
|
||||||
# subscriptions on the fresh socket.
|
|
||||||
#
|
|
||||||
# The room is derived from the verified stream name rather than taken as a parameter, so
|
|
||||||
# there's nothing for a subscriber to point somewhere else. The subscriber also doesn't
|
|
||||||
# get to choose the channel: Turbo::StreamsChannel turns these stream names away, so this
|
|
||||||
# is the only way onto them. See config/initializers/turbo_streams_authorization.rb.
|
|
||||||
class RoomMessagesChannel < ApplicationCable::Channel
|
|
||||||
extend Turbo::Streams::StreamName
|
|
||||||
include Turbo::Streams::StreamName::ClassMethods
|
|
||||||
|
|
||||||
STREAM_SUFFIX = "messages"
|
|
||||||
|
|
||||||
class << self
|
|
||||||
# True for the stream names this channel exists to guard, whoever is asking.
|
|
||||||
def guarded_stream?(stream_name)
|
|
||||||
stream_name.to_s.split(":", 2).second == STREAM_SUFFIX
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribable_room(user, stream_name)
|
|
||||||
gid_param, suffix = stream_name.to_s.split(":", 2)
|
|
||||||
|
|
||||||
if suffix == STREAM_SUFFIX && room = room_from(gid_param)
|
|
||||||
user.rooms.find_by(id: room.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def room_from(gid_param)
|
|
||||||
GlobalID::Locator.locate gid_param, only: Room
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribed
|
|
||||||
if stream_name = authorized_stream_name
|
|
||||||
stream_from stream_name
|
|
||||||
else
|
|
||||||
reject
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def authorized_stream_name
|
|
||||||
stream_name = verified_stream_name_from_params
|
|
||||||
stream_name if stream_name.present? && self.class.subscribable_room(current_user, stream_name)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
class UnreadRoomsChannel < ApplicationCable::Channel
|
class UnreadRoomsChannel < ApplicationCable::Channel
|
||||||
# Scoped per user, like ReadRoomsChannel. A single global stream would tell every
|
|
||||||
# authenticated connection when any room on the account is active, including closed
|
|
||||||
# rooms and direct conversations they're not part of.
|
|
||||||
def self.stream_name_for(user_id)
|
|
||||||
"user_#{user_id}_unreads"
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_from self.class.stream_name_for(current_user.id)
|
stream_from "unread_rooms"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -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.visible_to(Current.user).ordered.member, per_page: 500
|
set_page_and_extract_portion_from User.active.ordered.without_bots, 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 = User.visible_to(Current.user).ordered
|
users = account_users.ordered.without_bots
|
||||||
@administrators = users.administrator
|
@administrators, @members = users.partition(&:administrator?)
|
||||||
set_page_and_extract_portion_from users.member, per_page: 500
|
set_page_and_extract_portion_from users, per_page: 500
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -21,4 +21,12 @@ 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, CachedResponses, InlineScriptHashes
|
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
|
||||||
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
||||||
end
|
end
|
||||||
@@ -1,26 +1,11 @@
|
|||||||
class Autocompletable::UsersController < ApplicationController
|
class Autocompletable::UsersController < ApplicationController
|
||||||
def index
|
def index
|
||||||
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
|
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
format.html { render layout: false } # <lexxy-prompt-item> elements for the mentions prompt
|
|
||||||
format.json
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def find_autocompletable_users
|
def find_autocompletable_users
|
||||||
if query.present?
|
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
|
||||||
users_scope.active.filtered_by(query)
|
|
||||||
else
|
|
||||||
users_scope.active
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# The rich text editor's mentions prompt filters with `filter`, the
|
|
||||||
# autocomplete inputs with `query`
|
|
||||||
def query
|
|
||||||
params[:filter].presence || params[:query].presence
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def users_scope
|
def users_scope
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
module ActiveStorageAuthentication
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
include Authentication::SessionLookup
|
|
||||||
|
|
||||||
private
|
|
||||||
def require_active_storage_authentication
|
|
||||||
head :unauthorized unless find_session_by_cookie
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -7,7 +7,7 @@ module Authentication
|
|||||||
before_action :deny_bots
|
before_action :deny_bots
|
||||||
helper_method :signed_in?
|
helper_method :signed_in?
|
||||||
|
|
||||||
protect_from_forgery using: :header_only, with: :exception, unless: -> { authenticated_by.bot_key? }
|
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
|
||||||
end
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
@@ -57,7 +57,7 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def start_new_session_for(user)
|
def start_new_session_for(user)
|
||||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||||
authenticated_as session
|
authenticated_as session
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -71,13 +71,6 @@ module Authentication
|
|||||||
Current.session&.destroy!
|
Current.session&.destroy!
|
||||||
reset_session
|
reset_session
|
||||||
remove_authentication_cookie
|
remove_authentication_cookie
|
||||||
disconnect_remote_connections
|
|
||||||
end
|
|
||||||
|
|
||||||
def disconnect_remote_connections
|
|
||||||
Current.user&.reset_remote_connections
|
|
||||||
rescue => error
|
|
||||||
Rails.logger.warn "Could not disconnect remote connections on sign out: #{error.class}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticated_as(session)
|
def authenticated_as(session)
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
module Authentication::SessionLookup
|
module Authentication::SessionLookup
|
||||||
def find_session_by_cookie
|
def find_session_by_cookie
|
||||||
if token = cookies.signed[:session_token]
|
if token = cookies.signed[:session_token]
|
||||||
version = @response_cache_version if @response_cache_version && (request.get? || request.head?)
|
Session.find_by(token: token)
|
||||||
session, user = RecordCache.fetch([ "session", Digest::SHA256.hexdigest(token.to_s) ], version) do
|
|
||||||
session = Session.find_by(token: token)
|
|
||||||
[ session, session&.user ]
|
|
||||||
end
|
|
||||||
session&.tap { |record| record.user = user }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
require "zlib"
|
|
||||||
|
|
||||||
module CachedResponses
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
CACHE_HEADERS = %w[ content-type content-encoding cache-control etag last-modified vary ].freeze
|
|
||||||
|
|
||||||
included do
|
|
||||||
prepend_before_action :capture_response_cache_version
|
|
||||||
end
|
|
||||||
|
|
||||||
def perform_caching
|
|
||||||
return false unless super && @response_cache_version.present? && !FragmentCache.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_context ||= [
|
|
||||||
request.base_url, request.script_name, request.format.to_s, I18n.locale,
|
|
||||||
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session)
|
|
||||||
].freeze
|
|
||||||
version = @response_cache_version unless Array(key).flatten.any? { |part| part.is_a?(FragmentCache::ContentKey) }
|
|
||||||
super([ version, @fragment_cache_context, key ])
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def read_record_cache_version
|
|
||||||
@response_cache_version if request.get? || request.head?
|
|
||||||
end
|
|
||||||
|
|
||||||
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?
|
|
||||||
encoding = Rack::Utils.select_best_encoding(%w[ gzip identity ], Rack::Utils.q_values(request.headers["Accept-Encoding"]))
|
|
||||||
return yield unless encoding
|
|
||||||
|
|
||||||
key = response_cache_key(encoding)
|
|
||||||
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
|
|
||||||
original_session = session.to_hash.deep_dup
|
|
||||||
yield
|
|
||||||
rendered = true
|
|
||||||
entry = cache_completed_response(key, original_session, encoding)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if entry
|
|
||||||
response.headers.merge!(entry[:headers])
|
|
||||||
response.headers.delete("Content-Length")
|
|
||||||
self.response_body = entry[:body]
|
|
||||||
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, encoding)
|
|
||||||
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session && !response.headers["Content-Encoding"]
|
|
||||||
body = encoding == "gzip" ? Zlib.gzip(response.body) : response.body
|
|
||||||
unless body.empty? || response.headers["ETag"] || response.headers["Last-Modified"]
|
|
||||||
# Match Rack::ETag once, rather than hashing the same bytes on each hit.
|
|
||||||
response.headers["ETag"] = %(W/"#{Digest::SHA256.hexdigest(body).byteslice(0, 32)}")
|
|
||||||
end
|
|
||||||
response.headers["Content-Encoding"] = "gzip" if encoding == "gzip"
|
|
||||||
response.headers["Vary"] = (response.headers["Vary"].to_s.split(/,\s*/) | [ "Accept-Encoding" ]).join(", ")
|
|
||||||
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
|
|
||||||
entry = { body: body.freeze, headers: headers }.freeze
|
|
||||||
ResponseCache.instance.write(key, @response_cache_version, entry)
|
|
||||||
entry
|
|
||||||
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 &&
|
|
||||||
!FragmentCache.transaction_open?
|
|
||||||
end
|
|
||||||
|
|
||||||
def response_cache_key(encoding)
|
|
||||||
ActiveSupport::JSON.encode([
|
|
||||||
controller_path, request.fullpath, request.base_url, request.user_agent, encoding,
|
|
||||||
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
|
|
||||||
# Old token-bearing installation cookies remain valid without affecting HTML.
|
|
||||||
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
|
|
||||||
cookies.to_h.except("_campfire_session", "session_token")
|
|
||||||
])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Allows the scripts the importmap helper emits and nothing else: the inline
|
|
||||||
# importmap and its entry-point module by content hash, and the asset
|
|
||||||
# directory its URLs point into. Not the whole origin, because uploads are
|
|
||||||
# served from this origin too. The hashes change only when the importmap does
|
|
||||||
# (a deploy), so cached HTML stays valid, unlike a per-request nonce.
|
|
||||||
module InlineScriptHashes
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
ENTRY_POINT_SCRIPT = %(import "application")
|
|
||||||
|
|
||||||
included do
|
|
||||||
# With the policy turned off there's nothing to add to, so leave the
|
|
||||||
# request without one rather than starting an empty policy
|
|
||||||
content_security_policy if: -> { request.content_security_policy } do |policy|
|
|
||||||
if sources = policy.directives["script-src"]
|
|
||||||
importmap = Rails.application.importmap.to_json(resolver: helpers)
|
|
||||||
policy.directives["script-src"] = (sources - [ "'self'" ]) | script_sources(importmap)
|
|
||||||
policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
mattr_accessor :last_script_sources
|
|
||||||
|
|
||||||
private
|
|
||||||
# Parsing the importmap and resolving each of its URLs costs about a
|
|
||||||
# millisecond a page, yet the answer changes only with the importmap and
|
|
||||||
# the host the page is served from. Keep the last one: a single entry, so
|
|
||||||
# a request with a made-up Host header can't grow it.
|
|
||||||
def script_sources(importmap)
|
|
||||||
key = [ importmap, request.base_url, request.host_with_port, config.relative_url_root ]
|
|
||||||
cached_key, sources = InlineScriptHashes.last_script_sources
|
|
||||||
|
|
||||||
if cached_key == key
|
|
||||||
sources
|
|
||||||
else
|
|
||||||
(script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)).freeze.tap do |sources|
|
|
||||||
InlineScriptHashes.last_script_sources = [ key, sources ].freeze
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# The importmap is resolved by Rails' asset helpers, so its URLs already
|
|
||||||
# carry any relative root and asset host, however the host is written:
|
|
||||||
# with or without a scheme, protocol-relative, a %d set, or a proc. Each
|
|
||||||
# is allowed up to its asset directory, the last one in its path, since an
|
|
||||||
# asset host's own path may contain one too; a URL outside it is allowed
|
|
||||||
# exactly.
|
|
||||||
#
|
|
||||||
# This origin's own sources leave out the scheme, so they match the page's
|
|
||||||
# scheme even behind a proxy that terminates TLS without saying so.
|
|
||||||
#
|
|
||||||
# A path only limits the first request: browsers check just the origin
|
|
||||||
# after a redirect. Nothing under the asset directory redirects.
|
|
||||||
def script_locations(importmap)
|
|
||||||
assets = File.join(config.relative_url_root.to_s, Rails.application.config.assets.prefix, "")
|
|
||||||
|
|
||||||
JSON.parse(importmap)["imports"].values.map do |url|
|
|
||||||
url = URI.join(request.base_url, url)
|
|
||||||
origin = url.origin == request.base_url ? request.host_with_port : url.origin
|
|
||||||
directory = url.path[0, url.path.rindex(assets) + assets.length] if url.path.include?(assets)
|
|
||||||
"#{origin}#{directory || url.path}"
|
|
||||||
end.uniq
|
|
||||||
end
|
|
||||||
|
|
||||||
def script_hashes(*scripts)
|
|
||||||
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
module RawRequestBody
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def raw_request_body
|
|
||||||
request.body.rewind
|
|
||||||
request.body.read.force_encoding("UTF-8")
|
|
||||||
ensure
|
|
||||||
request.body.rewind
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -7,10 +7,7 @@ module RoomScoped
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
def set_room
|
||||||
@membership, @room = RecordCache.fetch([ "membership", Current.user.id, params[:room_id] ], read_record_cache_version) do
|
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
|
||||||
membership = Current.user.memberships.find_by!(room_id: params[:room_id])
|
@room = @membership.room
|
||||||
[ membership, membership.room ]
|
|
||||||
end
|
|
||||||
@membership.room = @room
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -8,6 +8,6 @@ module SetCurrentRequest
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_url_options
|
def default_url_options
|
||||||
{ host: Current.request_host, port: Current.request&.optional_port, protocol: Current.request_protocol }.compact_blank
|
{ host: Current.request_host, protocol: Current.request_protocol }.compact_blank
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
class Messages::Boosts::ByBotsController < Messages::BoostsController
|
|
||||||
include RawRequestBody
|
|
||||||
|
|
||||||
allow_bot_access only: %i[ create destroy ]
|
|
||||||
|
|
||||||
before_action :ensure_content_present, only: :create
|
|
||||||
|
|
||||||
def create
|
|
||||||
@boost = @message.boosts.create!(boost_params)
|
|
||||||
|
|
||||||
broadcast_create
|
|
||||||
render :show, status: :created
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def set_message
|
|
||||||
if room = Current.user.rooms.find_by(id: params[:room_id])
|
|
||||||
@message = room.messages.find_by(id: params[:message_id])
|
|
||||||
end
|
|
||||||
|
|
||||||
head :not_found unless @message
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_boost
|
|
||||||
super
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
head :not_found
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_content_present
|
|
||||||
if raw_request_body.blank?
|
|
||||||
head :unprocessable_content
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def boost_params
|
|
||||||
{ content: raw_request_body }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
class Messages::BoostsController < ApplicationController
|
class Messages::BoostsController < ApplicationController
|
||||||
before_action :set_message
|
before_action :set_message
|
||||||
before_action :set_boost, only: :destroy
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
end
|
end
|
||||||
@@ -16,6 +15,7 @@ class Messages::BoostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
@boost = Current.user.boosts.find(params[:id])
|
||||||
@boost.destroy!
|
@boost.destroy!
|
||||||
|
|
||||||
broadcast_remove
|
broadcast_remove
|
||||||
@@ -26,17 +26,13 @@ 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: helpers.dom_id(@boost.message, :boosts), partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_remove
|
def broadcast_remove
|
||||||
|
|||||||
@@ -1,64 +1,24 @@
|
|||||||
class Messages::ByBotsController < MessagesController
|
class Messages::ByBotsController < MessagesController
|
||||||
include RawRequestBody
|
allow_bot_access only: :create
|
||||||
|
|
||||||
allow_bot_access only: %i[ index create update destroy ]
|
|
||||||
|
|
||||||
before_action :set_room
|
|
||||||
before_action :set_message, only: %i[ update destroy ]
|
|
||||||
before_action :ensure_can_administer, only: %i[ update destroy ]
|
|
||||||
before_action :ensure_body_or_attachment_present, only: :create
|
|
||||||
|
|
||||||
def index
|
|
||||||
@messages = find_paged_messages
|
|
||||||
set_pagination_headers
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
def create
|
||||||
super
|
super
|
||||||
head :created, location: message_url(@message)
|
head :created, location: message_url(@message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
|
||||||
super
|
|
||||||
head :no_content
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
|
||||||
@room = Current.user.rooms.find_by(id: params[:room_id])
|
|
||||||
|
|
||||||
head :not_found unless @room
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_body_or_attachment_present
|
|
||||||
if params[:attachment].blank? && raw_request_body.blank?
|
|
||||||
head :unprocessable_content
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_pagination_headers
|
|
||||||
headers["X-Total-Count"] = @room.messages_count.to_s
|
|
||||||
|
|
||||||
if next_page = next_page_params
|
|
||||||
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def next_page_params
|
|
||||||
if @messages.any?
|
|
||||||
if params[:after].present?
|
|
||||||
{ after: @messages.last.id } if @room.messages.after(@messages.last).exists?
|
|
||||||
else
|
|
||||||
{ before: @messages.first.id } if @room.messages.before(@messages.first).exists?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_params
|
def message_params
|
||||||
if params[:attachment]
|
if params[:attachment]
|
||||||
params.permit(:attachment)
|
params.permit(:attachment)
|
||||||
else
|
else
|
||||||
{ body: raw_request_body }
|
reading(request.body) { |body| { body: body } }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reading(io)
|
||||||
|
io.rewind
|
||||||
|
yield io.read.force_encoding("UTF-8")
|
||||||
|
ensure
|
||||||
|
io.rewind
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -4,7 +4,6 @@ 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
|
||||||
|
|
||||||
@@ -12,13 +11,7 @@ class MessagesController < ApplicationController
|
|||||||
@messages = find_paged_messages
|
@messages = find_paged_messages
|
||||||
|
|
||||||
if @messages.any?
|
if @messages.any?
|
||||||
body = render_to_string(:index)
|
fresh_when @messages
|
||||||
# Creator, body and boost edits can change HTML without touching messages.
|
|
||||||
fresh_when etag: Digest::SHA256.hexdigest(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
|
||||||
@@ -28,9 +21,7 @@ class MessagesController < ApplicationController
|
|||||||
set_room
|
set_room
|
||||||
@message = @room.messages.create_with_attachment!(message_params)
|
@message = @room.messages.create_with_attachment!(message_params)
|
||||||
|
|
||||||
# Both deliveries contain the same token-free, viewer-independent markup.
|
@message.broadcast_create
|
||||||
@message_html = render_to_string partial: "messages/message", formats: :html, locals: { message: @message }
|
|
||||||
@message.broadcast_create(html: @message_html)
|
|
||||||
deliver_webhooks_to_bots
|
deliver_webhooks_to_bots
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render action: :room_not_found
|
render action: :room_not_found
|
||||||
@@ -46,11 +37,7 @@ class MessagesController < ApplicationController
|
|||||||
@message.update!(message_params)
|
@message.update!(message_params)
|
||||||
|
|
||||||
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
|
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
|
||||||
|
redirect_to room_message_url(@room, @message)
|
||||||
respond_to do |format|
|
|
||||||
format.html { redirect_to room_message_url(@room, @message) }
|
|
||||||
format.json { render :show }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@@ -71,11 +58,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_presentation.page_before(@room.messages.find(params[:before]))
|
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
|
||||||
when params[:after].present?
|
when params[:after].present?
|
||||||
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
|
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
|
||||||
else
|
else
|
||||||
@room.messages.with_presentation.last_page
|
@room.messages.with_creator.last_page
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,5 @@ class QrCodeController < ApplicationController
|
|||||||
|
|
||||||
expires_in 1.year, public: true
|
expires_in 1.year, public: true
|
||||||
render plain: qr_code, content_type: "image/svg+xml"
|
render plain: qr_code, content_type: "image/svg+xml"
|
||||||
rescue ArgumentError, RQRCodeCore::QRCodeRunTimeError
|
|
||||||
head :bad_request
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -42,12 +42,6 @@ class Rooms::ClosedsController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Closed)
|
@room = @room.becomes!(Rooms::Closed)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
|
||||||
# rooms never are: converting one would let its creator revise who's in it.
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms.without_directs
|
|
||||||
end
|
|
||||||
|
|
||||||
def grantees
|
def grantees
|
||||||
User.where(id: grantee_ids)
|
User.where(id: grantee_ids)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,13 +29,8 @@ class Rooms::DirectsController < RoomsController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# All users in a direct room can administer it. Only direct rooms, though: this
|
# All users in a direct room can administer it
|
||||||
# relaxation is why room_scope below has to keep every other type out of reach.
|
|
||||||
def ensure_can_administer
|
def ensure_can_administer
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms.directs
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -40,12 +40,6 @@ class Rooms::OpensController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Open)
|
@room = @room.becomes!(Rooms::Open)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
|
||||||
# rooms never are: promoting one would republish its history to the whole account.
|
|
||||||
def room_scope
|
|
||||||
Current.user.rooms.without_directs
|
|
||||||
end
|
|
||||||
|
|
||||||
def broadcast_create_room(room)
|
def broadcast_create_room(room)
|
||||||
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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_presentation.page_created_since(@last_updated_at)
|
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
|
||||||
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
|
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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)
|
||||||
@@ -13,7 +12,7 @@ class RoomsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@room.destroy_later
|
@room.destroy
|
||||||
|
|
||||||
broadcast_remove_room
|
broadcast_remove_room
|
||||||
redirect_to root_url
|
redirect_to root_url
|
||||||
@@ -21,22 +20,13 @@ class RoomsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
def set_room
|
||||||
room = RecordCache.fetch([ "room", self.class.name, Current.user.id, params[:room_id] || params[:id] ], read_record_cache_version) do
|
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
|
||||||
[ room_scope.find_by(id: params[:room_id] || params[:id]) ]
|
|
||||||
end.first
|
|
||||||
if room
|
|
||||||
@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
|
||||||
@@ -48,9 +38,9 @@ class RoomsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_messages
|
def find_messages
|
||||||
messages = @room.messages.with_presentation
|
messages = @room.messages.with_creator.with_attachment_details.with_boosts
|
||||||
|
|
||||||
if params[:message_id].present? && (show_first_message = messages.find_by(id: params[:message_id]))
|
if 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,5 +1,4 @@
|
|||||||
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
|
||||||
@@ -21,7 +20,7 @@ class SearchesController < ApplicationController
|
|||||||
private
|
private
|
||||||
def set_messages
|
def set_messages
|
||||||
if query.present?
|
if query.present?
|
||||||
@messages = Message.search_reachable(Current.user, query)
|
@messages = Current.user.reachable_messages.search(query).last(100)
|
||||||
else
|
else
|
||||||
@messages = Message.none
|
@messages = Message.none
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,7 +11,5 @@ class Sessions::TransfersController < ApplicationController
|
|||||||
else
|
else
|
||||||
head :bad_request
|
head :bad_request
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordInvalid
|
|
||||||
head :bad_request
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -14,8 +14,6 @@ class SessionsController < ApplicationController
|
|||||||
else
|
else
|
||||||
render_rejection :unauthorized
|
render_rejection :unauthorized
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordInvalid
|
|
||||||
render_rejection :unauthorized
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
class UnfurlLinksController < ApplicationController
|
class UnfurlLinksController < ApplicationController
|
||||||
# The pasted link decides which hosts an unfurl looks up and fetches from, and how quickly they answer.
|
|
||||||
DEADLINE = 10.seconds
|
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if opengraph = unfurl(url_param)
|
opengraph = Opengraph::Metadata.from_url(url_param)
|
||||||
|
|
||||||
|
if opengraph.valid?
|
||||||
render json: opengraph
|
render json: opengraph
|
||||||
else
|
else
|
||||||
head :no_content
|
head :no_content
|
||||||
@@ -14,14 +13,4 @@ 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
|
||||||
@@ -1,26 +1,14 @@
|
|||||||
class Users::ProfilesController < ApplicationController
|
class Users::ProfilesController < ApplicationController
|
||||||
before_action :set_user
|
before_action :set_user
|
||||||
|
|
||||||
# Limited like signing in, so the current password can't be guessed here instead.
|
|
||||||
rate_limit to: 10, within: 3.minutes, only: :update, if: :answering_password_challenge?,
|
|
||||||
with: -> { redirect_to user_profile_url, alert: "Too many attempts. Try again in a few minutes." }
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@direct_memberships, @shared_memberships =
|
@direct_memberships, @shared_memberships =
|
||||||
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
|
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
if @user.update user_params
|
@user.update user_params
|
||||||
start_new_session_for @user if @user.saved_change_to_password_digest?
|
redirect_to user_profile_url, notice: update_notice
|
||||||
|
|
||||||
redirect_to user_profile_url, notice: update_notice
|
|
||||||
else
|
|
||||||
redirect_to user_profile_url, alert: update_alert
|
|
||||||
end
|
|
||||||
rescue ActiveRecord::RecordInvalid
|
|
||||||
# The password changed again before this browser's new session could start, and that change signed it out.
|
|
||||||
redirect_to new_session_url
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -28,35 +16,11 @@ class Users::ProfilesController < ApplicationController
|
|||||||
@user = Current.user
|
@user = Current.user
|
||||||
end
|
end
|
||||||
|
|
||||||
# Always with the challenge, so the model checks it whenever the save would change the password, however the
|
|
||||||
# request spelled the new one.
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
|
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
|
||||||
.merge(password_challenge: password_challenge)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only a string is a challenge, so the rate limit counts exactly what the model checks.
|
|
||||||
def password_challenge
|
|
||||||
challenge = params.dig(:user, :password_challenge)
|
|
||||||
challenge.is_a?(String) ? challenge : ""
|
|
||||||
end
|
|
||||||
|
|
||||||
# Guessing the current password means sending one, so this is what the rate limit counts.
|
|
||||||
def answering_password_challenge?
|
|
||||||
!password_challenge.empty?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_notice
|
def update_notice
|
||||||
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
|
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_alert
|
|
||||||
if !@user.errors.include?(:password_challenge)
|
|
||||||
@user.errors.full_messages.to_sentence
|
|
||||||
elsif password_challenge.empty?
|
|
||||||
"Enter your current password to change it."
|
|
||||||
else
|
|
||||||
"That isn't your current password."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -5,15 +5,13 @@ class Users::PushSubscriptionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params)
|
if subscription = @push_subscriptions.find_by(push_subscription_params)
|
||||||
subscription.user_agent = request.user_agent if subscription.new_record?
|
subscription.touch
|
||||||
|
|
||||||
# Existing endpoints must pass current validations too
|
|
||||||
if subscription.valid?
|
|
||||||
head register_while_signed_in(subscription) ? :ok : :unauthorized
|
|
||||||
else
|
else
|
||||||
head :unprocessable_entity
|
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@@ -26,24 +24,6 @@ class Users::PushSubscriptionsController < ApplicationController
|
|||||||
@push_subscriptions = Current.user.push_subscriptions
|
@push_subscriptions = Current.user.push_subscriptions
|
||||||
end
|
end
|
||||||
|
|
||||||
# A password change ends every session and subscription. A request that authenticated just before it mustn't
|
|
||||||
# put one back, so the session is checked under the same write lock as the save, and the endpoint is looked up
|
|
||||||
# again there, past the query cache, so two tabs posting at once register it once. Validation, which resolves
|
|
||||||
# the endpoint's address, runs before taking the lock.
|
|
||||||
def register_while_signed_in(subscription)
|
|
||||||
Push::Subscription.uncached do
|
|
||||||
Push::Subscription.transaction do
|
|
||||||
if Session.exists?(Current.session.id)
|
|
||||||
if existing = @push_subscriptions.find_by(push_subscription_params)
|
|
||||||
existing.touch
|
|
||||||
else
|
|
||||||
subscription.save!(validate: false)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def push_subscription_params
|
def push_subscription_params
|
||||||
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
|
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
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
|
||||||
visible_memberships = Current.user.memberships.visible
|
all_memberships = Current.user.memberships.visible.with_ordered_room
|
||||||
@direct_memberships = visible_memberships.with_direct_rooms
|
@direct_memberships = extract_direct_memberships(all_memberships)
|
||||||
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms
|
@other_memberships = all_memberships.without(@direct_memberships)
|
||||||
|
|
||||||
@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)
|
||||||
|
|||||||
@@ -36,11 +36,6 @@ module ApplicationHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# Header-only forgery protection needs no secret in forms or cached HTML.
|
|
||||||
def token_tag(*)
|
|
||||||
""
|
|
||||||
end
|
|
||||||
|
|
||||||
def admin_body_class
|
def admin_body_class
|
||||||
"admin" if Current.user&.can_administer?
|
"admin" if Current.user&.can_administer?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
module ContentFilters
|
module ContentFilters
|
||||||
# Formatting the rich text editor produces that Rails' sanitizers don't
|
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
|
||||||
# allow by default. A message passes through three sanitization layers on
|
|
||||||
# its way to the screen, and each builds on this list:
|
|
||||||
#
|
|
||||||
# * SanitizeTags strips disallowed markup from the message body
|
|
||||||
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
|
|
||||||
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
|
|
||||||
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
|
|
||||||
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
|
|
||||||
|
|
||||||
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, SanitizeTags, SanitizeAttributes)
|
|
||||||
end
|
end
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
||||||
TWITTER_DOMAINS = %w[ x.com twitter.com ]
|
|
||||||
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
|
|
||||||
|
|
||||||
def applicable?
|
def applicable?
|
||||||
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
|
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply
|
def apply
|
||||||
if trix_body?
|
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
||||||
remove_link_text_from_wrapping_div
|
|
||||||
else
|
|
||||||
remove_link_paragraphs
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
TWITTER_DOMAINS = %w[ x.com twitter.com ]
|
||||||
|
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
|
||||||
|
|
||||||
def solo_unfurled_url
|
def solo_unfurled_url
|
||||||
ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1
|
unfurled_links.first["href"] if unfurled_links.size == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
def unfurled_links
|
def unfurled_links
|
||||||
@@ -39,20 +35,4 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
|||||||
def twitter_url?(url)
|
def twitter_url?(url)
|
||||||
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
|
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def trix_body?
|
|
||||||
fragment.find_all("div").any?
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_link_text_from_wrapping_div
|
|
||||||
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_link_paragraphs
|
|
||||||
fragment.replace("p") do |node|
|
|
||||||
if node.at_css("action-text-attachment")
|
|
||||||
node
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
class ContentFilters::SanitizeAttributes < ActionText::Content::Filter
|
|
||||||
def applicable?
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
# Scrub attributes on the tags that SanitizeTags allows, using Rails' safe-list
|
|
||||||
# sanitizer so unsafe URI schemes and event-handler attributes are stripped.
|
|
||||||
# Runs after SanitizeTags, so passing the same allowed tags makes the tag pass
|
|
||||||
# a no-op and only attributes are scrubbed.
|
|
||||||
def apply
|
|
||||||
sanitizer.sanitize fragment.to_html, tags: allowed_tags, attributes: allowed_attributes
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
# Presentation styling relies on class attributes (e.g. unfurled link embeds),
|
|
||||||
# which the standard ActionText set doesn't include.
|
|
||||||
EXTRA_ALLOWED_ATTRIBUTES = %w[ class ]
|
|
||||||
|
|
||||||
# ActionText::ContentHelper.sanitizer is a single process-wide instance, and
|
|
||||||
# rails-html-sanitizer reuses one mutable permit scrubber that it reconfigures
|
|
||||||
# from the tags/attributes on every #sanitize call. Sharing it here would race
|
|
||||||
# our stricter tag set against ActionText's default rendering on concurrent
|
|
||||||
# requests, so use a dedicated instance of the same sanitizer class to keep the
|
|
||||||
# scrubber isolated.
|
|
||||||
def sanitizer
|
|
||||||
sanitizer_class.new
|
|
||||||
end
|
|
||||||
|
|
||||||
def sanitizer_class
|
|
||||||
ActionText::ContentHelper.sanitizer.class
|
|
||||||
end
|
|
||||||
|
|
||||||
def allowed_tags
|
|
||||||
ContentFilters::SanitizeTags::ALLOWED_TAGS
|
|
||||||
end
|
|
||||||
|
|
||||||
def allowed_attributes
|
|
||||||
standard_allowed_attributes + EXTRA_ALLOWED_ATTRIBUTES
|
|
||||||
end
|
|
||||||
|
|
||||||
# Mirrors ActionText::ContentHelper#sanitizer_allowed_attributes, which isn't
|
|
||||||
# exposed at the module level.
|
|
||||||
def standard_allowed_attributes
|
|
||||||
ActionText::ContentHelper.allowed_attributes ||
|
|
||||||
(sanitizer_class.allowed_attributes + ActionText::Attachment::ATTRIBUTES).to_a
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
||||||
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
|
|
||||||
p pre samp small span strong sub sup time tt ul var ] + ContentFilters::EDITOR_FORMATTING_TAGS +
|
|
||||||
[ ActionText::Attachment.tag_name, "figure", "figcaption" ]
|
|
||||||
|
|
||||||
def applicable?
|
def applicable?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
@@ -12,6 +8,9 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
|
||||||
|
p pre samp small span strong sub sup time tt ul var ] + [ ActionText::Attachment.tag_name, "figure", "figcaption" ]
|
||||||
|
|
||||||
def not_allowed_tags_css_selector
|
def not_allowed_tags_css_selector
|
||||||
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
|
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
class ContentFilters::StyleUnfurledTwitterAvatars < ActionText::Content::Filter
|
||||||
|
def applicable?
|
||||||
|
unfurled_twitter_avatars.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply
|
||||||
|
fragment.update do |source|
|
||||||
|
div = source.at_css("div")
|
||||||
|
div["class"] = UNFURLED_TWITTER_AVATAR_CSS_CLASS
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
|
||||||
|
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
||||||
|
|
||||||
|
def unfurled_twitter_avatars
|
||||||
|
fragment.find_all("#{opengraph_css_selector}[url*='#{TWITTER_AVATAR_URL_PREFIX}']")
|
||||||
|
end
|
||||||
|
|
||||||
|
def opengraph_css_selector
|
||||||
|
"action-text-attachment[@content-type='#{ActionText::Attachment::OpengraphEmbed::OPENGRAPH_EMBED_CONTENT_TYPE}']"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -17,16 +17,11 @@ 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
|
||||||
case
|
if message.attachment.video?
|
||||||
when message.attachment.video?
|
|
||||||
video_preview_tag
|
video_preview_tag
|
||||||
when message.attachment.representation(:thumb).processed?
|
|
||||||
lightboxed_image_preview_tag
|
|
||||||
else
|
else
|
||||||
render_link
|
lightboxed_image_preview_tag
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -35,17 +30,11 @@ 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: video_poster_url,
|
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 ])),
|
||||||
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,12 +1,4 @@
|
|||||||
module MessagesHelper
|
module MessagesHelper
|
||||||
# Rendered text is re-sanitized before linking. Rails' default safe list
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
|
|
||||||
LINKABLE_TEXT = /:|@|www\./i
|
|
||||||
|
|
||||||
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",
|
||||||
@@ -39,7 +31,6 @@ 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,
|
||||||
@@ -66,7 +57,7 @@ module MessagesHelper
|
|||||||
when "sound"
|
when "sound"
|
||||||
message_sound_presentation(message)
|
message_sound_presentation(message)
|
||||||
else
|
else
|
||||||
text_message_presentation(message.body.body)
|
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
|
||||||
end
|
end
|
||||||
rescue Exception => e
|
rescue Exception => e
|
||||||
Sentry.capture_exception(e, extra: { message: message })
|
Sentry.capture_exception(e, extra: { message: message })
|
||||||
@@ -75,65 +66,7 @@ module MessagesHelper
|
|||||||
""
|
""
|
||||||
end
|
end
|
||||||
|
|
||||||
# These controls contain URLs and static markup, but no viewer or token state.
|
|
||||||
# Keep them across database commits; attachment controls still render afresh.
|
|
||||||
def cache_message_actions(message, &block)
|
|
||||||
return capture(&block) unless controller.perform_caching && !message.attachment?
|
|
||||||
|
|
||||||
key = fragment_name_with_digest([
|
|
||||||
"message-actions-v1", request.base_url, request.script_name, I18n.locale, message.id, message.room_id
|
|
||||||
], nil)
|
|
||||||
FragmentCache.store.fetch(key) { capture(&block) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_fragment_cache_key(message)
|
|
||||||
return message unless controller.perform_caching &&
|
|
||||||
%i[room creator rich_text_body boosts attachment_attachment].all? { |name| message.association(name).loaded? }
|
|
||||||
|
|
||||||
room = message.room
|
|
||||||
body = message.body.body
|
|
||||||
return message if room.direct? || message.attachment? || !body || body.to_html.include?("<action-text-attachment") ||
|
|
||||||
message.boosts.any? { |boost| !boost.association(:booster).loaded? }
|
|
||||||
|
|
||||||
# Collection caching preloads these rows before deriving keys. Read their
|
|
||||||
# actual content: foreign SQL writers need not touch Rails timestamps.
|
|
||||||
dependencies = [ message.attributes, room.attributes.slice("id", "type", "name"), message.creator.attributes,
|
|
||||||
body.to_html, message.boosts.sort_by(&:created_at).map { |boost| [ boost.attributes, boost.booster.attributes ] } ]
|
|
||||||
FragmentCache::ContentKey.new(Digest::SHA256.hexdigest(ActiveSupport::JSON.encode(dependencies)))
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
def text_message_presentation(body)
|
|
||||||
render = -> { auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(body)) }
|
|
||||||
|
|
||||||
# Embedded attachments render database-backed metadata and signed URLs.
|
|
||||||
# Plain text HTML depends only on its content, even after a foreign edit.
|
|
||||||
html = body.to_html
|
|
||||||
if controller.perform_caching && !html.include?("<action-text-attachment") && html.bytesize <= ResponseCache::MAX_ENTRY_BYTES
|
|
||||||
FragmentCache.store.fetch([ "text-presentation-v2", I18n.locale, Digest::SHA256.hexdigest(html) ]) { render.call }
|
|
||||||
else
|
|
||||||
render.call
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# auto_link finds URLs and email addresses with regular expressions, which
|
|
||||||
# can't tell an attribute value from text. Hand it one text node at a time,
|
|
||||||
# escaped as the serializer writes text, so a link can only land between tags.
|
|
||||||
def auto_link_text(html)
|
|
||||||
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
|
|
||||||
|
|
||||||
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
|
|
||||||
if node.text.match?(LINKABLE_TEXT)
|
|
||||||
text = node.to_html
|
|
||||||
linked = auto_link(text, html: { target: "_blank" })
|
|
||||||
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# A <pre> starting with a newline needs an extra one written out to survive the next parse
|
|
||||||
fragment.to_html(preserve_newline: true).html_safe
|
|
||||||
end
|
|
||||||
|
|
||||||
def messages_actions
|
def messages_actions
|
||||||
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,33 +1,11 @@
|
|||||||
module RichTextHelper
|
module RichTextHelper
|
||||||
def rich_text_data_actions
|
def rich_text_data_actions
|
||||||
# submitByKeyboard runs in the capture phase so it can submit on Enter
|
default_actions =
|
||||||
# before the editor turns the keystroke into a newline
|
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
|
||||||
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
|
|
||||||
end
|
|
||||||
|
|
||||||
def mention_prompt_tag(room)
|
autocomplete_actions =
|
||||||
tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id),
|
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
|
||||||
"remote-filtering": true, "empty-results": "No matches"
|
|
||||||
end
|
|
||||||
|
|
||||||
# The editor keeps an attachment's content as it finds it, so every
|
[ default_actions, autocomplete_actions ].join(" ")
|
||||||
# attachment is rebuilt from its attachable before editing: a Trix-era
|
|
||||||
# embed carries its details as node attributes the editor doesn't
|
|
||||||
# round-trip, a mention edited under Trix carries the generic content type
|
|
||||||
# the editor doesn't permit, and a hand-written embed carries whatever
|
|
||||||
# markup the author put there.
|
|
||||||
def editable_body(message)
|
|
||||||
fragment = ActionText::Fragment.wrap(message.body.body_before_type_cast)
|
|
||||||
|
|
||||||
transformed = fragment.replace(ActionText::Attachment.tag_name) do |node|
|
|
||||||
attachment = ActionText::Attachment.from_node(node)
|
|
||||||
|
|
||||||
node.tap do |n|
|
|
||||||
n["content-type"] = attachment.attachable.attachable_content_type
|
|
||||||
n["content"] = render_action_text_attachment(attachment)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
ActionText::RichText.new(body: transformed.to_html)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -73,12 +73,12 @@ module RoomsHelper
|
|||||||
def composer_data_actions
|
def composer_data_actions
|
||||||
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
|
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
|
||||||
|
|
||||||
attachment_actions =
|
trix_attachment_actions =
|
||||||
"lexxy:file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
||||||
|
|
||||||
remaining_actions =
|
remaining_actions =
|
||||||
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
|
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
|
||||||
|
|
||||||
[ drop_target_actions, drag_and_drop_actions, attachment_actions, remaining_actions ].join(" ")
|
[ drop_target_actions, drag_and_drop_actions, trix_attachment_actions, remaining_actions ].join(" ")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2,7 +2,6 @@ module TranslationsHelper
|
|||||||
TRANSLATIONS = {
|
TRANSLATIONS = {
|
||||||
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
|
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
|
||||||
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
|
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
|
||||||
current_password: { "🇺🇸": "Enter your current password", "🇪🇸": "Introduce tu contraseña actual", "🇫🇷": "Saisissez votre mot de passe actuel", "🇮🇳": "अपना वर्तमान पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr aktuelles Passwort ein", "🇧🇷": "Insira sua senha atual", "🇯🇵": "現在のパスワードを入力してください" },
|
|
||||||
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
|
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
|
||||||
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
|
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
|
||||||
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
|
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
module Users::PushSubscriptionsHelper
|
|
||||||
# Names the session without revealing its token, so the browser can tell when it's in a new one. The creation
|
|
||||||
# time keeps an id reused by a new database from looking like the same session.
|
|
||||||
def push_subscription_session_marker
|
|
||||||
Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||||
import "@hotwired/turbo-rails"
|
import "@hotwired/turbo-rails"
|
||||||
|
import "trix"
|
||||||
|
import "@rails/actiontext"
|
||||||
import "initializers"
|
import "initializers"
|
||||||
import "controllers"
|
import "controllers"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
import FileUploader from "models/file_uploader"
|
import FileUploader from "models/file_uploader"
|
||||||
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
|
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
|
||||||
import { escapeHTML } from "helpers/string_helpers"
|
import { escapeHTML } from "helpers/dom_helpers"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static classes = ["toolbar"]
|
static classes = ["toolbar"]
|
||||||
@@ -12,21 +12,11 @@ export default class extends Controller {
|
|||||||
#files = []
|
#files = []
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.#restoreDraft()
|
|
||||||
|
|
||||||
if (!this.#usingTouchDevice) {
|
if (!this.#usingTouchDevice) {
|
||||||
onNextEventLoopTick(() => this.textTarget.focus())
|
onNextEventLoopTick(() => this.textTarget.focus())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDraft() {
|
|
||||||
if (this.textTarget.isBlank) {
|
|
||||||
localStorage.removeItem(this.#draftKey)
|
|
||||||
} else {
|
|
||||||
localStorage.setItem(this.#draftKey, this.textTarget.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(event) {
|
submit(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
@@ -54,20 +44,21 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceMessageContent(content) {
|
replaceMessageContent(content) {
|
||||||
this.textTarget.value = content
|
const editor = this.textTarget.editor
|
||||||
this.textTarget.focus()
|
|
||||||
this.textTarget.selection.placeCursorAtTheEnd()
|
editor.recordUndoEntry("Format reply")
|
||||||
|
editor.setSelectedRange([0, editor.getDocument().toString().length])
|
||||||
|
editor.deleteInDirection("forward")
|
||||||
|
editor.insertHTML(content)
|
||||||
|
editor.setSelectedRange([editor.getDocument().toString().length - 1])
|
||||||
}
|
}
|
||||||
|
|
||||||
submitByKeyboard(event) {
|
submitByKeyboard(event) {
|
||||||
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
|
|
||||||
|
|
||||||
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
|
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
|
||||||
const metaEnter = event.metaKey || event.ctrlKey
|
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
|
||||||
const plainEnter = !event.shiftKey && !event.isComposing
|
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
|
||||||
|
|
||||||
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
||||||
event.stopPropagation()
|
|
||||||
this.submit(event)
|
this.submit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,19 +108,6 @@ export default class extends Controller {
|
|||||||
this.fieldsTarget.disabled = true
|
this.fieldsTarget.disabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
#restoreDraft() {
|
|
||||||
const draft = localStorage.getItem(this.#draftKey)
|
|
||||||
|
|
||||||
if (draft) {
|
|
||||||
this.textTarget.value = draft
|
|
||||||
this.textTarget.selection.placeCursorAtTheEnd()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get #draftKey() {
|
|
||||||
return `composer-draft-${this.roomIdValue}`
|
|
||||||
}
|
|
||||||
|
|
||||||
get #usingTouchDevice() {
|
get #usingTouchDevice() {
|
||||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||||||
}
|
}
|
||||||
@@ -148,7 +126,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#validInput() {
|
#validInput() {
|
||||||
return !this.textTarget.isBlank
|
return this.textTarget.textContent.trim().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async #submitFiles() {
|
async #submitFiles() {
|
||||||
@@ -181,7 +159,6 @@ export default class extends Controller {
|
|||||||
|
|
||||||
#reset() {
|
#reset() {
|
||||||
this.textTarget.value = ""
|
this.textTarget.value = ""
|
||||||
localStorage.removeItem(this.#draftKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateFileList() {
|
#updateFileList() {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
messageTargetConnected(target) {
|
messageTargetConnected(target) {
|
||||||
this.#reconcilePendingMessage(target)
|
|
||||||
this.#formatter.format(target, ThreadStyle.thread)
|
this.#formatter.format(target, ThreadStyle.thread)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +89,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async editMyLastMessage() {
|
async editMyLastMessage() {
|
||||||
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
|
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
|
||||||
|
|
||||||
if (editorEmpty && this.#paginator.upToDate) {
|
if (editorEmpty && this.#paginator.upToDate) {
|
||||||
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
||||||
@@ -126,25 +125,6 @@ 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()
|
||||||
|
|||||||
@@ -106,8 +106,7 @@ export default class extends Controller {
|
|||||||
|
|
||||||
async #syncPushSubscription(subscription) {
|
async #syncPushSubscription(subscription) {
|
||||||
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
|
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
|
||||||
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
|
if (!response.ok) subscription.unsubscribe()
|
||||||
if (response.unprocessableEntity) subscription.unsubscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async #requestPermissionAndSubscribe(registration) {
|
async #requestPermissionAndSubscribe(registration) {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
import { post } from "@rails/request.js"
|
|
||||||
|
|
||||||
const SYNCED_SESSION_KEY = "push-subscription-synced-session"
|
|
||||||
|
|
||||||
// A password change removes the account's push subscriptions, the one this browser holds included. Post it again
|
|
||||||
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
|
|
||||||
// browser's own subscription alone: the next session tries again.
|
|
||||||
export default class extends Controller {
|
|
||||||
static values = { url: String, session: String }
|
|
||||||
|
|
||||||
async connect() {
|
|
||||||
if (this.#allowed && this.sessionValue && localStorage.getItem(SYNCED_SESSION_KEY) !== this.sessionValue) {
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration(window.location.origin)
|
|
||||||
const subscription = await registration?.pushManager?.getSubscription()
|
|
||||||
|
|
||||||
if (Notification.permission === "granted" && subscription) {
|
|
||||||
await post(this.urlValue, { body: this.#payload(subscription), responseKind: "turbo-stream" })
|
|
||||||
localStorage.setItem(SYNCED_SESSION_KEY, this.sessionValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get #allowed() {
|
|
||||||
return navigator.serviceWorker && window.Notification
|
|
||||||
}
|
|
||||||
|
|
||||||
#payload(subscription) {
|
|
||||||
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
|
|
||||||
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ export default class extends Controller {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#read = ({ room_id, at }) => {
|
#read = ({ room_id }) => {
|
||||||
this.dispatch("read", { detail: { roomId: room_id, at } })
|
this.dispatch("read", { detail: { roomId: room_id } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reply() {
|
reply() {
|
||||||
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><p><br></p>`
|
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><br>`
|
||||||
this.composerOutlet.replaceMessageContent(content)
|
this.composerOutlet.replaceMessageContent(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +23,12 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get #bodyContent() {
|
get #bodyContent() {
|
||||||
const body = this.bodyTarget.querySelector(".lexxy-content").cloneNode(true)
|
const body = this.bodyTarget.querySelector(".trix-content").cloneNode(true)
|
||||||
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
|
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
#stripMentionAttachments(node) {
|
#stripMentionAttachments(node) {
|
||||||
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
|
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import MentionsAutocompleteHandler from "lib/autocomplete/mentions_autocomplete_handler"
|
||||||
|
import { debounce } from "helpers/timing_helpers"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { url: String }
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
this.handlers = []
|
||||||
|
this.search = debounce(this.search.bind(this), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.element == document.activeElement) {
|
||||||
|
this.#installHandlers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(event) {
|
||||||
|
this.#installHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
search(event) {
|
||||||
|
const content = this.editor.getDocument().toString()
|
||||||
|
const position = this.editor.getPosition()
|
||||||
|
this.handlers.forEach(handler => handler.updateWithContentAndPosition(content, position))
|
||||||
|
}
|
||||||
|
|
||||||
|
blur(event) {
|
||||||
|
this.#uninstallHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
#installHandlers() {
|
||||||
|
this.#uninstallHandlers()
|
||||||
|
this.handlers = [ new MentionsAutocompleteHandler(this.element, this.urlValue) ]
|
||||||
|
}
|
||||||
|
|
||||||
|
#uninstallHandlers() {
|
||||||
|
this.handlers.forEach(handler => handler.destroy())
|
||||||
|
this.handlers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
get editor() {
|
||||||
|
return this.element.editor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,8 @@ 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),
|
||||||
@@ -22,11 +17,7 @@ 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
|
||||||
})
|
})
|
||||||
@@ -36,58 +27,19 @@ export default class extends Controller {
|
|||||||
this.read({ detail: { roomId: Current.room.id } })
|
this.read({ detail: { roomId: Current.room.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
read({ detail: { roomId, at } }) {
|
read({ detail: { roomId } }) {
|
||||||
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 } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve the live picker through background refreshes, but allow deliberate navigation.
|
#channelConnected() {
|
||||||
#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
|
this.element.reload()
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +47,11 @@ export default class extends Controller {
|
|||||||
this.#disconnected = true
|
this.#disconnected = true
|
||||||
}
|
}
|
||||||
|
|
||||||
#unread({ roomId, at }) {
|
#unread({ roomId }) {
|
||||||
const unreadRoom = this.#findRoomTarget(roomId)
|
const unreadRoom = this.#findRoomTarget(roomId)
|
||||||
|
|
||||||
if (unreadRoom) {
|
if (unreadRoom) {
|
||||||
if (Current.room.id != roomId && !this.#readSince(roomId, at)) {
|
if (Current.room.id != roomId) {
|
||||||
unreadRoom.classList.add(this.unreadClass)
|
unreadRoom.classList.add(this.unreadClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,12 +59,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
import { post } from "@rails/request.js"
|
|
||||||
import { escapeHTML, truncateString } from "helpers/string_helpers"
|
|
||||||
|
|
||||||
const OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
|
|
||||||
|
|
||||||
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "og-embed--twitter-avatar"
|
|
||||||
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
#abortController
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.#abortController?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
async unfurl(event) {
|
|
||||||
if (!this.element.permitsAttachmentContentType(OPENGRAPH_EMBED_CONTENT_TYPE)) return
|
|
||||||
|
|
||||||
const { url, insertBelowLink } = event.detail
|
|
||||||
|
|
||||||
const metadata = await this.#fetchOpengraphMetadata(url)
|
|
||||||
|
|
||||||
if (metadata) {
|
|
||||||
insertBelowLink(this.#opengraphEmbedHTML(metadata), { attachment: { contentType: OPENGRAPH_EMBED_CONTENT_TYPE } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #fetchOpengraphMetadata(url) {
|
|
||||||
this.#abortController?.abort()
|
|
||||||
this.#abortController = new AbortController()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await post("/unfurl_link", {
|
|
||||||
body: { url },
|
|
||||||
contentType: "application/json",
|
|
||||||
signal: this.#abortController.signal
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok && response.statusCode !== 204) {
|
|
||||||
const { title, url: href, image, description } = await response.json
|
|
||||||
if (title && href) return { title, href, image, description }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore aborted and failed requests
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
#opengraphEmbedHTML({ title, href, image, description }) {
|
|
||||||
return `<actiontext-opengraph-embed>
|
|
||||||
<div class="og-embed gap ${this.#embedClass(image)}">
|
|
||||||
<div class="og-embed__content">
|
|
||||||
<div class="og-embed__title">
|
|
||||||
<a href="${escapeHTML(href)}" rel="noreferrer" target="_blank">${escapeHTML(truncateString(title, 280))}</a>
|
|
||||||
</div>
|
|
||||||
<div class="og-embed__description">${escapeHTML(truncateString(description, 560))}</div>
|
|
||||||
</div>
|
|
||||||
${this.#imageHTML(image)}
|
|
||||||
</div>
|
|
||||||
</actiontext-opengraph-embed>`
|
|
||||||
}
|
|
||||||
|
|
||||||
#embedClass(image) {
|
|
||||||
if (this.#isTwitterAvatar(image)) {
|
|
||||||
return UNFURLED_TWITTER_AVATAR_CSS_CLASS
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#isTwitterAvatar(image) {
|
|
||||||
return Boolean(image) && image.startsWith(TWITTER_AVATAR_URL_PREFIX)
|
|
||||||
}
|
|
||||||
|
|
||||||
#imageHTML(image) {
|
|
||||||
if (image) {
|
|
||||||
return `<div class="og-embed__image"><img src="${escapeHTML(image)}" class="image center" alt="" /></div>`
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,12 @@ export function scrollToBottom(container) {
|
|||||||
container.scrollTop = container.scrollHeight
|
container.scrollTop = container.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function escapeHTML(html) {
|
||||||
|
const div = document.createElement("div")
|
||||||
|
div.textContent = html
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
export function parseHTMLFragment(html) {
|
export function parseHTMLFragment(html) {
|
||||||
const template = document.createElement("template")
|
const template = document.createElement("template")
|
||||||
template.innerHTML = html
|
template.innerHTML = html
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
const HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }
|
|
||||||
|
|
||||||
export function truncateString(string, length, omission = "…") {
|
export function truncateString(string, length, omission = "…") {
|
||||||
if (string.length <= length) {
|
if (string.length <= length) {
|
||||||
return string
|
return string
|
||||||
@@ -7,7 +5,3 @@ export function truncateString(string, length, omission = "…") {
|
|||||||
return string.slice(0, length - omission.length) + omission
|
return string.slice(0, length - omission.length) + omission
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeHTML(string) {
|
|
||||||
return String(string ?? "").replace(/[&<>"']/g, character => HTML_ESCAPES[character])
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
import * as Lexxy from "lexxy"
|
import Unfurler from "lib/rich_text/unfurl/unfurler"
|
||||||
import CampfireRichTextExtension from "lib/rich_text/campfire_extension"
|
|
||||||
|
|
||||||
Lexxy.configure({
|
// Support a `cite` block for attribution links
|
||||||
global: {
|
Trix.config.blockAttributes.cite = {
|
||||||
// Keep the content type of mention attachments (application/vnd.campfire.mention)
|
tagName: "cite",
|
||||||
// that Campfire has used since its Trix days
|
inheritable: false,
|
||||||
attachmentContentTypeNamespace: "campfire",
|
}
|
||||||
extensions: [ CampfireRichTextExtension ]
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
// Campfire sends files as separate messages via the composer's own
|
|
||||||
// attach button, never through the editor
|
|
||||||
toolbar: { attachments: false },
|
|
||||||
|
|
||||||
// Trix offered a single heading level, rendered as h1
|
const unfurler = new Unfurler()
|
||||||
headings: [ "h1" ]
|
unfurler.install()
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
|
|||||||
|
|
||||||
#fetchAutocompletables(url) {
|
#fetchAutocompletables(url) {
|
||||||
if (url) {
|
if (url) {
|
||||||
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
|
return fetch(url, { as: "json" }).then(response => response.json())
|
||||||
} else {
|
} else {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
|
||||||
|
import { PUNCTUATION_PATTERN } from "lib/autocomplete/constants"
|
||||||
|
|
||||||
|
export default class extends BaseAutocompleteHandler {
|
||||||
|
get pattern() {
|
||||||
|
return new RegExp(`^@(.*?)(${PUNCTUATION_PATTERN.source}*)$`)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAutocompletable(autocompletable, range, terminator, options = {}) {
|
||||||
|
const attachment = this.#createAttachmentForAutocompletable(autocompletable)
|
||||||
|
this.#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override to set selector's position relative to the cursor in the editor
|
||||||
|
getOffsetsAtPosition(position) {
|
||||||
|
return this.#getOffsetsFromEditorAtPosition(this.#editor, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
#createAttachmentForAutocompletable(mentionable) {
|
||||||
|
const mention = `
|
||||||
|
<span class="mention" sgid=${mentionable.sgid}>
|
||||||
|
<img src="${mentionable.avatar_url}" class="avatar" alt="${mentionable.name}">
|
||||||
|
${mentionable.name}
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
|
||||||
|
return new Trix.Attachment({
|
||||||
|
content: mention,
|
||||||
|
contentType: "application/vnd.campfire.mention",
|
||||||
|
sgid: mentionable.sgid
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range) {
|
||||||
|
if (range) { this.#editor.setSelectedRange(range) }
|
||||||
|
this.#editor.insertAttachment(attachment)
|
||||||
|
this.#editor.insertString(terminator)
|
||||||
|
}
|
||||||
|
|
||||||
|
get #editor() {
|
||||||
|
return this.element.editor
|
||||||
|
}
|
||||||
|
|
||||||
|
#getOffsetsFromEditorAtPosition(editor, position) {
|
||||||
|
const rect = this.#editor.getClientRectAtPosition(position)
|
||||||
|
return rect ? rect : {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import * as Lexxy from "lexxy"
|
|
||||||
import CiteNode from "lib/rich_text/cite_node"
|
|
||||||
|
|
||||||
export default class CampfireRichTextExtension extends Lexxy.Extension {
|
|
||||||
get allowedElements() {
|
|
||||||
return [
|
|
||||||
"cite",
|
|
||||||
"figure",
|
|
||||||
"figcaption",
|
|
||||||
"actiontext-opengraph-embed",
|
|
||||||
{ tag: "div", attributes: [ "sgid" ] },
|
|
||||||
{ tag: "span", attributes: [ "sgid" ] },
|
|
||||||
{ tag: "img", attributes: [ "alt" ] },
|
|
||||||
{ tag: "a", attributes: [ "rel", "target" ] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
get lexicalExtension() {
|
|
||||||
return this.defineExtension({
|
|
||||||
name: "campfire/rich-text",
|
|
||||||
nodes: [ CiteNode ]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import * as Lexxy from "lexxy"
|
|
||||||
|
|
||||||
const { ElementNode } = Lexxy.Lexical
|
|
||||||
|
|
||||||
export default class CiteNode extends ElementNode {
|
|
||||||
static getType() {
|
|
||||||
return "cite"
|
|
||||||
}
|
|
||||||
|
|
||||||
static clone(node) {
|
|
||||||
return new CiteNode(node.__key)
|
|
||||||
}
|
|
||||||
|
|
||||||
static importJSON(serializedNode) {
|
|
||||||
return new CiteNode().updateFromJSON(serializedNode)
|
|
||||||
}
|
|
||||||
|
|
||||||
static importDOM() {
|
|
||||||
return {
|
|
||||||
cite: () => ({ conversion: () => ({ node: new CiteNode() }), priority: 1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exportJSON() {
|
|
||||||
return { ...super.exportJSON(), type: "cite" }
|
|
||||||
}
|
|
||||||
|
|
||||||
createDOM() {
|
|
||||||
return document.createElement("cite")
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDOM() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
exportDOM() {
|
|
||||||
return { element: document.createElement("cite") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { post } from "@rails/request.js"
|
||||||
|
import { truncateString } from "helpers/string_helpers"
|
||||||
|
|
||||||
|
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
|
||||||
|
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
||||||
|
|
||||||
|
export default class OpengraphEmbedOperation {
|
||||||
|
constructor(paste) {
|
||||||
|
this.paste = paste
|
||||||
|
this.editor = this.paste.editor
|
||||||
|
this.url = this.paste.string
|
||||||
|
|
||||||
|
this.abortController = new AbortController()
|
||||||
|
}
|
||||||
|
|
||||||
|
perform() {
|
||||||
|
return this.#createOpenGraphMetadataRequest()
|
||||||
|
.then(response => response.json)
|
||||||
|
.then(this.#insertOpengraphAttachment.bind(this))
|
||||||
|
.catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
this.abortController.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
#createOpenGraphMetadataRequest() {
|
||||||
|
return post("/unfurl_link", {
|
||||||
|
body: { url: this.url },
|
||||||
|
contentType: "application/json",
|
||||||
|
signal: this.abortController.signal
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#insertOpengraphAttachment(response) {
|
||||||
|
if (this.#shouldInsertOpengraphPreview) {
|
||||||
|
const currentRange = this.editor.getSelectedRange()
|
||||||
|
this.editor.setSelectedRange(this.editor.getSelectedRange())
|
||||||
|
this.editor.recordUndoEntry("Insert Opengraph preview for Pasted URL")
|
||||||
|
this.editor.insertAttachment(this.#createOpengraphAttachment(response))
|
||||||
|
this.editor.setSelectedRange(currentRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get #shouldInsertOpengraphPreview() {
|
||||||
|
return this.editor.getDocument().toString().includes(this.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#createOpengraphAttachment(response) {
|
||||||
|
const { title, url, image, description } = response
|
||||||
|
const html = this.#generateOpengraphEmbedHTML({ title, url, image, description })
|
||||||
|
|
||||||
|
return new Trix.Attachment({
|
||||||
|
contentType: "application/vnd.actiontext.opengraph-embed",
|
||||||
|
content: html,
|
||||||
|
filename: title,
|
||||||
|
href: url,
|
||||||
|
url: image,
|
||||||
|
caption: description
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#generateOpengraphEmbedHTML(embed) {
|
||||||
|
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(embed) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ''}">
|
||||||
|
<div class="og-embed">
|
||||||
|
<div class="og-embed__content">
|
||||||
|
<div class="og-embed__title">${truncateString(embed.title, 560)}</div>
|
||||||
|
<div class="og-embed__description">${truncateString(embed.description, 560)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="og-embed__image">
|
||||||
|
<img src="${embed.image}" class="image" alt="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</actiontext-opengraph-embed>`
|
||||||
|
}
|
||||||
|
|
||||||
|
#isTwitterAvatar(embed) {
|
||||||
|
return embed.image.startsWith(TWITTER_AVATAR_URL_PREFIX)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export default class Paste {
|
||||||
|
constructor(range, editor, document) {
|
||||||
|
this.range = range
|
||||||
|
this.editor = editor
|
||||||
|
this.document = document
|
||||||
|
if (this.document == null) { this.document = this.editor.getDocument() }
|
||||||
|
this.string = this.document.getStringAtRange(this.range)
|
||||||
|
}
|
||||||
|
|
||||||
|
isURL() {
|
||||||
|
return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/.test(this.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPathname() {
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = this.string
|
||||||
|
return a.pathname
|
||||||
|
}
|
||||||
|
|
||||||
|
isLinked() {
|
||||||
|
const {href} = this.getCommonAttributes()
|
||||||
|
return (href != null) && (href !== this.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommonAttributes() {
|
||||||
|
return this.document.getCommonAttributesAtRange(this.range)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignificantPaste() {
|
||||||
|
return new this.constructor(this.getSignificantRange(), this.editor, this.document)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignificantRange() {
|
||||||
|
const significantString = this.string.trim()
|
||||||
|
const startOffset = this.range[0] + this.string.indexOf(significantString)
|
||||||
|
const endOffset = startOffset + significantString.length
|
||||||
|
return [startOffset, endOffset]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import OpengraphEmbedOperation from "lib/rich_text/unfurl/lib/opengraph_embed_operation"
|
||||||
|
import Paste from "lib/rich_text/unfurl/lib/paste"
|
||||||
|
|
||||||
|
const performOperation = (function() {
|
||||||
|
let operation = null
|
||||||
|
let requestId = null
|
||||||
|
|
||||||
|
return function(operationToPerform) {
|
||||||
|
operation?.abort()
|
||||||
|
cancelAnimationFrame(requestId)
|
||||||
|
|
||||||
|
requestId = requestAnimationFrame(function() {
|
||||||
|
operation = operationToPerform
|
||||||
|
operation.perform().then(() => operation = null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
export default class Unfurler {
|
||||||
|
install() {
|
||||||
|
this.#addEventListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
#addEventListeners() {
|
||||||
|
addEventListener("trix-initialize", function(event) {
|
||||||
|
if (this.#editorElementPermitsAttribute(event.target, "href")) {
|
||||||
|
return event.target.addEventListener("trix-paste", this.#didPaste.bind(this))
|
||||||
|
}
|
||||||
|
}.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
#didPaste(event) {
|
||||||
|
const {range} = event.paste
|
||||||
|
const {editor} = event.target
|
||||||
|
|
||||||
|
if (range != null) {
|
||||||
|
const paste = new Paste(range, editor).getSignificantPaste()
|
||||||
|
|
||||||
|
if (paste.isURL()) {
|
||||||
|
if (this.#editorElementPermitsOpengraphAttachment(event.target)) {
|
||||||
|
performOperation(new OpengraphEmbedOperation(paste))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#editorElementPermitsAttribute(element, attributeName) {
|
||||||
|
if (element.hasAttribute("data-permitted-attributes")) {
|
||||||
|
return Array.from(element.getAttribute("data-permitted-attributes").split(" ")).includes(attributeName)
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#editorElementPermitsOpengraphAttachment(element) {
|
||||||
|
const permittedAttachmentTypes = element.getAttribute("data-permitted-attachment-types")
|
||||||
|
return permittedAttachmentTypes && permittedAttachmentTypes.includes("application/vnd.actiontext.opengraph-embed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export default class ClientMessage {
|
|||||||
body,
|
body,
|
||||||
messageTimestamp: Math.floor(now.getTime()),
|
messageTimestamp: Math.floor(now.getTime()),
|
||||||
messageDatetime: now.toISOString(),
|
messageDatetime: now.toISOString(),
|
||||||
messageClasses: this.#messageClassesFromNode(node),
|
messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,37 +52,26 @@ export default class ClientMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#messageClassesFromNode(node) {
|
|
||||||
if (this.#containsOnlyEmoji(this.#plainTextFromNode(node))) {
|
|
||||||
return "message--emoji"
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#isPlayCommand(node) {
|
#isPlayCommand(node) {
|
||||||
return this.#matchPlayCommand(node)
|
return this.#matchPlayCommand(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
#matchPlayCommand(node) {
|
#matchPlayCommand(node) {
|
||||||
return this.#plainTextFromNode(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
return this.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
#plainTextFromNode(node) {
|
#stripWrapperElement(node) {
|
||||||
if (this.#isRichText(node)) {
|
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
|
||||||
return node.toString().trim()
|
|
||||||
} else {
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#isRichText(node) {
|
#isRichText(node) {
|
||||||
return typeof(node) != "string"
|
return typeof(node) != "string"
|
||||||
}
|
}
|
||||||
|
|
||||||
#richTextContent(node) {
|
#richTextContent(node) {
|
||||||
return `<div class="lexxy-content">${node.value}</div>`
|
return `<div class="trix-content">${node.innerHTML}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default class FileUploader {
|
|||||||
|
|
||||||
const req = new XMLHttpRequest()
|
const req = new XMLHttpRequest()
|
||||||
req.open("POST", this.url)
|
req.open("POST", this.url)
|
||||||
|
req.setRequestHeader("X-CSRF-Token", document.querySelector("meta[name=csrf-token]").content)
|
||||||
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
|
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
|
||||||
|
|
||||||
const result = new Promise((resolve, reject) => {
|
const result = new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -76,21 +76,7 @@ export default class MessageFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#highlightCodeBlock(block) {
|
#highlightCodeBlock(block) {
|
||||||
this.#normalizeLineBreaks(block)
|
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
|
||||||
|
|
||||||
if (this.#isPlainText(block)) {
|
|
||||||
const language = block.dataset.language
|
|
||||||
if (language && window.hljs.getLanguage(language)) {
|
|
||||||
block.classList.add(`language-${language}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.hljs.highlightElement(block)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lexxy breaks code block lines with <br>, Trix-era blocks used newlines
|
|
||||||
#normalizeLineBreaks(block) {
|
|
||||||
block.querySelectorAll("br").forEach(br => br.replaceWith("\n"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#isPlainText(element) {
|
#isPlainText(element) {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
class Message::BroadcastUnreadRoomJob < ApplicationJob
|
|
||||||
def perform(message)
|
|
||||||
message.broadcast_unread_room
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Native view caches share one byte budget across every database generation.
|
|
||||||
class FragmentCache
|
|
||||||
# Only callers that fingerprint every rendered dependency may omit the epoch.
|
|
||||||
ContentKey = Data.define(:digest) do
|
|
||||||
def cache_key
|
|
||||||
digest
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
STORE = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes)
|
|
||||||
private_constant :STORE
|
|
||||||
|
|
||||||
def self.store
|
|
||||||
STORE
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.transaction_open?
|
|
||||||
ActiveRecord::Base.connection_pool.with_connection(&:transaction_open?)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -9,8 +9,7 @@ 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 :with_direct_rooms, -> { includes(:room).joins(:room).merge(Room.directs).order(rooms: { updated_at: :desc }) }
|
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
|
||||||
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) }
|
||||||
|
|||||||
+9
-21
@@ -9,29 +9,23 @@ class Message < ApplicationRecord
|
|||||||
has_rich_text :body
|
has_rich_text :body
|
||||||
|
|
||||||
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
|
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
|
||||||
# Run after Action Text and Active Storage autosave, while creation is still atomic.
|
after_create_commit -> { room.receive(self) }
|
||||||
after_save :record_creation, if: :previously_new_record?
|
|
||||||
after_create_commit -> { room.push_later(self) }
|
|
||||||
|
|
||||||
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_attached_attachment
|
with_rich_text_body_and_embeds
|
||||||
|
with_attached_attachment
|
||||||
.includes(attachment_blob: :variant_records)
|
.includes(attachment_blob: :variant_records)
|
||||||
}
|
}
|
||||||
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
|
scope :with_boosts, -> { includes(boosts: :booster) }
|
||||||
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
|
|
||||||
|
|
||||||
def plain_text_body
|
def plain_text_body
|
||||||
content = body.body
|
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
||||||
text = if content && content.fragment.find_all(ActionText::Attachment.tag_name).empty?
|
end
|
||||||
# ActionText::Content#to_plain_text duplicates the fragment to replace
|
|
||||||
# attachments. Without them, reuse its native Fragment conversion instead.
|
def to_key
|
||||||
content.fragment.to_plain_text.dup
|
[ client_message_id ]
|
||||||
else
|
|
||||||
body.to_plain_text
|
|
||||||
end
|
|
||||||
text.presence || attachment&.filename&.to_s || ""
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def content_type
|
def content_type
|
||||||
@@ -47,10 +41,4 @@ class Message < ApplicationRecord
|
|||||||
Sound.find_by_name match[:name]
|
Sound.find_by_name match[:name]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
def record_creation
|
|
||||||
create_in_index
|
|
||||||
room.unread_memberships(self)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -4,14 +4,9 @@ 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
|
||||||
|
|
||||||
@@ -35,25 +30,12 @@ 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(:poster).processed
|
attachment.preview(format: :webp).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,29 +1,10 @@
|
|||||||
module Message::Broadcasts
|
module Message::Broadcasts
|
||||||
def broadcast_create(html: nil)
|
def broadcast_create
|
||||||
broadcast_append_to room, :messages, target: [ room, :messages ], **(html ? { html: html } : {})
|
broadcast_append_to room, :messages, target: [ room, :messages ]
|
||||||
broadcast_unread_room_later
|
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_remove
|
def broadcast_remove
|
||||||
broadcast_remove_to room, :messages
|
broadcast_remove_to room, :messages
|
||||||
end
|
end
|
||||||
|
|
||||||
# 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
|
||||||
@@ -2,8 +2,7 @@ module Message::Mentionee
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
def mentionees
|
def mentionees
|
||||||
ids = mentioned_users.map(&:id)
|
room.users.where(id: mentioned_users.map(&:id))
|
||||||
ids.empty? ? User.none : room.users.where(id: ids)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -3,31 +3,9 @@ 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
|
||||||
# Keep presentation data lazy until the collection cache knows which messages missed.
|
scope :last_page, -> { ordered.last(PAGE_SIZE) }
|
||||||
scope :last_page, -> { last_page_of(PAGE_SIZE) }
|
scope :first_page, -> { ordered.first(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) }
|
||||||
@@ -36,23 +14,16 @@ 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 }
|
||||||
# Sorting on +created_at, which no index covers, has SQLite find the few messages updated since
|
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
|
||||||
# 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.new(page_before(message) + [ message ] + page_after(message), ordered)
|
page_before(message) + [ message ] + page_after(message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def paged?
|
def paged?
|
||||||
offset(PAGE_SIZE).exists?
|
count > PAGE_SIZE
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2,45 +2,11 @@ module Message::Searchable
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
|
after_create_commit :create_in_index
|
||||||
after_update_commit :update_in_index, if: :indexed_text_changed?
|
after_update_commit :update_in_index
|
||||||
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 ?", match_terms(query)).ordered }
|
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", 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
|
||||||
@@ -48,12 +14,6 @@ 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,7 +5,6 @@ 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
|
||||||
@@ -23,14 +22,9 @@ class Opengraph::Fetch
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
|
|
||||||
# would no longer go to the checked IP.
|
|
||||||
#
|
|
||||||
# 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, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
|
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") 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"])
|
||||||
@@ -44,11 +38,6 @@ 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,61 +1,7 @@
|
|||||||
require "restricted_http/private_network_guard"
|
|
||||||
|
|
||||||
class Push::Subscription < ApplicationRecord
|
class Push::Subscription < ApplicationRecord
|
||||||
PERMITTED_ENDPOINT_HOSTS = %w[
|
|
||||||
jmt17.google.com
|
|
||||||
fcm.googleapis.com
|
|
||||||
updates.push.services.mozilla.com
|
|
||||||
web.push.apple.com
|
|
||||||
notify.windows.com
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
validates :endpoint, presence: true
|
def notification(**params)
|
||||||
validate :validate_endpoint_url
|
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
|
||||||
|
|
||||||
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
|
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
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Rebuild request-local models from bounded, immutable database snapshots.
|
|
||||||
# The observer must still see the captured epoch after lookup and admission.
|
|
||||||
class RecordCache
|
|
||||||
def self.fetch(key, version)
|
|
||||||
return yield unless version && ResponseCache.instance.budget.positive? && !FragmentCache.transaction_open?
|
|
||||||
|
|
||||||
key = ActiveSupport::Cache.expand_cache_key([ "record-snapshot-v1", version, key ])
|
|
||||||
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
|
|
||||||
|
|
||||||
if snapshot = FragmentCache.store.read(key)
|
|
||||||
if ResponseCache.instance.version == version
|
|
||||||
return ActiveSupport::JSON.decode(snapshot).map { |name, attributes| name.constantize.instantiate(attributes) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
records = yield
|
|
||||||
if records.all? { |record| record&.persisted? && !record.changed? } && ResponseCache.instance.version == version
|
|
||||||
# Raw database values retain timestamp precision and native enum binding.
|
|
||||||
snapshot = ActiveSupport::JSON.encode(records.map { |record| [ record.class.name, record.attributes_before_type_cast ] })
|
|
||||||
FragmentCache.store.write(key, snapshot) if snapshot.bytesize <= ResponseCache::MAX_ENTRY_BYTES
|
|
||||||
end
|
|
||||||
records
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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
|
|
||||||
+6
-50
@@ -22,8 +22,6 @@ class Room < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||||
|
|
||||||
validate :direct_rooms_keep_their_type, on: :update
|
|
||||||
|
|
||||||
scope :opens, -> { where(type: "Rooms::Open") }
|
scope :opens, -> { where(type: "Rooms::Open") }
|
||||||
scope :closeds, -> { where(type: "Rooms::Closed") }
|
scope :closeds, -> { where(type: "Rooms::Closed") }
|
||||||
scope :directs, -> { where(type: "Rooms::Direct") }
|
scope :directs, -> { where(type: "Rooms::Direct") }
|
||||||
@@ -45,52 +43,11 @@ 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)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Rewriting every member on every message is most of what posting to a large room writes,
|
|
||||||
# so members who are unread already stay as they are. Directs keep touching them all: a
|
|
||||||
# direct's sidebar row is cached by membership and shows the room's recency.
|
|
||||||
def unread_memberships(message)
|
|
||||||
recipients = memberships.visible.disconnected.where.not(user: message.creator)
|
|
||||||
recipients = recipients.where(unread_at: nil) unless direct?
|
|
||||||
recipients.update_all(unread_at: message.created_at, updated_at: Time.current)
|
|
||||||
end
|
|
||||||
|
|
||||||
def push_later(message)
|
|
||||||
Room::PushMessageJob.perform_later(self, message)
|
|
||||||
end
|
|
||||||
|
|
||||||
def open?
|
def open?
|
||||||
is_a?(Rooms::Open)
|
is_a?(Rooms::Open)
|
||||||
end
|
end
|
||||||
@@ -108,12 +65,11 @@ class Room < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# Open and closed rooms convert into each other freely. A direct room can't become
|
def unread_memberships(message)
|
||||||
# either: its participants agreed to a private conversation, not to one whose
|
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
||||||
# audience someone else gets to widen afterwards.
|
end
|
||||||
def direct_rooms_keep_their_type
|
|
||||||
if type_changed? && type_was == "Rooms::Direct"
|
def push_later(message)
|
||||||
errors.add :type, "can't be changed for a direct room"
|
Room::PushMessageJob.perform_later(self, message)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -6,9 +6,9 @@ class Room::MessagePusher
|
|||||||
end
|
end
|
||||||
|
|
||||||
def push
|
def push
|
||||||
subscriptions = push_subscriptions_for_users_involved_in_everything.or(push_subscriptions_for_mentionable_users(message.mentionees))
|
build_payload.tap do |payload|
|
||||||
if subscriptions.exists?
|
push_to_users_involved_in_everything(payload)
|
||||||
Rails.configuration.x.web_push_pool.queue(build_payload, subscriptions)
|
push_to_users_involved_in_mentions(payload)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -37,6 +37,14 @@ class Room::MessagePusher
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def push_to_users_involved_in_everything(payload)
|
||||||
|
enqueue_payload_for_delivery payload, push_subscriptions_for_users_involved_in_everything
|
||||||
|
end
|
||||||
|
|
||||||
|
def push_to_users_involved_in_mentions(payload)
|
||||||
|
enqueue_payload_for_delivery payload, push_subscriptions_for_mentionable_users(message.mentionees)
|
||||||
|
end
|
||||||
|
|
||||||
def push_subscriptions_for_users_involved_in_everything
|
def push_subscriptions_for_users_involved_in_everything
|
||||||
relevant_subscriptions.merge(Membership.involved_in_everything)
|
relevant_subscriptions.merge(Membership.involved_in_everything)
|
||||||
end
|
end
|
||||||
@@ -45,12 +53,13 @@ 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_id: message.creator_id))
|
end
|
||||||
|
|
||||||
|
def enqueue_payload_for_delivery(payload, subscriptions)
|
||||||
|
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user