mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84ad886114 | ||
|
|
2fd1483e16 | ||
|
|
424219e485 |
No files matched your search
@@ -50,6 +50,3 @@
|
||||
|
||||
# Ignore test files
|
||||
/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-patch-days: 2
|
||||
default-days: 7
|
||||
exclude:
|
||||
- "brakeman"
|
||||
|
||||
# No docker ecosystem. Our base image tag is a build arg
|
||||
# (`FROM ruby:$RUBY_VERSION-slim`), which the updater cannot read, so the
|
||||
# entry only ever matched nothing — worse than no entry, because it looked
|
||||
# like coverage. Ruby base image bumps are a manual, reviewed step here, as
|
||||
# they are across the rest of the fleet. Keep Dockerfile, Dockerfile-export
|
||||
# and .ruby-version on the same version by hand.
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
semver-major-days: 7
|
||||
semver-minor-days: 3
|
||||
semver-patch-days: 2
|
||||
default-days: 7
|
||||
+14
-40
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main, "release-*" ]
|
||||
branches: [ main ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
@@ -35,12 +35,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
@@ -48,29 +48,6 @@ jobs:
|
||||
- name: Lint code for consistent style
|
||||
run: bin/rubocop
|
||||
|
||||
herb:
|
||||
name: Herb check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Check that every HTML template compiles through Herb
|
||||
run: bin/rails herb:check
|
||||
|
||||
lint-actions:
|
||||
name: GitHub Actions audit
|
||||
runs-on: ubuntu-latest
|
||||
@@ -78,15 +55,15 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run actionlint
|
||||
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
|
||||
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
with:
|
||||
advanced-security: false
|
||||
|
||||
@@ -102,15 +79,15 @@ jobs:
|
||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
with:
|
||||
@@ -120,9 +97,6 @@ jobs:
|
||||
- name: Run tests
|
||||
run: bin/rails db:setup test
|
||||
|
||||
- name: Test benchmark response validation
|
||||
run: bundle exec ruby bench/test_http_client.rb
|
||||
|
||||
test_system:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -135,15 +109,15 @@ jobs:
|
||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
with:
|
||||
|
||||
@@ -44,15 +44,15 @@ jobs:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata (tags, labels) with arch suffix
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
with:
|
||||
images: ${{ steps.vars.outputs.canonical }}
|
||||
tags: |
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Build and push (${{ matrix.platform }})
|
||||
id: build
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
|
||||
- name: Attest image provenance (per-arch)
|
||||
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:
|
||||
subject-name: ${{ steps.vars.outputs.canonical }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
@@ -139,10 +139,10 @@ jobs:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
steps:
|
||||
- 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
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|
||||
- name: Compute base tags (no suffix)
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
with:
|
||||
images: ${{ steps.vars.outputs.canonical }}
|
||||
tags: |
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
done <<< "$tags"
|
||||
|
||||
- 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)
|
||||
shell: bash
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.10
|
||||
3.4.5
|
||||
+2
-15
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.4.10
|
||||
ARG RUBY_VERSION=3.4.5
|
||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
# Rails app lives here
|
||||
@@ -9,23 +9,10 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y ca-certificates curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
|
||||
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
|
||||
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archive
|
||||
|
||||
# Naming ca-certificates above takes the distribution's current trusted roots rather than the
|
||||
# ones frozen into the base image. Fail the build if either of these withdrawn roots is still
|
||||
# where Ruby's OpenSSL looks for trusted certificates.
|
||||
RUN ruby -ropenssl -e ' \
|
||||
file = ENV[OpenSSL::X509::DEFAULT_CERT_FILE_ENV] || OpenSSL::X509::DEFAULT_CERT_FILE; \
|
||||
dir = ENV[OpenSSL::X509::DEFAULT_CERT_DIR_ENV] || OpenSSL::X509::DEFAULT_CERT_DIR; \
|
||||
pems = [file, *Dir.glob(File.join(dir, "*"))].select { |path| File.file?(path) } \
|
||||
.flat_map { |path| File.read(path).scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m) }; \
|
||||
subjects = pems.map { |pem| OpenSSL::X509::Certificate.new(pem).subject.to_s }.uniq; \
|
||||
abort "Only #{subjects.size} roots in #{file} and #{dir}" if subjects.size < 100; \
|
||||
withdrawn = subjects.grep(%r{/CN=(emSign ECC Root CA - G3|AAA Certificate Services)\z}); \
|
||||
abort "Withdrawn roots still trusted: #{withdrawn}" if withdrawn.any?'
|
||||
|
||||
# Set production environment
|
||||
ENV RAILS_ENV="production" \
|
||||
BUNDLE_DEPLOYMENT="1" \
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.4.10
|
||||
ARG RUBY_VERSION=3.4.7
|
||||
FROM ruby:$RUBY_VERSION-slim as base
|
||||
|
||||
# Install the tools we need
|
||||
|
||||
@@ -26,9 +26,6 @@ gem "importmap-rails", github: "rails/importmap-rails"
|
||||
gem "turbo-rails", github: "hotwired/turbo-rails"
|
||||
gem "stimulus-rails"
|
||||
|
||||
# Rich text
|
||||
gem "lexxy", "~> 0.9.24"
|
||||
|
||||
# Media handling
|
||||
gem "image_processing", ">= 1.2"
|
||||
|
||||
@@ -43,7 +40,7 @@ gem "rqrcode"
|
||||
gem "rails_autolink"
|
||||
gem "geared_pagination"
|
||||
gem "jbuilder"
|
||||
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
|
||||
gem "net-http-persistent"
|
||||
gem "kredis"
|
||||
gem "platform_agent"
|
||||
gem "thruster"
|
||||
|
||||
+54
-78
@@ -1,9 +1,3 @@
|
||||
GIT
|
||||
remote: https://github.com/basecamp/surfguard.git
|
||||
revision: 59e278c01a537755f22791429051891949231ead
|
||||
specs:
|
||||
surfguard (0.2.0)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/hotwired/turbo-rails.git
|
||||
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
|
||||
@@ -23,16 +17,16 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/propshaft.git
|
||||
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623
|
||||
revision: e49a9de659ff27462015e54dd832e86e762a6ddc
|
||||
specs:
|
||||
propshaft (1.3.2)
|
||||
propshaft (1.2.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: e3d5c569d217c56c022b04acb127a8c24dbe9685
|
||||
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
|
||||
branch: main
|
||||
specs:
|
||||
actioncable (8.2.0.alpha)
|
||||
@@ -63,10 +57,10 @@ GIT
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.7)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.2.0.alpha)
|
||||
action_text-trix (~> 2.1.16)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.2.0.alpha)
|
||||
activerecord (= 8.2.0.alpha)
|
||||
activestorage (= 8.2.0.alpha)
|
||||
@@ -77,12 +71,11 @@ GIT
|
||||
activesupport (= 8.2.0.alpha)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
herb (>= 0.10)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.7)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.2.0.alpha)
|
||||
activesupport (= 8.2.0.alpha)
|
||||
globalid (>= 1.4.0)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.2.0.alpha)
|
||||
activesupport (= 8.2.0.alpha)
|
||||
activerecord (8.2.0.alpha)
|
||||
@@ -94,7 +87,7 @@ GIT
|
||||
activejob (= 8.2.0.alpha)
|
||||
activerecord (= 8.2.0.alpha)
|
||||
activesupport (= 8.2.0.alpha)
|
||||
marcel (~> 2.0)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.2.0.alpha)
|
||||
base64
|
||||
bigdecimal
|
||||
@@ -105,8 +98,6 @@ GIT
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
psych (>= 4)
|
||||
ractor-dispatch (>= 0.3.0)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
@@ -145,8 +136,8 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.3)
|
||||
brakeman (8.1.0)
|
||||
bigdecimal (3.3.1)
|
||||
brakeman (8.0.5)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.3)
|
||||
@@ -162,7 +153,7 @@ GEM
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.8)
|
||||
concurrent-ruby (1.3.7)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -173,11 +164,10 @@ GEM
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
drb (2.2.3)
|
||||
erb (6.0.7)
|
||||
erb (6.0.4)
|
||||
erubi (1.13.1)
|
||||
faker (3.8.0)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.17.2)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
@@ -185,28 +175,23 @@ GEM
|
||||
geared_pagination (1.2.0)
|
||||
activesupport (>= 5.0)
|
||||
addressable (>= 2.5.0)
|
||||
globalid (1.4.0)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
hashdiff (1.2.0)
|
||||
herb (0.11.0-aarch64-linux-gnu)
|
||||
herb (0.11.0-arm64-darwin)
|
||||
herb (0.11.0-x86_64-darwin)
|
||||
herb (0.11.0-x86_64-linux-gnu)
|
||||
i18n (1.15.2)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.9.4)
|
||||
irb (1.18.0)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.3)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.21.2)
|
||||
json (2.20.0)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
kredis (1.8.0)
|
||||
@@ -214,41 +199,39 @@ GEM
|
||||
activesupport (>= 6.0.0)
|
||||
redis (>= 4.2, < 6)
|
||||
language_server-protocol (3.17.0.5)
|
||||
lexxy (0.9.24)
|
||||
rails (>= 8.0.2)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.1)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (2.1.0)
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
mocha (3.1.0)
|
||||
minitest (5.26.2)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
mono_logger (1.1.2)
|
||||
multi_json (1.17.0)
|
||||
mustermann (3.0.4)
|
||||
ruby2_keywords (~> 0.0.1)
|
||||
net-imap (0.6.7)
|
||||
net-http-persistent (4.0.6)
|
||||
connection_pool (~> 2.2, >= 2.2.4)
|
||||
net-imap (0.6.4.1)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.4.0)
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.2)
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
@@ -259,7 +242,7 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
openssl (4.0.2)
|
||||
openssl (3.3.0)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.9.0)
|
||||
@@ -268,18 +251,18 @@ GEM
|
||||
platform_agent (1.0.1)
|
||||
activesupport (>= 5.2.0)
|
||||
useragent (~> 0.16.3)
|
||||
pp (0.6.4)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
psych (5.5.0)
|
||||
prism (1.4.0)
|
||||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.7)
|
||||
rack (3.2.6)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
@@ -289,9 +272,8 @@ GEM
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
ractor-dispatch (0.3.0)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -304,24 +286,19 @@ GEM
|
||||
activesupport (> 3.1)
|
||||
railties (> 3.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.4.2)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.1.0)
|
||||
rake (13.3.1)
|
||||
rdoc (6.16.1)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redis (5.4.1)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.30.1)
|
||||
redis-client (0.25.2)
|
||||
connection_pool
|
||||
redis-namespace (1.11.0)
|
||||
redis (>= 4)
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.7.0)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
resque (2.7.0)
|
||||
mono_logger (~> 1)
|
||||
@@ -369,9 +346,9 @@ GEM
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (3.7.0)
|
||||
rubyzip (3.0.2)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.49.0)
|
||||
selenium-webdriver (4.35.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -390,18 +367,18 @@ GEM
|
||||
rack-protection (= 4.2.1)
|
||||
rack-session (>= 2.0.0, < 3)
|
||||
tilt (~> 2.0)
|
||||
sqlite3 (2.9.6-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.6-arm64-darwin)
|
||||
sqlite3 (2.9.6-x86_64-darwin)
|
||||
sqlite3 (2.9.6-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.5-arm64-darwin)
|
||||
sqlite3 (2.9.5-x86_64-darwin)
|
||||
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.23-aarch64-linux)
|
||||
thruster (0.1.23-arm64-darwin)
|
||||
thruster (0.1.23-x86_64-darwin)
|
||||
thruster (0.1.23-x86_64-linux)
|
||||
stringio (3.1.8)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.15-aarch64-linux)
|
||||
thruster (0.1.15-arm64-darwin)
|
||||
thruster (0.1.15-x86_64-darwin)
|
||||
thruster (0.1.15-x86_64-linux)
|
||||
tilt (2.6.1)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
@@ -412,9 +389,9 @@ GEM
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
web-push (3.1.0)
|
||||
web-push (3.0.2)
|
||||
jwt (~> 3.0)
|
||||
openssl (>= 3.0)
|
||||
openssl (~> 3.0)
|
||||
webmock (3.25.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
@@ -426,7 +403,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.8.3)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -447,8 +424,8 @@ DEPENDENCIES
|
||||
importmap-rails!
|
||||
jbuilder
|
||||
kredis
|
||||
lexxy (~> 0.9.24)
|
||||
mocha
|
||||
net-http-persistent
|
||||
ostruct
|
||||
platform_agent
|
||||
propshaft!
|
||||
@@ -465,7 +442,6 @@ DEPENDENCIES
|
||||
sentry-ruby
|
||||
sqlite3
|
||||
stimulus-rails
|
||||
surfguard!
|
||||
thruster
|
||||
turbo-rails!
|
||||
web-push
|
||||
|
||||
@@ -49,23 +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
|
||||
> email address you want and create yourself a new admin account.
|
||||
|
||||
## 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 | 230 | 62 | 760 | 2,622 | 942 | 31,673 | 35,484 | 141,834 |
|
||||
| Messages page | 402 | 70 | 924 | 3,245 | 1,267 | 30,746 | 40,674 | 151,564 |
|
||||
| Sidebar | 468 | 229 | 1,383 | 34,938 | 2,515 | 18,586 | 34,479 | 159,850 |
|
||||
| Search | 399 | 118 | 1,135 | 6,613 | 1,814 | 29,765 | 34,432 | 155,456 |
|
||||
| Post a message | 248 | 112 | 498 | 2,088 | 1,400 | 9,073 | 8,998 | 7,460 |
|
||||
|
||||
[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
|
||||
|
||||
You are welcome - and encouraged - to modify Campfire to your liking.
|
||||
|
||||
@@ -1,160 +1,165 @@
|
||||
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
|
||||
so everything follows Campfire's light/dark color scheme */
|
||||
.lexxy-content {
|
||||
--lexxy-color-canvas: transparent;
|
||||
--lexxy-color-text: var(--color-text);
|
||||
--lexxy-color-text-subtle: var(--color-text);
|
||||
--lexxy-color-link: var(--color-link);
|
||||
--lexxy-color-ink: var(--color-text);
|
||||
--lexxy-color-ink-inverted: var(--color-text-reversed);
|
||||
--lexxy-color-ink-medium: var(--color-text);
|
||||
--lexxy-color-ink-lighter: var(--color-border-darker);
|
||||
--lexxy-color-ink-lightest: var(--color-bg);
|
||||
--lexxy-color-selected: var(--color-selected, var(--color-bg));
|
||||
--lexxy-color-code-bg: var(--color-bg);
|
||||
trix-toolbar {
|
||||
display: none;
|
||||
inset: auto 0 0 0;
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
|
||||
/* Campfire's message lines stack without extra block margins */
|
||||
--lexxy-content-margin: 0;
|
||||
.trix-dialog {
|
||||
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 {
|
||||
--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;
|
||||
}
|
||||
|
||||
.attachment--content {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* The mentions menu needs a solid surface — the editor's transparent canvas
|
||||
would let messages show through it */
|
||||
.lexxy-prompt-menu {
|
||||
--lexxy-color-canvas: var(--color-bg);
|
||||
|
||||
border: 1px solid var(--color-border-dark);
|
||||
}
|
||||
|
||||
/* Same for the code block language picker: with a transparent canvas the
|
||||
native dropdown renders white-on-white in dark mode */
|
||||
lexxy-editor lexxy-code-language-picker select {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
|
||||
option {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
/* The toolbar only shows when rich text mode is toggled on (see composer.css) */
|
||||
lexxy-toolbar {
|
||||
/* Wide, airy button spacing like the Trix toolbar */
|
||||
--lexxy-toolbar-gap: 1.3em;
|
||||
|
||||
/* Match the compact size of the Trix toolbar buttons */
|
||||
--lexxy-toolbar-button-size: 1.7em;
|
||||
--lexxy-toolbar-icon-size: 1.125em;
|
||||
|
||||
display: none;
|
||||
border-block-end: 0;
|
||||
min-width: 0;
|
||||
z-index: 3;
|
||||
|
||||
/* Tools Campfire doesn't offer, matching the Trix toolbar: underline,
|
||||
color highlights, tables, dividers and undo/redo (history tools were
|
||||
hidden under Trix as well) */
|
||||
button[name="underline"],
|
||||
button[name="table"],
|
||||
button[name="divider"],
|
||||
button[name="undo"],
|
||||
button[name="redo"],
|
||||
lexxy-highlight-dropdown {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
.attachment__toolbar {
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
background-color: var(--color-bg) !important;
|
||||
border-radius: 0.5em;
|
||||
@@ -174,13 +179,11 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
|
||||
blockquote {
|
||||
border: 0 solid var(--color-border-darker) !important;
|
||||
border-inline-start-width: var(--blockquote-border) !important;
|
||||
font-style: normal;
|
||||
margin: 0 var(--blockquote-margin) !important;
|
||||
padding-inline-start: var(--blockquote-padding) !important;
|
||||
}
|
||||
|
||||
cite {
|
||||
display: block;
|
||||
margin-inline-start: calc(var(--blockquote-padding));
|
||||
font-style: normal;
|
||||
font-size: 0.8rem;
|
||||
@@ -196,14 +199,13 @@ action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed
|
||||
color: var(--color-link);
|
||||
}
|
||||
|
||||
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */
|
||||
ul:not(.lexxy-prompt-menu),
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
li:not(.lexxy-prompt-menu__item) {
|
||||
li {
|
||||
margin: 0 0 0 var(--inline-space-double) !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;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -100,42 +100,16 @@
|
||||
}
|
||||
|
||||
.composer--rich-text {
|
||||
lexxy-toolbar {
|
||||
display: flex;
|
||||
trix-toolbar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
lexxy-editor.input {
|
||||
trix-editor.input {
|
||||
margin-block-start: var(--block-space-half);
|
||||
}
|
||||
}
|
||||
|
||||
/* In the message composer the toolbar goes below the text, on the same
|
||||
row as the surrounding action buttons, like the Trix toolbar did.
|
||||
The edit composer keeps it on top. */
|
||||
#composer.composer--rich-text lexxy-editor.input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Sit the toolbar row on the same baseline as the composer's round
|
||||
buttons, which pull themselves down with negative margins */
|
||||
margin-block-end: -0.25em;
|
||||
|
||||
.lexxy-editor__content {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
lexxy-toolbar {
|
||||
margin-block-start: 0.4em;
|
||||
|
||||
/* Dropdowns open upward — the toolbar sits at the bottom of the screen */
|
||||
[data-dropdown-panel] {
|
||||
inset-block-start: auto;
|
||||
inset-block-end: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.composer--edit lexxy-toolbar {
|
||||
.composer--edit trix-toolbar {
|
||||
border-block-end: 1px solid var(--color-border);
|
||||
margin-inline: -0.8em;
|
||||
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 {
|
||||
inset-block-start: var(--indicator-position, 0.15rem);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
.lexxy-content {
|
||||
trix-editor,
|
||||
.trix-content {
|
||||
.og-embed {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
@@ -15,7 +15,7 @@
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&.og-embed--twitter-avatar {
|
||||
.cf-twitter-avatar & {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,10 @@
|
||||
|
||||
.og-embed__title {
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
|
||||
/* Underline the link itself so the line is link-colored */
|
||||
a {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +53,7 @@
|
||||
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;
|
||||
inline-size: 100%;
|
||||
object-fit: contain;
|
||||
@@ -68,7 +63,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.og-embed--twitter-avatar & {
|
||||
.cf-twitter-avatar & {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
inline-size: 2lh;
|
||||
|
||||
@@ -537,7 +537,7 @@ img.message__attachment {
|
||||
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
|
||||
text-shadow: 0 0 3px var(--color-text-reversed);
|
||||
|
||||
.lexxy-content {
|
||||
.trix-content {
|
||||
font-size: 2.4em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -2,42 +2,19 @@ module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
include Authentication::SessionLookup
|
||||
|
||||
SESSION_CHECK_INTERVAL = 1.minute
|
||||
|
||||
identified_by :current_user
|
||||
|
||||
def connect
|
||||
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
|
||||
|
||||
private
|
||||
def find_verified_user
|
||||
if @verified_session = find_session_by_cookie
|
||||
@verified_session.user
|
||||
if verified_session = find_session_by_cookie
|
||||
verified_session.user
|
||||
else
|
||||
reject_unauthorized_connection
|
||||
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
|
||||
@@ -22,6 +22,6 @@ class PresenceChannel < RoomChannel
|
||||
end
|
||||
|
||||
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
|
||||
@@ -1,5 +1,14 @@
|
||||
class Accounts::CustomStylesController < ApplicationController
|
||||
before_action :ensure_can_administer, :set_account
|
||||
allow_unauthenticated_access only: :show
|
||||
before_action :ensure_can_administer, :set_account, except: :show
|
||||
|
||||
def show
|
||||
if stale?(etag: Current.account)
|
||||
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
|
||||
|
||||
render plain: Current.account&.custom_styles.to_s, content_type: "text/css"
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ class Accounts::UsersController < ApplicationController
|
||||
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
|
||||
|
||||
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
|
||||
|
||||
def update
|
||||
|
||||
@@ -3,9 +3,9 @@ class AccountsController < ApplicationController
|
||||
before_action :set_account
|
||||
|
||||
def edit
|
||||
users = User.visible_to(Current.user).ordered
|
||||
@administrators = users.administrator
|
||||
set_page_and_extract_portion_from users.member, per_page: 500
|
||||
users = account_users.ordered.without_bots
|
||||
@administrators, @members = users.partition(&:administrator?)
|
||||
set_page_and_extract_portion_from users, per_page: 500
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -21,4 +21,12 @@ class AccountsController < ApplicationController
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :logo, settings: {})
|
||||
end
|
||||
|
||||
def account_users
|
||||
if Current.user.can_administer?
|
||||
User.where(status: [ :active, :banned ])
|
||||
else
|
||||
User.active
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
|
||||
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
||||
end
|
||||
@@ -1,26 +1,11 @@
|
||||
class Autocompletable::UsersController < ApplicationController
|
||||
def index
|
||||
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
|
||||
|
||||
private
|
||||
def find_autocompletable_users
|
||||
if query.present?
|
||||
users_scope.active.filtered_by(query)
|
||||
else
|
||||
users_scope.active
|
||||
end
|
||||
end
|
||||
|
||||
# The rich text editor's mentions prompt filters with `filter`, the
|
||||
# autocomplete inputs with `query`
|
||||
def query
|
||||
params[:filter].presence || params[:query].presence
|
||||
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
|
||||
end
|
||||
|
||||
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
|
||||
@@ -57,7 +57,7 @@ module Authentication
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -71,13 +71,6 @@ module Authentication
|
||||
Current.session&.destroy!
|
||||
reset_session
|
||||
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
|
||||
|
||||
def authenticated_as(session)
|
||||
|
||||
@@ -1,50 +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_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)
|
||||
policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# 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
|
||||
@@ -8,6 +8,6 @@ module SetCurrentRequest
|
||||
end
|
||||
|
||||
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
|
||||
@@ -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
|
||||
before_action :set_message
|
||||
before_action :set_boost, only: :destroy
|
||||
|
||||
def index
|
||||
end
|
||||
@@ -16,6 +15,7 @@ class Messages::BoostsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@boost = Current.user.boosts.find(params[:id])
|
||||
@boost.destroy!
|
||||
|
||||
broadcast_remove
|
||||
@@ -26,17 +26,13 @@ class Messages::BoostsController < ApplicationController
|
||||
@message = Current.user.reachable_messages.find(params[:message_id])
|
||||
end
|
||||
|
||||
def set_boost
|
||||
@boost = @message.boosts.find_by!(id: params[:id], booster: Current.user)
|
||||
end
|
||||
|
||||
def boost_params
|
||||
params.require(:boost).permit(:content)
|
||||
end
|
||||
|
||||
def broadcast_create
|
||||
@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
|
||||
|
||||
def broadcast_remove
|
||||
|
||||
@@ -1,64 +1,24 @@
|
||||
class Messages::ByBotsController < MessagesController
|
||||
include RawRequestBody
|
||||
|
||||
allow_bot_access only: %i[ index create update destroy ]
|
||||
|
||||
before_action :set_room
|
||||
before_action :set_message, only: %i[ update destroy ]
|
||||
before_action :ensure_can_administer, only: %i[ update destroy ]
|
||||
before_action :ensure_body_or_attachment_present, only: :create
|
||||
|
||||
def index
|
||||
@messages = find_paged_messages
|
||||
set_pagination_headers
|
||||
end
|
||||
allow_bot_access only: :create
|
||||
|
||||
def create
|
||||
super
|
||||
head :created, location: message_url(@message)
|
||||
end
|
||||
|
||||
def destroy
|
||||
super
|
||||
head :no_content
|
||||
end
|
||||
|
||||
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
|
||||
if params[:attachment]
|
||||
params.permit(:attachment)
|
||||
else
|
||||
{ body: raw_request_body }
|
||||
reading(request.body) { |body| { body: body } }
|
||||
end
|
||||
end
|
||||
|
||||
def reading(io)
|
||||
io.rewind
|
||||
yield io.read.force_encoding("UTF-8")
|
||||
ensure
|
||||
io.rewind
|
||||
end
|
||||
end
|
||||
@@ -37,11 +37,7 @@ class MessagesController < ApplicationController
|
||||
@message.update!(message_params)
|
||||
|
||||
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to room_message_url(@room, @message) }
|
||||
format.json { render :show }
|
||||
end
|
||||
redirect_to room_message_url(@room, @message)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -62,11 +58,11 @@ class MessagesController < ApplicationController
|
||||
def find_paged_messages
|
||||
case
|
||||
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?
|
||||
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
|
||||
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
|
||||
else
|
||||
@room.messages.with_presentation.last_page
|
||||
@room.messages.with_creator.last_page
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
|
||||
before_action :set_last_updated_at
|
||||
|
||||
def show
|
||||
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at)
|
||||
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
|
||||
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
|
||||
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -12,7 +12,7 @@ class RoomsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@room.destroy_later
|
||||
@room.destroy
|
||||
|
||||
broadcast_remove_room
|
||||
redirect_to root_url
|
||||
@@ -44,9 +44,9 @@ class RoomsController < ApplicationController
|
||||
end
|
||||
|
||||
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)
|
||||
else
|
||||
@messages = messages.last_page
|
||||
|
||||
@@ -20,7 +20,7 @@ class SearchesController < ApplicationController
|
||||
private
|
||||
def set_messages
|
||||
if query.present?
|
||||
@messages = Message.search_reachable(Current.user, query)
|
||||
@messages = Current.user.reachable_messages.search(query).last(100)
|
||||
else
|
||||
@messages = Message.none
|
||||
end
|
||||
|
||||
@@ -11,7 +11,5 @@ class Sessions::TransfersController < ApplicationController
|
||||
else
|
||||
head :bad_request
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
@@ -14,8 +14,6 @@ class SessionsController < ApplicationController
|
||||
else
|
||||
render_rejection :unauthorized
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
render_rejection :unauthorized
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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
|
||||
if opengraph = unfurl(url_param)
|
||||
opengraph = Opengraph::Metadata.from_url(url_param)
|
||||
|
||||
if opengraph.valid?
|
||||
render json: opengraph
|
||||
else
|
||||
head :no_content
|
||||
@@ -14,14 +13,4 @@ class UnfurlLinksController < ApplicationController
|
||||
def url_param
|
||||
params.require(:url)
|
||||
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
|
||||
@@ -1,26 +1,14 @@
|
||||
class Users::ProfilesController < ApplicationController
|
||||
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
|
||||
@direct_memberships, @shared_memberships =
|
||||
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
|
||||
end
|
||||
|
||||
def update
|
||||
if @user.update user_params
|
||||
start_new_session_for @user if @user.saved_change_to_password_digest?
|
||||
|
||||
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
|
||||
@user.update user_params
|
||||
redirect_to user_profile_url, notice: update_notice
|
||||
end
|
||||
|
||||
private
|
||||
@@ -28,35 +16,11 @@ class Users::ProfilesController < ApplicationController
|
||||
@user = Current.user
|
||||
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
|
||||
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
|
||||
|
||||
def update_notice
|
||||
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
|
||||
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
|
||||
@@ -5,15 +5,13 @@ class Users::PushSubscriptionsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params)
|
||||
subscription.user_agent = request.user_agent if subscription.new_record?
|
||||
|
||||
# Existing endpoints must pass current validations too
|
||||
if subscription.valid?
|
||||
head register_while_signed_in(subscription) ? :ok : :unauthorized
|
||||
if subscription = @push_subscriptions.find_by(push_subscription_params)
|
||||
subscription.touch
|
||||
else
|
||||
head :unprocessable_entity
|
||||
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -26,24 +24,6 @@ class Users::PushSubscriptionsController < ApplicationController
|
||||
@push_subscriptions = Current.user.push_subscriptions
|
||||
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
|
||||
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
|
||||
end
|
||||
|
||||
@@ -2,14 +2,18 @@ class Users::SidebarsController < ApplicationController
|
||||
DIRECT_PLACEHOLDERS = 20
|
||||
|
||||
def show
|
||||
visible_memberships = Current.user.memberships.visible
|
||||
@direct_memberships = visible_memberships.with_direct_rooms
|
||||
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms
|
||||
all_memberships = Current.user.memberships.visible.with_ordered_room
|
||||
@direct_memberships = extract_direct_memberships(all_memberships)
|
||||
@other_memberships = all_memberships.without(@direct_memberships)
|
||||
|
||||
@direct_placeholder_users = find_direct_placeholder_users
|
||||
end
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -12,12 +12,6 @@ module ApplicationHelper
|
||||
end
|
||||
end
|
||||
|
||||
def custom_styles_tag
|
||||
if custom_styles = Current.account&.custom_styles
|
||||
tag.style(custom_styles.to_s.html_safe, data: { turbo_track: "reload" })
|
||||
end
|
||||
end
|
||||
|
||||
def body_classes
|
||||
[ @body_class, admin_body_class, account_logo_body_class ].compact.join(" ")
|
||||
end
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
module ContentFilters
|
||||
# Formatting the rich text editor produces that Rails' sanitizers don't
|
||||
# allow by default. A message passes through three sanitization layers on
|
||||
# its way to the screen, and each builds on this list:
|
||||
#
|
||||
# * SanitizeTags strips disallowed markup from the message body
|
||||
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
|
||||
# * 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)
|
||||
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
|
||||
end
|
||||
@@ -1,22 +1,18 @@
|
||||
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
||||
TWITTER_DOMAINS = %w[ x.com twitter.com ]
|
||||
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
|
||||
|
||||
def applicable?
|
||||
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
|
||||
end
|
||||
|
||||
def apply
|
||||
if trix_body?
|
||||
remove_link_text_from_wrapping_div
|
||||
else
|
||||
remove_link_paragraphs
|
||||
end
|
||||
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
||||
end
|
||||
|
||||
private
|
||||
TWITTER_DOMAINS = %w[ x.com twitter.com ]
|
||||
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
|
||||
|
||||
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
|
||||
|
||||
def unfurled_links
|
||||
@@ -39,20 +35,4 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
||||
def twitter_url?(url)
|
||||
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
|
||||
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
|
||||
@@ -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
|
||||
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?
|
||||
true
|
||||
end
|
||||
@@ -12,6 +8,9 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
||||
end
|
||||
|
||||
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
|
||||
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
|
||||
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
|
||||
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
|
||||
case
|
||||
when message.attachment.video?
|
||||
if message.attachment.video?
|
||||
video_preview_tag
|
||||
when message.attachment.representation(:thumb).processed?
|
||||
lightboxed_image_preview_tag
|
||||
else
|
||||
render_link
|
||||
lightboxed_image_preview_tag
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,17 +30,11 @@ class Messages::AttachmentPresentation
|
||||
|
||||
inline_media_dimension_constraints(width, height) do
|
||||
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"
|
||||
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
|
||||
width, height = preview_dimensions
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
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, &)
|
||||
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
||||
controller: "messages presence drop-target",
|
||||
@@ -39,7 +31,6 @@ module MessagesHelper
|
||||
controller: "reply",
|
||||
user_id: message.creator_id,
|
||||
message_id: message.id,
|
||||
client_message_id: message.client_message_id,
|
||||
message_timestamp: message_timestamp_milliseconds,
|
||||
message_updated_at: message.updated_at.to_fs(:epoch),
|
||||
sort_value: message_timestamp_milliseconds,
|
||||
@@ -66,7 +57,7 @@ module MessagesHelper
|
||||
when "sound"
|
||||
message_sound_presentation(message)
|
||||
else
|
||||
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
|
||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
|
||||
end
|
||||
rescue Exception => e
|
||||
Sentry.capture_exception(e, extra: { message: message })
|
||||
@@ -76,24 +67,6 @@ module MessagesHelper
|
||||
end
|
||||
|
||||
private
|
||||
# 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
|
||||
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
||||
end
|
||||
|
||||
@@ -1,33 +1,11 @@
|
||||
module RichTextHelper
|
||||
def rich_text_data_actions
|
||||
# submitByKeyboard runs in the capture phase so it can submit on Enter
|
||||
# before the editor turns the keystroke into a newline
|
||||
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
|
||||
end
|
||||
default_actions =
|
||||
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
|
||||
|
||||
def mention_prompt_tag(room)
|
||||
tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id),
|
||||
"remote-filtering": true, "empty-results": "No matches"
|
||||
end
|
||||
autocomplete_actions =
|
||||
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
|
||||
|
||||
# The editor keeps an attachment's content as it finds it, so every
|
||||
# attachment is rebuilt from its attachable before editing: a Trix-era
|
||||
# embed carries its details as node attributes the editor doesn't
|
||||
# round-trip, a mention edited under Trix carries the generic content type
|
||||
# the editor doesn't permit, and a hand-written embed carries whatever
|
||||
# markup the author put there.
|
||||
def editable_body(message)
|
||||
fragment = ActionText::Fragment.wrap(message.body.body_before_type_cast)
|
||||
|
||||
transformed = fragment.replace(ActionText::Attachment.tag_name) do |node|
|
||||
attachment = ActionText::Attachment.from_node(node)
|
||||
|
||||
node.tap do |n|
|
||||
n["content-type"] = attachment.attachable.attachable_content_type
|
||||
n["content"] = render_action_text_attachment(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
ActionText::RichText.new(body: transformed.to_html)
|
||||
[ default_actions, autocomplete_actions ].join(" ")
|
||||
end
|
||||
end
|
||||
@@ -73,12 +73,12 @@ module RoomsHelper
|
||||
def composer_data_actions
|
||||
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
|
||||
|
||||
attachment_actions =
|
||||
"lexxy:file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
||||
trix_attachment_actions =
|
||||
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
||||
|
||||
remaining_actions =
|
||||
"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
|
||||
@@ -2,7 +2,6 @@ module TranslationsHelper
|
||||
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", "🇯🇵": "メールアドレスを入力してください" },
|
||||
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", "🇯🇵": "パスワードを変更" },
|
||||
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", "🇯🇵": "アカウントに名前を付ける" },
|
||||
|
||||
@@ -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
|
||||
import "@hotwired/turbo-rails"
|
||||
import "trix"
|
||||
import "@rails/actiontext"
|
||||
import "initializers"
|
||||
import "controllers"
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import FileUploader from "models/file_uploader"
|
||||
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
|
||||
import { escapeHTML } from "helpers/string_helpers"
|
||||
import { escapeHTML } from "helpers/dom_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static classes = ["toolbar"]
|
||||
@@ -12,21 +12,11 @@ export default class extends Controller {
|
||||
#files = []
|
||||
|
||||
connect() {
|
||||
this.#restoreDraft()
|
||||
|
||||
if (!this.#usingTouchDevice) {
|
||||
onNextEventLoopTick(() => this.textTarget.focus())
|
||||
}
|
||||
}
|
||||
|
||||
saveDraft() {
|
||||
if (this.textTarget.isBlank) {
|
||||
localStorage.removeItem(this.#draftKey)
|
||||
} else {
|
||||
localStorage.setItem(this.#draftKey, this.textTarget.value)
|
||||
}
|
||||
}
|
||||
|
||||
submit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -54,20 +44,21 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
replaceMessageContent(content) {
|
||||
this.textTarget.value = content
|
||||
this.textTarget.focus()
|
||||
this.textTarget.selection.placeCursorAtTheEnd()
|
||||
const editor = this.textTarget.editor
|
||||
|
||||
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) {
|
||||
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
|
||||
|
||||
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
|
||||
const metaEnter = event.metaKey || event.ctrlKey
|
||||
const plainEnter = !event.shiftKey && !event.isComposing
|
||||
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
|
||||
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
|
||||
|
||||
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
||||
event.stopPropagation()
|
||||
this.submit(event)
|
||||
}
|
||||
}
|
||||
@@ -117,19 +108,6 @@ export default class extends Controller {
|
||||
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() {
|
||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||||
}
|
||||
@@ -148,7 +126,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
#validInput() {
|
||||
return !this.textTarget.isBlank
|
||||
return this.textTarget.textContent.trim().length > 0
|
||||
}
|
||||
|
||||
async #submitFiles() {
|
||||
@@ -181,7 +159,6 @@ export default class extends Controller {
|
||||
|
||||
#reset() {
|
||||
this.textTarget.value = ""
|
||||
localStorage.removeItem(this.#draftKey)
|
||||
}
|
||||
|
||||
#updateFileList() {
|
||||
|
||||
@@ -46,7 +46,6 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
messageTargetConnected(target) {
|
||||
this.#reconcilePendingMessage(target)
|
||||
this.#formatter.format(target, ThreadStyle.thread)
|
||||
}
|
||||
|
||||
@@ -90,7 +89,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
async editMyLastMessage() {
|
||||
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
|
||||
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
|
||||
|
||||
if (editorEmpty && this.#paginator.upToDate) {
|
||||
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
||||
@@ -126,25 +125,6 @@ export default class extends Controller {
|
||||
|
||||
// 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() {
|
||||
if (!this.#paginator.upToDate) {
|
||||
await this.#paginator.resetToLastPage()
|
||||
|
||||
@@ -106,8 +106,7 @@ export default class extends Controller {
|
||||
|
||||
async #syncPushSubscription(subscription) {
|
||||
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.unprocessableEntity) subscription.unsubscribe()
|
||||
if (!response.ok) subscription.unsubscribe()
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
this.dispatch("read", { detail: { roomId: room_id, at } })
|
||||
#read = ({ room_id }) => {
|
||||
this.dispatch("read", { detail: { roomId: room_id } })
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#stripMentionAttachments(node) {
|
||||
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
|
||||
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
|
||||
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" ]
|
||||
|
||||
#disconnected = true
|
||||
#connection = 0
|
||||
#discardDirectEditor = false
|
||||
#readAt = new Map()
|
||||
|
||||
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" }, {
|
||||
connected: this.#channelConnected.bind(this),
|
||||
disconnected: this.#channelDisconnected.bind(this),
|
||||
@@ -22,11 +17,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("turbo:click", this.#directNavigation)
|
||||
this.element.removeEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
|
||||
this.#discardDirectEditor = false
|
||||
ignoringBriefDisconnects(this.element, () => {
|
||||
this.#channelDisconnected()
|
||||
this.channel?.unsubscribe()
|
||||
this.channel = null
|
||||
})
|
||||
@@ -36,58 +27,19 @@ export default class extends Controller {
|
||||
this.read({ detail: { roomId: Current.room.id } })
|
||||
}
|
||||
|
||||
read({ detail: { roomId, at } }) {
|
||||
read({ detail: { 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) {
|
||||
room.classList.remove(this.unreadClass)
|
||||
this.dispatch("read", { detail: { targetId: roomId } })
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the live picker through background refreshes, but allow deliberate navigation.
|
||||
#directNavigation = event => {
|
||||
if (event.target.closest("a")?.dataset.turboFrame === this.element.id) {
|
||||
this.#discardDirectEditor = true
|
||||
}
|
||||
}
|
||||
|
||||
#preserveDirectEditor = event => {
|
||||
if (event.target !== this.element) return
|
||||
const discard = this.#discardDirectEditor
|
||||
this.#discardDirectEditor = false
|
||||
if (discard) return
|
||||
|
||||
const current = this.element.querySelector("#direct_rooms_control")
|
||||
const incoming = event.detail.newFrame.querySelector("#direct_rooms_control")
|
||||
if (!current?.querySelector('[data-autocomplete-target="input"]') || !incoming) return
|
||||
|
||||
// Turbo's Bardo moves the actual element, keeping input, selections and controllers.
|
||||
const controls = [ current, incoming ].map(element => [ element, element.hasAttribute("data-turbo-permanent") ])
|
||||
for (const [ element ] of controls) element.setAttribute("data-turbo-permanent", "")
|
||||
const render = event.detail.render
|
||||
event.detail.render = (...args) => {
|
||||
try {
|
||||
return render(...args)
|
||||
} finally {
|
||||
for (const [ element, permanent ] of controls) element.toggleAttribute("data-turbo-permanent", permanent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #channelConnected() {
|
||||
#channelConnected() {
|
||||
if (this.#disconnected) {
|
||||
this.#disconnected = false
|
||||
const connection = ++this.#connection
|
||||
// Reloading an unfinished frame aborts its response body reader.
|
||||
await this.element.loaded
|
||||
if (this.element.isConnected && !this.#disconnected && connection === this.#connection) {
|
||||
this.element.reload()
|
||||
}
|
||||
this.element.reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,11 +47,11 @@ export default class extends Controller {
|
||||
this.#disconnected = true
|
||||
}
|
||||
|
||||
#unread({ roomId, at }) {
|
||||
#unread({ roomId }) {
|
||||
const unreadRoom = this.#findRoomTarget(roomId)
|
||||
|
||||
if (unreadRoom) {
|
||||
if (Current.room.id != roomId && !this.#readSince(roomId, at)) {
|
||||
if (Current.room.id != roomId) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
export function escapeHTML(html) {
|
||||
const div = document.createElement("div")
|
||||
div.textContent = html
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
export function parseHTMLFragment(html) {
|
||||
const template = document.createElement("template")
|
||||
template.innerHTML = html
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
const HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }
|
||||
|
||||
export function truncateString(string, length, omission = "…") {
|
||||
if (string.length <= length) {
|
||||
return string
|
||||
@@ -7,7 +5,3 @@ export function truncateString(string, 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 CampfireRichTextExtension from "lib/rich_text/campfire_extension"
|
||||
import Unfurler from "lib/rich_text/unfurl/unfurler"
|
||||
|
||||
Lexxy.configure({
|
||||
global: {
|
||||
// Keep the content type of mention attachments (application/vnd.campfire.mention)
|
||||
// that Campfire has used since its Trix days
|
||||
attachmentContentTypeNamespace: "campfire",
|
||||
extensions: [ CampfireRichTextExtension ]
|
||||
},
|
||||
default: {
|
||||
// Campfire sends files as separate messages via the composer's own
|
||||
// attach button, never through the editor
|
||||
toolbar: { attachments: false },
|
||||
// Support a `cite` block for attribution links
|
||||
Trix.config.blockAttributes.cite = {
|
||||
tagName: "cite",
|
||||
inheritable: false,
|
||||
}
|
||||
|
||||
// Trix offered a single heading level, rendered as h1
|
||||
headings: [ "h1" ]
|
||||
}
|
||||
})
|
||||
const unfurler = new Unfurler()
|
||||
unfurler.install()
|
||||
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
|
||||
|
||||
#fetchAutocompletables(url) {
|
||||
if (url) {
|
||||
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
|
||||
return fetch(url, { as: "json" }).then(response => response.json())
|
||||
} else {
|
||||
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,
|
||||
messageTimestamp: Math.floor(now.getTime()),
|
||||
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) {
|
||||
return this.#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) {
|
||||
if (this.#isRichText(node)) {
|
||||
return node.toString().trim()
|
||||
} else {
|
||||
return node
|
||||
}
|
||||
#stripWrapperElement(node) {
|
||||
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
|
||||
}
|
||||
|
||||
|
||||
#isRichText(node) {
|
||||
return typeof(node) != "string"
|
||||
}
|
||||
|
||||
#richTextContent(node) {
|
||||
return `<div class="lexxy-content">${node.value}</div>`
|
||||
return `<div class="trix-content">${node.innerHTML}</div>`
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -76,21 +76,7 @@ export default class MessageFormatter {
|
||||
}
|
||||
|
||||
#highlightCodeBlock(block) {
|
||||
this.#normalizeLineBreaks(block)
|
||||
|
||||
if (this.#isPlainText(block)) {
|
||||
const language = block.dataset.language
|
||||
if (language && window.hljs.getLanguage(language)) {
|
||||
block.classList.add(`language-${language}`)
|
||||
}
|
||||
|
||||
window.hljs.highlightElement(block)
|
||||
}
|
||||
}
|
||||
|
||||
// Lexxy breaks code block lines with <br>, Trix-era blocks used newlines
|
||||
#normalizeLineBreaks(block) {
|
||||
block.querySelectorAll("br").forEach(br => br.replaceWith("\n"))
|
||||
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
|
||||
}
|
||||
|
||||
#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
|
||||
@@ -9,8 +9,7 @@ class Membership < ApplicationRecord
|
||||
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_direct_rooms, -> { includes(:room).joins(:room).merge(Room.directs).order(rooms: { updated_at: :desc }) }
|
||||
scope :without_direct_rooms, -> { joins(:room).merge(Room.without_directs) }
|
||||
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
|
||||
|
||||
scope :visible, -> { where.not(involvement: :invisible) }
|
||||
scope :unread, -> { where.not(unread_at: nil) }
|
||||
|
||||
@@ -14,16 +14,20 @@ class Message < ApplicationRecord
|
||||
scope :ordered, -> { order(:created_at) }
|
||||
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
||||
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)
|
||||
}
|
||||
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
|
||||
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
|
||||
scope :with_boosts, -> { includes(boosts: :booster) }
|
||||
|
||||
def plain_text_body
|
||||
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
||||
end
|
||||
|
||||
def to_key
|
||||
[ client_message_id ]
|
||||
end
|
||||
|
||||
def content_type
|
||||
case
|
||||
when attachment? then "attachment"
|
||||
|
||||
@@ -4,14 +4,9 @@ module Message::Attachment
|
||||
THUMBNAIL_MAX_WIDTH = 1200
|
||||
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
|
||||
has_one_attached :attachment do |attachable|
|
||||
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
|
||||
|
||||
@@ -35,25 +30,12 @@ module Message::Attachment
|
||||
attachment&.analyze
|
||||
end
|
||||
|
||||
# A file that ffmpeg or libvips can't decode is still the message: post it without a preview.
|
||||
def process_attachment_thumbnail
|
||||
return if too_many_pixels_to_preview?
|
||||
|
||||
case
|
||||
when attachment.video?
|
||||
attachment.preview(:poster).processed
|
||||
attachment.preview(format: :webp).processed
|
||||
when attachment.representable?
|
||||
attachment.representation(:thumb).processed
|
||||
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
|
||||
@@ -1,29 +1,19 @@
|
||||
module Message::Broadcasts
|
||||
def broadcast_create
|
||||
broadcast_append_to room, :messages, target: [ room, :messages ]
|
||||
broadcast_unread_room_later
|
||||
broadcast_unread_room
|
||||
end
|
||||
|
||||
def broadcast_remove
|
||||
broadcast_remove_to room, :messages
|
||||
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)
|
||||
# Fanned out to the room's members rather than published on one global stream, so
|
||||
# that the timing of activity in a room only reaches people who are in it.
|
||||
def broadcast_unread_room
|
||||
room.memberships.pluck(:user_id).each do |user_id|
|
||||
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,31 +3,9 @@ module Message::Pagination
|
||||
|
||||
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
|
||||
# Keep presentation data lazy until the collection cache knows which messages missed.
|
||||
scope :last_page, -> { last_page_of(PAGE_SIZE) }
|
||||
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
|
||||
scope :last_page, -> { ordered.last(PAGE_SIZE) }
|
||||
scope :first_page, -> { ordered.first(PAGE_SIZE) }
|
||||
|
||||
scope :before, ->(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_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
|
||||
# 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) }
|
||||
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def last_page_of(size)
|
||||
Page.load(ordered, :last, size)
|
||||
end
|
||||
|
||||
def page_around(message)
|
||||
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
|
||||
page_before(message) + [ message ] + page_after(message)
|
||||
end
|
||||
|
||||
def paged?
|
||||
offset(PAGE_SIZE).exists?
|
||||
count > PAGE_SIZE
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,45 +3,10 @@ module Message::Searchable
|
||||
|
||||
included do
|
||||
after_create_commit :create_in_index
|
||||
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
|
||||
after_update_commit :update_in_index, if: :indexed_text_changed?
|
||||
after_update_commit :update_in_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 }
|
||||
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
|
||||
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
|
||||
end
|
||||
|
||||
private
|
||||
@@ -49,12 +14,6 @@ module Message::Searchable
|
||||
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
|
||||
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
|
||||
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
|
||||
end
|
||||
|
||||
@@ -5,7 +5,6 @@ class Opengraph::Fetch
|
||||
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
|
||||
MAX_BODY_SIZE = 5.megabytes
|
||||
MAX_REDIRECTS = 10
|
||||
TIMEOUT = 7.seconds # to connect (TLS included), and for each read and write, as Webhook does
|
||||
|
||||
class TooManyRedirectsError < StandardError; end
|
||||
class RedirectDeniedError < StandardError; end
|
||||
@@ -23,14 +22,9 @@ class Opengraph::Fetch
|
||||
end
|
||||
|
||||
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:)
|
||||
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|
|
||||
if response.is_a?(Net::HTTPRedirection)
|
||||
url, ip = resolve_redirect(response["location"])
|
||||
@@ -44,11 +38,6 @@ class Opengraph::Fetch
|
||||
raise TooManyRedirectsError
|
||||
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)
|
||||
url = URI.parse(location)
|
||||
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
|
||||
|
||||
@@ -1,61 +1,7 @@
|
||||
require "restricted_http/private_network_guard"
|
||||
|
||||
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
|
||||
|
||||
validates :endpoint, presence: true
|
||||
validate :validate_endpoint_url
|
||||
|
||||
def notification(badge: user.memberships.unread.count, **params)
|
||||
# Defer DNS lookup to the delivery worker to prevent rebinding
|
||||
WebPush::Notification.new(**params, badge: badge, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
|
||||
def notification(**params)
|
||||
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
|
||||
end
|
||||
|
||||
# Validate at point of use, not just when saved.
|
||||
def resolved_endpoint_ip
|
||||
RestrictedHTTP::PrivateNetworkGuard.resolve(endpoint_uri.host) if permitted_endpoint_uri?
|
||||
rescue RestrictedHTTP::Violation, Surfguard::Unresolvable
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
# Validate endpoint shape. Belt & suspenders.
|
||||
def permitted_endpoint_uri?
|
||||
endpoint_uri&.scheme == "https" && endpoint_uri.port == 443 && permitted_endpoint_host?
|
||||
end
|
||||
|
||||
def endpoint_uri
|
||||
URI.parse(endpoint) if endpoint.present?
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_endpoint_url
|
||||
if endpoint_uri.nil?
|
||||
errors.add(:endpoint, "is not a valid URL")
|
||||
elsif endpoint_uri.scheme != "https"
|
||||
errors.add(:endpoint, "must use HTTPS")
|
||||
elsif endpoint_uri.port != 443
|
||||
errors.add(:endpoint, "must use the default HTTPS port")
|
||||
elsif !permitted_endpoint_host?
|
||||
errors.add(:endpoint, "is not a permitted push service")
|
||||
elsif resolved_endpoint_ip.nil?
|
||||
errors.add(:endpoint, "resolves to a private or invalid IP address")
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_endpoint_host?
|
||||
host = endpoint_uri&.host&.downcase
|
||||
host.present? && PERMITTED_ENDPOINT_HOSTS.any? do |permitted|
|
||||
host == permitted || host.end_with?(".#{permitted}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -45,34 +45,6 @@ class Room < ApplicationRecord
|
||||
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)
|
||||
unread_memberships(message)
|
||||
push_later(message)
|
||||
|
||||
@@ -53,12 +53,9 @@ class Room::MessagePusher
|
||||
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
|
||||
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
|
||||
Push::Subscription
|
||||
.joins(user: :memberships)
|
||||
.merge(User.active)
|
||||
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
|
||||
end
|
||||
|
||||
|
||||
@@ -7,15 +7,12 @@ class Rooms::Direct < Room
|
||||
end
|
||||
|
||||
private
|
||||
# Among the first user's rooms, the one whose members are exactly these users: as many
|
||||
# memberships as users, and all of them theirs.
|
||||
# FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
|
||||
# which could be to store the membership id list as a hash on the room, and use that for lookup.
|
||||
def find_for(users)
|
||||
user_ids = users.pluck(:id).uniq
|
||||
|
||||
where(id: Membership.where(user_id: user_ids.first).select(:room_id))
|
||||
.joins(:memberships).group(:id)
|
||||
.having("COUNT(*) = :size AND COUNT(CASE WHEN memberships.user_id IN (:user_ids) THEN 1 END) = :size", size: user_ids.size, user_ids: user_ids)
|
||||
.first
|
||||
all.joins(:users).detect do |room|
|
||||
Set.new(room.user_ids) == Set.new(users.pluck(:id))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+3
-12
@@ -7,22 +7,13 @@ class Session < ApplicationRecord
|
||||
|
||||
before_create { self.last_active_at ||= Time.now }
|
||||
|
||||
validate :user_password_unchanged, on: :create
|
||||
def self.start!(user_agent:, ip_address:)
|
||||
create! user_agent: user_agent, ip_address: ip_address
|
||||
end
|
||||
|
||||
def resume(user_agent:, ip_address:)
|
||||
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
|
||||
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# A password change ends every session, so one can't start on a password checked before the change. Validated in
|
||||
# the save's transaction, which holds the database's write lock from the start, so a change commits either before
|
||||
# this check, and fails it, or after the insert, and ends this session with the others. Read past the query cache,
|
||||
# and compared here rather than in the query, which logs its values.
|
||||
def user_password_unchanged
|
||||
unless User.uncached { User.where(id: user_id).pick(:password_digest) } == user.password_digest
|
||||
errors.add :user, :invalid
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-38
@@ -18,10 +18,8 @@ class User < ApplicationRecord
|
||||
enum :status, %i[ active deactivated banned ], default: :active
|
||||
|
||||
has_secure_password validations: false
|
||||
validate :password_challenge_matches, if: :will_save_change_to_password_digest?, unless: -> { password_challenge.nil? }
|
||||
|
||||
after_create_commit :grant_membership_to_open_rooms
|
||||
after_update :sign_out_all_sessions, if: :saved_change_to_password_digest?
|
||||
|
||||
scope :ordered, -> { order("LOWER(name)") }
|
||||
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
|
||||
@@ -52,43 +50,8 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
# The profile always sets password_challenge, and it's checked whenever the save would change the password; scripts
|
||||
# that reset one don't set it.
|
||||
def password_challenge_matches
|
||||
unless password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password_challenge)
|
||||
errors.add :password_challenge, :invalid
|
||||
end
|
||||
end
|
||||
|
||||
# A new password is how someone shuts out whoever else got into their account, so it ends every session, copies
|
||||
# of the changing browser's cookie included, and every push subscription; browsers still signed in post theirs
|
||||
# again. Connections close once that commits, registered on the transaction because a later save in it would
|
||||
# replace the saved change an after_commit condition reads.
|
||||
def sign_out_all_sessions
|
||||
sessions.delete_all
|
||||
push_subscriptions.delete_all
|
||||
self.class.current_transaction.after_commit { disconnect_signed_out_sessions }
|
||||
end
|
||||
|
||||
# The password is saved by now, so a realtime service that can't be reached mustn't fail the change.
|
||||
def disconnect_signed_out_sessions
|
||||
reset_remote_connections
|
||||
rescue => error
|
||||
Rails.logger.warn "Could not disconnect remote connections after a password change: #{error.class}"
|
||||
end
|
||||
|
||||
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
|
||||
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
|
||||
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
|
||||
# as insert_all did.
|
||||
def grant_membership_to_open_rooms
|
||||
now = Time.current
|
||||
|
||||
Membership.connection.execute Membership.sanitize_sql([ <<~SQL, id, now, now ])
|
||||
insert into memberships (room_id, user_id, created_at, updated_at)
|
||||
select id, ?, ?, ? from rooms where type = 'Rooms::Open'
|
||||
on conflict do nothing
|
||||
SQL
|
||||
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
|
||||
end
|
||||
|
||||
def deactived_email_address
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
module User::Bannable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# Administrators still see banned users, so they can lift the ban.
|
||||
scope :visible_to, ->(user) { user.can_administer? ? where(status: %i[ active banned ]) : active }
|
||||
end
|
||||
|
||||
def ban
|
||||
transaction do
|
||||
create_bans_from_sessions
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
module User::Mentionable
|
||||
include ActionText::Attachable
|
||||
|
||||
MENTION_CONTENT_TYPE = "application/vnd.campfire.mention"
|
||||
|
||||
def attachable_content_type
|
||||
MENTION_CONTENT_TYPE
|
||||
end
|
||||
|
||||
def to_attachable_partial_path
|
||||
"users/mention"
|
||||
end
|
||||
|
||||
# How a mention appears inside the editor, matching the prompt's editor template.
|
||||
def to_editor_content_attachment_partial_path
|
||||
def to_trix_content_attachment_partial_path
|
||||
"users/mention"
|
||||
end
|
||||
|
||||
|
||||
@@ -3,20 +3,13 @@ module User::Transferable
|
||||
|
||||
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
|
||||
|
||||
included do
|
||||
# Bound to the password, so changing it also ends sign-in links made before.
|
||||
generates_token_for :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION do
|
||||
password_salt&.last(10)
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def find_by_transfer_id(id)
|
||||
find_by_token_for(:transfer, id)
|
||||
find_signed(id, purpose: :transfer)
|
||||
end
|
||||
end
|
||||
|
||||
def transfer_id
|
||||
generate_token_for(:transfer)
|
||||
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
|
||||
end
|
||||
end
|
||||
@@ -63,7 +63,7 @@ class Webhook < ApplicationRecord
|
||||
end
|
||||
|
||||
def extract_attachment_from(response)
|
||||
if response.code == "200" && response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
||||
if response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
||||
ActiveStorage::Blob.create_and_upload! \
|
||||
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
|
||||
end
|
||||
|
||||
@@ -76,9 +76,10 @@
|
||||
<% end %>
|
||||
|
||||
<label class="switch">
|
||||
<%= tag.input type: "checkbox", class: "switch__input",
|
||||
checked: Current.account.settings.restrict_room_creation_to_administrators?,
|
||||
data: { action: "change->form#submit" } %>
|
||||
<input type="checkbox"
|
||||
class="switch__input"
|
||||
<%= "checked" if Current.account.settings.restrict_room_creation_to_administrators? %>
|
||||
data-action="change->form#submit">
|
||||
<span class="switch__btn round"></span>
|
||||
<span class="for-screen-reader">
|
||||
Must be admin to create new rooms
|
||||
@@ -101,11 +102,11 @@
|
||||
<turbo-frame id="account_users">
|
||||
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
|
||||
|
||||
<% if @administrators.any? && @page.records.any? %>
|
||||
<% if @administrators.any? && @members.any? %>
|
||||
<hr class="separator full-width" style="--border-style: solid">
|
||||
<% end %>
|
||||
|
||||
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
|
||||
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
|
||||
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
|
||||
</turbo-frame>
|
||||
</menu>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<figure class="attachment attachment--content attachment--og">
|
||||
<actiontext-opengraph-embed>
|
||||
<div class="og-embed gap <%= "og-embed--twitter-avatar" if opengraph_embed.twitter_avatar? %>">
|
||||
<div class="og-embed gap">
|
||||
<div class="og-embed__content">
|
||||
<div class="og-embed__title">
|
||||
<%= link_to_if opengraph_embed.href.present?, truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
||||
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
||||
</div>
|
||||
<div class="og-embed__description"><%= truncate(opengraph_embed.description.to_s, length: 560, omission: "…") %></div>
|
||||
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %></div>
|
||||
</div>
|
||||
<% if opengraph_embed.url %>
|
||||
<div class="og-embed__image">
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<%# Files are posted as a message's own attachment, which gets its preview when it's posted; the composer adds none to
|
||||
the rich text. One that's there anyway is shown by its name, with no preview: Action Text's partial links to a URL
|
||||
that makes one on view, and nothing else in the app would make it. %>
|
||||
<figure class="attachment attachment--file attachment--<%= blob.filename.extension %>">
|
||||
<figcaption class="attachment__caption">
|
||||
<% if caption = blob.try(:caption) %>
|
||||
<%= caption %>
|
||||
<% else %>
|
||||
<span class="attachment__name"><%= blob.filename %></span>
|
||||
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
|
||||
<% end %>
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -1,11 +0,0 @@
|
||||
<lexxy-prompt-item search="<%= user.name %>" sgid="<%= user.attachable_sgid %>">
|
||||
<template type="menu">
|
||||
<span class="autocomplete__item flex align-center gap unpad">
|
||||
<%= avatar_tag user %>
|
||||
<span class="autocompletable__name"><%= user.name %></span>
|
||||
</span>
|
||||
</template>
|
||||
<template type="editor">
|
||||
<%= render "users/mention", user: user %>
|
||||
</template>
|
||||
</lexxy-prompt-item>
|
||||
@@ -1 +0,0 @@
|
||||
<%= render partial: "autocompletable/users/prompt_item", collection: @page.records, as: :user %>
|
||||
@@ -1,3 +1,3 @@
|
||||
<div class="lexxy-content">
|
||||
<div class="trix-content">
|
||||
<%= yield -%>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<%= tag.link rel: "apple-touch-icon", href: fresh_account_logo_path %>
|
||||
|
||||
<%= stylesheet_link_tag :all, "data-turbo-track": "reload" %>
|
||||
<%= custom_styles_tag %>
|
||||
<%= tag.link rel: "stylesheet", href: fresh_custom_styles_path, data: { turbo_track: "reload" } %>
|
||||
|
||||
<%= javascript_importmap_tags %>
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
<div class="flash" data-controller="element-removal" data-action="animationend->element-removal#remove">
|
||||
<div class="flash__inner shadow" style="<%= "--flash-background: var(--color-negative)" if flash[:alert] %>">
|
||||
<% if flash[:alert] %>
|
||||
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
|
||||
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
|
||||
<% else %>
|
||||
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
|
||||
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="for-screen-reader" role="alert" aria-atomic="true"><%= notice %></span>
|
||||
@@ -63,11 +63,6 @@
|
||||
|
||||
<%= render "layouts/lightbox" %>
|
||||
|
||||
<% if Current.session %>
|
||||
<%= tag.div hidden: true, data: { controller: "push-subscription-sync",
|
||||
push_subscription_sync_url_value: user_push_subscriptions_path, push_subscription_sync_session_value: push_subscription_session_marker } %>
|
||||
<% end %>
|
||||
|
||||
<a href="https://once.com" id="app-logo" target="_blank" aria-label="Once software from 37signals home page">
|
||||
<%= image_tag "campfire-icon.png", alt: "Campfire logo", width: 256, height: 216 %>
|
||||
</a>
|
||||
|
||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user