mirror of https://framagit.org/tykayn/mastodon.git
Compare commits
71 Commits
d3e1b5ab31
...
48540d459b
Author | SHA1 | Date |
---|---|---|
Tykayn | 48540d459b | |
Tykayn | 4494d73cbc | |
Claire | 93a6c143af | |
Claire | bb7b2868a0 | |
Wonderfall | a06dda41d0 | |
Claire | bf005edd30 | |
Claire | df68d2eab8 | |
Claire | b27f50da5a | |
Claire | e2009ced3a | |
Puck Meerburg | fe0210074f | |
Claire | c8dbbd60eb | |
Claire | 6d831fe274 | |
Claire | 1c8c318281 | |
Claire | d722222fe1 | |
Claire | 03f0e98b32 | |
Eugen Rochko | 2c83b9076d | |
Eugen Rochko | c8301bcfc3 | |
Yusuke Nakamura | 0ae91e45de | |
Claire | 2363b026e6 | |
Jeong Arm | 959234c1e4 | |
Claire | 0dc103ea11 | |
Claire | b782f86b51 | |
Eugen Rochko | fd868f8ca0 | |
Claire | 4cd33a2c71 | |
Claire | f264cca1d2 | |
Claire | 5e4b04de88 | |
Claire | 3c18311d86 | |
Claire | e5113a8cad | |
Claire | 22cd1e6ab5 | |
Claire | e65ede1ac5 | |
Takeshi Umeda | 1bcb3daf7e | |
Claire | 9c610ca0a4 | |
Claire | 77d0297313 | |
Eugen Rochko | 4b6668868e | |
Eugen Rochko | 5c47a18c8d | |
Eugen Rochko | 8a74d851d2 | |
Claire | 76c2028859 | |
Claire | 3251b8eead | |
Claire | f60bb0784f | |
Claire | c3a6f7b941 | |
Claire | 986397b3a2 | |
Claire | c79d4711e9 | |
Claire | be56033715 | |
Claire | 8815e98aa2 | |
Claire | 4bc1fde105 | |
Claire | 34ab4111a7 | |
Claire | aebcb722aa | |
Claire | 9a468c895b | |
Claire | a1e5ff04e3 | |
Claire | e40d5414cc | |
Claire | 40eaa8706b | |
Claire | 4cc7efcb08 | |
Claire | 9b34647c9b | |
Eugen Rochko | 6b98fd0b4f | |
Claire | c7f534ab95 | |
Eugen Rochko | d5a50e9dfb | |
Jeong Arm | e1cf8d4d37 | |
Jeong Arm | f366a23a23 | |
Claire | aa828aea02 | |
Claire | 123a88b6b5 | |
Claire | e63370db19 | |
Claire | 2396c9061a | |
Holger | 663b58aaae | |
Claire | 75441ac63d | |
Claire | 5899fe70b6 | |
Claire | 2688f18d06 | |
Claire | f51c6cba1f | |
Claire | 4f852448e1 | |
Takeshi Umeda | c02d6c46e3 | |
Takeshi Umeda | 987f945930 | |
Claire | e62f488be5 |
|
@ -1,152 +1,295 @@
|
|||
version: 2.1
|
||||
version: 2
|
||||
|
||||
orbs:
|
||||
ruby: circleci/ruby@1.2.0
|
||||
node: circleci/node@4.7.0
|
||||
|
||||
executors:
|
||||
default:
|
||||
parameters:
|
||||
ruby-version:
|
||||
type: string
|
||||
aliases:
|
||||
- &defaults
|
||||
docker:
|
||||
- image: cimg/ruby:<< parameters.ruby-version >>
|
||||
environment:
|
||||
- image: circleci/ruby:2.7-buster-node
|
||||
environment: &ruby_environment
|
||||
BUNDLE_JOBS: 3
|
||||
BUNDLE_RETRY: 3
|
||||
CONTINUOUS_INTEGRATION: true
|
||||
BUNDLE_APP_CONFIG: ./.bundle/
|
||||
BUNDLE_PATH: ./vendor/bundle/
|
||||
DB_HOST: localhost
|
||||
DB_USER: root
|
||||
DISABLE_SIMPLECOV: true
|
||||
RAILS_ENV: test
|
||||
- image: cimg/postgres:14.0
|
||||
ALLOW_NOPAM: true
|
||||
CONTINUOUS_INTEGRATION: true
|
||||
DISABLE_SIMPLECOV: true
|
||||
PAM_ENABLED: true
|
||||
PAM_DEFAULT_SERVICE: pam_test
|
||||
PAM_CONTROLLED_SERVICE: pam_test_controlled
|
||||
working_directory: ~/projects/mastodon/
|
||||
|
||||
- &attach_workspace
|
||||
attach_workspace:
|
||||
at: ~/projects/
|
||||
|
||||
- &persist_to_workspace
|
||||
persist_to_workspace:
|
||||
root: ~/projects/
|
||||
paths:
|
||||
- ./mastodon/
|
||||
|
||||
- &restore_ruby_dependencies
|
||||
restore_cache:
|
||||
keys:
|
||||
- v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
|
||||
- v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-
|
||||
- v3-ruby-dependencies-
|
||||
|
||||
- &install_steps
|
||||
steps:
|
||||
- checkout
|
||||
- *attach_workspace
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v2-node-dependencies-{{ checksum "yarn.lock" }}
|
||||
- v2-node-dependencies-
|
||||
- run:
|
||||
name: Install yarn dependencies
|
||||
command: yarn install --frozen-lockfile
|
||||
- save_cache:
|
||||
key: v2-node-dependencies-{{ checksum "yarn.lock" }}
|
||||
paths:
|
||||
- ./node_modules/
|
||||
- *persist_to_workspace
|
||||
|
||||
- &install_system_dependencies
|
||||
run:
|
||||
name: Install system dependencies
|
||||
command: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libicu-dev libidn11-dev libprotobuf-dev protobuf-compiler
|
||||
|
||||
- &install_ruby_dependencies
|
||||
steps:
|
||||
- *attach_workspace
|
||||
- *install_system_dependencies
|
||||
- run:
|
||||
name: Set Ruby version
|
||||
command: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
|
||||
- *restore_ruby_dependencies
|
||||
- run:
|
||||
name: Set bundler settings
|
||||
command: |
|
||||
bundle config --local clean 'true'
|
||||
bundle config --local deployment 'true'
|
||||
bundle config --local with 'pam_authentication'
|
||||
bundle config --local without 'development production'
|
||||
bundle config --local frozen 'true'
|
||||
bundle config --local path $BUNDLE_PATH
|
||||
- run:
|
||||
name: Install bundler dependencies
|
||||
command: bundle check || (bundle install && bundle clean)
|
||||
- save_cache:
|
||||
key: v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
|
||||
paths:
|
||||
- ./.bundle/
|
||||
- ./vendor/bundle/
|
||||
- persist_to_workspace:
|
||||
root: ~/projects/
|
||||
paths:
|
||||
- ./mastodon/.bundle/
|
||||
- ./mastodon/vendor/bundle/
|
||||
|
||||
- &test_steps
|
||||
parallelism: 4
|
||||
steps:
|
||||
- *attach_workspace
|
||||
- *install_system_dependencies
|
||||
- run:
|
||||
name: Install FFMPEG
|
||||
command: sudo apt-get install -y ffmpeg
|
||||
- run:
|
||||
name: Load database schema
|
||||
command: ./bin/rails db:create db:schema:load db:seed
|
||||
- run:
|
||||
name: Run rspec in parallel
|
||||
command: |
|
||||
bundle exec rspec --profile 10 \
|
||||
--format RspecJunitFormatter \
|
||||
--out test_results/rspec.xml \
|
||||
--format progress \
|
||||
$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||
- store_test_results:
|
||||
path: test_results
|
||||
jobs:
|
||||
install:
|
||||
<<: *defaults
|
||||
<<: *install_steps
|
||||
|
||||
install-ruby2.7:
|
||||
<<: *defaults
|
||||
<<: *install_ruby_dependencies
|
||||
|
||||
install-ruby2.6:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.6-buster-node
|
||||
environment: *ruby_environment
|
||||
<<: *install_ruby_dependencies
|
||||
|
||||
install-ruby3.0:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:3.0-buster-node
|
||||
environment: *ruby_environment
|
||||
<<: *install_ruby_dependencies
|
||||
|
||||
build:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- *attach_workspace
|
||||
- *install_system_dependencies
|
||||
- run:
|
||||
name: Precompile assets
|
||||
command: ./bin/rails assets:precompile
|
||||
- persist_to_workspace:
|
||||
root: ~/projects/
|
||||
paths:
|
||||
- ./mastodon/public/assets
|
||||
- ./mastodon/public/packs-test/
|
||||
|
||||
test-migrations:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.7-buster-node
|
||||
environment: *ruby_environment
|
||||
- image: circleci/postgres:12.2
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
- image: circleci/redis:5-alpine
|
||||
steps:
|
||||
- *attach_workspace
|
||||
- *install_system_dependencies
|
||||
- run:
|
||||
name: Create database
|
||||
command: ./bin/rails db:create
|
||||
- run:
|
||||
command: ./bin/rails db:migrate VERSION=20171010025614
|
||||
name: Run migrations up to v2.0.0
|
||||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate
|
||||
name: Run all remaining migrations
|
||||
|
||||
commands:
|
||||
install-system-dependencies:
|
||||
steps:
|
||||
- run:
|
||||
name: Install system dependencies
|
||||
command: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libicu-dev libidn11-dev libprotobuf-dev protobuf-compiler
|
||||
install-ruby-dependencies:
|
||||
parameters:
|
||||
ruby-version:
|
||||
type: string
|
||||
steps:
|
||||
- run:
|
||||
command: |
|
||||
bundle config clean 'true'
|
||||
bundle config frozen 'true'
|
||||
bundle config without 'development production'
|
||||
name: Set bundler settings
|
||||
- ruby/install-deps:
|
||||
bundler-version: '2.2.29'
|
||||
key: ruby<< parameters.ruby-version >>-gems-v1
|
||||
wait-db:
|
||||
steps:
|
||||
- run:
|
||||
command: dockerize -wait tcp://localhost:5432 -wait tcp://localhost:6379 -timeout 1m
|
||||
name: Wait for PostgreSQL and Redis
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test-two-step-migrations:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: cimg/ruby:2.7-node
|
||||
- image: circleci/ruby:2.7-buster-node
|
||||
environment: *ruby_environment
|
||||
- image: circleci/postgres:12.2
|
||||
environment:
|
||||
RAILS_ENV: test
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
- image: circleci/redis:5-alpine
|
||||
steps:
|
||||
- checkout
|
||||
- install-system-dependencies
|
||||
- install-ruby-dependencies:
|
||||
ruby-version: '2.7'
|
||||
- node/install-packages:
|
||||
cache-version: v1
|
||||
pkg-manager: yarn
|
||||
- run:
|
||||
command: ./bin/rails assets:precompile
|
||||
name: Precompile assets
|
||||
- persist_to_workspace:
|
||||
paths:
|
||||
- public/assets
|
||||
- public/packs-test
|
||||
root: .
|
||||
|
||||
test:
|
||||
parameters:
|
||||
ruby-version:
|
||||
type: string
|
||||
executor:
|
||||
name: default
|
||||
ruby-version: << parameters.ruby-version >>
|
||||
environment:
|
||||
ALLOW_NOPAM: true
|
||||
PAM_ENABLED: true
|
||||
PAM_DEFAULT_SERVICE: pam_test
|
||||
PAM_CONTROLLED_SERVICE: pam_test_controlled
|
||||
parallelism: 4
|
||||
steps:
|
||||
- checkout
|
||||
- install-system-dependencies
|
||||
- run:
|
||||
command: sudo apt-get install -y ffmpeg imagemagick libpam-dev
|
||||
name: Install additional system dependencies
|
||||
- run:
|
||||
command: bundle config with 'pam_authentication'
|
||||
name: Enable PAM authentication
|
||||
- install-ruby-dependencies:
|
||||
ruby-version: << parameters.ruby-version >>
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- wait-db
|
||||
- run:
|
||||
command: ./bin/rails db:create db:schema:load db:seed
|
||||
name: Load database schema
|
||||
- ruby/rspec-test
|
||||
|
||||
test-migrations:
|
||||
executor:
|
||||
name: default
|
||||
ruby-version: '2.7'
|
||||
steps:
|
||||
- checkout
|
||||
- install-system-dependencies
|
||||
- install-ruby-dependencies:
|
||||
ruby-version: '2.7'
|
||||
- wait-db
|
||||
- *attach_workspace
|
||||
- *install_system_dependencies
|
||||
- run:
|
||||
command: ./bin/rails db:create
|
||||
name: Create database
|
||||
- run:
|
||||
command: ./bin/rails db:migrate VERSION=20171010025614
|
||||
name: Run migrations up to v2.0.0
|
||||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate
|
||||
name: Run migrations
|
||||
name: Run all pre-deployment migrations
|
||||
evironment:
|
||||
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
|
||||
- run:
|
||||
command: ./bin/rails db:migrate
|
||||
name: Run all post-deployment remaining migrations
|
||||
|
||||
test-ruby2.7:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.7-buster-node
|
||||
environment: *ruby_environment
|
||||
- image: circleci/postgres:12.2
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
- image: circleci/redis:5-alpine
|
||||
<<: *test_steps
|
||||
|
||||
test-ruby2.6:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.6-buster-node
|
||||
environment: *ruby_environment
|
||||
- image: circleci/postgres:12.2
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
- image: circleci/redis:5-alpine
|
||||
<<: *test_steps
|
||||
|
||||
test-ruby3.0:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:3.0-buster-node
|
||||
environment: *ruby_environment
|
||||
- image: circleci/postgres:12.2
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
- image: circleci/redis:5-alpine
|
||||
<<: *test_steps
|
||||
|
||||
test-webui:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/node:12-buster
|
||||
steps:
|
||||
- *attach_workspace
|
||||
- run:
|
||||
name: Run jest
|
||||
command: yarn test:jest
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build-and-test:
|
||||
jobs:
|
||||
- build
|
||||
- test:
|
||||
matrix:
|
||||
parameters:
|
||||
ruby-version:
|
||||
- '2.7'
|
||||
- '3.0'
|
||||
name: test-ruby<< matrix.ruby-version >>
|
||||
- install
|
||||
- install-ruby2.7:
|
||||
requires:
|
||||
- build
|
||||
- install
|
||||
- install-ruby2.6:
|
||||
requires:
|
||||
- install
|
||||
- install-ruby2.7
|
||||
- install-ruby3.0:
|
||||
requires:
|
||||
- install
|
||||
- install-ruby2.7
|
||||
- build:
|
||||
requires:
|
||||
- install-ruby2.7
|
||||
- test-migrations:
|
||||
requires:
|
||||
- build
|
||||
- node/run:
|
||||
cache-version: v1
|
||||
name: test-webui
|
||||
pkg-manager: yarn
|
||||
- install-ruby2.7
|
||||
- test-two-step-migrations:
|
||||
requires:
|
||||
- install-ruby2.7
|
||||
- test-ruby2.7:
|
||||
requires:
|
||||
- install-ruby2.7
|
||||
- build
|
||||
version: lts
|
||||
yarn-run: test:jest
|
||||
- test-ruby2.6:
|
||||
requires:
|
||||
- install-ruby2.6
|
||||
- build
|
||||
- test-ruby3.0:
|
||||
requires:
|
||||
- install-ruby3.0
|
||||
- build
|
||||
- test-webui:
|
||||
requires:
|
||||
- install
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
name: Build container image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
|
@ -13,15 +14,14 @@ jobs:
|
|||
- uses: docker/setup-buildx-action@v1
|
||||
- uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v3
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/mastodon
|
||||
images: tootsuite/mastodon
|
||||
flavor: |
|
||||
latest=true
|
||||
latest=auto
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
type=semver,pattern={{ raw }}
|
||||
|
@ -30,5 +30,5 @@ jobs:
|
|||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/mastodon:latest
|
||||
cache-from: type=registry,ref=tootsuite/mastodon:latest
|
||||
cache-to: type=inline
|
||||
|
|
41
CHANGELOG.md
41
CHANGELOG.md
|
@ -3,6 +3,47 @@ Changelog
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [3.4.6] - 2022-02-03
|
||||
### Fixed
|
||||
- Fix `mastodon:webpush:generate_vapid_key` task requiring a functional environment ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17338))
|
||||
- Fix spurious errors when receiving an Add activity for a private post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17425))
|
||||
|
||||
### Security
|
||||
- Fix error-prone SQL queries ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15828))
|
||||
- Fix not compacting incoming signed JSON-LD activities ([puckipedia](https://github.com/mastodon/mastodon/pull/17426), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17428)) (CVE-2022-24307)
|
||||
- Fix insufficient sanitization of report comments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17430))
|
||||
- Fix stop condition of a Common Table Expression ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17427))
|
||||
- Disable legacy XSS filtering ([Wonderfall](https://github.com/mastodon/mastodon/pull/17289))
|
||||
|
||||
## [3.4.5] - 2022-01-31
|
||||
### Added
|
||||
- Add more advanced migration tests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17393))
|
||||
- Add github workflow to build Docker images ([unasuke](https://github.com/mastodon/mastodon/pull/16973), [Gargron](https://github.com/mastodon/mastodon/pull/16980), [Gargron](https://github.com/mastodon/mastodon/pull/17000))
|
||||
|
||||
### Fixed
|
||||
- Fix some old migrations failing when skipping releases ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17394))
|
||||
- Fix migrations script failing in certain edge cases ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17398))
|
||||
- Fix Docker build ([tribela](https://github.com/mastodon/mastodon/pull/17188))
|
||||
- Fix Ruby 3.0 dependencies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16723))
|
||||
- Fix followers synchronization mechanism ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16510))
|
||||
|
||||
## [3.4.4] - 2021-11-26
|
||||
### Fixed
|
||||
|
||||
- Fix error when suspending user with an already blocked canonical email ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17036))
|
||||
- Fix overflow of long profile fields in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17010))
|
||||
- Fix confusing error when WebFinger request returns empty document ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16986))
|
||||
- Fix upload of remote media with OpenStack Swift sometimes failing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16998))
|
||||
- Fix logout link not working in Safari ([noellabo](https://github.com/mastodon/mastodon/pull/16574))
|
||||
- Fix “open” link of media modal not closing modal in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16524))
|
||||
- Fix replying from modal in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16516))
|
||||
- Fix `mastodon:setup` command crashing in some circumstances ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16976))
|
||||
|
||||
### Security
|
||||
|
||||
- Fix filtering DMs from non-followed users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17042))
|
||||
- Fix handling of recursive toots in WebUI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17041))
|
||||
|
||||
## [3.4.3] - 2021-11-06
|
||||
### Fixed
|
||||
|
||||
|
|
11
Dockerfile
11
Dockerfile
|
@ -3,8 +3,8 @@ FROM ubuntu:20.04 as build-dep
|
|||
# Use bash for the shell
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
# Install Node v14 (LTS)
|
||||
ENV NODE_VER="14.17.6"
|
||||
# Install Node v12 (LTS)
|
||||
ENV NODE_VER="12.21.0"
|
||||
RUN ARCH= && \
|
||||
dpkgArch="$(dpkg --print-architecture)" && \
|
||||
case "${dpkgArch##*-}" in \
|
||||
|
@ -26,7 +26,7 @@ RUN ARCH= && \
|
|||
mv node-v$NODE_VER-linux-$ARCH /opt/node
|
||||
|
||||
# Install Ruby
|
||||
ENV RUBY_VER="2.7.4"
|
||||
ENV RUBY_VER="2.7.2"
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential \
|
||||
bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \
|
||||
|
@ -54,9 +54,8 @@ RUN npm install -g yarn && \
|
|||
COPY Gemfile* package.json yarn.lock /opt/mastodon/
|
||||
|
||||
RUN cd /opt/mastodon && \
|
||||
bundle config set deployment 'true' && \
|
||||
bundle config set without 'development test' && \
|
||||
bundle config set silence_root_warning true && \
|
||||
bundle config set --local deployment 'true' && \
|
||||
bundle config set --local without 'development test' && \
|
||||
bundle install -j"$(nproc)" && \
|
||||
yarn install --pure-lockfile
|
||||
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
|
||||
[![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases]
|
||||
[![Build Status](https://img.shields.io/circleci/project/github/mastodon/mastodon.svg)][circleci]
|
||||
[![Code Climate](https://img.shields.io/codeclimate/maintainability/mastodon/mastodon.svg)][code_climate]
|
||||
[![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate]
|
||||
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
|
||||
[![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker]
|
||||
|
||||
[releases]: https://github.com/mastodon/mastodon/releases
|
||||
[circleci]: https://circleci.com/gh/mastodon/mastodon
|
||||
[code_climate]: https://codeclimate.com/github/mastodon/mastodon
|
||||
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
|
||||
[crowdin]: https://crowdin.com/project/mastodon
|
||||
[docker]: https://hub.docker.com/r/tootsuite/mastodon/
|
||||
|
||||
|
|
|
@ -23,11 +23,8 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
|
||||
def create
|
||||
super do |resource|
|
||||
# We only need to call this if this hasn't already been
|
||||
# called from one of the two-factor or sign-in token
|
||||
# authentication methods
|
||||
|
||||
on_authentication_success(resource, :password) unless @on_authentication_success_called
|
||||
resource.update_sign_in!(request, new_sign_in: true)
|
||||
flash.delete(:notice)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -42,10 +39,9 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
def webauthn_options
|
||||
user = User.find_by(id: session[:attempt_user_id])
|
||||
|
||||
if user&.webauthn_enabled?
|
||||
if user.webauthn_enabled?
|
||||
options_for_get = WebAuthn::Credential.options_for_get(
|
||||
allow: user.webauthn_credentials.pluck(:external_id),
|
||||
user_verification: 'discouraged'
|
||||
allow: user.webauthn_credentials.pluck(:external_id)
|
||||
)
|
||||
|
||||
session[:webauthn_challenge] = options_for_get.challenge
|
||||
|
@ -141,33 +137,4 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
session.delete(:attempt_user_id)
|
||||
session.delete(:attempt_user_updated_at)
|
||||
end
|
||||
|
||||
def on_authentication_success(user, security_measure)
|
||||
@on_authentication_success_called = true
|
||||
|
||||
clear_attempt_from_session
|
||||
|
||||
user.update_sign_in!(request, new_sign_in: true)
|
||||
sign_in(user)
|
||||
flash.delete(:notice)
|
||||
|
||||
LoginActivity.create(
|
||||
user: user,
|
||||
success: true,
|
||||
authentication_method: security_measure,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent
|
||||
)
|
||||
end
|
||||
|
||||
def on_authentication_failure(user, security_measure, failure_reason)
|
||||
LoginActivity.create(
|
||||
user: user,
|
||||
success: false,
|
||||
authentication_method: security_measure,
|
||||
failure_reason: failure_reason,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -33,9 +33,9 @@ module SignInTokenAuthenticationConcern
|
|||
|
||||
def authenticate_with_sign_in_token_attempt(user)
|
||||
if valid_sign_in_token_attempt?(user)
|
||||
on_authentication_success(user, :sign_in_token)
|
||||
clear_attempt_from_session
|
||||
sign_in(user)
|
||||
else
|
||||
on_authentication_failure(user, :sign_in_token, :invalid_sign_in_token)
|
||||
flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
|
||||
prompt_for_sign_in_token(user)
|
||||
end
|
||||
|
|
|
@ -56,19 +56,19 @@ module TwoFactorAuthenticationConcern
|
|||
webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential])
|
||||
|
||||
if valid_webauthn_credential?(user, webauthn_credential)
|
||||
on_authentication_success(user, :webauthn)
|
||||
clear_attempt_from_session
|
||||
sign_in(user)
|
||||
render json: { redirect_path: root_path }, status: :ok
|
||||
else
|
||||
on_authentication_failure(user, :webauthn, :invalid_credential)
|
||||
render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_with_two_factor_via_otp(user)
|
||||
if valid_otp_attempt?(user)
|
||||
on_authentication_success(user, :otp)
|
||||
clear_attempt_from_session
|
||||
sign_in(user)
|
||||
else
|
||||
on_authentication_failure(user, :otp, :invalid_otp_token)
|
||||
flash.now[:alert] = I18n.t('users.invalid_otp_token')
|
||||
prompt_for_two_factor(user)
|
||||
end
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ContextHelper
|
||||
NAMED_CONTEXT_MAP = {
|
||||
activitystreams: 'https://www.w3.org/ns/activitystreams',
|
||||
security: 'https://w3id.org/security/v1',
|
||||
}.freeze
|
||||
|
||||
CONTEXT_EXTENSION_MAP = {
|
||||
manually_approves_followers: { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers' },
|
||||
sensitive: { 'sensitive' => 'as:sensitive' },
|
||||
hashtag: { 'Hashtag' => 'as:Hashtag' },
|
||||
moved_to: { 'movedTo' => { '@id' => 'as:movedTo', '@type' => '@id' } },
|
||||
also_known_as: { 'alsoKnownAs' => { '@id' => 'as:alsoKnownAs', '@type' => '@id' } },
|
||||
emoji: { 'toot' => 'http://joinmastodon.org/ns#', 'Emoji' => 'toot:Emoji' },
|
||||
featured: { 'toot' => 'http://joinmastodon.org/ns#', 'featured' => { '@id' => 'toot:featured', '@type' => '@id' }, 'featuredTags' => { '@id' => 'toot:featuredTags', '@type' => '@id' } },
|
||||
property_value: { 'schema' => 'http://schema.org#', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value' },
|
||||
atom_uri: { 'ostatus' => 'http://ostatus.org#', 'atomUri' => 'ostatus:atomUri' },
|
||||
conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
|
||||
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
||||
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
||||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
}.freeze
|
||||
|
||||
def full_context
|
||||
serialized_context(NAMED_CONTEXT_MAP, CONTEXT_EXTENSION_MAP)
|
||||
end
|
||||
|
||||
def serialized_context(named_contexts_map, context_extensions_map)
|
||||
context_array = []
|
||||
|
||||
named_contexts = named_contexts_map.keys
|
||||
context_extensions = context_extensions_map.keys
|
||||
|
||||
named_contexts.each do |key|
|
||||
context_array << NAMED_CONTEXT_MAP[key]
|
||||
end
|
||||
|
||||
extensions = context_extensions.each_with_object({}) do |key, h|
|
||||
h.merge!(CONTEXT_EXTENSION_MAP[key])
|
||||
end
|
||||
|
||||
context_array << extensions unless extensions.empty?
|
||||
|
||||
if context_array.size == 1
|
||||
context_array.first
|
||||
else
|
||||
context_array
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module JsonLdHelper
|
||||
include ContextHelper
|
||||
|
||||
def equals_or_includes?(haystack, needle)
|
||||
haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
|
||||
end
|
||||
|
@ -63,6 +65,84 @@ module JsonLdHelper
|
|||
graph.dump(:normalize)
|
||||
end
|
||||
|
||||
def compact(json)
|
||||
compacted = JSON::LD::API.compact(json.without('signature'), full_context, documentLoader: method(:load_jsonld_context))
|
||||
compacted['signature'] = json['signature']
|
||||
compacted
|
||||
end
|
||||
|
||||
# Patches a JSON-LD document to avoid compatibility issues on redistribution
|
||||
#
|
||||
# Since compacting a JSON-LD document against Mastodon's built-in vocabulary
|
||||
# means other extension namespaces will be expanded, malformed JSON-LD
|
||||
# attributes lost, and some values “unexpectedly” compacted this method
|
||||
# patches the following likely sources of incompatibility:
|
||||
# - 'https://www.w3.org/ns/activitystreams#Public' being compacted to
|
||||
# 'as:Public' (for instance, pre-3.4.0 Mastodon does not understand
|
||||
# 'as:Public')
|
||||
# - single-item arrays being compacted to the item itself (`[foo]` being
|
||||
# compacted to `foo`)
|
||||
#
|
||||
# It is not always possible for `patch_for_forwarding!` to produce a document
|
||||
# deemed safe for forwarding. Use `safe_for_forwarding?` to check the status
|
||||
# of the output document.
|
||||
#
|
||||
# @param original [Hash] The original JSON-LD document used as reference
|
||||
# @param compacted [Hash] The compacted JSON-LD document to be patched
|
||||
# @return [void]
|
||||
def patch_for_forwarding!(original, compacted)
|
||||
original.without('@context', 'signature').each do |key, value|
|
||||
next if value.nil? || !compacted.key?(key)
|
||||
|
||||
compacted_value = compacted[key]
|
||||
if value.is_a?(Hash) && compacted_value.is_a?(Hash)
|
||||
patch_for_forwarding!(value, compacted_value)
|
||||
elsif value.is_a?(Array)
|
||||
compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
|
||||
return if value.size != compacted_value.size
|
||||
|
||||
compacted[key] = value.zip(compacted_value).map do |v, vc|
|
||||
if v.is_a?(Hash) && vc.is_a?(Hash)
|
||||
patch_for_forwarding!(v, vc)
|
||||
vc
|
||||
elsif v == 'https://www.w3.org/ns/activitystreams#Public' && vc == 'as:Public'
|
||||
v
|
||||
else
|
||||
vc
|
||||
end
|
||||
end
|
||||
elsif value == 'https://www.w3.org/ns/activitystreams#Public' && compacted_value == 'as:Public'
|
||||
compacted[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Tests whether a JSON-LD compaction is deemed safe for redistribution,
|
||||
# that is, if it doesn't change its meaning to consumers that do not actually
|
||||
# handle JSON-LD, but rely on values being serialized in a certain way.
|
||||
#
|
||||
# See `patch_for_forwarding!` for details.
|
||||
#
|
||||
# @param original [Hash] The original JSON-LD document used as reference
|
||||
# @param compacted [Hash] The compacted JSON-LD document to be patched
|
||||
# @return [Boolean] Whether the patched document is deemed safe
|
||||
def safe_for_forwarding?(original, compacted)
|
||||
original.without('@context', 'signature').all? do |key, value|
|
||||
compacted_value = compacted[key]
|
||||
return false unless value.class == compacted_value.class
|
||||
|
||||
if value.is_a?(Hash)
|
||||
safe_for_forwarding?(value, compacted_value)
|
||||
elsif value.is_a?(Array)
|
||||
value.zip(compacted_value).all? do |v, vc|
|
||||
v.is_a?(Hash) ? (vc.is_a?(Hash) && safe_for_forwarding?(v, vc)) : v == vc
|
||||
end
|
||||
else
|
||||
value == compacted_value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_resource(uri, id, on_behalf_of = nil)
|
||||
unless id
|
||||
json = fetch_resource_without_id_validation(uri, on_behalf_of)
|
||||
|
|
|
@ -6,7 +6,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import { debounce } from 'lodash';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import {
|
||||
lookupAccount,
|
||||
fetchAccount,
|
||||
fetchFollowers,
|
||||
expandFollowers,
|
||||
|
@ -20,26 +19,15 @@ import ScrollableList from '../../components/scrollable_list';
|
|||
import MissingIndicator from 'mastodon/components/missing_indicator';
|
||||
import TimelineHint from 'mastodon/components/timeline_hint';
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', acct]);
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
isLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
|
||||
remoteUrl: state.getIn(['accounts', accountId, 'url']),
|
||||
isAccount: !!state.getIn(['accounts', accountId]),
|
||||
accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
|
||||
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
|
||||
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
|
||||
};
|
||||
};
|
||||
const mapStateToProps = (state, props) => ({
|
||||
remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
|
||||
remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
|
||||
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
||||
accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
|
||||
isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true),
|
||||
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
|
||||
});
|
||||
|
||||
const RemoteHint = ({ url }) => (
|
||||
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
|
||||
|
@ -53,11 +41,7 @@ export default @connect(mapStateToProps)
|
|||
class Followers extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.shape({
|
||||
acct: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
}).isRequired,
|
||||
accountId: PropTypes.string,
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
hasMore: PropTypes.bool,
|
||||
|
@ -69,35 +53,22 @@ class Followers extends ImmutablePureComponent {
|
|||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
_load () {
|
||||
const { accountId, isAccount, dispatch } = this.props;
|
||||
|
||||
if (!isAccount) dispatch(fetchAccount(accountId));
|
||||
dispatch(fetchFollowers(accountId));
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { params: { acct }, accountId, dispatch } = this.props;
|
||||
|
||||
if (accountId) {
|
||||
this._load();
|
||||
} else {
|
||||
dispatch(lookupAccount(acct));
|
||||
componentWillMount () {
|
||||
if (!this.props.accountIds) {
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(fetchFollowers(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { params: { acct }, accountId, dispatch } = this.props;
|
||||
|
||||
if (prevProps.accountId !== accountId && accountId) {
|
||||
this._load();
|
||||
} else if (prevProps.params.acct !== acct) {
|
||||
dispatch(lookupAccount(acct));
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
||||
this.props.dispatch(fetchFollowers(nextProps.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFollowers(this.props.accountId));
|
||||
this.props.dispatch(expandFollowers(this.props.params.accountId));
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
|
@ -140,7 +111,7 @@ class Followers extends ImmutablePureComponent {
|
|||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
|
||||
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
|
||||
alwaysPrepend
|
||||
append={remoteMessage}
|
||||
emptyMessage={emptyMessage}
|
||||
|
|
|
@ -6,7 +6,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import { debounce } from 'lodash';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import {
|
||||
lookupAccount,
|
||||
fetchAccount,
|
||||
fetchFollowing,
|
||||
expandFollowing,
|
||||
|
@ -20,26 +19,15 @@ import ScrollableList from '../../components/scrollable_list';
|
|||
import MissingIndicator from 'mastodon/components/missing_indicator';
|
||||
import TimelineHint from 'mastodon/components/timeline_hint';
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', acct]);
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
isLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
|
||||
remoteUrl: state.getIn(['accounts', accountId, 'url']),
|
||||
isAccount: !!state.getIn(['accounts', accountId]),
|
||||
accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
|
||||
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
|
||||
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
|
||||
};
|
||||
};
|
||||
const mapStateToProps = (state, props) => ({
|
||||
remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
|
||||
remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
|
||||
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
||||
accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
|
||||
isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true),
|
||||
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
|
||||
});
|
||||
|
||||
const RemoteHint = ({ url }) => (
|
||||
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
|
||||
|
@ -53,11 +41,7 @@ export default @connect(mapStateToProps)
|
|||
class Following extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.shape({
|
||||
acct: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
}).isRequired,
|
||||
accountId: PropTypes.string,
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
hasMore: PropTypes.bool,
|
||||
|
@ -69,35 +53,22 @@ class Following extends ImmutablePureComponent {
|
|||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
_load () {
|
||||
const { accountId, isAccount, dispatch } = this.props;
|
||||
|
||||
if (!isAccount) dispatch(fetchAccount(accountId));
|
||||
dispatch(fetchFollowing(accountId));
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { params: { acct }, accountId, dispatch } = this.props;
|
||||
|
||||
if (accountId) {
|
||||
this._load();
|
||||
} else {
|
||||
dispatch(lookupAccount(acct));
|
||||
componentWillMount () {
|
||||
if (!this.props.accountIds) {
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(fetchFollowing(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { params: { acct }, accountId, dispatch } = this.props;
|
||||
|
||||
if (prevProps.accountId !== accountId && accountId) {
|
||||
this._load();
|
||||
} else if (prevProps.params.acct !== acct) {
|
||||
dispatch(lookupAccount(acct));
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
||||
this.props.dispatch(fetchFollowing(nextProps.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFollowing(this.props.accountId));
|
||||
this.props.dispatch(expandFollowing(this.props.params.accountId));
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
|
@ -140,7 +111,7 @@ class Following extends ImmutablePureComponent {
|
|||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
|
||||
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
|
||||
alwaysPrepend
|
||||
append={remoteMessage}
|
||||
emptyMessage={emptyMessage}
|
||||
|
|
|
@ -120,7 +120,7 @@ class Footer extends ImmutablePureComponent {
|
|||
onClose();
|
||||
}
|
||||
|
||||
router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
|
||||
router.history.push(`/statuses/${status.get('id')}`);
|
||||
}
|
||||
|
||||
render () {
|
||||
|
|
|
@ -83,7 +83,7 @@ const makeMapStateToProps = () => {
|
|||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
||||
let id = statusId;
|
||||
|
||||
while (id) {
|
||||
while (id && !mutable.includes(id)) {
|
||||
mutable.unshift(id);
|
||||
id = inReplyTos.get(id);
|
||||
}
|
||||
|
@ -101,7 +101,7 @@ const makeMapStateToProps = () => {
|
|||
const ids = [statusId];
|
||||
|
||||
while (ids.length > 0) {
|
||||
let id = ids.shift();
|
||||
let id = ids.pop();
|
||||
const replies = contextReplies.get(id);
|
||||
|
||||
if (statusId !== id) {
|
||||
|
@ -110,7 +110,7 @@ const makeMapStateToProps = () => {
|
|||
|
||||
if (replies) {
|
||||
replies.reverse().forEach(reply => {
|
||||
ids.unshift(reply);
|
||||
if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,15 +24,15 @@ export default @injectIntl
|
|||
class MediaModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media : ImmutablePropTypes.list.isRequired,
|
||||
statusId : PropTypes.string,
|
||||
index : PropTypes.number.isRequired,
|
||||
onClose : PropTypes.func.isRequired,
|
||||
intl : PropTypes.object.isRequired,
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
statusId: PropTypes.string,
|
||||
index: PropTypes.number.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChangeBackgroundColor: PropTypes.func.isRequired,
|
||||
currentTime : PropTypes.number,
|
||||
autoPlay : PropTypes.bool,
|
||||
volume : PropTypes.number,
|
||||
currentTime: PropTypes.number,
|
||||
autoPlay: PropTypes.bool,
|
||||
volume: PropTypes.number,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -128,6 +128,13 @@ class MediaModal extends ImmutablePureComponent {
|
|||
}));
|
||||
};
|
||||
|
||||
handleStatusClick = e => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/statuses/${this.props.statusId}`);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, statusId, intl, onClose } = this.props;
|
||||
const { navigationHidden } = this.state;
|
||||
|
|
|
@ -21,7 +21,7 @@ export default class VideoModal extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { media, onChangeBackgroundColor } = this.props;
|
||||
const { media, onChangeBackgroundColor, onClose } = this.props;
|
||||
|
||||
const backgroundColor = getAverageFromBlurhash(media.get('blurhash'));
|
||||
|
||||
|
|
|
@ -145,7 +145,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
|
||||
render () {
|
||||
const { children, mobile } = this.props;
|
||||
const redirect = mobile ? <Redirect from='/' to='/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
|
||||
const redirect = mobile ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
|
||||
|
||||
return (
|
||||
<ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}>
|
||||
|
@ -153,40 +153,33 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
{redirect}
|
||||
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
|
||||
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
|
||||
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
|
||||
|
||||
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
|
||||
<WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} />
|
||||
<WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} />
|
||||
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
|
||||
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
||||
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
||||
<WrappedRoute path='/notifications' component={Notifications} content={children} />
|
||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||
|
||||
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
|
||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
||||
|
||||
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
|
||||
<WrappedRoute path='/search' component={Search} content={children} />
|
||||
<WrappedRoute path='/directory' component={Directory} content={children} />
|
||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||
|
||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
|
||||
<WrappedRoute path={['/@:acct/followers', '/accounts/:id/followers']} component={Followers} content={children} />
|
||||
<WrappedRoute path={['/@:acct/following', '/accounts/:id/following']} component={Following} content={children} />
|
||||
<WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
|
||||
|
||||
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
|
||||
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
|
||||
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
|
||||
|
||||
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
|
||||
<WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
|
||||
|
||||
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
|
||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
|
||||
|
@ -460,7 +453,7 @@ class UI extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleHotkeyGoToHome = () => {
|
||||
this.context.router.history.push('/home');
|
||||
this.context.router.history.push('/timelines/home');
|
||||
}
|
||||
|
||||
handleHotkeyGoToNotifications = () => {
|
||||
|
@ -468,15 +461,15 @@ class UI extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleHotkeyGoToLocal = () => {
|
||||
this.context.router.history.push('/public/local');
|
||||
this.context.router.history.push('/timelines/public/local');
|
||||
}
|
||||
|
||||
handleHotkeyGoToFederated = () => {
|
||||
this.context.router.history.push('/public');
|
||||
this.context.router.history.push('/timelines/public');
|
||||
}
|
||||
|
||||
handleHotkeyGoToDirect = () => {
|
||||
this.context.router.history.push('/conversations');
|
||||
this.context.router.history.push('/timelines/direct');
|
||||
}
|
||||
|
||||
handleHotkeyGoToStart = () => {
|
||||
|
@ -492,7 +485,7 @@ class UI extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleHotkeyGoToProfile = () => {
|
||||
this.context.router.history.push(`/@${this.props.username}`);
|
||||
this.context.router.history.push(`/accounts/${me}`);
|
||||
}
|
||||
|
||||
handleHotkeyGoToBlocked = () => {
|
||||
|
|
|
@ -1,18 +1,15 @@
|
|||
import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
|
||||
import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
export default function modal(state = ImmutableStack(), action) {
|
||||
switch(action.type) {
|
||||
case MODAL_OPEN:
|
||||
return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps }));
|
||||
return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps }));
|
||||
case MODAL_CLOSE:
|
||||
return (action.modalType === undefined || action.modalType === state.getIn([0, 'modalType'])) ? state.shift() : state;
|
||||
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
||||
return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state;
|
||||
case TIMELINE_DELETE:
|
||||
return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
|
||||
return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -831,6 +831,7 @@ a.name-tag,
|
|||
padding: 0 5px;
|
||||
margin-bottom: 10px;
|
||||
flex: 1 0 50%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account__header__fields,
|
||||
|
|
|
@ -7,7 +7,7 @@ class ActivityPub::Activity::Add < ActivityPub::Activity
|
|||
status = status_from_uri(object_uri)
|
||||
status ||= fetch_remote_original_status
|
||||
|
||||
return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status)
|
||||
return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status) && status.distributable?
|
||||
|
||||
StatusPin.create!(account: @account, status: status)
|
||||
end
|
||||
|
|
|
@ -1,30 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||
NAMED_CONTEXT_MAP = {
|
||||
activitystreams: 'https://www.w3.org/ns/activitystreams',
|
||||
security: 'https://w3id.org/security/v1',
|
||||
}.freeze
|
||||
|
||||
CONTEXT_EXTENSION_MAP = {
|
||||
manually_approves_followers: { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers' },
|
||||
sensitive: { 'sensitive' => 'as:sensitive' },
|
||||
hashtag: { 'Hashtag' => 'as:Hashtag' },
|
||||
moved_to: { 'movedTo' => { '@id' => 'as:movedTo', '@type' => '@id' } },
|
||||
also_known_as: { 'alsoKnownAs' => { '@id' => 'as:alsoKnownAs', '@type' => '@id' } },
|
||||
emoji: { 'toot' => 'http://joinmastodon.org/ns#', 'Emoji' => 'toot:Emoji' },
|
||||
featured: { 'toot' => 'http://joinmastodon.org/ns#', 'featured' => { '@id' => 'toot:featured', '@type' => '@id' }, 'featuredTags' => { '@id' => 'toot:featuredTags', '@type' => '@id' } },
|
||||
property_value: { 'schema' => 'http://schema.org#', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value' },
|
||||
atom_uri: { 'ostatus' => 'http://ostatus.org#', 'atomUri' => 'ostatus:atomUri' },
|
||||
conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
|
||||
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
||||
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
||||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
}.freeze
|
||||
include ContextHelper
|
||||
|
||||
def self.default_key_transform
|
||||
:camel_lower
|
||||
|
@ -35,7 +12,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
|||
end
|
||||
|
||||
def serializable_hash(options = nil)
|
||||
named_contexts = {}
|
||||
named_contexts = { activitystreams: NAMED_CONTEXT_MAP['activitystreams'] }
|
||||
context_extensions = {}
|
||||
|
||||
options = serialization_options(options)
|
||||
|
@ -45,29 +22,4 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
|||
|
||||
{ '@context' => serialized_context(named_contexts, context_extensions) }.merge(serialized_hash)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialized_context(named_contexts_map, context_extensions_map)
|
||||
context_array = []
|
||||
|
||||
named_contexts = [:activitystreams] + named_contexts_map.keys
|
||||
context_extensions = context_extensions_map.keys
|
||||
|
||||
named_contexts.each do |key|
|
||||
context_array << NAMED_CONTEXT_MAP[key]
|
||||
end
|
||||
|
||||
extensions = context_extensions.each_with_object({}) do |key, h|
|
||||
h.merge!(CONTEXT_EXTENSION_MAP[key])
|
||||
end
|
||||
|
||||
context_array << extensions unless extensions.empty?
|
||||
|
||||
if context_array.size == 1
|
||||
context_array.first
|
||||
else
|
||||
context_array
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -46,7 +46,9 @@ class Webfinger
|
|||
def body_from_webfinger(url = standard_url, use_fallback = true)
|
||||
webfinger_request(url).perform do |res|
|
||||
if res.code == 200
|
||||
res.body_with_limit
|
||||
body = res.body_with_limit
|
||||
raise Webfinger::Error, "Request for #{@uri} returned empty response" if body.empty?
|
||||
body
|
||||
elsif res.code == 404 && use_fallback
|
||||
body_from_host_meta
|
||||
elsif res.code == 410
|
||||
|
|
|
@ -426,6 +426,9 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
class << self
|
||||
DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/.freeze
|
||||
TEXTSEARCH = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
|
||||
|
||||
def readonly_attributes
|
||||
super - %w(statuses_count following_count followers_count)
|
||||
end
|
||||
|
@ -436,70 +439,29 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
def search_for(terms, limit = 10, offset = 0)
|
||||
textsearch, query = generate_query_for_search(terms)
|
||||
tsquery = generate_query_for_search(terms)
|
||||
|
||||
sql = <<-SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
|
||||
ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
WHERE #{query} @@ #{textsearch}
|
||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
ORDER BY rank DESC
|
||||
LIMIT ? OFFSET ?
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
|
||||
records = find_by_sql([sql, limit, offset])
|
||||
records = find_by_sql([sql, limit: limit, offset: offset, tsquery: tsquery])
|
||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
||||
records
|
||||
end
|
||||
|
||||
def advanced_search_for(terms, account, limit = 10, following = false, offset = 0)
|
||||
textsearch, query = generate_query_for_search(terms)
|
||||
|
||||
if following
|
||||
sql = <<-SQL.squish
|
||||
WITH first_degree AS (
|
||||
SELECT target_account_id
|
||||
FROM follows
|
||||
WHERE account_id = ?
|
||||
UNION ALL
|
||||
SELECT ?
|
||||
)
|
||||
SELECT
|
||||
accounts.*,
|
||||
(count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)
|
||||
WHERE accounts.id IN (SELECT * FROM first_degree)
|
||||
AND #{query} @@ #{textsearch}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
GROUP BY accounts.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT ? OFFSET ?
|
||||
SQL
|
||||
|
||||
records = find_by_sql([sql, account.id, account.id, account.id, limit, offset])
|
||||
else
|
||||
sql = <<-SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
(count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)
|
||||
WHERE #{query} @@ #{textsearch}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
GROUP BY accounts.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT ? OFFSET ?
|
||||
SQL
|
||||
|
||||
records = find_by_sql([sql, account.id, account.id, limit, offset])
|
||||
end
|
||||
|
||||
tsquery = generate_query_for_search(terms)
|
||||
sql = advanced_search_for_sql_template(following)
|
||||
records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery])
|
||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
||||
records
|
||||
end
|
||||
|
@ -521,12 +483,55 @@ class Account < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
def generate_query_for_search(terms)
|
||||
terms = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' ')))
|
||||
textsearch = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
|
||||
query = "to_tsquery('simple', ''' ' || #{terms} || ' ''' || ':*')"
|
||||
def generate_query_for_search(unsanitized_terms)
|
||||
terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
|
||||
|
||||
[textsearch, query]
|
||||
# The final ":*" is for prefix search.
|
||||
# The trailing space does not seem to fit any purpose, but `to_tsquery`
|
||||
# behaves differently with and without a leading space if the terms start
|
||||
# with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
|
||||
# the same query.
|
||||
"' #{terms} ':*"
|
||||
end
|
||||
|
||||
def advanced_search_for_sql_template(following)
|
||||
if following
|
||||
<<-SQL.squish
|
||||
WITH first_degree AS (
|
||||
SELECT target_account_id
|
||||
FROM follows
|
||||
WHERE account_id = :id
|
||||
UNION ALL
|
||||
SELECT :id
|
||||
)
|
||||
SELECT
|
||||
accounts.*,
|
||||
(count(f.id) + 1) * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
|
||||
WHERE accounts.id IN (SELECT * FROM first_degree)
|
||||
AND to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
GROUP BY accounts.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
else
|
||||
<<-SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
(count(f.id) + 1) * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
|
||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
GROUP BY accounts.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ class CanonicalEmailBlock < ApplicationRecord
|
|||
|
||||
belongs_to :reference_account, class_name: 'Account'
|
||||
|
||||
validates :canonical_email_hash, presence: true
|
||||
validates :canonical_email_hash, presence: true, uniqueness: true
|
||||
|
||||
def email=(email)
|
||||
self.canonical_email_hash = email_to_canonical_email_hash(email)
|
||||
|
|
|
@ -96,15 +96,12 @@ class Status < ApplicationRecord
|
|||
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
|
||||
scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
|
||||
scope :tagged_with_all, ->(tag_ids) {
|
||||
Array(tag_ids).reduce(self) do |result, id|
|
||||
Array(tag_ids).map(&:to_i).reduce(self) do |result, id|
|
||||
result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
|
||||
end
|
||||
}
|
||||
scope :tagged_with_none, ->(tag_ids) {
|
||||
Array(tag_ids).reduce(self) do |result, id|
|
||||
result.joins("LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
|
||||
.where("t#{id}.tag_id IS NULL")
|
||||
end
|
||||
where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
|
||||
}
|
||||
|
||||
cache_associated :application,
|
||||
|
|
|
@ -5,11 +5,27 @@ class ActivityPub::ProcessCollectionService < BaseService
|
|||
|
||||
def call(body, account, **options)
|
||||
@account = account
|
||||
@json = Oj.load(body, mode: :strict)
|
||||
@json = original_json = Oj.load(body, mode: :strict)
|
||||
@options = options
|
||||
|
||||
begin
|
||||
@json = compact(@json) if @json['signature'].is_a?(Hash)
|
||||
rescue JSON::LD::JsonLdError => e
|
||||
Rails.logger.debug "Error when compacting JSON-LD document for #{value_or_id(@json['actor'])}: #{e.message}"
|
||||
@json = original_json.without('signature')
|
||||
end
|
||||
|
||||
return if !supported_context? || (different_actor? && verify_account!.nil?) || suspended_actor? || @account.local?
|
||||
|
||||
if @json['signature'].present?
|
||||
# We have verified the signature, but in the compaction step above, might
|
||||
# have introduced incompatibilities with other servers that do not
|
||||
# normalize the JSON-LD documents (for instance, previous Mastodon
|
||||
# versions), so skip redistribution if we can't get a safe document.
|
||||
patch_for_forwarding!(original_json, @json)
|
||||
@json.delete('signature') unless safe_for_forwarding?(original_json, @json)
|
||||
end
|
||||
|
||||
case @json['type']
|
||||
when 'Collection', 'CollectionPage'
|
||||
process_items @json['items']
|
||||
|
|
|
@ -67,8 +67,53 @@ class NotifyService < BaseService
|
|||
message? && @notification.target_status.direct_visibility?
|
||||
end
|
||||
|
||||
# Returns true if the sender has been mentionned by the recipient up the thread
|
||||
def response_to_recipient?
|
||||
@notification.target_status.in_reply_to_account_id == @recipient.id && @notification.target_status.thread&.direct_visibility?
|
||||
return false if @notification.target_status.in_reply_to_id.nil?
|
||||
|
||||
# Using an SQL CTE to avoid unneeded back-and-forth with SQL server in case of long threads
|
||||
!Status.count_by_sql([<<-SQL.squish, id: @notification.target_status.in_reply_to_id, recipient_id: @recipient.id, sender_id: @notification.from_account.id]).zero?
|
||||
WITH RECURSIVE ancestors(id, in_reply_to_id, replying_to_sender, path) AS (
|
||||
SELECT
|
||||
s.id,
|
||||
s.in_reply_to_id,
|
||||
(CASE
|
||||
WHEN s.account_id = :recipient_id THEN
|
||||
EXISTS (
|
||||
SELECT *
|
||||
FROM mentions m
|
||||
WHERE m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id
|
||||
)
|
||||
ELSE
|
||||
FALSE
|
||||
END),
|
||||
ARRAY[s.id]
|
||||
FROM statuses s
|
||||
WHERE s.id = :id
|
||||
UNION ALL
|
||||
SELECT
|
||||
s.id,
|
||||
s.in_reply_to_id,
|
||||
(CASE
|
||||
WHEN s.account_id = :recipient_id THEN
|
||||
EXISTS (
|
||||
SELECT *
|
||||
FROM mentions m
|
||||
WHERE m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id
|
||||
)
|
||||
ELSE
|
||||
FALSE
|
||||
END),
|
||||
st.path || s.id
|
||||
FROM ancestors st
|
||||
JOIN statuses s ON s.id = st.in_reply_to_id
|
||||
WHERE st.replying_to_sender IS FALSE AND NOT s.id = ANY(path)
|
||||
)
|
||||
SELECT COUNT(*)
|
||||
FROM ancestors st
|
||||
JOIN statuses s ON s.id = st.id
|
||||
WHERE st.replying_to_sender IS TRUE AND s.visibility = 3
|
||||
SQL
|
||||
end
|
||||
|
||||
def from_staff?
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class StatusLengthValidator < ActiveModel::Validator
|
||||
MAX_CHARS = 7777
|
||||
MAX_CHARS = 500
|
||||
URL_PLACEHOLDER_CHARS = 23
|
||||
URL_PLACEHOLDER = "\1#{'x' * URL_PLACEHOLDER_CHARS}"
|
||||
|
||||
|
|
|
@ -1,82 +1,82 @@
|
|||
.page-show
|
||||
- content_for :page_title do
|
||||
= site_hostname
|
||||
- content_for :page_title do
|
||||
= site_hostname
|
||||
|
||||
- content_for :header_tags do
|
||||
%link{ rel: 'canonical', href: about_url }/
|
||||
= render partial: 'shared/og'
|
||||
- content_for :header_tags do
|
||||
%link{ rel: 'canonical', href: about_url }/
|
||||
= render partial: 'shared/og'
|
||||
|
||||
.landing
|
||||
.landing
|
||||
|
||||
%span.brand__tagline=t 'about.tagline'
|
||||
.landing__brand
|
||||
= link_to root_url, class: 'brand' do
|
||||
= svg_logo_full
|
||||
%span.brand__tagline=t 'about.tagline'
|
||||
.landing__brand
|
||||
= link_to root_url, class: 'brand' do
|
||||
= svg_logo_full
|
||||
%span.brand__tagline=t 'about.tagline'
|
||||
|
||||
.landing__grid
|
||||
.landing__grid__column.landing__grid__column-registration
|
||||
.box-widget
|
||||
= render 'registration'
|
||||
|
||||
.directory
|
||||
- if Setting.profile_directory
|
||||
.directory__tag
|
||||
= optional_link_to Setting.profile_directory, explore_path do
|
||||
%h4
|
||||
= fa_icon 'address-book fw'
|
||||
= t('about.discover_users')
|
||||
%small= t('about.browse_directory')
|
||||
|
||||
.avatar-stack
|
||||
- @instance_presenter.sample_accounts.each do |account|
|
||||
= image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, alt: '', class: 'account__avatar'
|
||||
|
||||
- if Setting.timeline_preview
|
||||
.directory__tag
|
||||
= optional_link_to Setting.timeline_preview, public_timeline_path do
|
||||
%h4
|
||||
= fa_icon 'globe fw fa-3x'
|
||||
= t('about.see_whats_happening')
|
||||
%small= t('about.browse_public_posts')
|
||||
.landing__grid
|
||||
.landing__grid__column.landing__grid__column-registration
|
||||
.box-widget
|
||||
= render 'registration'
|
||||
|
||||
.directory
|
||||
- if Setting.profile_directory
|
||||
.directory__tag
|
||||
= link_to 'https://joinmastodon.org/apps', target: '_blank', rel: 'noopener noreferrer' do
|
||||
= optional_link_to Setting.profile_directory, explore_path do
|
||||
%h4
|
||||
= fa_icon 'tablet fw'
|
||||
= t('about.get_apps')
|
||||
%small= t('about.apps_platforms')
|
||||
= fa_icon 'address-book fw'
|
||||
= t('about.discover_users')
|
||||
%small= t('about.browse_directory')
|
||||
|
||||
.landing__grid__column.landing__grid__column-login
|
||||
.box-widget
|
||||
= render 'login'
|
||||
.avatar-stack
|
||||
- @instance_presenter.sample_accounts.each do |account|
|
||||
= image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, alt: '', class: 'account__avatar'
|
||||
|
||||
.hero-widget
|
||||
.hero-widget__img
|
||||
= image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title
|
||||
- if Setting.timeline_preview
|
||||
.directory__tag
|
||||
= optional_link_to Setting.timeline_preview, public_timeline_path do
|
||||
%h4
|
||||
= fa_icon 'globe fw fa-3x'
|
||||
= t('about.see_whats_happening')
|
||||
%small= t('about.browse_public_posts')
|
||||
|
||||
.hero-widget__text
|
||||
%p
|
||||
= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html')
|
||||
= link_to about_more_path do
|
||||
= t('about.learn_more')
|
||||
= fa_icon 'angle-double-right'
|
||||
.directory__tag
|
||||
= link_to 'https://joinmastodon.org/apps', target: '_blank', rel: 'noopener noreferrer' do
|
||||
%h4
|
||||
= fa_icon 'tablet fw'
|
||||
= t('about.get_apps')
|
||||
%small= t('about.apps_platforms')
|
||||
|
||||
.hero-widget__footer
|
||||
.hero-widget__footer__column
|
||||
%h4= t 'about.administered_by'
|
||||
.landing__grid__column.landing__grid__column-login
|
||||
.box-widget
|
||||
= render 'login'
|
||||
|
||||
= account_link_to @instance_presenter.contact_account
|
||||
.hero-widget
|
||||
.hero-widget__img
|
||||
= image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title
|
||||
|
||||
.hero-widget__footer__column
|
||||
%h4= t 'about.server_stats'
|
||||
.hero-widget__text
|
||||
%p
|
||||
= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html')
|
||||
= link_to about_more_path do
|
||||
= t('about.learn_more')
|
||||
= fa_icon 'angle-double-right'
|
||||
|
||||
.hero-widget__counters__wrapper
|
||||
.hero-widget__counter
|
||||
%strong= friendly_number_to_human @instance_presenter.user_count
|
||||
%span= t 'about.user_count_after', count: @instance_presenter.user_count
|
||||
.hero-widget__counter
|
||||
%strong= friendly_number_to_human @instance_presenter.active_user_count
|
||||
%span
|
||||
= t 'about.active_count_after'
|
||||
%abbr{ title: t('about.active_footnote') } *
|
||||
.hero-widget__footer
|
||||
.hero-widget__footer__column
|
||||
%h4= t 'about.administered_by'
|
||||
|
||||
= account_link_to @instance_presenter.contact_account
|
||||
|
||||
.hero-widget__footer__column
|
||||
%h4= t 'about.server_stats'
|
||||
|
||||
.hero-widget__counters__wrapper
|
||||
.hero-widget__counter
|
||||
%strong= friendly_number_to_human @instance_presenter.user_count
|
||||
%span= t 'about.user_count_after', count: @instance_presenter.user_count
|
||||
.hero-widget__counter
|
||||
%strong= friendly_number_to_human @instance_presenter.active_user_count
|
||||
%span
|
||||
= t 'about.active_count_after'
|
||||
%abbr{ title: t('about.active_footnote') } *
|
||||
|
|
|
@ -1,14 +1,6 @@
|
|||
- content_for :header_tags do
|
||||
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||
|
||||
- content_for :page_title do
|
||||
= t('admin.dashboard.title')
|
||||
|
||||
- content_for :heading_actions do
|
||||
= l(@time_period.first)
|
||||
= ' - '
|
||||
= l(@time_period.last)
|
||||
|
||||
- unless @system_checks.empty?
|
||||
.flash-message-stack
|
||||
- @system_checks.each do |message|
|
||||
|
@ -17,52 +9,131 @@
|
|||
- if message.action
|
||||
= link_to t("admin.system_checks.#{message.key}.action"), message.action
|
||||
|
||||
.dashboard
|
||||
.dashboard__item
|
||||
= react_admin_component :counter, measure: 'new_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.new_users'), href: admin_accounts_path
|
||||
.dashboard__counters
|
||||
%div
|
||||
= link_to admin_accounts_url(local: 1, recent: 1) do
|
||||
.dashboard__counters__num{ title: number_with_delimiter(@users_count, strip_insignificant_zeros: true) }
|
||||
= friendly_number_to_human @users_count
|
||||
.dashboard__counters__label= t 'admin.dashboard.total_users'
|
||||
%div
|
||||
%div
|
||||
.dashboard__counters__num{ title: number_with_delimiter(@registrations_week, strip_insignificant_zeros: true) }
|
||||
= friendly_number_to_human @registrations_week
|
||||
.dashboard__counters__label= t 'admin.dashboard.week_users_new'
|
||||
%div
|
||||
%div
|
||||
.dashboard__counters__num{ title: number_with_delimiter(@logins_week, strip_insignificant_zeros: true) }
|
||||
= friendly_number_to_human @logins_week
|
||||
.dashboard__counters__label= t 'admin.dashboard.week_users_active'
|
||||
%div
|
||||
= link_to admin_pending_accounts_path do
|
||||
.dashboard__counters__num{ title: number_with_delimiter(@pending_users_count, strip_insignificant_zeros: true) }
|
||||
= friendly_number_to_human @pending_users_count
|
||||
.dashboard__counters__label= t 'admin.dashboard.pending_users'
|
||||
%div
|
||||
= link_to admin_reports_url do
|
||||
.dashboard__counters__num{ title: number_with_delimiter(@reports_count, strip_insignificant_zeros: true) }
|
||||
= friendly_number_to_human @reports_count
|
||||
.dashboard__counters__label= t 'admin.dashboard.open_reports'
|
||||
%div
|
||||
= link_to admin_tags_path(pending_review: '1') do
|
||||
.dashboard__counters__num{ title: number_with_delimiter(@pending_tags_count, strip_insignificant_zeros: true) }
|
||||
= friendly_number_to_human @pending_tags_count
|
||||
.dashboard__counters__label= t 'admin.dashboard.pending_tags'
|
||||
%div
|
||||
%div
|
||||
.dashboard__counters__num{ title: number_with_delimiter(@interactions_week, strip_insignificant_zeros: true) }
|
||||
= friendly_number_to_human @interactions_week
|
||||
.dashboard__counters__label= t 'admin.dashboard.week_interactions'
|
||||
%div
|
||||
= link_to sidekiq_url do
|
||||
.dashboard__counters__num{ title: number_with_delimiter(@queue_backlog, strip_insignificant_zeros: true) }
|
||||
= friendly_number_to_human @queue_backlog
|
||||
.dashboard__counters__label= t 'admin.dashboard.backlog'
|
||||
|
||||
.dashboard__item
|
||||
= react_admin_component :counter, measure: 'active_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.active_users'), href: admin_accounts_path
|
||||
.dashboard__widgets
|
||||
.dashboard__widgets__users
|
||||
%div
|
||||
%h4= t 'admin.dashboard.recent_users'
|
||||
%ul
|
||||
- @recent_users.each do |user|
|
||||
%li= admin_account_link_to(user.account)
|
||||
|
||||
.dashboard__item
|
||||
= react_admin_component :counter, measure: 'interactions', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.interactions')
|
||||
.dashboard__widgets__features
|
||||
%div
|
||||
%h4= t 'admin.dashboard.features'
|
||||
%ul
|
||||
%li
|
||||
= feature_hint(link_to(t('admin.dashboard.feature_registrations'), edit_admin_settings_path), @registrations_enabled)
|
||||
%li
|
||||
= feature_hint(link_to(t('admin.dashboard.feature_invites'), edit_admin_settings_path), @invites_enabled)
|
||||
%li
|
||||
= feature_hint(link_to(t('admin.dashboard.feature_deletions'), edit_admin_settings_path), @deletions_enabled)
|
||||
%li
|
||||
= feature_hint(link_to(t('admin.dashboard.feature_profile_directory'), edit_admin_settings_path), @profile_directory)
|
||||
%li
|
||||
= feature_hint(link_to(t('admin.dashboard.feature_timeline_preview'), edit_admin_settings_path), @timeline_preview)
|
||||
%li
|
||||
= feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled)
|
||||
%li
|
||||
= feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled)
|
||||
|
||||
.dashboard__item
|
||||
= react_admin_component :counter, measure: 'opened_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.opened_reports'), href: admin_reports_path
|
||||
.dashboard__widgets__versions
|
||||
%div
|
||||
%h4= t 'admin.dashboard.software'
|
||||
%ul
|
||||
%li
|
||||
Mastodon
|
||||
%span.pull-right= @version
|
||||
%li
|
||||
Ruby
|
||||
%span.pull-right= "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
|
||||
%li
|
||||
PostgreSQL
|
||||
%span.pull-right= @database_version
|
||||
%li
|
||||
Redis
|
||||
%span.pull-right= @redis_version
|
||||
|
||||
.dashboard__item
|
||||
= react_admin_component :counter, measure: 'resolved_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.resolved_reports'), href: admin_reports_path(resolved: '1')
|
||||
.dashboard__widgets__space
|
||||
%div
|
||||
%h4= t 'admin.dashboard.space'
|
||||
%ul
|
||||
%li
|
||||
PostgreSQL
|
||||
%span.pull-right= number_to_human_size @database_size
|
||||
%li
|
||||
Redis
|
||||
%span.pull-right= number_to_human_size @redis_size
|
||||
|
||||
.dashboard__item
|
||||
= link_to admin_reports_path, class: 'dashboard__quick-access' do
|
||||
%span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count)
|
||||
= fa_icon 'chevron-right fw'
|
||||
.dashboard__widgets__config
|
||||
%div
|
||||
%h4= t 'admin.dashboard.config'
|
||||
%ul
|
||||
%li
|
||||
= feature_hint(t('admin.dashboard.search'), @search_enabled)
|
||||
%li
|
||||
= feature_hint(t('admin.dashboard.single_user_mode'), @single_user_mode)
|
||||
%li
|
||||
= feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch)
|
||||
%li
|
||||
= feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled)
|
||||
%li
|
||||
= feature_hint('LDAP', @ldap_enabled)
|
||||
%li
|
||||
= feature_hint('CAS', @cas_enabled)
|
||||
%li
|
||||
= feature_hint('SAML', @saml_enabled)
|
||||
%li
|
||||
= feature_hint('PAM', @pam_enabled)
|
||||
%li
|
||||
= feature_hint(t('admin.dashboard.hidden_service'), @hidden_service)
|
||||
|
||||
= link_to admin_pending_accounts_path, class: 'dashboard__quick-access' do
|
||||
%span= t('admin.dashboard.pending_users_html', count: @pending_users_count)
|
||||
= fa_icon 'chevron-right fw'
|
||||
|
||||
= link_to admin_tags_path(pending_review: '1'), class: 'dashboard__quick-access' do
|
||||
%span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
|
||||
= fa_icon 'chevron-right fw'
|
||||
|
||||
.dashboard__item
|
||||
= react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources')
|
||||
|
||||
.dashboard__item
|
||||
= react_admin_component :dimension, dimension: 'languages', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_languages')
|
||||
|
||||
.dashboard__item
|
||||
= react_admin_component :dimension, dimension: 'servers', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_servers')
|
||||
|
||||
.dashboard__item.dashboard__item--span-double-column
|
||||
= react_admin_component :retention, start_at: @time_period.last - 6.months, end_at: @time_period.last, frequency: 'month'
|
||||
|
||||
.dashboard__item.dashboard__item--span-double-row
|
||||
= react_admin_component :trends, limit: 7
|
||||
|
||||
.dashboard__item
|
||||
= react_admin_component :dimension, dimension: 'software_versions', start_at: @time_period.first, end_at: @time_period.last, limit: 4, label: t('admin.dashboard.software')
|
||||
|
||||
.dashboard__item
|
||||
= react_admin_component :dimension, dimension: 'space_usage', start_at: @time_period.first, end_at: @time_period.last, limit: 3, label: t('admin.dashboard.space')
|
||||
.dashboard__widgets__trends
|
||||
%div
|
||||
%h4= t 'admin.dashboard.trends'
|
||||
%ul
|
||||
- @trending_hashtags.each do |tag|
|
||||
%li
|
||||
= link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id)
|
||||
%span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i)
|
||||
|
|
|
@ -92,7 +92,7 @@
|
|||
%hr.spacer
|
||||
|
||||
.speech-bubble
|
||||
.speech-bubble__bubble= simple_format(@report.comment.presence || t('admin.reports.comment.none'))
|
||||
.speech-bubble__bubble= simple_format(h(@report.comment.presence || t('admin.reports.comment.none')))
|
||||
.speech-bubble__owner
|
||||
- if @report.account.local?
|
||||
= admin_account_link_to @report.account
|
||||
|
|
|
@ -8,7 +8,7 @@ image:
|
|||
# built from the most recent commit
|
||||
#
|
||||
# tag: latest
|
||||
tag: v3.3.0
|
||||
tag: v3.4.6
|
||||
# use `Always` when using `latest` tag
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
|
|
|
@ -60,46 +60,6 @@
|
|||
"confidence": "High",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "SQL Injection",
|
||||
"warning_code": 0,
|
||||
"fingerprint": "6e4051854bb62e2ddbc671f82d6c2328892e1134b8b28105ecba9b0122540714",
|
||||
"check_name": "SQL",
|
||||
"message": "Possible SQL injection",
|
||||
"file": "app/models/account.rb",
|
||||
"line": 479,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||
"code": "find_by_sql([\" WITH first_degree AS (\\n SELECT target_account_id\\n FROM follows\\n WHERE account_id = ?\\n UNION ALL\\n SELECT ?\\n )\\n SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)\\n WHERE accounts.id IN (SELECT * FROM first_degree)\\n AND #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, account.id, limit, offset])",
|
||||
"render_path": null,
|
||||
"location": {
|
||||
"type": "method",
|
||||
"class": "Account",
|
||||
"method": "advanced_search_for"
|
||||
},
|
||||
"user_input": "textsearch",
|
||||
"confidence": "Medium",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "SQL Injection",
|
||||
"warning_code": 0,
|
||||
"fingerprint": "6f075c1484908e3ec9bed21ab7cf3c7866be8da3881485d1c82e13093aefcbd7",
|
||||
"check_name": "SQL",
|
||||
"message": "Possible SQL injection",
|
||||
"file": "app/models/status.rb",
|
||||
"line": 105,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||
"code": "result.joins(\"LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")",
|
||||
"render_path": null,
|
||||
"location": {
|
||||
"type": "method",
|
||||
"class": "Status",
|
||||
"method": null
|
||||
},
|
||||
"user_input": "id",
|
||||
"confidence": "Weak",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "Mass Assignment",
|
||||
"warning_code": 105,
|
||||
|
@ -140,26 +100,6 @@
|
|||
"confidence": "High",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "SQL Injection",
|
||||
"warning_code": 0,
|
||||
"fingerprint": "9251d682c4e2840e1b2fea91e7d758efe2097ecb7f6255c065e3750d25eb178c",
|
||||
"check_name": "SQL",
|
||||
"message": "Possible SQL injection",
|
||||
"file": "app/models/account.rb",
|
||||
"line": 448,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||
"code": "find_by_sql([\" SELECT\\n accounts.*,\\n ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, limit, offset])",
|
||||
"render_path": null,
|
||||
"location": {
|
||||
"type": "method",
|
||||
"class": "Account",
|
||||
"method": "search_for"
|
||||
},
|
||||
"user_input": "textsearch",
|
||||
"confidence": "Medium",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "SQL Injection",
|
||||
"warning_code": 0,
|
||||
|
@ -220,26 +160,6 @@
|
|||
"confidence": "High",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "SQL Injection",
|
||||
"warning_code": 0,
|
||||
"fingerprint": "e21d8fee7a5805761679877ca35ed1029c64c45ef3b4012a30262623e1ba8bb9",
|
||||
"check_name": "SQL",
|
||||
"message": "Possible SQL injection",
|
||||
"file": "app/models/account.rb",
|
||||
"line": 495,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||
"code": "find_by_sql([\" SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, limit, offset])",
|
||||
"render_path": null,
|
||||
"location": {
|
||||
"type": "method",
|
||||
"class": "Account",
|
||||
"method": "advanced_search_for"
|
||||
},
|
||||
"user_input": "textsearch",
|
||||
"confidence": "Medium",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "Mass Assignment",
|
||||
"warning_code": 105,
|
||||
|
|
|
@ -118,7 +118,7 @@ Rails.application.configure do
|
|||
'Server' => 'Mastodon',
|
||||
'X-Frame-Options' => 'DENY',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'X-XSS-Protection' => '1; mode=block',
|
||||
'X-XSS-Protection' => '0',
|
||||
'Permissions-Policy' => 'interest-cohort=()',
|
||||
}
|
||||
|
||||
|
|
|
@ -24,9 +24,10 @@ module Twitter::TwitterText
|
|||
)
|
||||
\)
|
||||
/iox
|
||||
UCHARS = '\u{A0}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFEF}\u{10000}-\u{1FFFD}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}\u{40000}-\u{4FFFD}\u{50000}-\u{5FFFD}\u{60000}-\u{6FFFD}\u{70000}-\u{7FFFD}\u{80000}-\u{8FFFD}\u{90000}-\u{9FFFD}\u{A0000}-\u{AFFFD}\u{B0000}-\u{BFFFD}\u{C0000}-\u{CFFFD}\u{D0000}-\u{DFFFD}\u{E1000}-\u{EFFFD}\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}'
|
||||
REGEXEN[:valid_url_query_chars] = /[a-z0-9!?\*'\(\);:&=\+\$\/%#\[\]\-_\.,~|@#{UCHARS}]/iou
|
||||
REGEXEN[:valid_url_query_ending_chars] = /[a-z0-9_&=#\/\-#{UCHARS}]/iou
|
||||
REGEXEN[:valid_iri_ucschar] = /[\u{A0}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFEF}\u{10000}-\u{1FFFD}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}\u{40000}-\u{4FFFD}\u{50000}-\u{5FFFD}\u{60000}-\u{6FFFD}\u{70000}-\u{7FFFD}\u{80000}-\u{8FFFD}\u{90000}-\u{9FFFD}\u{A0000}-\u{AFFFD}\u{B0000}-\u{BFFFD}\u{C0000}-\u{CFFFD}\u{D0000}-\u{DFFFD}\u{E1000}-\u{EFFFD}]/iou
|
||||
REGEXEN[:valid_iri_iprivate] = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/iou
|
||||
REGEXEN[:valid_url_query_chars] = /(?:#{REGEXEN[:valid_iri_ucschar]})|(?:#{REGEXEN[:valid_iri_iprivate]})|[a-z0-9!?\*'\(\);:&=\+\$\/%#\[\]\-_\.,~|@]/iou
|
||||
REGEXEN[:valid_url_query_ending_chars] = /(?:#{REGEXEN[:valid_iri_ucschar]})|(?:#{REGEXEN[:valid_iri_iprivate]})|[a-z0-9_&=#\/\-]/iou
|
||||
REGEXEN[:valid_url_path] = /(?:
|
||||
(?:
|
||||
#{REGEXEN[:valid_general_url_path_chars]}*
|
||||
|
@ -56,21 +57,23 @@ module Twitter::TwitterText
|
|||
#{REGEXEN[:validate_url_pct_encoded]}|
|
||||
#{REGEXEN[:validate_url_sub_delims]}
|
||||
)/iox
|
||||
REGEXEN[:xmpp_uri] = %r{
|
||||
(xmpp:) # Protocol
|
||||
(//#{REGEXEN[:validate_nodeid]}+@#{REGEXEN[:valid_domain]}/)? # Authority (optional)
|
||||
(#{REGEXEN[:validate_nodeid]}+@)? # Username in path (optional)
|
||||
(#{REGEXEN[:valid_domain]}) # Domain in path
|
||||
(/#{REGEXEN[:validate_resid]}+)? # Resource in path (optional)
|
||||
(\?#{REGEXEN[:valid_url_query_chars]}*#{REGEXEN[:valid_url_query_ending_chars]})? # Query String
|
||||
}iox
|
||||
REGEXEN[:magnet_uri] = %r{
|
||||
(magnet:) # Protocol
|
||||
(\?#{REGEXEN[:valid_url_query_chars]}*#{REGEXEN[:valid_url_query_ending_chars]}) # Query String
|
||||
}iox
|
||||
REGEXEN[:valid_extended_uri] = %r{
|
||||
( # $1 total match
|
||||
(#{REGEXEN[:valid_url_preceding_chars]}) # $2 Preceding character
|
||||
( # $3 URL
|
||||
(
|
||||
(xmpp:) # Protocol
|
||||
(//#{REGEXEN[:validate_nodeid]}+@#{REGEXEN[:valid_domain]}/)? # Authority (optional)
|
||||
(#{REGEXEN[:validate_nodeid]}+@)? # Username in path (optional)
|
||||
(#{REGEXEN[:valid_domain]}) # Domain in path
|
||||
(/#{REGEXEN[:validate_resid]}+)? # Resource in path (optional)
|
||||
(\?#{REGEXEN[:valid_url_query_chars]}*#{REGEXEN[:valid_url_query_ending_chars]})? # Query String
|
||||
) | (
|
||||
(magnet:) # Protocol
|
||||
(\?#{REGEXEN[:valid_url_query_chars]}*#{REGEXEN[:valid_url_query_ending_chars]}) # Query String
|
||||
)
|
||||
(#{REGEXEN[:xmpp_uri]}) | (#{REGEXEN[:magnet_uri]})
|
||||
)
|
||||
)
|
||||
}iox
|
||||
|
|
|
@ -1,6 +1,46 @@
|
|||
class RemoveFauxRemoteAccountDuplicates < ActiveRecord::Migration[5.2]
|
||||
disable_ddl_transaction!
|
||||
|
||||
class StreamEntry < ApplicationRecord
|
||||
# Dummy class, to make migration possible across version changes
|
||||
belongs_to :account, inverse_of: :stream_entries
|
||||
end
|
||||
|
||||
class Status < ApplicationRecord
|
||||
# Dummy class, to make migration possible across version changes
|
||||
belongs_to :account, inverse_of: :statuses
|
||||
has_many :favourites, inverse_of: :status, dependent: :destroy
|
||||
has_many :mentions, dependent: :destroy, inverse_of: :status
|
||||
end
|
||||
|
||||
class Favourite < ApplicationRecord
|
||||
# Dummy class, to make migration possible across version changes
|
||||
belongs_to :account, inverse_of: :favourites
|
||||
belongs_to :status, inverse_of: :favourites
|
||||
end
|
||||
|
||||
class Mention < ApplicationRecord
|
||||
# Dummy class, to make migration possible across version changes
|
||||
belongs_to :account, inverse_of: :mentions
|
||||
belongs_to :status
|
||||
end
|
||||
|
||||
class Notification < ApplicationRecord
|
||||
# Dummy class, to make migration possible across version changes
|
||||
belongs_to :account, optional: true
|
||||
belongs_to :from_account, class_name: 'Account', optional: true
|
||||
belongs_to :activity, polymorphic: true, optional: true
|
||||
end
|
||||
|
||||
class Account < ApplicationRecord
|
||||
# Dummy class, to make migration possible across version changes
|
||||
has_many :stream_entries, inverse_of: :account, dependent: :destroy
|
||||
has_many :statuses, inverse_of: :account, dependent: :destroy
|
||||
has_many :favourites, inverse_of: :account, dependent: :destroy
|
||||
has_many :mentions, inverse_of: :account, dependent: :destroy
|
||||
has_many :notifications, inverse_of: :account, dependent: :destroy
|
||||
end
|
||||
|
||||
def up
|
||||
local_domain = Rails.configuration.x.local_domain
|
||||
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
class AddInstanceActor < ActiveRecord::Migration[5.2]
|
||||
class Account < ApplicationRecord
|
||||
# Dummy class, to make migration possible across version changes
|
||||
validates :username, uniqueness: { scope: :domain, case_sensitive: false }
|
||||
end
|
||||
|
||||
def up
|
||||
Account.create!(id: -99, actor_type: 'Application', locked: true, username: Rails.configuration.x.local_domain)
|
||||
end
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
class UpdatePtLocales < ActiveRecord::Migration[5.2]
|
||||
class User < ApplicationRecord
|
||||
# Dummy class, to make migration possible across version changes
|
||||
end
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
|
|
52
db/schema.rb
52
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
||||
ActiveRecord::Schema.define(version: 2021_06_30_000137) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -114,23 +114,6 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
t.index ["account_id"], name: "index_account_stats_on_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "account_statuses_cleanup_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.integer "min_status_age", default: 1209600, null: false
|
||||
t.boolean "keep_direct", default: true, null: false
|
||||
t.boolean "keep_pinned", default: true, null: false
|
||||
t.boolean "keep_polls", default: false, null: false
|
||||
t.boolean "keep_media", default: false, null: false
|
||||
t.boolean "keep_self_fav", default: true, null: false
|
||||
t.boolean "keep_self_bookmark", default: true, null: false
|
||||
t.integer "min_favs"
|
||||
t.integer "min_reblogs"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.index ["account_id"], name: "index_account_statuses_cleanup_policies_on_account_id"
|
||||
end
|
||||
|
||||
create_table "account_warning_presets", force: :cascade do |t|
|
||||
t.text "text", default: "", null: false
|
||||
t.datetime "created_at", null: false
|
||||
|
@ -253,13 +236,6 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
t.bigint "status_ids", array: true
|
||||
end
|
||||
|
||||
create_table "articles", force: :cascade do |t|
|
||||
t.string "title"
|
||||
t.text "text"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "backups", force: :cascade do |t|
|
||||
t.bigint "user_id"
|
||||
t.string "dump_file_name"
|
||||
|
@ -518,18 +494,6 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
t.index ["account_id"], name: "index_lists_on_account_id"
|
||||
end
|
||||
|
||||
create_table "login_activities", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.string "authentication_method"
|
||||
t.string "provider"
|
||||
t.boolean "success"
|
||||
t.string "failure_reason"
|
||||
t.inet "ip"
|
||||
t.string "user_agent"
|
||||
t.datetime "created_at"
|
||||
t.index ["user_id"], name: "index_login_activities_on_user_id"
|
||||
end
|
||||
|
||||
create_table "markers", force: :cascade do |t|
|
||||
t.bigint "user_id"
|
||||
t.string "timeline", default: "", null: false
|
||||
|
@ -904,17 +868,6 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
t.index ["domain"], name: "index_unavailable_domains_on_domain", unique: true
|
||||
end
|
||||
|
||||
create_table "user_groups", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.datetime "createdAt"
|
||||
t.string "visibility"
|
||||
t.integer "account_id"
|
||||
t.integer "creator_id"
|
||||
t.integer "statuses_count"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "user_invite_requests", force: :cascade do |t|
|
||||
t.bigint "user_id"
|
||||
t.text "text"
|
||||
|
@ -962,7 +915,6 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
t.datetime "sign_in_token_sent_at"
|
||||
t.string "webauthn_id"
|
||||
t.inet "sign_up_ip"
|
||||
t.boolean "skip_sign_in_token"
|
||||
t.index ["account_id"], name: "index_users_on_account_id"
|
||||
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
|
||||
t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"
|
||||
|
@ -1019,7 +971,6 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade
|
||||
add_foreign_key "account_pins", "accounts", on_delete: :cascade
|
||||
add_foreign_key "account_stats", "accounts", on_delete: :cascade
|
||||
add_foreign_key "account_statuses_cleanup_policies", "accounts", on_delete: :cascade
|
||||
add_foreign_key "account_warnings", "accounts", column: "target_account_id", on_delete: :cascade
|
||||
add_foreign_key "account_warnings", "accounts", on_delete: :nullify
|
||||
add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify
|
||||
|
@ -1059,7 +1010,6 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
add_foreign_key "list_accounts", "follows", on_delete: :cascade
|
||||
add_foreign_key "list_accounts", "lists", on_delete: :cascade
|
||||
add_foreign_key "lists", "accounts", on_delete: :cascade
|
||||
add_foreign_key "login_activities", "users", on_delete: :cascade
|
||||
add_foreign_key "markers", "users", on_delete: :cascade
|
||||
add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify
|
||||
add_foreign_key "media_attachments", "scheduled_statuses", on_delete: :nullify
|
||||
|
|
|
@ -20,7 +20,7 @@ FROM (
|
|||
HAVING count(follows.id) >= 5
|
||||
UNION ALL
|
||||
SELECT accounts.id AS account_id,
|
||||
sum(reblogs_count + favourites_count) / (1.0 + sum(reblogs_count + favourites_count)) AS rank,
|
||||
sum(status_stats.reblogs_count + status_stats.favourites_count) / (1.0 + sum(status_stats.reblogs_count + status_stats.favourites_count)) AS rank,
|
||||
'most_interactions' AS reason
|
||||
FROM status_stats
|
||||
INNER JOIN statuses ON statuses.id = status_stats.status_id
|
||||
|
@ -32,7 +32,7 @@ FROM (
|
|||
AND accounts.locked = 'f'
|
||||
AND accounts.discoverable = 't'
|
||||
GROUP BY accounts.id
|
||||
HAVING sum(reblogs_count + favourites_count) >= 5
|
||||
HAVING sum(status_stats.reblogs_count + status_stats.favourites_count) >= 5
|
||||
) t0
|
||||
GROUP BY account_id
|
||||
ORDER BY rank DESC
|
||||
|
|
|
@ -18,7 +18,7 @@ FROM (
|
|||
HAVING count(follows.id) >= 5
|
||||
UNION ALL
|
||||
SELECT account_summaries.account_id AS account_id,
|
||||
sum(reblogs_count + favourites_count) / (1.0 + sum(reblogs_count + favourites_count)) AS rank,
|
||||
sum(status_stats.reblogs_count + status_stats.favourites_count) / (1.0 + sum(status_stats.reblogs_count + status_stats.favourites_count)) AS rank,
|
||||
'most_interactions' AS reason
|
||||
FROM status_stats
|
||||
INNER JOIN statuses ON statuses.id = status_stats.status_id
|
||||
|
@ -28,7 +28,7 @@ FROM (
|
|||
AND account_summaries.sensitive = 'f'
|
||||
AND follow_recommendation_suppressions.id IS NULL
|
||||
GROUP BY account_summaries.account_id
|
||||
HAVING sum(reblogs_count + favourites_count) >= 5
|
||||
HAVING sum(status_stats.reblogs_count + status_stats.favourites_count) >= 5
|
||||
) t0
|
||||
GROUP BY account_id
|
||||
ORDER BY rank DESC
|
||||
|
|
|
@ -43,7 +43,7 @@ services:
|
|||
|
||||
web:
|
||||
build: .
|
||||
image: tootsuite/mastodon
|
||||
image: tootsuite/mastodon:v3.4.6
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
||||
|
@ -63,7 +63,7 @@ services:
|
|||
|
||||
streaming:
|
||||
build: .
|
||||
image: tootsuite/mastodon
|
||||
image: tootsuite/mastodon:v3.4.6
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: node ./streaming
|
||||
|
@ -80,7 +80,7 @@ services:
|
|||
|
||||
sidekiq:
|
||||
build: .
|
||||
image: tootsuite/mastodon
|
||||
image: tootsuite/mastodon:v3.4.6
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec sidekiq
|
||||
|
|
|
@ -295,7 +295,7 @@ module Mastodon
|
|||
table = Arel::Table.new(table_name)
|
||||
|
||||
total = estimate_rows_in_table(table_name).to_i
|
||||
if total == 0
|
||||
if total < 1
|
||||
count_arel = table.project(Arel.star.count.as('count'))
|
||||
count_arel = yield table, count_arel if block_given?
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ module Mastodon
|
|||
end
|
||||
|
||||
def patch
|
||||
3
|
||||
6
|
||||
end
|
||||
|
||||
def flags
|
||||
|
|
|
@ -17,9 +17,9 @@ module Paperclip
|
|||
|
||||
def cache_current_values
|
||||
@original_filename = filename_from_content_disposition.presence || filename_from_path.presence || 'data'
|
||||
@size = @target.response.content_length
|
||||
@tempfile = copy_to_tempfile(@target)
|
||||
@content_type = ContentTypeDetector.new(@tempfile.path).detect
|
||||
@size = File.size(@tempfile)
|
||||
end
|
||||
|
||||
def copy_to_tempfile(source)
|
||||
|
|
|
@ -441,7 +441,7 @@ namespace :mastodon do
|
|||
|
||||
namespace :webpush do
|
||||
desc 'Generate VAPID key'
|
||||
task generate_vapid_key: :environment do
|
||||
task :generate_vapid_key do
|
||||
vapid_key = Webpush.generate_key
|
||||
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
|
||||
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
|
||||
|
|
|
@ -0,0 +1,181 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
namespace :tests do
|
||||
namespace :migrations do
|
||||
desc 'Populate the database with test data for 2.0.0'
|
||||
task populate_v2: :environment do
|
||||
admin_key = OpenSSL::PKey::RSA.new(2048)
|
||||
user_key = OpenSSL::PKey::RSA.new(2048)
|
||||
remote_key = OpenSSL::PKey::RSA.new(2048)
|
||||
remote_key2 = OpenSSL::PKey::RSA.new(2048)
|
||||
remote_key3 = OpenSSL::PKey::RSA.new(2048)
|
||||
admin_private_key = ActiveRecord::Base.connection.quote(admin_key.to_pem)
|
||||
admin_public_key = ActiveRecord::Base.connection.quote(admin_key.public_key.to_pem)
|
||||
user_private_key = ActiveRecord::Base.connection.quote(user_key.to_pem)
|
||||
user_public_key = ActiveRecord::Base.connection.quote(user_key.public_key.to_pem)
|
||||
remote_public_key = ActiveRecord::Base.connection.quote(remote_key.public_key.to_pem)
|
||||
remote_public_key2 = ActiveRecord::Base.connection.quote(remote_key2.public_key.to_pem)
|
||||
remote_public_key_ap = ActiveRecord::Base.connection.quote(remote_key3.public_key.to_pem)
|
||||
local_domain = ActiveRecord::Base.connection.quote(Rails.configuration.x.local_domain)
|
||||
|
||||
ActiveRecord::Base.connection.execute(<<~SQL)
|
||||
-- accounts
|
||||
|
||||
INSERT INTO "accounts"
|
||||
(id, username, domain, private_key, public_key, created_at, updated_at)
|
||||
VALUES
|
||||
(1, 'admin', NULL, #{admin_private_key}, #{admin_public_key}, now(), now()),
|
||||
(2, 'user', NULL, #{user_private_key}, #{user_public_key}, now(), now());
|
||||
|
||||
INSERT INTO "accounts"
|
||||
(id, username, domain, private_key, public_key, created_at, updated_at, remote_url, salmon_url)
|
||||
VALUES
|
||||
(3, 'remote', 'remote.com', NULL, #{remote_public_key}, now(), now(),
|
||||
'https://remote.com/@remote', 'https://remote.com/salmon/1'),
|
||||
(4, 'Remote', 'remote.com', NULL, #{remote_public_key}, now(), now(),
|
||||
'https://remote.com/@Remote', 'https://remote.com/salmon/1'),
|
||||
(5, 'REMOTE', 'Remote.com', NULL, #{remote_public_key2}, now(), now(),
|
||||
'https://remote.com/stale/@REMOTE', 'https://remote.com/stale/salmon/1');
|
||||
|
||||
INSERT INTO "accounts"
|
||||
(id, username, domain, private_key, public_key, created_at, updated_at, protocol, inbox_url, outbox_url, followers_url)
|
||||
VALUES
|
||||
(6, 'bob', 'activitypub.com', NULL, #{remote_public_key_ap}, now(), now(),
|
||||
1, 'https://activitypub.com/users/bob/inbox', 'https://activitypub.com/users/bob/outbox', 'https://activitypub.com/users/bob/followers');
|
||||
|
||||
INSERT INTO "accounts"
|
||||
(id, username, domain, private_key, public_key, created_at, updated_at)
|
||||
VALUES
|
||||
(7, 'user', #{local_domain}, #{user_private_key}, #{user_public_key}, now(), now()),
|
||||
(8, 'pt_user', NULL, #{user_private_key}, #{user_public_key}, now(), now());
|
||||
|
||||
-- users
|
||||
|
||||
INSERT INTO "users"
|
||||
(id, account_id, email, created_at, updated_at, admin)
|
||||
VALUES
|
||||
(1, 1, 'admin@localhost', now(), now(), true),
|
||||
(2, 2, 'user@localhost', now(), now(), false);
|
||||
|
||||
INSERT INTO "users"
|
||||
(id, account_id, email, created_at, updated_at, admin, locale)
|
||||
VALUES
|
||||
(3, 7, 'ptuser@localhost', now(), now(), false, 'pt');
|
||||
|
||||
-- statuses
|
||||
|
||||
INSERT INTO "statuses"
|
||||
(id, account_id, text, created_at, updated_at)
|
||||
VALUES
|
||||
(1, 1, 'test', now(), now()),
|
||||
(2, 1, '@remote@remote.com hello', now(), now()),
|
||||
(3, 1, '@Remote@remote.com hello', now(), now()),
|
||||
(4, 1, '@REMOTE@remote.com hello', now(), now());
|
||||
|
||||
INSERT INTO "statuses"
|
||||
(id, account_id, text, created_at, updated_at, uri, local)
|
||||
VALUES
|
||||
(5, 1, 'activitypub status', now(), now(), 'https://localhost/users/admin/statuses/4', true);
|
||||
|
||||
INSERT INTO "statuses"
|
||||
(id, account_id, text, created_at, updated_at)
|
||||
VALUES
|
||||
(6, 3, 'test', now(), now());
|
||||
|
||||
INSERT INTO "statuses"
|
||||
(id, account_id, text, created_at, updated_at, in_reply_to_id, in_reply_to_account_id)
|
||||
VALUES
|
||||
(7, 4, '@admin hello', now(), now(), 3, 1);
|
||||
|
||||
INSERT INTO "statuses"
|
||||
(id, account_id, text, created_at, updated_at)
|
||||
VALUES
|
||||
(8, 5, 'test', now(), now());
|
||||
|
||||
INSERT INTO "statuses"
|
||||
(id, account_id, reblog_of_id, created_at, updated_at)
|
||||
VALUES
|
||||
(9, 1, 2, now(), now());
|
||||
|
||||
-- mentions (from previous statuses)
|
||||
|
||||
INSERT INTO "mentions"
|
||||
(status_id, account_id, created_at, updated_at)
|
||||
VALUES
|
||||
(2, 3, now(), now()),
|
||||
(3, 4, now(), now()),
|
||||
(4, 5, now(), now());
|
||||
|
||||
-- stream entries
|
||||
|
||||
INSERT INTO "stream_entries"
|
||||
(activity_id, account_id, activity_type, created_at, updated_at)
|
||||
VALUES
|
||||
(1, 1, 'status', now(), now()),
|
||||
(2, 1, 'status', now(), now()),
|
||||
(3, 1, 'status', now(), now()),
|
||||
(4, 1, 'status', now(), now()),
|
||||
(5, 1, 'status', now(), now()),
|
||||
(6, 3, 'status', now(), now()),
|
||||
(7, 4, 'status', now(), now()),
|
||||
(8, 5, 'status', now(), now()),
|
||||
(9, 1, 'status', now(), now());
|
||||
|
||||
|
||||
-- custom emoji
|
||||
|
||||
INSERT INTO "custom_emojis"
|
||||
(shortcode, created_at, updated_at)
|
||||
VALUES
|
||||
('test', now(), now()),
|
||||
('Test', now(), now()),
|
||||
('blobcat', now(), now());
|
||||
|
||||
INSERT INTO "custom_emojis"
|
||||
(shortcode, domain, uri, created_at, updated_at)
|
||||
VALUES
|
||||
('blobcat', 'remote.org', 'https://remote.org/emoji/blobcat', now(), now()),
|
||||
('blobcat', 'Remote.org', 'https://remote.org/emoji/blobcat', now(), now()),
|
||||
('Blobcat', 'remote.org', 'https://remote.org/emoji/Blobcat', now(), now());
|
||||
|
||||
-- favourites
|
||||
|
||||
INSERT INTO "favourites"
|
||||
(account_id, status_id, created_at, updated_at)
|
||||
VALUES
|
||||
(1, 1, now(), now()),
|
||||
(1, 7, now(), now()),
|
||||
(4, 1, now(), now()),
|
||||
(3, 1, now(), now()),
|
||||
(5, 1, now(), now());
|
||||
|
||||
-- pinned statuses
|
||||
|
||||
INSERT INTO "status_pins"
|
||||
(account_id, status_id, created_at, updated_at)
|
||||
VALUES
|
||||
(1, 1, now(), now()),
|
||||
(3, 6, now(), now()),
|
||||
(4, 7, now(), now());
|
||||
|
||||
-- follows
|
||||
|
||||
INSERT INTO "follows"
|
||||
(account_id, target_account_id, created_at, updated_at)
|
||||
VALUES
|
||||
(1, 5, now(), now()),
|
||||
(6, 2, now(), now()),
|
||||
(5, 2, now(), now()),
|
||||
(6, 1, now(), now());
|
||||
|
||||
-- follow requests
|
||||
|
||||
INSERT INTO "follow_requests"
|
||||
(account_id, target_account_id, created_at, updated_at)
|
||||
VALUES
|
||||
(2, 5, now(), now()),
|
||||
(5, 1, now(), now());
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
|
@ -89,4 +89,86 @@ describe JsonLdHelper do
|
|||
expect(fetch_resource_without_id_validation('https://host.test/')).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'compaction and forwarding' do
|
||||
let(:json) do
|
||||
{
|
||||
'@context' => [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{
|
||||
'obsolete' => 'http://ostatus.org#',
|
||||
'convo' => 'obsolete:conversation',
|
||||
'new' => 'https://obscure-unreleased-test.joinmastodon.org/#',
|
||||
},
|
||||
],
|
||||
'type' => 'Create',
|
||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'object' => {
|
||||
'id' => 'https://example.com/status',
|
||||
'type' => 'Note',
|
||||
'inReplyTo' => nil,
|
||||
'convo' => 'https://example.com/conversation',
|
||||
'tag' => [
|
||||
{
|
||||
'type' => 'Mention',
|
||||
'href' => ['foo'],
|
||||
}
|
||||
],
|
||||
},
|
||||
'signature' => {
|
||||
'type' => 'RsaSignature2017',
|
||||
'created' => '2022-02-02T12:00:00Z',
|
||||
'creator' => 'https://example.com/actor#main-key',
|
||||
'signatureValue' => 'some-sig',
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
describe '#compact' do
|
||||
it 'properly compacts JSON-LD with alternative context definitions' do
|
||||
expect(compact(json).dig('object', 'conversation')).to eq 'https://example.com/conversation'
|
||||
end
|
||||
|
||||
it 'compacts single-item arrays' do
|
||||
expect(compact(json).dig('object', 'tag', 'href')).to eq 'foo'
|
||||
end
|
||||
|
||||
it 'compacts the activistreams Public collection' do
|
||||
expect(compact(json)['to']).to eq 'as:Public'
|
||||
end
|
||||
|
||||
it 'properly copies signature' do
|
||||
expect(compact(json)['signature']).to eq json['signature']
|
||||
end
|
||||
end
|
||||
|
||||
describe 'patch_for_forwarding!' do
|
||||
it 'properly patches incompatibilities' do
|
||||
json['object'].delete('convo')
|
||||
compacted = compact(json)
|
||||
patch_for_forwarding!(json, compacted)
|
||||
expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public']
|
||||
expect(compacted.dig('object', 'tag', 0, 'href')).to eq ['foo']
|
||||
expect(safe_for_forwarding?(json, compacted)).to eq true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'safe_for_forwarding?' do
|
||||
it 'deems a safe compacting as such' do
|
||||
json['object'].delete('convo')
|
||||
compacted = compact(json)
|
||||
deemed_compatible = patch_for_forwarding!(json, compacted)
|
||||
expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public']
|
||||
expect(safe_for_forwarding?(json, compacted)).to eq true
|
||||
end
|
||||
|
||||
it 'deems an unsafe compacting as such' do
|
||||
compacted = compact(json)
|
||||
deemed_compatible = patch_for_forwarding!(json, compacted)
|
||||
expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public']
|
||||
expect(safe_for_forwarding?(json, compacted)).to eq false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,6 +5,37 @@ RSpec.describe Account, type: :model do
|
|||
let(:bob) { Fabricate(:account, username: 'bob') }
|
||||
subject { Fabricate(:account) }
|
||||
|
||||
describe '#suspend!' do
|
||||
it 'marks the account as suspended' do
|
||||
subject.suspend!
|
||||
expect(subject.suspended?).to be true
|
||||
end
|
||||
|
||||
it 'creates a deletion request' do
|
||||
subject.suspend!
|
||||
expect(AccountDeletionRequest.where(account: subject).exists?).to be true
|
||||
end
|
||||
|
||||
context 'when the account is of a local user' do
|
||||
let!(:subject) { Fabricate(:account, user: Fabricate(:user, email: 'foo+bar@domain.org')) }
|
||||
|
||||
it 'creates a canonical domain block' do
|
||||
subject.suspend!
|
||||
expect(CanonicalEmailBlock.block?(subject.user_email)).to be true
|
||||
end
|
||||
|
||||
context 'when a canonical domain block already exists for that email' do
|
||||
before do
|
||||
Fabricate(:canonical_email_block, email: subject.user_email)
|
||||
end
|
||||
|
||||
it 'does not raise an error' do
|
||||
expect { subject.suspend! }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#follow!' do
|
||||
it 'creates a follow' do
|
||||
follow = subject.follow!(bob)
|
||||
|
|
|
@ -267,6 +267,87 @@ RSpec.describe Status, type: :model do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.tagged_with' do
|
||||
let(:tag1) { Fabricate(:tag) }
|
||||
let(:tag2) { Fabricate(:tag) }
|
||||
let(:tag3) { Fabricate(:tag) }
|
||||
let!(:status1) { Fabricate(:status, tags: [tag1]) }
|
||||
let!(:status2) { Fabricate(:status, tags: [tag2]) }
|
||||
let!(:status3) { Fabricate(:status, tags: [tag3]) }
|
||||
let!(:status4) { Fabricate(:status, tags: []) }
|
||||
let!(:status5) { Fabricate(:status, tags: [tag1, tag2, tag3]) }
|
||||
|
||||
context 'when given one tag' do
|
||||
it 'returns the expected statuses' do
|
||||
expect(Status.tagged_with([tag1.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status5.id]
|
||||
expect(Status.tagged_with([tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status5.id]
|
||||
expect(Status.tagged_with([tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status3.id, status5.id]
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given multiple tags' do
|
||||
it 'returns the expected statuses' do
|
||||
expect(Status.tagged_with([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status2.id, status5.id]
|
||||
expect(Status.tagged_with([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status3.id, status5.id]
|
||||
expect(Status.tagged_with([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status3.id, status5.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.tagged_with_all' do
|
||||
let(:tag1) { Fabricate(:tag) }
|
||||
let(:tag2) { Fabricate(:tag) }
|
||||
let(:tag3) { Fabricate(:tag) }
|
||||
let!(:status1) { Fabricate(:status, tags: [tag1]) }
|
||||
let!(:status2) { Fabricate(:status, tags: [tag2]) }
|
||||
let!(:status3) { Fabricate(:status, tags: [tag3]) }
|
||||
let!(:status4) { Fabricate(:status, tags: []) }
|
||||
let!(:status5) { Fabricate(:status, tags: [tag1, tag2]) }
|
||||
|
||||
context 'when given one tag' do
|
||||
it 'returns the expected statuses' do
|
||||
expect(Status.tagged_with_all([tag1.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status5.id]
|
||||
expect(Status.tagged_with_all([tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status5.id]
|
||||
expect(Status.tagged_with_all([tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status3.id]
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given multiple tags' do
|
||||
it 'returns the expected statuses' do
|
||||
expect(Status.tagged_with_all([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status5.id]
|
||||
expect(Status.tagged_with_all([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq []
|
||||
expect(Status.tagged_with_all([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.tagged_with_none' do
|
||||
let(:tag1) { Fabricate(:tag) }
|
||||
let(:tag2) { Fabricate(:tag) }
|
||||
let(:tag3) { Fabricate(:tag) }
|
||||
let!(:status1) { Fabricate(:status, tags: [tag1]) }
|
||||
let!(:status2) { Fabricate(:status, tags: [tag2]) }
|
||||
let!(:status3) { Fabricate(:status, tags: [tag3]) }
|
||||
let!(:status4) { Fabricate(:status, tags: []) }
|
||||
let!(:status5) { Fabricate(:status, tags: [tag1, tag2, tag3]) }
|
||||
|
||||
context 'when given one tag' do
|
||||
it 'returns the expected statuses' do
|
||||
expect(Status.tagged_with_none([tag1.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status3.id, status4.id]
|
||||
expect(Status.tagged_with_none([tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status3.id, status4.id]
|
||||
expect(Status.tagged_with_none([tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status2.id, status4.id]
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given multiple tags' do
|
||||
it 'returns the expected statuses' do
|
||||
expect(Status.tagged_with_none([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status3.id, status4.id]
|
||||
expect(Status.tagged_with_none([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status4.id]
|
||||
expect(Status.tagged_with_none([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status4.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.permitted_for' do
|
||||
subject { described_class.permitted_for(target_account, account).pluck(:visibility) }
|
||||
|
||||
|
|
|
@ -64,8 +64,9 @@ RSpec.describe NotifyService, type: :service do
|
|||
is_expected.to_not change(Notification, :count)
|
||||
end
|
||||
|
||||
context 'if the message chain initiated by recipient, but is not direct message' do
|
||||
context 'if the message chain is initiated by recipient, but is not direct message' do
|
||||
let(:reply_to) { Fabricate(:status, account: recipient) }
|
||||
let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) }
|
||||
let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) }
|
||||
|
||||
it 'does not notify' do
|
||||
|
@ -73,8 +74,20 @@ RSpec.describe NotifyService, type: :service do
|
|||
end
|
||||
end
|
||||
|
||||
context 'if the message chain initiated by recipient and is direct message' do
|
||||
context 'if the message chain is initiated by recipient, but without a mention to the sender, even if the sender sends multiple messages in a row' do
|
||||
let(:reply_to) { Fabricate(:status, account: recipient) }
|
||||
let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) }
|
||||
let(:dummy_reply) { Fabricate(:status, account: sender, visibility: :direct, thread: reply_to) }
|
||||
let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: dummy_reply)) }
|
||||
|
||||
it 'does not notify' do
|
||||
is_expected.to_not change(Notification, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'if the message chain is initiated by the recipient with a mention to the sender' do
|
||||
let(:reply_to) { Fabricate(:status, account: recipient, visibility: :direct) }
|
||||
let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) }
|
||||
let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) }
|
||||
|
||||
it 'does notify' do
|
||||
|
|
Loading…
Reference in New Issue