Jay Logan 2022-02-03 22:04:33 +00:00
commit 5e26274583
830 changed files with 26202 additions and 11352 deletions

View File

@ -1,255 +1,189 @@
version: 2
version: 2.1
aliases:
- &defaults
orbs:
ruby: circleci/ruby@1.2.0
node: circleci/node@4.7.0
executors:
default:
parameters:
ruby-version:
type: string
docker:
- image: circleci/ruby:2.7-buster-node
environment: &ruby_environment
- image: cimg/ruby:<< parameters.ruby-version >>
environment:
BUNDLE_JOBS: 3
BUNDLE_RETRY: 3
BUNDLE_APP_CONFIG: ./.bundle/
BUNDLE_PATH: ./vendor/bundle/
CONTINUOUS_INTEGRATION: true
DB_HOST: localhost
DB_USER: root
RAILS_ENV: test
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/
RAILS_ENV: test
- image: cimg/postgres:14.0
environment:
POSTGRES_USER: root
POSTGRES_HOST_AUTH_METHOD: trust
- image: circleci/redis:6-alpine
- &attach_workspace
attach_workspace:
at: ~/projects/
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.31'
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
- &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
jobs:
build:
docker:
- image: cimg/ruby:3.0-node
environment:
RAILS_ENV: test
steps:
- checkout
- *attach_workspace
- restore_cache:
keys:
- v2-node-dependencies-{{ checksum "yarn.lock" }}
- v2-node-dependencies-
- install-system-dependencies
- install-ruby-dependencies:
ruby-version: '3.0'
- node/install-packages:
cache-version: v1
pkg-manager: yarn
- 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
name: Precompile assets
- persist_to_workspace:
root: ~/projects/
paths:
- ./mastodon/public/assets
- ./mastodon/public/packs-test/
- 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:
<<: *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
executor:
name: default
ruby-version: '3.0'
steps:
- *attach_workspace
- *install_system_dependencies
- checkout
- install-system-dependencies
- install-ruby-dependencies:
ruby-version: '3.0'
- wait-db
- run:
name: Create database
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:
name: Run migrations
command: ./bin/rails db:migrate
name: Run all 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:14-buster
test-two-step-migrations:
executor:
name: default
ruby-version: '3.0'
steps:
- *attach_workspace
- checkout
- install-system-dependencies
- install-ruby-dependencies:
ruby-version: '3.0'
- wait-db
- run:
name: Run jest
command: yarn test:jest
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 all pre-deployment migrations
evironment:
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
- run:
command: ./bin/rails db:migrate
name: Run all post-deployment remaining migrations
workflows:
version: 2
build-and-test:
jobs:
- install
- install-ruby2.7:
- build
- test:
matrix:
parameters:
ruby-version:
- '2.7'
- '3.0'
name: test-ruby<< matrix.ruby-version >>
requires:
- install
- install-ruby2.6:
requires:
- install
- install-ruby2.7
- install-ruby3.0:
requires:
- install
- install-ruby2.7
- build:
requires:
- install-ruby2.7
- build
- test-migrations:
requires:
- install-ruby2.7
- test-ruby2.7:
requires:
- install-ruby2.7
- build
- test-ruby2.6:
- test-two-step-migrations:
requires:
- install-ruby2.6
- build
- test-ruby3.0:
- node/run:
cache-version: v1
name: test-webui
pkg-manager: yarn
requires:
- install-ruby3.0
- build
- test-webui:
requires:
- install
version: lts
yarn-run: test:jest

View File

@ -35,4 +35,7 @@ plugins:
enabled: true
exclude_patterns:
- spec/
- vendor/asset
- vendor/asset/
- app/javascript/mastodon/locales/**/*.json
- config/locales/**/*.yml

View File

@ -15,6 +15,7 @@ vendor/bundle
*.swp
*~
postgres
postgres14
redis
elasticsearch
chart

View File

@ -13,7 +13,7 @@ DB_PORT=5432
# DATABASE_URL=postgresql://$DATA_DB_USER:$DATA_DB_PASS@$DATA_DB_HOST/gonano
# Optional ElasticSearch configuration
# Optional Elasticsearch configuration
ES_ENABLED=true
ES_HOST=$DATA_ELASTIC_HOST
ES_PORT=9200
@ -202,10 +202,6 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io
# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default)
# PAM_CONTROLLED_SERVICE=rpam
# Global OAuth settings (optional) :
# If you have only one strategy, you may want to enable this
# OAUTH_REDIRECT_AT_SIGN_IN=true
# Optional CAS authentication (cf. omniauth-cas) :
# CAS_ENABLED=true
# CAS_URL=https://sso.myserver.com/

View File

@ -4,6 +4,12 @@
# not demonstrate all available configuration options. Please look at
# https://docs.joinmastodon.org/admin/config/ for the full documentation.
# Note that this file accepts slightly different syntax depending on whether
# you are using `docker-compose` or not. In particular, if you use
# `docker-compose`, the value of each declared variable will be taken verbatim,
# including surrounding quotes.
# See: https://github.com/mastodon/mastodon/issues/16895
# Federation
# ----------
# This identifies your server and cannot be changed safely later
@ -50,11 +56,14 @@ DB_PASS=
DB_PORT=5432
# ElasticSearch (optional)
# Elasticsearch (optional)
# ------------------------
#ES_ENABLED=true
#ES_HOST=localhost
#ES_PORT=9200
# Authentication for ES (optional)
#ES_USER=elastic
#ES_PASS=password
# Secrets
@ -276,3 +285,7 @@ MAX_POLL_OPTION_CHARS=100
# Units are in bytes
MAX_EMOJI_SIZE=51200
MAX_REMOTE_EMOJI_SIZE=204800
# Optional hCaptcha support
# HCAPTCHA_SECRET_KEY=
# HCAPTCHA_SITE_KEY=

View File

@ -8,6 +8,17 @@ body:
Make sure that you are submitting a new bug that was not previously reported or already fixed.
Please use a concise and distinct title for the issue.
- type: textarea
attributes:
label: Steps to reproduce the problem
description: What were you trying to do?
value: |
1.
2.
3.
...
validations:
required: true
- type: input
attributes:
label: Expected behaviour
@ -20,17 +31,6 @@ body:
description: What happened?
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce the problem
description: What were you trying to do?
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
attributes:
label: Specifications

View File

@ -0,0 +1,35 @@
name: Build container image
on:
workflow_dispatch:
push:
branches:
- "main"
tags:
- "*"
jobs:
build-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-buildx-action@v1
- uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v3
id: meta
with:
images: ghcr.io/${{ github.repository_owner }}/mastodon
flavor: |
latest=true
tags: |
type=edge,branch=main
type=semver,pattern={{ raw }}
- uses: docker/build-push-action@v2
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/mastodon:latest
cache-to: type=inline

View File

@ -22,7 +22,7 @@ jobs:
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '2.7'
ruby-version: '3.0'
bundler-cache: true
- name: Check locale file normalization
run: bundle exec i18n-tasks check-normalized

1
.gitignore vendored
View File

@ -40,6 +40,7 @@
# Ignore postgres + redis + elasticsearch volume optionally created by docker-compose
/postgres
/postgres14
/redis
/elasticsearch

View File

@ -1 +1 @@
2.7.4
3.0.3

View File

@ -3,6 +3,95 @@ 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
- Fix login being broken due to inaccurately applied backport fix in 3.4.2 ([Gargron](https://github.com/mastodon/mastodon/commit/5c47a18c8df3231aa25c6d1f140a71a7fac9cbf9))
## [3.4.2] - 2021-11-06
### Added
- Add `configuration` attribute to `GET /api/v1/instance` ([Gargron](https://github.com/mastodon/mastodon/pull/16485))
### Fixed
- Fix handling of back button with modal windows in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16499))
- Fix pop-in player when author has long username in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16468))
- Fix crash when a status with a playing video gets deleted in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16384))
- Fix crash with Microsoft Translate in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16525))
- Fix PWA not being usable from alternate domains ([HolgerHuo](https://github.com/mastodon/mastodon/pull/16714))
- Fix locale-specific number rounding errors ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16469))
- Fix scheduling a status decreasing status count ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16791))
- Fix user's canonical email address being blocked when user deletes own account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16503))
- Fix not being able to suspend users that already have their canonical e-mail blocked ([Gargron](https://github.com/mastodon/mastodon/pull/16455))
- Fix anonymous access to outbox not being cached by the reverse proxy ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16458))
- Fix followers synchronization mechanism not working when URI has empty path ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16744))
- Fix serialization of counts in REST API when user hides their network ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16418))
- Fix inefficiencies in auto-linking code ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16506))
- Fix `tootctl self-destruct` not sending delete activities for recently-suspended accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16688))
- Fix suspicious sign-in e-mail text being out of date ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16690))
- Fix some frameworks being unnecessarily loaded ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16725))
- Fix canonical e-mail blocks missing foreign key constraints ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16448))
- Fix inconsistent order on account's statuses page in admin UI ([tribela](https://github.com/mastodon/mastodon/pull/16937))
- Fix media from blocked domains being redownloaded by `tootctl media refresh` ([tribela](https://github.com/mastodon/mastodon/pull/16914))
- Fix `mastodon:setup` generated env-file syntax ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16896))
- Fix link previews being incorrectly generated from earlier links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16885))
- Fix wrong `to`/`cc` values for remote groups in ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16700))
- Fix mentions with non-ascii TLDs not being processed ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16689))
- Fix authentication failures halfway through a sign-in attempt ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16607), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16792))
- Fix suspended accounts statuses being merged back into timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16628))
- Fix crash when encountering invalid account fields ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16598))
- Fix invalid blurhash handling for remote activities ([noellabo](https://github.com/mastodon/mastodon/pull/16583))
- Fix newlines being added to accout notes when an account moves ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16415), [noellabo](https://github.com/mastodon/mastodon/pull/16576))
- Fix crash when creating an announcement with links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16941))
- Fix logging out from one browser logging out all other sessions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16943))
### Security
- Fix user notes not having a length limit ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16942))
- Fix revoking a specific session not working ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16943))
## [3.4.1] - 2021-06-03
### Added

View File

@ -2,9 +2,10 @@ FROM ubuntu:20.04 as build-dep
# Use bash for the shell
SHELL ["/bin/bash", "-c"]
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
# Install Node v14 (LTS)
ENV NODE_VER="14.17.6"
# Install Node v16 (LTS)
ENV NODE_VER="16.13.2"
RUN ARCH= && \
dpkgArch="$(dpkg --print-architecture)" && \
case "${dpkgArch##*-}" in \
@ -18,15 +19,15 @@ RUN ARCH= && \
esac && \
echo "Etc/UTC" > /etc/localtime && \
apt-get update && \
apt-get install -y --no-install-recommends ca-certificates wget python && \
apt-get install -y --no-install-recommends ca-certificates wget python apt-utils && \
cd ~ && \
wget -q https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-$ARCH.tar.gz && \
tar xf node-v$NODE_VER-linux-$ARCH.tar.gz && \
rm node-v$NODE_VER-linux-$ARCH.tar.gz && \
mv node-v$NODE_VER-linux-$ARCH /opt/node
# Install Ruby
ENV RUBY_VER="2.7.4"
# Install Ruby 3.0
ENV RUBY_VER="3.0.3"
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential \
bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \
@ -45,7 +46,8 @@ RUN apt-get update && \
ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin"
RUN npm install -g yarn && \
RUN npm install -g npm@latest && \
npm install -g yarn && \
gem install bundler && \
apt-get update && \
apt-get install -y --no-install-recommends git libicu-dev libidn11-dev \
@ -54,8 +56,9 @@ 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 --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle config set silence_root_warning true && \
bundle install -j"$(nproc)" && \
yarn install --pure-lockfile
@ -81,11 +84,12 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*
# Install mastodon runtime deps
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
RUN apt-get update && \
apt-get -y --no-install-recommends install \
libssl1.1 libpq5 imagemagick ffmpeg libjemalloc2 \
libicu66 libprotobuf17 libidn11 libyaml-0-2 \
file ca-certificates tzdata libreadline8 gcc tini && \
file ca-certificates tzdata libreadline8 gcc tini apt-utils && \
ln -s /opt/mastodon /mastodon && \
gem install bundler && \
rm -rf /var/cache && \

30
FEDERATION.md 100644
View File

@ -0,0 +1,30 @@
## ActivityPub federation in Mastodon
Mastodon largely follows the ActivityPub server-to-server specification but it makes uses of some non-standard extensions, some of which are required for interacting with Mastodon at all.
Supported vocabulary: https://docs.joinmastodon.org/spec/activitypub/
### Required extensions
#### Webfinger
In Mastodon, users are identified by a `username` and `domain` pair (e.g., `Gargron@mastodon.social`).
This is used both for discovery and for unambiguously mentioning users across the fediverse. Furthermore, this is part of Mastodon's database design from its very beginnings.
As a result, Mastodon requires that each ActivityPub actor uniquely maps back to an `acct:` URI that can be resolved via WebFinger.
More information and examples are available at: https://docs.joinmastodon.org/spec/webfinger/
#### HTTP Signatures
In order to authenticate activities, Mastodon relies on HTTP Signatures, signing every `POST` and `GET` request to other ActivityPub implementations on behalf of the user authoring an activity (for `POST` requests) or an actor representing the Mastodon server itself (for most `GET` requests).
Mastodon requires all `POST` requests to be signed, and MAY require `GET` requests to be signed, depending on the configuration of the Mastodon server.
More information on HTTP Signatures, as well as examples, can be found here: https://docs.joinmastodon.org/spec/security/#http
### Optional extensions
- Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld
- Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/
- Followers collection synchronization: https://git.activitypub.dev/ActivityPubDev/Fediverse-Enhancement-Proposals/src/branch/main/feps/fep-8fcf.md

51
Gemfile
View File

@ -4,20 +4,21 @@ source 'https://rubygems.org'
ruby '>= 2.5.0', '< 3.1.0'
gem 'pkg-config', '~> 1.4'
gem 'rexml', '~> 3.2'
gem 'puma', '~> 5.4'
gem 'puma', '~> 5.5'
gem 'rails', '~> 6.1.4'
gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 1.1'
gem 'thor', '~> 1.2'
gem 'rack', '~> 2.2.3'
gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 1.2'
gem 'pg', '~> 1.3'
gem 'makara', '~> 0.5'
gem 'pghero', '~> 2.8'
gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.103', require: false
gem 'aws-sdk-s3', '~> 1.111', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'kt-paperclip', '~> 7.0'
@ -25,12 +26,12 @@ gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.9.1', require: false
gem 'bootsnap', '~> 1.10.2', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.7'
gem 'iso-639'
gem 'chewy', '~> 5.2'
gem 'cld3', '~> 3.4.2'
gem 'chewy', '~> 7.2'
gem 'cld3', '~> 3.4.4'
gem 'devise', '~> 4.8'
gem 'devise-two-factor', '~> 4.0'
@ -47,7 +48,7 @@ gem 'omniauth-rails_csrf_protection', '~> 0.1'
gem 'color_diff', '~> 0.1'
gem 'discard', '~> 1.2'
gem 'doorkeeper', '~> 5.5'
gem 'ed25519', '~> 1.2'
gem 'ed25519', '~> 1.3'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'hiredis', '~> 0.6'
@ -59,13 +60,12 @@ gem 'httplog', '~> 1.5.0'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar'
gem 'nokogiri', '~> 1.12'
gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar'
gem 'nokogiri', '~> 1.13'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.13'
gem 'ox', '~> 2.14'
gem 'parslet'
gem 'parallel', '~> 1.21'
gem 'posix-spawn'
gem 'pundit', '~> 2.1'
gem 'premailer-rails'
@ -73,19 +73,19 @@ gem 'rack-attack', '~> 6.5'
gem 'rack-cors', '~> 1.1', require: 'rack/cors'
gem 'rails-i18n', '~> 6.0'
gem 'rails-settings-cached', '~> 0.6'
gem 'redis', '~> 4.4', require: ['redis', 'redis/connection/hiredis']
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 2.1'
gem 'ruby-progressbar', '~> 1.11'
gem 'sanitize', '~> 6.0'
gem 'scenic', '~> 1.5'
gem 'sidekiq', '~> 6.2'
gem 'sidekiq', '~> 6.4'
gem 'sidekiq-scheduler', '~> 3.1'
gem 'sidekiq-unique-jobs', '~> 7.1'
gem 'sidekiq-bulk', '~>0.2.0'
gem 'simple-navigation', '~> 4.3'
gem 'simple_form', '~> 5.1'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.2.1'
gem 'strong_migrations', '~> 0.7'
gem 'tty-prompt', '~> 0.23', require: false
@ -96,13 +96,13 @@ gem 'webpush', '~> 0.3'
gem 'webauthn', '~> 3.0.0.alpha1'
gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.1'
gem 'rdf-normalize', '~> 0.4'
gem 'json-ld-preloaded', '~> 3.2'
gem 'rdf-normalize', '~> 0.5'
gem 'redcarpet', '~> 3.5'
group :development, :test do
gem 'fabrication', '~> 2.22'
gem 'fabrication', '~> 2.24'
gem 'fuubar', '~> 2.5'
gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-byebug', '~> 3.9'
@ -115,7 +115,7 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.35'
gem 'capybara', '~> 3.36'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.19'
gem 'microformats', '~> 4.2'
@ -123,8 +123,7 @@ group :test do
gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.21', require: false
gem 'webmock', '~> 3.14'
gem 'parallel_tests', '~> 3.7'
gem 'rspec_junit_formatter', '~> 0.4'
gem 'rspec_junit_formatter', '~> 0.5'
end
group :development do
@ -132,13 +131,13 @@ group :development do
gem 'annotate', '~> 3.1'
gem 'better_errors', '~> 2.9'
gem 'binding_of_caller', '~> 1.0'
gem 'bullet', '~> 6.1'
gem 'bullet', '~> 7.0'
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4'
gem 'letter_opener_web', '~> 2.0'
gem 'memory_profiler'
gem 'rubocop', '~> 1.21', require: false
gem 'rubocop-rails', '~> 2.12', require: false
gem 'brakeman', '~> 5.1', require: false
gem 'rubocop', '~> 1.25', require: false
gem 'rubocop-rails', '~> 2.13', require: false
gem 'brakeman', '~> 5.2', require: false
gem 'bundler-audit', '~> 0.9', require: false
gem 'capistrano', '~> 3.16'
@ -157,3 +156,5 @@ gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1'
gem 'hcaptcha', '~> 7.1'

View File

@ -1,66 +1,66 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
actioncable (6.1.4.4)
actionpack (= 6.1.4.4)
activesupport (= 6.1.4.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
actionmailbox (6.1.4.4)
actionpack (= 6.1.4.4)
activejob (= 6.1.4.4)
activerecord (= 6.1.4.4)
activestorage (= 6.1.4.4)
activesupport (= 6.1.4.4)
mail (>= 2.7.1)
actionmailer (6.1.4.1)
actionpack (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activesupport (= 6.1.4.1)
actionmailer (6.1.4.4)
actionpack (= 6.1.4.4)
actionview (= 6.1.4.4)
activejob (= 6.1.4.4)
activesupport (= 6.1.4.4)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4.1)
actionview (= 6.1.4.1)
activesupport (= 6.1.4.1)
actionpack (6.1.4.4)
actionview (= 6.1.4.4)
activesupport (= 6.1.4.4)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.1)
actionpack (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
actiontext (6.1.4.4)
actionpack (= 6.1.4.4)
activerecord (= 6.1.4.4)
activestorage (= 6.1.4.4)
activesupport (= 6.1.4.4)
nokogiri (>= 1.8.5)
actionview (6.1.4.1)
activesupport (= 6.1.4.1)
actionview (6.1.4.4)
activesupport (= 6.1.4.4)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_model_serializers (0.10.12)
actionpack (>= 4.1, < 6.2)
activemodel (>= 4.1, < 6.2)
active_model_serializers (0.10.13)
actionpack (>= 4.1, < 7.1)
activemodel (>= 4.1, < 7.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.8)
activejob (6.1.4.1)
activesupport (= 6.1.4.1)
activejob (6.1.4.4)
activesupport (= 6.1.4.4)
globalid (>= 0.3.6)
activemodel (6.1.4.1)
activesupport (= 6.1.4.1)
activerecord (6.1.4.1)
activemodel (= 6.1.4.1)
activesupport (= 6.1.4.1)
activestorage (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activesupport (= 6.1.4.1)
activemodel (6.1.4.4)
activesupport (= 6.1.4.4)
activerecord (6.1.4.4)
activemodel (= 6.1.4.4)
activesupport (= 6.1.4.4)
activestorage (6.1.4.4)
actionpack (= 6.1.4.4)
activejob (= 6.1.4.4)
activerecord (= 6.1.4.4)
activesupport (= 6.1.4.4)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4.1)
activesupport (6.1.4.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -79,17 +79,17 @@ GEM
encryptor (~> 3.0.0)
awrence (1.1.1)
aws-eventstream (1.2.0)
aws-partitions (1.503.0)
aws-sdk-core (3.121.0)
aws-partitions (1.549.0)
aws-sdk-core (3.125.5)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.48.0)
aws-sdk-core (~> 3, >= 3.120.0)
aws-sdk-kms (1.53.0)
aws-sdk-core (~> 3, >= 3.125.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.103.0)
aws-sdk-core (~> 3, >= 3.120.0)
aws-sdk-s3 (1.111.3)
aws-sdk-core (~> 3, >= 3.125.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.4.0)
@ -104,15 +104,15 @@ GEM
debug_inspector (>= 0.0.1)
blurhash (0.1.5)
ffi (~> 1.14)
bootsnap (1.9.1)
msgpack (~> 1.0)
brakeman (5.1.1)
bootsnap (1.10.2)
msgpack (~> 1.2)
brakeman (5.2.0)
browser (4.2.0)
brpoplpush-redis_script (0.1.2)
concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, <= 5.0)
builder (3.2.4)
bullet (6.1.5)
bullet (7.0.1)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.9.0.1)
@ -134,8 +134,9 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.35.3)
capybara (3.36.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
rack (>= 1.6.0)
@ -146,12 +147,12 @@ GEM
activesupport
cbor (0.5.9.6)
charlock_holmes (0.7.7)
chewy (5.2.0)
chewy (7.2.3)
activesupport (>= 5.2)
elasticsearch (>= 2.0.0)
elasticsearch (>= 7.12.0, < 7.14.0)
elasticsearch-dsl
chunky_png (1.4.0)
cld3 (3.4.2)
cld3 (3.4.4)
ffi (>= 1.1.0, < 1.16.0)
climate_control (0.2.0)
coderay (1.1.3)
@ -167,7 +168,7 @@ GEM
css_parser (1.7.1)
addressable
debug_inspector (1.0.0)
devise (4.8.0)
devise (4.8.1)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
@ -183,26 +184,26 @@ GEM
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.4.4)
discard (1.2.0)
activerecord (>= 4.2, < 7)
discard (1.2.1)
activerecord (>= 4.2, < 8)
docile (1.3.4)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.5.3)
doorkeeper (5.5.4)
railties (>= 5)
dotenv (2.7.6)
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
e2mmap (0.1.0)
ed25519 (1.2.4)
elasticsearch (7.10.1)
elasticsearch-api (= 7.10.1)
elasticsearch-transport (= 7.10.1)
elasticsearch-api (7.10.1)
ed25519 (1.3.0)
elasticsearch (7.13.3)
elasticsearch-api (= 7.13.3)
elasticsearch-transport (= 7.13.3)
elasticsearch-api (7.13.3)
multi_json
elasticsearch-dsl (0.1.9)
elasticsearch-transport (7.10.1)
elasticsearch-dsl (0.1.10)
elasticsearch-transport (7.13.3)
faraday (~> 1)
multi_json
encryptor (3.0.0)
@ -210,17 +211,31 @@ GEM
et-orbi (1.2.4)
tzinfo
excon (0.76.0)
fabrication (2.22.0)
fabrication (2.24.0)
faker (2.19.0)
i18n (>= 1.6, < 2)
faraday (1.3.0)
faraday (1.8.0)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0.1)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.1)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
multipart-post (>= 1.2, < 3)
ruby2_keywords
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
fast_blank (1.0.1)
fastimage (2.2.5)
ffi (1.15.4)
fastimage (2.2.6)
ffi (1.15.5)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
@ -243,7 +258,7 @@ GEM
fuubar (2.5.1)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
globalid (0.5.2)
globalid (1.0.0)
activesupport (>= 5.0)
hamlit (2.13.0)
temple (>= 0.8.2)
@ -254,15 +269,15 @@ GEM
activesupport (>= 4.0.1)
hamlit (>= 1.2.0)
railties (>= 4.0.1)
hamster (3.0.0)
concurrent-ruby (~> 1.0)
hashdiff (1.0.1)
hashie (4.1.0)
hcaptcha (7.1.0)
json
highline (2.0.3)
hiredis (0.6.3)
hkdf (0.3.0)
htmlentities (4.3.4)
http (5.0.2)
http (5.0.4)
addressable (~> 2.8)
http-cookie (~> 1.0)
http-form_data (~> 2.2)
@ -274,9 +289,9 @@ GEM
httplog (1.5.0)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.8.10)
i18n (1.8.11)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.34)
i18n-tasks (0.9.37)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
erubi
@ -286,37 +301,37 @@ GEM
rails-i18n
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
idn-ruby (0.1.2)
idn-ruby (0.1.4)
ipaddress (0.8.3)
iso-639 (0.3.5)
jmespath (1.4.0)
jmespath (1.5.0)
json (2.5.1)
json-canonicalization (0.2.1)
json-ld (3.1.10)
json-canonicalization (0.3.0)
json-ld (3.2.0)
htmlentities (~> 4.3)
json-canonicalization (~> 0.2)
json-canonicalization (~> 0.3)
link_header (~> 0.0, >= 0.0.8)
multi_json (~> 1.14)
rack (~> 2.0)
rdf (~> 3.1)
json-ld-preloaded (3.1.6)
json-ld (~> 3.1)
rdf (~> 3.1)
multi_json (~> 1.15)
rack (~> 2.2)
rdf (~> 3.2)
json-ld-preloaded (3.2.0)
json-ld (~> 3.2)
rdf (~> 3.2)
jsonapi-renderer (0.2.2)
jwt (2.2.2)
kaminari (1.2.1)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
kaminari-activerecord (= 1.2.1)
kaminari-core (= 1.2.1)
kaminari-actionview (1.2.1)
kaminari-actionview (= 1.2.2)
kaminari-activerecord (= 1.2.2)
kaminari-core (= 1.2.2)
kaminari-actionview (1.2.2)
actionview
kaminari-core (= 1.2.1)
kaminari-activerecord (1.2.1)
kaminari-core (= 1.2.2)
kaminari-activerecord (1.2.2)
activerecord
kaminari-core (= 1.2.1)
kaminari-core (1.2.1)
kt-paperclip (7.0.0)
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
kt-paperclip (7.0.1)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
marcel (~> 1.0.1)
@ -326,10 +341,11 @@ GEM
addressable (~> 2.7)
letter_opener (1.7.0)
launchy (~> 2.2)
letter_opener_web (1.4.0)
actionmailer (>= 3.2)
letter_opener (~> 1.0)
railties (>= 3.2)
letter_opener_web (2.0.0)
actionmailer (>= 5.2)
letter_opener (~> 1.7)
railties (>= 5.2)
rexml
link_header (0.0.8)
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
@ -339,7 +355,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.12.0)
loofah (2.13.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -349,18 +365,19 @@ GEM
marcel (1.0.1)
mario-redis-lock (1.2.1)
redis (>= 3.0.5)
matrix (0.4.2)
memory_profiler (1.0.0)
method_source (1.0.0)
microformats (4.3.1)
json (~> 2.2)
nokogiri (~> 1.10)
mime-types (3.3.1)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2020.0512)
mini_mime (1.1.1)
mini_portile2 (2.6.1)
minitest (5.14.4)
msgpack (1.4.2)
mime-types-data (3.2021.1115)
mini_mime (1.1.2)
mini_portile2 (2.7.1)
minitest (5.15.0)
msgpack (1.4.4)
multi_json (1.15.0)
multipart-post (2.1.1)
net-ldap (0.17.0)
@ -368,15 +385,15 @@ GEM
net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.1.0)
nio4r (2.5.8)
nokogiri (1.12.5)
mini_portile2 (~> 2.6.1)
nokogiri (1.13.1)
mini_portile2 (~> 2.7.0)
racc (~> 1.4)
nsa (0.2.8)
activesupport (>= 4.2, < 7)
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.13.7)
oj (3.13.11)
omniauth (1.9.1)
hashie (>= 3.4.6)
rack (>= 1.6.2, < 3)
@ -393,19 +410,17 @@ GEM
openssl (2.2.0)
openssl-signature_algorithm (0.4.0)
orm_adapter (0.5.0)
ox (2.14.5)
ox (2.14.6)
parallel (1.21.0)
parallel_tests (3.7.3)
parallel
parser (3.0.2.0)
parser (3.1.0.0)
ast (~> 2.4.1)
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.2.3)
pghero (2.8.1)
pg (1.3.0)
pghero (2.8.2)
activerecord (>= 5)
pkg-config (1.4.6)
pkg-config (1.4.7)
posix-spawn (0.3.15)
premailer (1.14.2)
addressable
@ -424,12 +439,12 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
puma (5.4.0)
puma (5.5.2)
nio4r (~> 2.0)
pundit (2.1.1)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.5.2)
racc (1.6.0)
rack (2.2.3)
rack-attack (6.5.0)
rack (>= 1.0, < 3)
@ -439,20 +454,20 @@ GEM
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (6.1.4.1)
actioncable (= 6.1.4.1)
actionmailbox (= 6.1.4.1)
actionmailer (= 6.1.4.1)
actionpack (= 6.1.4.1)
actiontext (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activemodel (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
rails (6.1.4.4)
actioncable (= 6.1.4.4)
actionmailbox (= 6.1.4.4)
actionmailer (= 6.1.4.4)
actionpack (= 6.1.4.4)
actiontext (= 6.1.4.4)
actionview (= 6.1.4.4)
activejob (= 6.1.4.4)
activemodel (= 6.1.4.4)
activerecord (= 6.1.4.4)
activestorage (= 6.1.4.4)
activesupport (= 6.1.4.4)
bundler (>= 1.15.0)
railties (= 6.1.4.1)
railties (= 6.1.4.4)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
@ -468,24 +483,23 @@ GEM
railties (>= 6.0.0, < 7)
rails-settings-cached (0.6.6)
rails (>= 4.2.0)
railties (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
railties (6.1.4.4)
actionpack (= 6.1.4.4)
activesupport (= 6.1.4.4)
method_source
rake (>= 0.13)
thor (~> 1.0)
rainbow (3.0.0)
rainbow (3.1.1)
rake (13.0.6)
rdf (3.1.15)
hamster (~> 3.0)
rdf (3.2.3)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.4.0)
rdf (~> 3.1)
rdf-normalize (0.5.0)
rdf (~> 3.2)
redcarpet (3.5.1)
redis (4.4.0)
redis (4.5.1)
redis-namespace (1.8.1)
redis (>= 3.0.4)
regexp_parser (2.1.1)
regexp_parser (2.2.0)
request_store (1.5.0)
rack (>= 1.4)
responders (3.0.1)
@ -517,21 +531,21 @@ GEM
rspec-sidekiq (3.1.0)
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.10.2)
rspec_junit_formatter (0.4.1)
rspec-support (3.10.3)
rspec_junit_formatter (0.5.1)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.21.0)
rubocop (1.25.0)
parallel (~> 1.10)
parser (>= 3.0.0.0)
parser (>= 3.1.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.9.1, < 2.0)
rubocop-ast (>= 1.15.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.11.0)
rubocop-ast (1.15.1)
parser (>= 3.0.1.1)
rubocop-rails (2.12.2)
rubocop-rails (2.13.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
@ -539,7 +553,7 @@ GEM
ruby-saml (1.13.0)
nokogiri (>= 1.10.5)
rexml
ruby2_keywords (0.0.4)
ruby2_keywords (0.0.5)
rufus-scheduler (3.7.0)
fugit (~> 1.1, >= 1.1.6)
safety_net_attestation (0.4.0)
@ -547,12 +561,12 @@ GEM
sanitize (6.0.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
scenic (1.5.4)
scenic (1.5.5)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securecompare (1.0.0)
semantic_range (3.0.0)
sidekiq (6.2.2)
sidekiq (6.4.0)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
@ -565,7 +579,7 @@ GEM
sidekiq (>= 3)
thwait
tilt (>= 1.4.0)
sidekiq-unique-jobs (7.1.7)
sidekiq-unique-jobs (7.1.12)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 5.0, < 8.0)
@ -584,9 +598,9 @@ GEM
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.2.2)
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sshkit (1.21.2)
net-scp (>= 1.1.2)
@ -594,14 +608,14 @@ GEM
stackprof (0.2.17)
statsd-ruby (1.5.0)
stoplight (2.2.1)
strong_migrations (0.7.8)
strong_migrations (0.7.9)
activerecord (>= 5)
temple (0.8.2)
terminal-table (3.0.0)
unicode-display_width (~> 1.1, >= 1.1.1)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0)
thor (1.1.0)
thor (1.2.1)
thwait (0.2.0)
e2mmap
tilt (2.0.10)
@ -623,12 +637,12 @@ GEM
unf (~> 0.1.0)
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.2)
tzinfo-data (1.2021.5)
tzinfo (>= 1.0.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (1.7.0)
unf_ext (0.0.8)
unicode-display_width (2.1.0)
uniform_notifier (1.14.2)
warden (1.2.9)
rack (>= 2.0.9)
@ -661,7 +675,7 @@ GEM
xorcist (1.1.2)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.4.2)
zeitwerk (2.5.3)
PLATFORMS
ruby
@ -671,23 +685,23 @@ DEPENDENCIES
active_record_query_trace (~> 1.8)
addressable (~> 2.8)
annotate (~> 3.1)
aws-sdk-s3 (~> 1.103)
aws-sdk-s3 (~> 1.111)
better_errors (~> 2.9)
binding_of_caller (~> 1.0)
blurhash (~> 0.1)
bootsnap (~> 1.9.1)
brakeman (~> 5.1)
bootsnap (~> 1.10.2)
brakeman (~> 5.2)
browser
bullet (~> 6.1)
bullet (~> 7.0)
bundler-audit (~> 0.9)
capistrano (~> 3.16)
capistrano-rails (~> 1.6)
capistrano-rbenv (~> 2.2)
capistrano-yarn (~> 2.0)
capybara (~> 3.35)
capybara (~> 3.36)
charlock_holmes (~> 0.7.7)
chewy (~> 5.2)
cld3 (~> 3.4.2)
chewy (~> 7.2)
cld3 (~> 3.4.4)
climate_control (~> 0.2)
color_diff (~> 0.1)
concurrent-ruby
@ -698,8 +712,8 @@ DEPENDENCIES
discard (~> 1.2)
doorkeeper (~> 5.5)
dotenv-rails (~> 2.7)
ed25519 (~> 1.2)
fabrication (~> 2.22)
ed25519 (~> 1.3)
fabrication (~> 2.24)
faker (~> 2.19)
fast_blank (~> 1.0)
fastimage
@ -707,6 +721,7 @@ DEPENDENCIES
fog-openstack (~> 0.3)
fuubar (~> 2.5)
hamlit-rails (~> 0.2)
hcaptcha (~> 7.1)
hiredis (~> 0.6)
htmlentities (~> 4.3)
http (~> 5.0)
@ -716,20 +731,20 @@ DEPENDENCIES
idn-ruby
iso-639
json-ld
json-ld-preloaded (~> 3.1)
json-ld-preloaded (~> 3.2)
kaminari (~> 1.2)
kt-paperclip (~> 7.0)
letter_opener (~> 1.7)
letter_opener_web (~> 1.4)
letter_opener_web (~> 2.0)
link_header (~> 0.0)
lograge (~> 0.11)
makara (~> 0.5)
mario-redis-lock (~> 1.2)
memory_profiler
microformats (~> 4.2)
mime-types (~> 3.3.1)
mime-types (~> 3.4.1)
net-ldap (~> 0.17)
nokogiri (~> 1.12)
nokogiri (~> 1.13)
nsa (~> 0.2)
oj (~> 3.13)
omniauth (~> 1.9)
@ -737,10 +752,8 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 0.1)
omniauth-saml (~> 1.10)
ox (~> 2.14)
parallel (~> 1.21)
parallel_tests (~> 3.7)
parslet
pg (~> 1.2)
pg (~> 1.3)
pghero (~> 2.8)
pkg-config (~> 1.4)
posix-spawn
@ -748,7 +761,7 @@ DEPENDENCIES
private_address_check (~> 0.5)
pry-byebug (~> 3.9)
pry-rails (~> 0.3)
puma (~> 5.4)
puma (~> 5.5)
pundit (~> 2.1)
rack (~> 2.2.3)
rack-attack (~> 6.5)
@ -757,20 +770,21 @@ DEPENDENCIES
rails-controller-testing (~> 1.0)
rails-i18n (~> 6.0)
rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.4)
rdf-normalize (~> 0.5)
redcarpet (~> 3.5)
redis (~> 4.4)
redis (~> 4.5)
redis-namespace (~> 1.8)
rexml (~> 3.2)
rqrcode (~> 2.1)
rspec-rails (~> 5.0)
rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.4)
rubocop (~> 1.21)
rubocop-rails (~> 2.12)
rspec_junit_formatter (~> 0.5)
rubocop (~> 1.25)
rubocop-rails (~> 2.13)
ruby-progressbar (~> 1.11)
sanitize (~> 6.0)
scenic (~> 1.5)
sidekiq (~> 6.2)
sidekiq (~> 6.4)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.1)
sidekiq-unique-jobs (~> 7.1)
@ -778,11 +792,11 @@ DEPENDENCIES
simple_form (~> 5.1)
simplecov (~> 0.21)
sprockets (~> 3.7.2)
sprockets-rails (~> 3.2)
sprockets-rails (~> 3.4)
stackprof
stoplight (~> 2.2.1)
strong_migrations (~> 0.7)
thor (~> 1.1)
thor (~> 1.2)
tty-prompt (~> 0.23)
twitter-text (~> 3.1.0)
tzinfo-data (~> 1.2021)

12
Vagrantfile vendored
View File

@ -45,16 +45,8 @@ sudo apt-get install \
# Install rvm
read RUBY_VERSION < .ruby-version
gpg_command="gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB"
$($gpg_command)
if [ $? -ne 0 ];then
echo "GPG command failed, This prevented RVM from installing."
echo "Retrying once..." && $($gpg_command)
if [ $? -ne 0 ];then
echo "GPG failed for the second time, please ensure network connectivity."
echo "Exiting..." && exit 1
fi
fi
curl -sSL https://rvm.io/mpapis.asc | gpg --import
curl -sSL https://rvm.io/pkuczynski.asc | gpg --import
curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION
source /home/vagrant/.rvm/scripts/rvm

View File

@ -23,21 +23,21 @@ class AccountsIndex < Chewy::Index
},
}
define_type ::Account.searchable.includes(:account_stat), delete_if: ->(account) { account.destroyed? || !account.searchable? } do
root date_detection: false do
field :id, type: 'long'
index_scope ::Account.searchable.includes(:account_stat), delete_if: ->(account) { account.destroyed? || !account.searchable? }
field :display_name, type: 'text', analyzer: 'content' do
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end
root date_detection: false do
field :id, type: 'long'
field :acct, type: 'text', analyzer: 'content', value: ->(account) { [account.username, account.domain].compact.join('@') } do
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end
field :following_count, type: 'long', value: ->(account) { account.following.local.count }
field :followers_count, type: 'long', value: ->(account) { account.followers.local.count }
field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }
field :display_name, type: 'text', analyzer: 'content' do
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end
field :acct, type: 'text', analyzer: 'content', value: ->(account) { [account.username, account.domain].compact.join('@') } do
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end
field :following_count, type: 'long', value: ->(account) { account.following.local.count }
field :followers_count, type: 'long', value: ->(account) { account.followers.local.count }
field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }
end
end

View File

@ -31,36 +31,36 @@ class StatusesIndex < Chewy::Index
},
}
define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll) do
crutch :mentions do |collection|
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll)
crutch :mentions do |collection|
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :favourites do |collection|
data = ::Favourite.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :reblogs do |collection|
data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :bookmarks do |collection|
data = ::Bookmark.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
root date_detection: false do
field :id, type: 'long'
field :account_id, type: 'long'
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
field :stemmed, type: 'text', analyzer: 'content'
end
crutch :favourites do |collection|
data = ::Favourite.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :reblogs do |collection|
data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :bookmarks do |collection|
data = ::Bookmark.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
root date_detection: false do
field :id, type: 'long'
field :account_id, type: 'long'
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
field :stemmed, type: 'text', analyzer: 'content'
end
field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
end
field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
end
end

View File

@ -23,15 +23,15 @@ class TagsIndex < Chewy::Index
},
}
define_type ::Tag.listable, delete_if: ->(tag) { tag.destroyed? || !tag.listable? } do
root date_detection: false do
field :name, type: 'text', analyzer: 'content' do
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end
index_scope ::Tag.listable, delete_if: ->(tag) { tag.destroyed? || !tag.listable? }
field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day[:accounts].to_i } }
field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
root date_detection: false do
field :name, type: 'text', analyzer: 'content' do
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end
field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day.accounts } }
field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
end
end

View File

@ -29,7 +29,7 @@ class AccountsController < ApplicationController
return
end
@pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses?
@pinned_statuses = cached_filtered_status_pins if show_pinned_statuses?
@statuses = cached_filtered_status_page
@rss_url = rss_url
@ -65,6 +65,10 @@ class AccountsController < ApplicationController
[replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
end
def filtered_pinned_statuses
@account.pinned_statuses.not_local_only.where(visibility: [:public, :unlisted])
end
def filtered_statuses
default_statuses.tap do |statuses|
statuses.merge!(hashtag_scope) if tag_requested?
@ -143,6 +147,13 @@ class AccountsController < ApplicationController
request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end
def cached_filtered_status_pins
cache_collection(
filtered_pinned_statuses,
Status
)
end
def cached_filtered_status_page
cache_collection_paginated_by_id(
filtered_statuses,

View File

@ -21,6 +21,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
case params[:id]
when 'featured'
@items = for_signed_account { cache_collection(@account.pinned_statuses.not_local_only, Status) }
@items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) }
when 'tags'
@items = for_signed_account { @account.featured_tags }
when 'devices'

View File

@ -14,7 +14,7 @@ module Admin
else
@account = @account_moderation_note.target_account
@moderation_notes = @account.targeted_moderation_notes.latest
@warnings = @account.targeted_account_warnings.latest.custom
@warnings = @account.strikes.custom.latest
render template: 'admin/accounts/show'
end

View File

@ -2,13 +2,24 @@
module Admin
class AccountsController < BaseController
before_action :set_account, except: [:index]
before_action :set_account, except: [:index, :batch]
before_action :require_remote_account!, only: [:redownload]
before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
def index
authorize :account, :index?
@accounts = filtered_accounts.page(params[:page])
@form = Form::AccountBatch.new
end
def batch
@form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
ensure
redirect_to admin_accounts_path(filter_params)
end
def show
@ -17,7 +28,7 @@ module Admin
@deletion_request = @account.deletion_request
@account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
@moderation_notes = @account.targeted_moderation_notes.latest
@warnings = @account.targeted_account_warnings.latest.custom
@warnings = @account.strikes.custom.latest
@domain_block = DomainBlock.rule_for(@account.domain)
end
@ -38,13 +49,13 @@ module Admin
def approve
authorize @account.user, :approve?
@account.user.approve!
redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
end
def reject
authorize @account.user, :reject?
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
end
def destroy
@ -106,6 +117,16 @@ module Admin
redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_header_msg', username: @account.acct)
end
def unblock_email
authorize @account, :unblock_email?
CanonicalEmailBlock.where(reference_account: @account).delete_all
log_action :unblock_email, @account
redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unblocked_email_msg', username: @account.acct)
end
private
def set_account
@ -121,11 +142,25 @@ module Admin
end
def filtered_accounts
AccountFilter.new(filter_params).results
AccountFilter.new(filter_params.with_defaults(order: 'recent')).results
end
def filter_params
params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS)
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
end
def action_from_button
if params[:suspend]
'suspend'
elsif params[:approve]
'approve'
elsif params[:reject]
'reject'
end
end
end
end

View File

@ -1,50 +1,17 @@
# frozen_string_literal: true
require 'sidekiq/api'
module Admin
class DashboardController < BaseController
def index
@system_checks = Admin::SystemCheck.perform
@users_count = User.count
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
@pending_users_count = User.pending.count
@registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0
@logins_week = Redis.current.pfcount("activity:logins:#{current_week}")
@interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0
@relay_enabled = Relay.enabled.exists?
@single_user_mode = Rails.configuration.x.single_user_mode
@registrations_enabled = Setting.registrations_mode != 'none'
@deletions_enabled = Setting.open_deletion
@invites_enabled = Setting.min_invite_role == 'user'
@search_enabled = Chewy.enabled?
@version = Mastodon::Version.to_s
@database_version = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
@redis_version = redis_info['redis_version']
@reports_count = Report.unresolved.count
@queue_backlog = Sidekiq::Stats.new.enqueued
@recent_users = User.confirmed.recent.includes(:account).limit(8)
@database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
@redis_size = redis_info['used_memory']
@ldap_enabled = ENV['LDAP_ENABLED'] == 'true'
@cas_enabled = ENV['CAS_ENABLED'] == 'true'
@saml_enabled = ENV['SAML_ENABLED'] == 'true'
@pam_enabled = ENV['PAM_ENABLED'] == 'true'
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
@trending_hashtags = TrendingTags.get(10, filtered: false)
@pending_reports_count = Report.unresolved.count
@pending_tags_count = Tag.pending_review.count
@authorized_fetch = authorized_fetch_mode?
@whitelist_enabled = whitelist_mode?
@profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview
@keybase_integration = Setting.enable_keybase
@trends_enabled = Setting.trends
end
private
def current_week
@current_week ||= Time.now.utc.to_date.cweek
end
def redis_info
@redis_info ||= begin
if Redis.current.is_a?(Redis::Namespace)

View File

@ -14,6 +14,15 @@ module Admin
authorize :instance, :show?
end
def destroy
authorize :instance, :destroy?
Admin::DomainPurgeWorker.perform_async(@instance.domain)
log_action :destroy, @instance
redirect_to admin_instances_path, notice: I18n.t('admin.instances.destroyed_msg', domain: @instance.domain)
end
def clear_delivery_errors
authorize :delivery, :clear_delivery_errors?

View File

@ -1,52 +0,0 @@
# frozen_string_literal: true
module Admin
class PendingAccountsController < BaseController
before_action :set_accounts, only: :index
def index
@form = Form::AccountBatch.new
end
def batch
@form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
ensure
redirect_to admin_pending_accounts_path(current_params)
end
def approve_all
Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'approve').save
redirect_to admin_pending_accounts_path(current_params)
end
def reject_all
Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'reject').save
redirect_to admin_pending_accounts_path(current_params)
end
private
def set_accounts
@accounts = Account.joins(:user).merge(User.pending.recent).includes(user: :invite_request).page(params[:page])
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
end
def action_from_button
if params[:approve]
'approve'
elsif params[:reject]
'reject'
end
end
def current_params
params.slice(:page).permit(:page)
end
end
end

View File

@ -14,20 +14,17 @@ module Admin
if params[:create_and_resolve]
@report.resolve!(current_account)
log_action :resolve, @report
redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
return
end
if params[:create_and_unresolve]
elsif params[:create_and_unresolve]
@report.unresolve!
log_action :reopen, @report
end
redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg')
redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg')
else
@report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
@form = Form::StatusBatch.new
@report_notes = @report.notes.includes(:account).order(id: :desc)
@action_logs = @report.history.includes(:target)
@form = Admin::StatusBatchAction.new
@statuses = @report.statuses.with_includes
render template: 'admin/reports/show'
end
@ -41,6 +38,14 @@ module Admin
private
def after_create_redirect_path
if params[:create_and_resolve]
admin_reports_path
else
admin_report_path(@report)
end
end
def resource_params
params.require(:report_note).permit(
:content,

View File

@ -1,44 +0,0 @@
# frozen_string_literal: true
module Admin
class ReportedStatusesController < BaseController
before_action :set_report
def create
authorize :status, :update?
@form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
redirect_to admin_report_path(@report)
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.statuses.no_status_selected')
redirect_to admin_report_path(@report)
end
private
def status_params
params.require(:status).permit(:sensitive)
end
def form_status_batch_params
params.require(:form_status_batch).permit(status_ids: [])
end
def action_from_button
if params[:nsfw_on]
'nsfw_on'
elsif params[:nsfw_off]
'nsfw_off'
elsif params[:delete]
'delete'
end
end
def set_report
@report = Report.find(params[:report_id])
end
end
end

View File

@ -13,8 +13,10 @@ module Admin
authorize @report, :show?
@report_note = @report.notes.new
@report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
@form = Form::StatusBatch.new
@report_notes = @report.notes.includes(:account).order(id: :desc)
@action_logs = @report.history.includes(:target)
@form = Admin::StatusBatchAction.new
@statuses = @report.statuses.with_includes
end
def assign_to_self

View File

@ -2,71 +2,57 @@
module Admin
class StatusesController < BaseController
helper_method :current_params
before_action :set_account
before_action :set_statuses
PER_PAGE = 20
def index
authorize :status, :index?
@statuses = @account.statuses.where(visibility: [:public, :unlisted])
if params[:media]
@statuses.merge!(Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id))
end
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
@form = Form::StatusBatch.new
@status_batch_action = Admin::StatusBatchAction.new
end
def show
authorize :status, :index?
@statuses = @account.statuses.where(id: params[:id])
authorize @statuses.first, :show?
@form = Form::StatusBatch.new
end
def create
authorize :status, :update?
@form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
redirect_to admin_account_statuses_path(@account.id, current_params)
def batch
@status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
@status_batch_action.save!
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.statuses.no_status_selected')
redirect_to admin_account_statuses_path(@account.id, current_params)
ensure
redirect_to after_create_redirect_path
end
private
def form_status_batch_params
params.require(:form_status_batch).permit(:action, status_ids: [])
def admin_status_batch_action_params
params.require(:admin_status_batch_action).permit(status_ids: [])
end
def after_create_redirect_path
if @status_batch_action.report_id.present?
admin_report_path(@status_batch_action.report_id)
else
admin_account_statuses_path(params[:account_id], current_params)
end
end
def set_account
@account = Account.find(params[:account_id])
end
def current_params
page = (params[:page] || 1).to_i
def set_statuses
@statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE)
end
{
media: params[:media],
page: page > 1 && page,
}.select { |_, value| value.present? }
def filter_params
params.slice(*Admin::StatusFilter::KEYS).permit(*Admin::StatusFilter::KEYS)
end
def action_from_button
if params[:nsfw_on]
'nsfw_on'
elsif params[:nsfw_off]
'nsfw_off'
if params[:report]
'report'
elsif params[:remove_from_report]
'remove_from_report'
elsif params[:delete]
'delete'
end

View File

@ -2,38 +2,12 @@
module Admin
class TagsController < BaseController
before_action :set_tag, except: [:index, :batch, :approve_all, :reject_all]
before_action :set_usage_by_domain, except: [:index, :batch, :approve_all, :reject_all]
before_action :set_counters, except: [:index, :batch, :approve_all, :reject_all]
def index
authorize :tag, :index?
@tags = filtered_tags.page(params[:page])
@form = Form::TagBatch.new
end
def batch
@form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
ensure
redirect_to admin_tags_path(filter_params)
end
def approve_all
Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'approve').save
redirect_to admin_tags_path(filter_params)
end
def reject_all
Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'reject').save
redirect_to admin_tags_path(filter_params)
end
before_action :set_tag
def show
authorize @tag, :show?
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
end
def update
@ -52,52 +26,8 @@ module Admin
@tag = Tag.find(params[:id])
end
def set_usage_by_domain
@usage_by_domain = @tag.statuses
.with_public_visibility
.excluding_silenced_accounts
.where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
.joins(:account)
.group('accounts.domain')
.reorder(statuses_count: :desc)
.pluck(Arel.sql('accounts.domain, count(*) AS statuses_count'))
end
def set_counters
@accounts_today = @tag.history.first[:accounts]
@accounts_week = Redis.current.pfcount(*current_week_days.map { |day| "activity:tags:#{@tag.id}:#{day}:accounts" })
end
def filtered_tags
TagFilter.new(filter_params).results
end
def filter_params
params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
end
def tag_params
params.require(:tag).permit(:name, :trendable, :usable, :listable)
end
def current_week_days
now = Time.now.utc.beginning_of_day.to_date
(Date.commercial(now.cwyear, now.cweek)..now).map do |date|
date.to_time(:utc).beginning_of_day.to_i
end
end
def form_tag_batch_params
params.require(:form_tag_batch).permit(:action, tag_ids: [])
end
def action_from_button
if params[:approve]
'approve'
elsif params[:reject]
'reject'
end
end
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseController
def index
authorize :preview_card_provider, :index?
@preview_card_providers = filtered_preview_card_providers.page(params[:page])
@form = Form::PreviewCardProviderBatch.new
end
def batch
@form = Form::PreviewCardProviderBatch.new(form_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
ensure
redirect_to admin_trends_links_preview_card_providers_path(filter_params)
end
private
def filtered_preview_card_providers
PreviewCardProviderFilter.new(filter_params).results
end
def filter_params
params.slice(:page, *PreviewCardProviderFilter::KEYS).permit(:page, *PreviewCardProviderFilter::KEYS)
end
def form_preview_card_provider_batch_params
params.require(:form_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
end
def action_from_button
if params[:approve]
'approve'
elsif params[:reject]
'reject'
end
end
end

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
class Admin::Trends::LinksController < Admin::BaseController
def index
authorize :preview_card, :index?
@preview_cards = filtered_preview_cards.page(params[:page])
@form = Form::PreviewCardBatch.new
end
def batch
@form = Form::PreviewCardBatch.new(form_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
ensure
redirect_to admin_trends_links_path(filter_params)
end
private
def filtered_preview_cards
PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results
end
def filter_params
params.slice(:page, *PreviewCardFilter::KEYS).permit(:page, *PreviewCardFilter::KEYS)
end
def form_preview_card_batch_params
params.require(:form_preview_card_batch).permit(:action, preview_card_ids: [])
end
def action_from_button
if params[:approve]
'approve'
elsif params[:approve_all]
'approve_all'
elsif params[:reject]
'reject'
elsif params[:reject_all]
'reject_all'
end
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class Admin::Trends::TagsController < Admin::BaseController
def index
authorize :tag, :index?
@tags = filtered_tags.page(params[:page])
@form = Form::TagBatch.new
end
def batch
@form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
ensure
redirect_to admin_trends_tags_path(filter_params)
end
private
def filtered_tags
TagFilter.new(filter_params).results
end
def filter_params
params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
end
def form_tag_batch_params
params.require(:form_tag_batch).permit(:action, tag_ids: [])
end
def action_from_button
if params[:approve]
'approve'
elsif params[:reject]
'reject'
end
end
end

View File

@ -1,23 +0,0 @@
# frozen_string_literal: true
class Api::ProofsController < Api::BaseController
include AccountOwnedConcern
skip_before_action :require_authenticated_user!
before_action :set_provider
def index
render json: @account, serializer: @provider.serializer_class
end
private
def set_provider
@provider = ProofProvider.find(params[:provider]) || raise(ActiveRecord::RecordNotFound)
end
def username_param
params[:username]
end
end

View File

@ -5,8 +5,7 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController
before_action :set_account
def index
@proofs = @account.suspended? ? [] : @account.identity_proofs.active
render json: @proofs, each_serializer: REST::IdentityProofSerializer
render json: []
end
private

View File

@ -46,9 +46,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end
def pinned_scope
return Status.none if @account.blocking?(current_account)
@account.pinned_statuses
@account.pinned_statuses.permitted_for(@account, current_account)
end
def no_replies_scope

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true
class Api::V1::AccountsController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :block, :unblock, :mute, :unmute]
before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow]
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :remove_from_followers, :block, :unblock, :mute, :unmute]
before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow, :remove_from_followers]
before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute]
before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, only: [:block, :unblock]
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create]
@ -53,6 +53,11 @@ class Api::V1::AccountsController < Api::BaseController
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def remove_from_followers
RemoveFromFollowersService.new.call(current_user.account, @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def unblock
UnblockService.new.call(current_user.account, @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
@ -78,10 +83,14 @@ class Api::V1::AccountsController < Api::BaseController
end
def check_enabled_registrations
forbidden if single_user_mode? || !allowed_registrations?
forbidden if single_user_mode? || omniauth_only? || !allowed_registrations?
end
def allowed_registrations?
Setting.registrations_mode != 'none'
end
def omniauth_only?
ENV['OMNIAUTH_ONLY'] == 'true'
end
end

View File

@ -1,7 +1,9 @@
# frozen_string_literal: true
class Api::V1::Admin::AccountActionsController < Api::BaseController
before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' }
protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }
before_action :require_staff!
before_action :set_account

View File

@ -1,13 +1,15 @@
# frozen_string_literal: true
class Api::V1::Admin::AccountsController < Api::BaseController
protect_from_forgery with: :exception
include Authorization
include AccountableConcern
LIMIT = 100
before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:accounts' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' }, except: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:accounts' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }, except: [:index, :show]
before_action :require_staff!
before_action :set_accounts, only: :index
before_action :set_account, except: :index
@ -94,7 +96,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController
private
def set_accounts
@accounts = filtered_accounts.order(id: :desc).includes(user: [:invite_request, :invite]).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
@accounts = filtered_accounts.order(id: :desc).includes(user: [:invite_request, :invite, :ips]).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def set_account

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
class Api::V1::Admin::DimensionsController < Api::BaseController
protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:read' }
before_action :require_staff!
before_action :set_dimensions
def create
render json: @dimensions, each_serializer: REST::Admin::DimensionSerializer
end
private
def set_dimensions
@dimensions = Admin::Metrics::Dimension.retrieve(
params[:keys],
params[:start_at],
params[:end_at],
params[:limit],
params
)
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Api::V1::Admin::MeasuresController < Api::BaseController
protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:read' }
before_action :require_staff!
before_action :set_measures
def create
render json: @measures, each_serializer: REST::Admin::MeasureSerializer
end
private
def set_measures
@measures = Admin::Metrics::Measure.retrieve(
params[:keys],
params[:start_at],
params[:end_at],
params
)
end
end

View File

@ -1,13 +1,15 @@
# frozen_string_literal: true
class Api::V1::Admin::ReportsController < Api::BaseController
protect_from_forgery with: :exception
include Authorization
include AccountableConcern
LIMIT = 100
before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:reports' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:reports' }, except: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:reports' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:reports' }, except: [:index, :show]
before_action :require_staff!
before_action :set_reports, only: :index
before_action :set_report, except: :index
@ -32,6 +34,12 @@ class Api::V1::Admin::ReportsController < Api::BaseController
render json: @report, serializer: REST::Admin::ReportSerializer
end
def update
authorize @report, :update?
@report.update!(report_params)
render json: @report, serializer: REST::Admin::ReportSerializer
end
def assign_to_self
authorize @report, :update?
@report.update!(assigned_account_id: current_account.id)
@ -74,6 +82,10 @@ class Api::V1::Admin::ReportsController < Api::BaseController
ReportFilter.new(filter_params).results
end
def report_params
params.permit(:category, rule_ids: [])
end
def filter_params
params.permit(*FILTER_PARAMS)
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Api::V1::Admin::RetentionController < Api::BaseController
protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:read' }
before_action :require_staff!
before_action :set_cohorts
def create
render json: @cohorts, each_serializer: REST::Admin::CohortSerializer
end
private
def set_cohorts
@cohorts = Admin::Metrics::Retention.new(
params[:start_at],
params[:end_at],
params[:frequency]
).cohorts
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Api::V1::Admin::Trends::TagsController < Api::BaseController
protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:read' }
before_action :require_staff!
before_action :set_tags
def index
render json: @tags, each_serializer: REST::Admin::TagSerializer
end
private
def set_tags
@tags = Trends.tags.get(false, limit_param(10))
end
end

View File

@ -14,22 +14,21 @@ class Api::V1::Instances::ActivityController < Api::BaseController
private
def activity
weeks = []
statuses_tracker = ActivityTracker.new('activity:statuses:local', :basic)
logins_tracker = ActivityTracker.new('activity:logins', :unique)
registrations_tracker = ActivityTracker.new('activity:accounts:local', :basic)
12.times do |i|
day = i.weeks.ago.to_date
week_id = day.cweek
week = Date.commercial(day.cwyear, week_id)
(0...12).map do |i|
start_of_week = i.weeks.ago
end_of_week = start_of_week + 6.days
weeks << {
week: week.to_time.to_i.to_s,
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0',
logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s,
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0',
{
week: start_of_week.to_i.to_s,
statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s,
logins: logins_tracker.sum(start_of_week, end_of_week).to_s,
registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s,
}
end
weeks
end
def require_enabled_api!

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class Api::V1::Statuses::HistoriesController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_status
def show
render json: @status.edits, each_serializer: REST::StatusEditSerializer
end
private
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class Api::V1::Statuses::SourcesController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
before_action :set_status
def show
render json: @status, serializer: REST::StatusSourceSerializer
end
private
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@ -57,7 +57,7 @@ class Api::V1::StatusesController < Api::BaseController
authorize @status, :destroy?
@status.discard
RemovalWorker.perform_async(@status.id, redraft: true)
RemovalWorker.perform_async(@status.id, { 'redraft' => true })
@status.account.statuses_count = @status.account.statuses_count - 1
render json: @status, serializer: REST::StatusSerializer, source_requested: true

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class Api::V1::Trends::LinksController < Api::BaseController
before_action :set_links
def index
render json: @links, each_serializer: REST::Trends::LinkSerializer
end
private
def set_links
@links = begin
if Setting.trends
Trends.links.get(true, limit_param(10))
else
[]
end
end
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class Api::V1::Trends::TagsController < Api::BaseController
before_action :set_tags
def index
render json: @tags, each_serializer: REST::TagSerializer
end
private
def set_tags
@tags = begin
if Setting.trends
Trends.tags.get(true, limit_param(10))
else
[]
end
end
end
end

View File

@ -1,15 +0,0 @@
# frozen_string_literal: true
class Api::V1::TrendsController < Api::BaseController
before_action :set_tags
def index
render json: @tags, each_serializer: REST::TagSerializer
end
private
def set_tags
@tags = TrendingTags.get(limit_param(10))
end
end

View File

@ -10,6 +10,7 @@ class ApplicationController < ActionController::Base
include SessionTrackingConcern
include CacheConcern
include DomainControlHelper
include ThemingConcern
helper_method :current_account
helper_method :current_session
@ -73,75 +74,6 @@ class ApplicationController < ActionController::Base
new_user_session_path
end
def pack(data, pack_name, skin = 'default')
return nil unless pack?(data, pack_name)
pack_data = {
common: pack_name == 'common' ? nil : resolve_pack(data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin),
flavour: data['name'],
pack: pack_name,
preload: nil,
skin: nil,
supported_locales: data['locales'],
}
if data['pack'][pack_name].is_a?(Hash)
pack_data[:common] = nil if data['pack'][pack_name]['use_common'] == false
pack_data[:pack] = nil unless data['pack'][pack_name]['filename']
if data['pack'][pack_name]['preload']
pack_data[:preload] = [data['pack'][pack_name]['preload']] if data['pack'][pack_name]['preload'].is_a?(String)
pack_data[:preload] = data['pack'][pack_name]['preload'] if data['pack'][pack_name]['preload'].is_a?(Array)
end
if skin != 'default' && data['skin'][skin]
pack_data[:skin] = skin if data['skin'][skin].include?(pack_name)
else # default skin
pack_data[:skin] = 'default' if data['pack'][pack_name]['stylesheet']
end
end
pack_data
end
def pack?(data, pack_name)
if data['pack'].is_a?(Hash) && data['pack'].key?(pack_name)
return true if data['pack'][pack_name].is_a?(String) || data['pack'][pack_name].is_a?(Hash)
end
false
end
def nil_pack(data, pack_name, skin = 'default')
{
common: pack_name == 'common' ? nil : resolve_pack(data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin),
flavour: data['name'],
pack: nil,
preload: nil,
skin: nil,
supported_locales: data['locales'],
}
end
def resolve_pack(data, pack_name, skin = 'default')
result = pack(data, pack_name, skin)
unless result
if data['name'] && data.key?('fallback')
if data['fallback'].nil?
return nil_pack(data, pack_name, skin)
elsif data['fallback'].is_a?(String) && Themes.instance.flavour(data['fallback'])
return resolve_pack(Themes.instance.flavour(data['fallback']), pack_name)
elsif data['fallback'].is_a?(Array)
data['fallback'].each do |fallback|
return resolve_pack(Themes.instance.flavour(fallback), pack_name) if Themes.instance.flavour(fallback)
end
end
return nil_pack(data, pack_name, skin)
end
return data.key?('name') && data['name'] != Setting.default_settings['flavour'] ? resolve_pack(Themes.instance.flavour(Setting.default_settings['flavour']), pack_name) : nil_pack(data, pack_name, skin)
end
result
end
def use_pack(pack_name)
@core = resolve_pack(Themes.instance.core, pack_name)
@theme = resolve_pack(Themes.instance.flavour(current_flavour), pack_name, current_skin)
end
protected
def truthy_param?(key)

View File

@ -1,12 +1,18 @@
# frozen_string_literal: true
class Auth::ConfirmationsController < Devise::ConfirmationsController
include CaptchaConcern
layout 'auth'
before_action :set_body_classes
before_action :set_pack
before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
before_action :require_unconfirmed!
before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
before_action :require_captcha_if_needed!, only: [:show]
skip_before_action :require_functional!
def new
@ -15,8 +21,46 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
end
def show
old_session_values = session.to_hash
reset_session
session.update old_session_values.except('session_id')
super
end
def confirm_captcha
check_captcha! do |message|
flash.now[:alert] = message
render :captcha
return
end
show
end
private
def require_captcha_if_needed!
render :captcha if captcha_required?
end
def set_confirmation_user!
# We need to reimplement looking up the user because
# Devise::ConfirmationsController#show looks up and confirms in one
# step.
confirmation_token = params[:confirmation_token]
return if confirmation_token.nil?
@confirmation_user = User.find_first_by_auth_conditions(confirmation_token: confirmation_token)
end
def captcha_user_bypass?
return true if @confirmation_user.nil? || @confirmation_user.confirmed?
invite = Invite.find(@confirmation_user.invite_id) if @confirmation_user.invite_id.present?
invite.present? && !invite.max_uses.nil?
end
def set_pack
use_pack 'auth'
end

View File

@ -11,7 +11,6 @@ class Auth::PasswordsController < Devise::PasswordsController
super do |resource|
if resource.errors.empty?
resource.session_activations.destroy_all
resource.forget_me!
end
end
end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true
class Auth::RegistrationsController < Devise::RegistrationsController
include Devise::Controllers::Rememberable
include RegistrationSpamConcern
layout :determine_layout
@ -31,8 +30,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
super do |resource|
if resource.saved_change_to_encrypted_password?
resource.clear_other_sessions(current_session.session_id)
resource.forget_me!
remember_me(resource)
end
end
end
@ -85,13 +82,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def check_enabled_registrations
redirect_to root_path if single_user_mode? || !allowed_registrations?
redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations?
end
def allowed_registrations?
Setting.registrations_mode != 'none' || @invite&.valid_for_use?
end
def omniauth_only?
ENV['OMNIAUTH_ONLY'] == 'true'
end
def invite_code
if params[:user]
params[:user][:invite_code]

View File

@ -1,8 +1,6 @@
# frozen_string_literal: true
class Auth::SessionsController < Devise::SessionsController
include Devise::Controllers::Rememberable
layout 'auth'
skip_before_action :require_no_authentication, only: [:create]
@ -17,14 +15,6 @@ class Auth::SessionsController < Devise::SessionsController
before_action :set_instance_presenter, only: [:new]
before_action :set_body_classes
def new
Devise.omniauth_configs.each do |provider, config|
return redirect_to(omniauth_authorize_path(resource_name, provider)) if config.strategy.redirect_at_sign_in
end
super
end
def create
super do |resource|
# We only need to call this if this hasn't already been
@ -91,14 +81,6 @@ class Auth::SessionsController < Devise::SessionsController
end
end
def after_sign_out_path_for(_resource_or_scope)
Devise.omniauth_configs.each_value do |config|
return root_path if config.strategy.redirect_at_sign_in
end
super
end
def require_no_authentication
super
@ -155,8 +137,7 @@ class Auth::SessionsController < Devise::SessionsController
clear_attempt_from_session
user.update_sign_in!(request, new_sign_in: true)
remember_me(user)
user.update_sign_in!(new_sign_in: true)
sign_in(user)
flash.delete(:notice)

View File

@ -8,6 +8,7 @@ module AccountOwnedConcern
before_action :set_account, if: :account_required?
before_action :check_account_approval, if: :account_required?
before_action :check_account_suspension, if: :account_required?
before_action :check_account_confirmation, if: :account_required?
end
private
@ -28,6 +29,10 @@ module AccountOwnedConcern
not_found if @account.local? && @account.user_pending?
end
def check_account_confirmation
not_found if @account.local? && !@account.user_confirmed?
end
def check_account_suspension
if @account.suspended_permanently?
permanent_suspension_response

View File

@ -3,7 +3,7 @@
module AccountableConcern
extend ActiveSupport::Concern
def log_action(action, target)
Admin::ActionLog.create(account: current_account, action: action, target: target)
def log_action(action, target, options = {})
Admin::ActionLog.create(account: current_account, action: action, target: target, recorded_changes: options.stringify_keys)
end
end

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
module CaptchaConcern
extend ActiveSupport::Concern
include Hcaptcha::Adapters::ViewMethods
included do
helper_method :render_captcha
end
def captcha_available?
ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
end
def captcha_enabled?
captcha_available? && Setting.captcha_enabled
end
def captcha_user_bypass?
false
end
def captcha_required?
captcha_enabled? && !captcha_user_bypass?
end
def check_captcha!
return true unless captcha_required?
if verify_hcaptcha
true
else
if block_given?
message = flash[:hcaptcha_error]
flash.delete(:hcaptcha_error)
yield message
end
false
end
end
def extend_csp_for_captcha!
policy = request.content_security_policy
return unless captcha_required? && policy.present?
%w(script_src frame_src style_src connect_src).each do |directive|
values = policy.send(directive)
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
policy.send(directive, *values)
end
end
def render_captcha
return unless captcha_required?
hcaptcha_tags
end
end

View File

@ -0,0 +1,80 @@
# frozen_string_literal: true
module ThemingConcern
extend ActiveSupport::Concern
def use_pack(pack_name)
@core = resolve_pack_with_common(Themes.instance.core, pack_name)
@theme = resolve_pack_with_common(Themes.instance.flavour(current_flavour), pack_name, current_skin)
end
private
def valid_pack_data?(data, pack_name)
data['pack'].is_a?(Hash) && [String, Hash].any? { |c| data['pack'][pack_name].is_a?(c) }
end
def nil_pack(data)
{
use_common: true,
flavour: data['name'],
pack: nil,
preload: nil,
skin: nil,
supported_locales: data['locales'],
}
end
def pack(data, pack_name, skin)
pack_data = {
use_common: true,
flavour: data['name'],
pack: pack_name,
preload: nil,
skin: nil,
supported_locales: data['locales'],
}
return pack_data unless data['pack'][pack_name].is_a?(Hash)
pack_data[:use_common] = false if data['pack'][pack_name]['use_common'] == false
pack_data[:pack] = nil unless data['pack'][pack_name]['filename']
preloads = data['pack'][pack_name]['preload']
pack_data[:preload] = [preloads] if preloads.is_a?(String)
pack_data[:preload] = preloads if preloads.is_a?(Array)
if skin != 'default' && data['skin'][skin]
pack_data[:skin] = skin if data['skin'][skin].include?(pack_name)
elsif data['pack'][pack_name]['stylesheet']
pack_data[:skin] = 'default'
end
pack_data
end
def resolve_pack(data, pack_name, skin)
return pack(data, pack_name, skin) if valid_pack_data?(data, pack_name)
return if data['name'].blank?
fallbacks = []
if data.key?('fallback')
fallbacks = data['fallback'] if data['fallback'].is_a?(Array)
fallbacks = [data['fallback']] if data['fallback'].is_a?(String)
elsif data['name'] != Setting.default_settings['flavour']
fallbacks = [Setting.default_settings['flavour']]
end
fallbacks.each do |fallback|
return resolve_pack(Themes.instance.flavour(fallback), pack_name) if Themes.instance.flavour(fallback)
end
nil
end
def resolve_pack_with_common(data, pack_name, skin = 'default')
result = resolve_pack(data, pack_name, skin) || nil_pack(data)
result[:common] = resolve_pack(data, 'common', skin) if result.delete(:use_common)
result
end
end

View File

@ -57,7 +57,7 @@ module TwoFactorAuthenticationConcern
if valid_webauthn_credential?(user, webauthn_credential)
on_authentication_success(user, :webauthn)
render json: { redirect_path: root_path }, status: :ok
render json: { redirect_path: after_sign_in_path_for(user) }, status: :ok
else
on_authentication_failure(user, :webauthn, :invalid_credential)
render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity

View File

@ -3,7 +3,7 @@
module UserTrackingConcern
extend ActiveSupport::Concern
UPDATE_SIGN_IN_HOURS = 24
UPDATE_SIGN_IN_FREQUENCY = 24.hours.freeze
included do
before_action :update_user_sign_in
@ -12,10 +12,10 @@ module UserTrackingConcern
private
def update_user_sign_in
current_user.update_sign_in!(request) if user_needs_sign_in_update?
current_user.update_sign_in! if user_needs_sign_in_update?
end
def user_needs_sign_in_update?
user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_HOURS.hours.ago)
user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_FREQUENCY.ago)
end
end

View File

@ -28,7 +28,12 @@ class MediaController < ApplicationController
private
def set_media_attachment
@media_attachment = MediaAttachment.attached.find_by!(shortcode: params[:id] || params[:medium_id])
id = params[:id] || params[:medium_id]
return if id.nil?
scope = MediaAttachment.local.attached
# If id is 19 characters long, it's a shortcode, otherwise it's an identifier
@media_attachment = id.size == 19 ? scope.find_by!(shortcode: id) : scope.find_by!(id: id)
end
def verify_permitted_status!

View File

@ -1,65 +0,0 @@
# frozen_string_literal: true
class Settings::IdentityProofsController < Settings::BaseController
before_action :check_required_params, only: :new
before_action :check_enabled, only: :new
def index
@proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc)
@proofs.each(&:refresh!)
end
def new
@proof = current_account.identity_proofs.new(
token: params[:token],
provider: params[:provider],
provider_username: params[:provider_username]
)
if current_account.username.casecmp(params[:username]).zero?
render layout: 'auth'
else
redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username)
end
end
def create
@proof = current_account.identity_proofs.where(provider: resource_params[:provider], provider_username: resource_params[:provider_username]).first_or_initialize(resource_params)
@proof.token = resource_params[:token]
if @proof.save
PostStatusService.new.call(current_user.account, text: post_params[:status_text]) if publish_proof?
redirect_to @proof.on_success_path(params[:user_agent])
else
redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize)
end
end
def destroy
@proof = current_account.identity_proofs.find(params[:id])
@proof.destroy!
redirect_to settings_identity_proofs_path, success: I18n.t('identity_proofs.removed')
end
private
def check_enabled
not_found unless Setting.enable_keybase
end
def check_required_params
redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :username, :token].all? { |k| params[k].present? }
end
def resource_params
params.require(:account_identity_proof).permit(:provider, :provider_username, :token)
end
def publish_proof?
ActiveModel::Type::Boolean.new.cast(post_params[:post_status])
end
def post_params
params.require(:account_identity_proof).permit(:post_status, :status_text)
end
end

View File

@ -1,17 +0,0 @@
# frozen_string_literal: true
module WellKnown
class KeybaseProofConfigController < ActionController::Base
before_action :check_enabled
def show
render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer, root: 'keybase_config'
end
private
def check_enabled
head 404 unless Setting.enable_keybase
end
end
end

View File

@ -31,11 +31,15 @@ module Admin::ActionLogsHelper
link_to truncate(record.text), edit_admin_announcement_path(record.id)
when 'IpBlock'
"#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
when 'Instance'
record.domain
end
end
def log_target_from_history(type, attributes)
case type
when 'User'
attributes['username']
when 'CustomEmoji'
attributes['shortcode']
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain'
@ -52,6 +56,8 @@ module Admin::ActionLogsHelper
truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
when 'IpBlock'
"#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
when 'Instance'
attributes['domain']
end
end
end

View File

@ -1,10 +1,41 @@
# frozen_string_literal: true
module Admin::DashboardHelper
def feature_hint(feature, enabled)
indicator = safe_join([enabled ? t('simple_form.yes') : t('simple_form.no'), fa_icon('power-off fw')], ' ')
class_names = enabled ? 'pull-right positive-hint' : 'pull-right neutral-hint'
def relevant_account_ip(account, ip_query)
ips = account.user.present? ? account.user.ips.to_a : []
safe_join([feature, content_tag(:span, indicator, class: class_names)])
matched_ip = begin
ip_query_addr = IPAddr.new(ip_query)
ips.find { |ip| ip_query_addr.include?(ip.ip) } || ips.first
rescue IPAddr::Error
ips.first
end
if matched_ip
link_to matched_ip.ip, admin_accounts_path(ip: matched_ip.ip)
else
'-'
end
end
def relevant_account_timestamp(account)
timestamp, exact = begin
if account.user_current_sign_in_at && account.user_current_sign_in_at < 24.hours.ago
[account.user_current_sign_in_at, true]
elsif account.user_current_sign_in_at
[account.user_current_sign_in_at, false]
elsif account.user_pending?
[account.user_created_at, true]
elsif account.last_status_at.present?
[account.last_status_at, true]
else
[nil, false]
end
end
return '-' if timestamp.nil?
return t('generic.today') unless exact
content_tag(:time, l(timestamp), class: 'time-ago', datetime: timestamp.iso8601, title: l(timestamp))
end
end

View File

@ -6,11 +6,14 @@ module Admin::FilterHelper
CustomEmojiFilter::KEYS,
ReportFilter::KEYS,
TagFilter::KEYS,
PreviewCardProviderFilter::KEYS,
PreviewCardFilter::KEYS,
InstanceFilter::KEYS,
InviteFilter::KEYS,
RelationshipFilter::KEYS,
AnnouncementFilter::KEYS,
Admin::ActionLogFilter::KEYS,
Admin::StatusFilter::KEYS,
].flatten.freeze
def filter_link_to(text, link_to_params, link_class_params = link_to_params)

View File

@ -8,4 +8,8 @@ module Admin::SettingsHelper
link = link_to t('admin.site_uploads.delete'), admin_site_upload_path(upload), data: { method: :delete }
safe_join([hint, link], '<br/>'.html_safe)
end
def captcha_available?
ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
end
end

View File

@ -50,13 +50,39 @@ module ApplicationHelper
end
def available_sign_up_path
if closed_registrations?
if closed_registrations? || omniauth_only?
'https://joinmastodon.org/#getting-started'
else
new_user_registration_path
end
end
def omniauth_only?
ENV['OMNIAUTH_ONLY'] == 'true'
end
def link_to_login(name = nil, html_options = nil, &block)
target = new_user_session_path
html_options = name if block_given?
if omniauth_only? && Devise.mappings[:user].omniauthable? && User.omniauth_providers.size == 1
target = omniauth_authorize_path(:user, User.omniauth_providers[0])
html_options ||= {}
html_options[:method] = :post
end
if block_given?
link_to(target, html_options, &block)
else
link_to(name, target, html_options)
end
end
def provider_sign_in_link(provider)
link_to I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize), omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
end
def open_deletion?
Setting.open_deletion
end
@ -137,6 +163,10 @@ module ApplicationHelper
end
end
def react_admin_component(name, props = {})
content_tag(:div, nil, data: { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) })
end
def body_classes
output = (@body_classes || '').split(' ')
output << "flavour-#{current_flavour.parameterize}"

View File

@ -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 = {
direct_message: { 'litepub' => 'http://litepub.social/ns#', 'directMessage' => 'litepub:directMessage' },
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' } },
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

View File

@ -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
@ -34,7 +36,13 @@ module JsonLdHelper
end
def as_array(value)
value.is_a?(Array) ? value : [value]
if value.nil?
[]
elsif value.is_a?(Array)
value
else
[value]
end
end
def value_or_id(value)
@ -63,6 +71,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)

View File

@ -0,0 +1,94 @@
# frozen_string_literal: true
module LanguagesHelper
HUMAN_LOCALES = {
af: 'Afrikaans',
ar: 'العربية',
ast: 'Asturianu',
bg: 'Български',
bn: 'বাংলা',
br: 'Breton',
ca: 'Català',
co: 'Corsu',
cs: 'Čeština',
cy: 'Cymraeg',
da: 'Dansk',
de: 'Deutsch',
el: 'Ελληνικά',
en: 'English',
eo: 'Esperanto',
'es-AR': 'Español (Argentina)',
'es-MX': 'Español (México)',
es: 'Español',
et: 'Eesti',
eu: 'Euskara',
fa: 'فارسی',
fi: 'Suomi',
fr: 'Français',
ga: 'Gaeilge',
gd: 'Gàidhlig',
gl: 'Galego',
he: 'עברית',
hi: 'हिन्दी',
hr: 'Hrvatski',
hu: 'Magyar',
hy: 'Հայերեն',
id: 'Bahasa Indonesia',
io: 'Ido',
is: 'Íslenska',
it: 'Italiano',
ja: '日本語',
ka: 'ქართული',
kab: 'Taqbaylit',
kk: 'Қазақша',
kmr: 'Kurmancî',
kn: 'ಕನ್ನಡ',
ko: '한국어',
ku: 'سۆرانی',
lt: 'Lietuvių',
lv: 'Latviešu',
mk: 'Македонски',
ml: 'മലയാളം',
mr: 'मराठी',
ms: 'Bahasa Melayu',
nl: 'Nederlands',
nn: 'Nynorsk',
no: 'Norsk',
oc: 'Occitan',
pl: 'Polski',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português (Portugal)',
pt: 'Português',
ro: 'Română',
ru: 'Русский',
sa: 'संस्कृतम्',
sc: 'Sardu',
si: 'සිංහල',
sk: 'Slovenčina',
sl: 'Slovenščina',
sq: 'Shqip',
'sr-Latn': 'Srpski (latinica)',
sr: 'Српски',
sv: 'Svenska',
ta: 'தமிழ்',
te: 'తెలుగు',
th: 'ไทย',
tr: 'Türkçe',
uk: 'Українська',
ur: 'اُردُو',
vi: 'Tiếng Việt',
zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ',
'zh-CN': '简体中文',
'zh-HK': '繁體中文(香港)',
'zh-TW': '繁體中文(臺灣)',
zh: '中文',
}.freeze
def human_locale(locale)
if locale == 'und'
I18n.t('generic.none')
else
HUMAN_LOCALES[locale.to_sym] || locale
end
end
end

View File

@ -1,94 +1,8 @@
# frozen_string_literal: true
module SettingsHelper
HUMAN_LOCALES = {
af: 'Afrikaans',
ar: 'العربية',
ast: 'Asturianu',
bg: 'Български',
bn: 'বাংলা',
br: 'Breton',
ca: 'Català',
co: 'Corsu',
cs: 'Čeština',
cy: 'Cymraeg',
da: 'Dansk',
de: 'Deutsch',
el: 'Ελληνικά',
en: 'English',
eo: 'Esperanto',
'es-AR': 'Español (Argentina)',
'es-MX': 'Español (México)',
es: 'Español',
et: 'Eesti',
eu: 'Euskara',
fa: 'فارسی',
fi: 'Suomi',
fr: 'Français',
ga: 'Gaeilge',
gd: 'Gàidhlig',
gl: 'Galego',
he: 'עברית',
hi: 'हिन्दी',
hr: 'Hrvatski',
hu: 'Magyar',
hy: 'Հայերեն',
id: 'Bahasa Indonesia',
io: 'Ido',
is: 'Íslenska',
it: 'Italiano',
ja: '日本語',
ka: 'ქართული',
kab: 'Taqbaylit',
kk: 'Қазақша',
kn: 'ಕನ್ನಡ',
ko: '한국어',
ku: 'سۆرانی',
lt: 'Lietuvių',
lv: 'Latviešu',
mk: 'Македонски',
ml: 'മലയാളം',
mr: 'मराठी',
ms: 'Bahasa Melayu',
nl: 'Nederlands',
nn: 'Nynorsk',
no: 'Norsk',
oc: 'Occitan',
pl: 'Polski',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português (Portugal)',
pt: 'Português',
ro: 'Română',
ru: 'Русский',
sa: 'संस्कृतम्',
sc: 'Sardu',
si: 'සිංහල',
sk: 'Slovenčina',
sl: 'Slovenščina',
sq: 'Shqip',
'sr-Latn': 'Srpski (latinica)',
sr: 'Српски',
sv: 'Svenska',
ta: 'தமிழ்',
te: 'తెలుగు',
th: 'ไทย',
tr: 'Türkçe',
uk: 'Українська',
ur: 'اُردُو',
vi: 'Tiếng Việt',
zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ',
'zh-CN': '简体中文',
'zh-HK': '繁體中文(香港)',
'zh-TW': '繁體中文(臺灣)',
zh: '中文',
}.freeze
def human_locale(locale)
HUMAN_LOCALES[locale]
end
def filterable_languages
LanguageDetector.instance.language_names.select(&HUMAN_LOCALES.method(:key?))
LanguageDetector.instance.language_names.select(&LanguagesHelper::HUMAN_LOCALES.method(:key?))
end
def hash_to_object(hash)

View File

@ -39,6 +39,7 @@ export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
export const COMPOSE_SUGGESTION_IGNORE = 'COMPOSE_SUGGESTION_IGNORE';
export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
@ -285,12 +286,15 @@ export function uploadCompose(files) {
if (status === 200) {
dispatch(uploadComposeSuccess(data, f));
} else if (status === 202) {
let tryCount = 1;
const poll = () => {
api(getState).get(`/api/v1/media/${data.id}`).then(response => {
if (response.status === 200) {
dispatch(uploadComposeSuccess(response.data, f));
} else if (response.status === 206) {
setTimeout(() => poll(), 1000);
let retryAfter = (Math.log2(tryCount) || 1) * 1000;
tryCount += 1;
setTimeout(() => poll(), retryAfter);
}
}).catch(error => dispatch(uploadComposeFail(error)));
};
@ -562,13 +566,25 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']);
}
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
position,
token,
completion,
path,
});
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
// the suggestions are dismissed and the cursor moves forward.
if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
position,
token,
completion,
path,
});
} else {
dispatch({
type: COMPOSE_SUGGESTION_IGNORE,
position,
token,
completion,
path,
});
}
};
};

View File

@ -54,15 +54,16 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.poll = status.poll.id;
}
// Only calculate these values when status first encountered
// Otherwise keep the ones already in the reducer
if (normalOldStatus) {
// Only calculate these values when status first encountered and
// when the underlying values change. Otherwise keep the ones
// already in the reducer
if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) {
normalStatus.search_index = normalOldStatus.get('search_index');
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
} else {
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;

View File

@ -128,6 +128,9 @@ export function deleteStatusFail(id, error) {
};
};
export const updateStatus = status => dispatch =>
dispatch(importFetchedStatus(status));
export function fetchContext(id) {
return (dispatch, getState) => {
dispatch(fetchContextRequest(id));

View File

@ -10,6 +10,7 @@ import {
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
import { updateStatus } from './statuses';
import {
fetchAnnouncements,
updateAnnouncements,
@ -75,6 +76,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
case 'update':
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
break;
case 'status.update':
dispatch(updateStatus(JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;

View File

@ -0,0 +1,116 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'flavours/glitch/util/api';
import { FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import classNames from 'classnames';
import Skeleton from 'flavours/glitch/components/skeleton';
const percIncrease = (a, b) => {
let percent;
if (b !== 0) {
if (a !== 0) {
percent = (b - a) / a;
} else {
percent = 1;
}
} else if (b === 0 && a === 0) {
percent = 0;
} else {
percent = - 1;
}
return percent;
};
export default class Counter extends React.PureComponent {
static propTypes = {
measure: PropTypes.string.isRequired,
start_at: PropTypes.string.isRequired,
end_at: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
href: PropTypes.string,
params: PropTypes.object,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { measure, start_at, end_at, params } = this.props;
api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { label, href } = this.props;
const { loading, data } = this.state;
let content;
if (loading) {
content = (
<React.Fragment>
<span className='sparkline__value__total'><Skeleton width={43} /></span>
<span className='sparkline__value__change'><Skeleton width={43} /></span>
</React.Fragment>
);
} else {
const measure = data[0];
const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
content = (
<React.Fragment>
<span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
</React.Fragment>
);
}
const inner = (
<React.Fragment>
<div className='sparkline__value'>
{content}
</div>
<div className='sparkline__label'>
{label}
</div>
<div className='sparkline__graph'>
{!loading && (
<Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}>
<SparklinesCurve />
</Sparklines>
)}
</div>
</React.Fragment>
);
if (href) {
return (
<a href={href} className='sparkline'>
{inner}
</a>
);
} else {
return (
<div className='sparkline'>
{inner}
</div>
);
}
}
}

View File

@ -0,0 +1,93 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'flavours/glitch/util/api';
import { FormattedNumber } from 'react-intl';
import { roundTo10 } from 'flavours/glitch/util/numbers';
import Skeleton from 'flavours/glitch/components/skeleton';
export default class Dimension extends React.PureComponent {
static propTypes = {
dimension: PropTypes.string.isRequired,
start_at: PropTypes.string.isRequired,
end_at: PropTypes.string.isRequired,
limit: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
params: PropTypes.object,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { start_at, end_at, dimension, limit, params } = this.props;
api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { label, limit } = this.props;
const { loading, data } = this.state;
let content;
if (loading) {
content = (
<table>
<tbody>
{Array.from(Array(limit)).map((_, i) => (
<tr className='dimension__item' key={i}>
<td className='dimension__item__key'>
<Skeleton width={100} />
</td>
<td className='dimension__item__value'>
<Skeleton width={60} />
</td>
</tr>
))}
</tbody>
</table>
);
} else {
const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
content = (
<table>
<tbody>
{data[0].data.map(item => (
<tr className='dimension__item' key={item.key}>
<td className='dimension__item__key'>
<span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} />
<span title={item.key}>{item.human_key}</span>
</td>
<td className='dimension__item__value'>
{typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />}
</td>
</tr>
))}
</tbody>
</table>
);
}
return (
<div className='dimension'>
<h4>{label}</h4>
{content}
</div>
);
}
}

View File

@ -0,0 +1,159 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'flavours/glitch/util/api';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
const messages = defineMessages({
other: { id: 'report.categories.other', defaultMessage: 'Other' },
spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
});
class Category extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
selected: PropTypes.bool,
disabled: PropTypes.bool,
onSelect: PropTypes.func,
children: PropTypes.node,
};
handleClick = () => {
const { id, disabled, onSelect } = this.props;
if (!disabled) {
onSelect(id);
}
};
render () {
const { id, text, disabled, selected, children } = this.props;
return (
<div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
{selected && <input type='hidden' name='report[category]' value={id} />}
<div className='report-reason-selector__category__label'>
<span className={classNames('poll__input', { active: selected, disabled })} />
{text}
</div>
{(selected && children) && (
<div className='report-reason-selector__category__rules'>
{children}
</div>
)}
</div>
);
}
}
class Rule extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
selected: PropTypes.bool,
disabled: PropTypes.bool,
onToggle: PropTypes.func,
};
handleClick = () => {
const { id, disabled, onToggle } = this.props;
if (!disabled) {
onToggle(id);
}
};
render () {
const { id, text, disabled, selected } = this.props;
return (
<div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
<span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
{selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
{text}
</div>
);
}
}
export default @injectIntl
class ReportReasonSelector extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
category: PropTypes.string.isRequired,
rule_ids: PropTypes.arrayOf(PropTypes.string),
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
state = {
category: this.props.category,
rule_ids: this.props.rule_ids || [],
rules: [],
};
componentDidMount() {
api().get('/api/v1/instance').then(res => {
this.setState({
rules: res.data.rules,
});
}).catch(err => {
console.error(err);
});
}
_save = () => {
const { id, disabled } = this.props;
const { category, rule_ids } = this.state;
if (disabled) {
return;
}
api().put(`/api/v1/admin/reports/${id}`, {
category,
rule_ids,
}).catch(err => {
console.error(err);
});
};
handleSelect = id => {
this.setState({ category: id }, () => this._save());
};
handleToggle = id => {
const { rule_ids } = this.state;
if (rule_ids.includes(id)) {
this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
} else {
this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
}
};
render () {
const { disabled, intl } = this.props;
const { rules, category, rule_ids } = this.state;
return (
<div className='report-reason-selector'>
<Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
<Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
{rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
</Category>
</div>
);
}
}

View File

@ -0,0 +1,151 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'flavours/glitch/util/api';
import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
import classNames from 'classnames';
import { roundTo10 } from 'flavours/glitch/util/numbers';
const dateForCohort = cohort => {
switch(cohort.frequency) {
case 'day':
return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
default:
return <FormattedDate value={cohort.period} month='long' year='numeric' />;
}
};
export default class Retention extends React.PureComponent {
static propTypes = {
start_at: PropTypes.string,
end_at: PropTypes.string,
frequency: PropTypes.string,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { start_at, end_at, frequency } = this.props;
api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { loading, data } = this.state;
const { frequency } = this.props;
let content;
if (loading) {
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
} else {
content = (
<table className='retention__table'>
<thead>
<tr>
<th>
<div className='retention__table__date retention__table__label'>
<FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
</div>
</th>
<th>
<div className='retention__table__number retention__table__label'>
<FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
</div>
</th>
{data[0].data.slice(1).map((retention, i) => (
<th key={retention.date}>
<div className='retention__table__number retention__table__label'>
{i + 1}
</div>
</th>
))}
</tr>
<tr>
<td>
<div className='retention__table__date retention__table__average'>
<FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
</div>
</td>
<td>
<div className='retention__table__size'>
<FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
</div>
</td>
{data[0].data.slice(1).map((retention, i) => {
const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0);
return (
<td key={retention.date}>
<div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}>
<FormattedNumber value={average} style='percent' />
</div>
</td>
);
})}
</tr>
</thead>
<tbody>
{data.slice(0, -1).map(cohort => (
<tr key={cohort.period}>
<td>
<div className='retention__table__date'>
{dateForCohort(cohort)}
</div>
</td>
<td>
<div className='retention__table__size'>
<FormattedNumber value={cohort.data[0].value} />
</div>
</td>
{cohort.data.slice(1).map(retention => (
<td key={retention.date}>
<div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.rate * 100)}`)}>
<FormattedNumber value={retention.rate} style='percent' />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
let title = null;
switch(frequency) {
case 'day':
title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />;
break;
default:
title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />;
};
return (
<div className='retention'>
<h4>{title}</h4>
{content}
</div>
);
}
}

View File

@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'flavours/glitch/util/api';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Hashtag from 'flavours/glitch/components/hashtag';
export default class Trends extends React.PureComponent {
static propTypes = {
limit: PropTypes.number.isRequired,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { limit } = this.props;
api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { limit } = this.props;
const { loading, data } = this.state;
let content;
if (loading) {
content = (
<div>
{Array.from(Array(limit)).map((_, i) => (
<Hashtag key={i} />
))}
</div>
);
} else {
content = (
<div>
{data.map(hashtag => (
<Hashtag
key={hashtag.name}
name={hashtag.name}
href={`/admin/tags/${hashtag.id}`}
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
history={hashtag.history.reverse().map(day => day.uses)}
className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
/>
))}
</div>
);
}
return (
<div className='trends trends--compact'>
<h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
{content}
</div>
);
}
}

View File

@ -6,6 +6,8 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink';
import ShortNumber from 'flavours/glitch/components/short_number';
import Skeleton from 'flavours/glitch/components/skeleton';
import classNames from 'classnames';
class SilentErrorBoundary extends React.Component {
@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
/>
);
const Hashtag = ({ hashtag }) => (
<div className='trends__item'>
export const ImmutableHashtag = ({ hashtag }) => (
<Hashtag
name={hashtag.get('name')}
href={hashtag.get('url')}
to={`/tags/${hashtag.get('name')}`}
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
/>
);
ImmutableHashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
const Hashtag = ({ name, href, to, people, uses, history, className }) => (
<div className={classNames('trends__item', className)}>
<div className='trends__item__name'>
<Permalink
href={hashtag.get('url')}
to={`/tags/${hashtag.get('name')}`}
>
#<span>{hashtag.get('name')}</span>
<Permalink href={href} to={to}>
{name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
</Permalink>
<ShortNumber
value={
hashtag.getIn(['history', 0, 'accounts']) * 1 +
hashtag.getIn(['history', 1, 'accounts']) * 1
}
renderer={accountsCountRenderer}
/>
{typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
</div>
<div className='trends__item__current'>
<ShortNumber
value={
hashtag.getIn(['history', 0, 'uses']) * 1 +
hashtag.getIn(['history', 1, 'uses']) * 1
}
/>
{typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
</div>
<div className='trends__item__sparkline'>
<SilentErrorBoundary>
<Sparklines
width={50}
height={28}
data={hashtag
.get('history')
.reverse()
.map((day) => day.get('uses'))
.toArray()}
>
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</SilentErrorBoundary>
@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
);
Hashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
name: PropTypes.string,
href: PropTypes.string,
to: PropTypes.string,
people: PropTypes.number,
uses: PropTypes.number,
history: PropTypes.arrayOf(PropTypes.number),
className: PropTypes.string,
};
export default Hashtag;

View File

@ -12,8 +12,18 @@ import RelativeTimestamp from './relative_timestamp';
import Icon from 'flavours/glitch/components/icon';
const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' },
voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
closed: {
id: 'poll.closed',
defaultMessage: 'Closed',
},
voted: {
id: 'poll.voted',
defaultMessage: 'You voted for this answer',
},
votes: {
id: 'poll.votes',
defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
},
});
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
@ -148,9 +158,16 @@ class Poll extends ImmutablePureComponent {
data-index={optionIndex}
/>
)}
{showResults && <span className='poll__number'>
{Math.round(percent)}%
</span>}
{showResults && (
<span
className='poll__number'
title={intl.formatMessage(messages.votes, {
votes: option.get('votes_count'),
})}
>
{Math.round(percent)}%
</span>
)}
<span
className='poll__option__text translate'

View File

@ -0,0 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;
Skeleton.propTypes = {
width: PropTypes.number,
height: PropTypes.number,
};
export default Skeleton;

View File

@ -425,6 +425,7 @@ class Status extends ImmutablePureComponent {
handleHotkeyOpen = () => {
let state = {...this.context.router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
const status = this.props.status;
this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`, state);
}

View File

@ -38,6 +38,7 @@ const messages = defineMessages({
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});
export default @injectIntl
@ -196,6 +197,7 @@ class StatusActionBar extends ImmutablePureComponent {
const anonymousAccess = !me;
const mutingConversation = status.get('muted');
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
const writtenByMe = status.getIn(['account', 'id']) === me;
let menu = [];
@ -212,7 +214,7 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push(null);
if (writtenByMe && publicStatus) {
if (writtenByMe && pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
}
@ -323,7 +325,9 @@ class StatusActionBar extends ImmutablePureComponent {
</div>,
]}
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a>
</div>
);
}

View File

@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from 'mastodon/locales';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
export default class AdminComponent extends React.PureComponent {
static propTypes = {
locale: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
render () {
const { locale, children } = this.props;
return (
<IntlProvider locale={locale} messages={messages}>
{children}
</IntlProvider>
);
}
}

View File

@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales';
import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar';
import MediaGallery from 'flavours/glitch/components/media_gallery';
import Poll from 'flavours/glitch/components/poll';
import Hashtag from 'flavours/glitch/components/hashtag';
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
import ModalRoot from 'flavours/glitch/components/modal_root';
import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
import Video from 'flavours/glitch/features/video';

View File

@ -58,6 +58,7 @@ class ComposeForm extends ImmutablePureComponent {
onPickEmoji: PropTypes.func,
showSearch: PropTypes.bool,
anyMedia: PropTypes.bool,
isInReply: PropTypes.bool,
singleColumn: PropTypes.bool,
advancedOptions: ImmutablePropTypes.map,
@ -233,7 +234,7 @@ class ComposeForm extends ImmutablePureComponent {
// Caret/selection handling.
if (focusDate !== prevProps.focusDate) {
switch (true) {
case preselectDate !== prevProps.preselectDate && preselectOnReply:
case preselectDate !== prevProps.preselectDate && this.props.isInReply && preselectOnReply:
selectionStart = text.search(/\s/) + 1;
selectionEnd = text.length;
break;

View File

@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from 'flavours/glitch/components/hashtag';
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
import Icon from 'flavours/glitch/components/icon';
import { searchEnabled } from 'flavours/glitch/util/initial_state';
import LoadMore from 'flavours/glitch/components/load_more';

View File

@ -68,6 +68,7 @@ function mapStateToProps (state) {
spoilersAlwaysOn: spoilersAlwaysOn,
mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
};
};

View File

@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Hashtag from 'flavours/glitch/components/hashtag';
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
import { FormattedMessage } from 'react-intl';
export default class Trends extends ImmutablePureComponent {

View File

@ -33,8 +33,8 @@ class ColumnSettings extends React.PureComponent {
tags (mode) {
let tags = this.props.settings.getIn(['tags', mode]) || [];
if (tags.toJSON) {
return tags.toJSON();
if (tags.toJS) {
return tags.toJS();
} else {
return tags;
}

View File

@ -11,21 +11,22 @@ const mapStateToProps = (state, { columnId }) => {
return {};
}
return { settings: columns.get(index).get('params') };
return {
settings: columns.get(index).get('params'),
onLoad (value) {
return api(() => state).get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
return (response.data.hashtags || []).map((tag) => {
return { value: tag.name, label: `#${tag.name}` };
});
});
},
};
};
const mapDispatchToProps = (dispatch, { columnId }) => ({
onChange (key, value) {
dispatch(changeColumnParams(columnId, key, value));
},
onLoad (value) {
return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
return (response.data.hashtags || []).map((tag) => {
return { value: tag.name, label: `#${tag.name}` };
});
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@ -27,11 +27,12 @@ export default class ColumnSettings extends React.PureComponent {
render () {
const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
@ -58,11 +59,11 @@ export default class ColumnSettings extends React.PureComponent {
<div role='group' aria-labelledby='notifications-unread-markers'>
<span id='notifications-unread-markers' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.unread_markers.category' defaultMessage='Unread notification markers' />
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
</span>
<div className='column-settings__row'>
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={filterShowStr} />
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={unreadMarkersShowStr} />
</div>
</div>
@ -72,7 +73,7 @@ export default class ColumnSettings extends React.PureComponent {
</span>
<div className='column-settings__row'>
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} />
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
</div>
</div>

View File

@ -146,6 +146,7 @@ class ActionBar extends React.PureComponent {
const { status, intl } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
const mutingConversation = status.get('muted');
const writtenByMe = status.getIn(['account', 'id']) === me;
@ -158,7 +159,7 @@ class ActionBar extends React.PureComponent {
}
if (writtenByMe) {
if (publicStatus) {
if (pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
}

View File

@ -7,7 +7,7 @@ import StatusContent from 'flavours/glitch/components/status_content';
import MediaGallery from 'flavours/glitch/components/media_gallery';
import AttachmentList from 'flavours/glitch/components/attachment_list';
import { Link } from 'react-router-dom';
import { FormattedDate } from 'react-intl';
import { injectIntl, FormattedDate, FormattedMessage } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from 'flavours/glitch/features/video';
@ -20,7 +20,8 @@ import Icon from 'flavours/glitch/components/icon';
import AnimatedNumber from 'flavours/glitch/components/animated_number';
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
export default class DetailedStatus extends ImmutablePureComponent {
export default @injectIntl
class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@ -40,6 +41,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
showMedia: PropTypes.bool,
usingPiP: PropTypes.bool,
onToggleMediaVisibility: PropTypes.func,
intl: PropTypes.object.isRequired,
};
state = {
@ -111,7 +113,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const { expanded, onToggleHidden, settings, usingPiP } = this.props;
const { expanded, onToggleHidden, settings, usingPiP, intl } = this.props;
const outerStyle = { boxSizing: 'border-box' };
const { compact } = this.props;
@ -125,6 +127,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
let reblogLink = '';
let reblogIcon = 'retweet';
let favouriteLink = '';
let edited = '';
if (this.props.measureHeight) {
outerStyle.height = `${this.state.height}px`;
@ -258,6 +261,15 @@ export default class DetailedStatus extends ImmutablePureComponent {
);
}
if (status.get('edited_at')) {
edited = (
<React.Fragment>
<React.Fragment> · </React.Fragment>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
</React.Fragment>
);
}
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
@ -283,7 +295,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More