mirror of
https://github.com/mastodon/mastodon.git
synced 2025-07-24 21:28:16 +00:00
Compare commits
50 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
547634dfa6 | ||
![]() |
f90daf58db | ||
![]() |
a42b48ea4e | ||
![]() |
251dd0b72b | ||
![]() |
18840cbc6e | ||
![]() |
727126255a | ||
![]() |
98d654b8bb | ||
![]() |
25c517144c | ||
![]() |
f036546c22 | ||
![]() |
9256d653a5 | ||
![]() |
d0c0808ad4 | ||
![]() |
cb622b23b1 | ||
![]() |
fe866f8afb | ||
![]() |
a1e765991e | ||
![]() |
76b9f42712 | ||
![]() |
708e590117 | ||
![]() |
a717aa929c | ||
![]() |
bbb7c54367 | ||
![]() |
282596a66e | ||
![]() |
e6f6fe6106 | ||
![]() |
86b1adf7d7 | ||
![]() |
4beeec4e50 | ||
![]() |
3c44ba0411 | ||
![]() |
339d4fa61c | ||
![]() |
62f0eab635 | ||
![]() |
8c8d578e38 | ||
![]() |
a8a3e86216 | ||
![]() |
be1caad933 | ||
![]() |
84a40824ad | ||
![]() |
533bf92d21 | ||
![]() |
6a2b48190c | ||
![]() |
6cbc589990 | ||
![]() |
a2bfb16cb8 | ||
![]() |
cfc0507010 | ||
![]() |
eade64097c | ||
![]() |
1f0be21317 | ||
![]() |
0ca877f084 | ||
![]() |
cc233af129 | ||
![]() |
83f1c6460a | ||
![]() |
e26dd2ea8f | ||
![]() |
da5d81c90d | ||
![]() |
ee66f5790f | ||
![]() |
696f7b3608 | ||
![]() |
b22e1476ca | ||
![]() |
105ab82425 | ||
![]() |
2dd8f977e8 | ||
![]() |
2db06e1d08 | ||
![]() |
063579373e | ||
![]() |
1659788de4 | ||
![]() |
47eaf85f02 |
|
@ -68,7 +68,9 @@ jobs:
|
||||||
cache-version: v1
|
cache-version: v1
|
||||||
pkg-manager: yarn
|
pkg-manager: yarn
|
||||||
- run:
|
- run:
|
||||||
command: ./bin/rails assets:precompile
|
command: |
|
||||||
|
export NODE_OPTIONS=--openssl-legacy-provider
|
||||||
|
./bin/rails assets:precompile
|
||||||
name: Precompile assets
|
name: Precompile assets
|
||||||
- persist_to_workspace:
|
- persist_to_workspace:
|
||||||
paths:
|
paths:
|
||||||
|
|
44
.github/workflows/build-image.yml
vendored
44
.github/workflows/build-image.yml
vendored
|
@ -10,33 +10,55 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- .github/workflows/build-image.yml
|
- .github/workflows/build-image.yml
|
||||||
- Dockerfile
|
- Dockerfile
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-image:
|
build-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: docker/setup-qemu-action@v1
|
- uses: docker/setup-qemu-action@v2
|
||||||
- uses: docker/setup-buildx-action@v1
|
- uses: docker/setup-buildx-action@v2
|
||||||
- uses: docker/login-action@v1
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request'
|
||||||
- uses: docker/metadata-action@v3
|
|
||||||
|
- name: Log in to the Github Container registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
if: github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request'
|
||||||
|
|
||||||
|
- uses: docker/metadata-action@v4
|
||||||
id: meta
|
id: meta
|
||||||
with:
|
with:
|
||||||
images: tootsuite/mastodon
|
images: |
|
||||||
|
tootsuite/mastodon
|
||||||
|
ghcr.io/mastodon/mastodon
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=auto
|
latest=auto
|
||||||
tags: |
|
tags: |
|
||||||
type=edge,branch=main
|
type=edge,branch=main
|
||||||
type=match,pattern=v(.*),group=0
|
type=match,pattern=v(.*),group=0
|
||||||
type=ref,event=pr
|
type=ref,event=pr
|
||||||
- uses: docker/build-push-action@v2
|
|
||||||
|
- uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
provenance: false
|
||||||
|
builder: ${{ steps.buildx.outputs.name }}
|
||||||
|
push: ${{ github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
cache-from: type=registry,ref=tootsuite/mastodon:latest
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-to: type=inline
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
68
CHANGELOG.md
68
CHANGELOG.md
|
@ -3,6 +3,74 @@ Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
# [3.5.7] - 2023-03-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add `lang` attribute to native language names in language picker in Web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23749))
|
||||||
|
- Add headers to outgoing mails to avoid auto-replies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23597))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix “Remove all followers from the selected domains” being more destructive than it claims ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23805))
|
||||||
|
- Fix case-sensitive check for previously used hashtags in hashtag autocompletion ([deanveloper](https://github.com/mastodon/mastodon/pull/23526))
|
||||||
|
- Fix inefficiency when searching accounts per username in admin interface ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23801))
|
||||||
|
- Fix server error when failing to follow back followers from `/relationships` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23787))
|
||||||
|
- Fix original account being unfollowed on migration before the follow request to the new account could be sent ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21957))
|
||||||
|
- Fix pgBouncer resetting application name on every transaction ([Gargron](https://github.com/mastodon/mastodon/pull/23958))
|
||||||
|
- Fix unconfirmed accounts being counted as active users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23803))
|
||||||
|
- Fix `/api/v1/streaming` sub-paths not being redirected ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23988))
|
||||||
|
- Fix drag'n'drop upload area text that spans multiple lines not being centered ([vintprox](https://github.com/mastodon/mastodon/pull/24029))
|
||||||
|
- Fix sidekiq jobs not triggering Elasticsearch index updates ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24046))
|
||||||
|
- Fix missing null check on applications on strike disputes ([kescherCode](https://github.com/mastodon/mastodon/pull/19851))
|
||||||
|
- Fix dashboard crash on ElasticSearch server error ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23751))
|
||||||
|
- Fix incorrect post links in strikes when the account is remote ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23611))
|
||||||
|
- Fix misleading error code when receiving invalid WebAuthn credentials ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23568))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Change user backups to use expiring URLs for download when possible ([Gargron](https://github.com/mastodon/mastodon/pull/24136))
|
||||||
|
- Add warning for object storage misconfiguration ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24137))
|
||||||
|
|
||||||
|
## [3.5.6] - 2023-02-09
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Fix changing domain block severity not undoing individual account effects** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23480))
|
||||||
|
- Fix suspension worker crashing on S3-compatible setups without ACL support ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23481))
|
||||||
|
- Fix possible race conditions when suspending/unsuspending accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23482))
|
||||||
|
- Fix some performance issues with `/admin/instances` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23483))
|
||||||
|
- Fix voter count not being cleared when a poll is reset ([afontenot](https://github.com/mastodon/mastodon/pull/23484))
|
||||||
|
- Fix attachments of edited statuses not being fetched ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23485))
|
||||||
|
- Fix 500 error when marking posts as sensitive while some of them are deleted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23486))
|
||||||
|
- Fix user clean-up scheduler crash when an unconfirmed account has a moderation note ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23487))
|
||||||
|
- Fix pending account approval and rejection not being recorded in the admin audit log ([FrancisMurillo](https://github.com/mastodon/mastodon/pull/23488))
|
||||||
|
- Fix replies sometimes being delivered to user-blocked domains ([tribela](https://github.com/mastodon/mastodon/pull/23490))
|
||||||
|
- Fix sanitizer parsing link text as HTML when stripping unsupported links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23491))
|
||||||
|
- Fix REST API serializer for `Account` not including `moved` when the moved account has itself moved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23492))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Add `form-action` CSP directive ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23478))
|
||||||
|
- Fix unbounded recursion in account discovery ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22026))
|
||||||
|
- Fix unbounded recursion in post discovery ([ClearlyClaire,nametoolong](https://github.com/mastodon/mastodon/pull/23507))
|
||||||
|
|
||||||
|
## [3.5.5] - 2022-11-14
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Fix nodes order being sometimes mangled when rewriting emoji ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20677))
|
||||||
|
|
||||||
|
## [3.5.4] - 2022-11-14
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix error when a remote report includes a private post the server has no access to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18760))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Fix emoji substitution not applying only to text nodes in backend code ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20641))
|
||||||
|
- Fix emoji substitution not applying only to text nodes in Web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20640))
|
||||||
|
- Fix rate limiting for paths with formats ([Gargron](https://github.com/mastodon/mastodon/pull/20675))
|
||||||
|
- Fix out-of-bound reads in blurhash transcoder ([delroth](https://github.com/mastodon/mastodon/pull/20388))
|
||||||
|
|
||||||
## [3.5.3] - 2022-05-26
|
## [3.5.3] - 2022-05-26
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|
1
Gemfile
1
Gemfile
|
@ -66,6 +66,7 @@ gem 'oj', '~> 3.13'
|
||||||
gem 'ox', '~> 2.14'
|
gem 'ox', '~> 2.14'
|
||||||
gem 'parslet'
|
gem 'parslet'
|
||||||
gem 'posix-spawn'
|
gem 'posix-spawn'
|
||||||
|
gem 'public_suffix', '~> 4.0.7'
|
||||||
gem 'pundit', '~> 2.2'
|
gem 'pundit', '~> 2.2'
|
||||||
gem 'premailer-rails'
|
gem 'premailer-rails'
|
||||||
gem 'rack-attack', '~> 6.6'
|
gem 'rack-attack', '~> 6.6'
|
||||||
|
|
|
@ -803,6 +803,7 @@ DEPENDENCIES
|
||||||
private_address_check (~> 0.5)
|
private_address_check (~> 0.5)
|
||||||
pry-byebug (~> 3.9)
|
pry-byebug (~> 3.9)
|
||||||
pry-rails (~> 0.3)
|
pry-rails (~> 0.3)
|
||||||
|
public_suffix (~> 4.0.7)
|
||||||
puma (~> 5.6)
|
puma (~> 5.6)
|
||||||
pundit (~> 2.2)
|
pundit (~> 2.2)
|
||||||
rack (~> 2.2.3)
|
rack (~> 2.2.3)
|
||||||
|
|
|
@ -5,13 +5,11 @@
|
||||||
[][circleci]
|
[][circleci]
|
||||||
[][code_climate]
|
[][code_climate]
|
||||||
[][crowdin]
|
[][crowdin]
|
||||||
[][docker]
|
|
||||||
|
|
||||||
[releases]: https://github.com/mastodon/mastodon/releases
|
[releases]: https://github.com/mastodon/mastodon/releases
|
||||||
[circleci]: https://circleci.com/gh/mastodon/mastodon
|
[circleci]: https://circleci.com/gh/mastodon/mastodon
|
||||||
[code_climate]: https://codeclimate.com/github/mastodon/mastodon
|
[code_climate]: https://codeclimate.com/github/mastodon/mastodon
|
||||||
[crowdin]: https://crowdin.com/project/mastodon
|
[crowdin]: https://crowdin.com/project/mastodon
|
||||||
[docker]: https://hub.docker.com/r/tootsuite/mastodon/
|
|
||||||
|
|
||||||
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub)!
|
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub)!
|
||||||
|
|
||||||
|
@ -28,6 +26,7 @@ Click below to **learn more** in a video:
|
||||||
- [View sponsors](https://joinmastodon.org/sponsors)
|
- [View sponsors](https://joinmastodon.org/sponsors)
|
||||||
- [Blog](https://blog.joinmastodon.org)
|
- [Blog](https://blog.joinmastodon.org)
|
||||||
- [Documentation](https://docs.joinmastodon.org)
|
- [Documentation](https://docs.joinmastodon.org)
|
||||||
|
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
||||||
- [Browse Mastodon servers](https://joinmastodon.org/communities)
|
- [Browse Mastodon servers](https://joinmastodon.org/communities)
|
||||||
- [Browse Mastodon apps](https://joinmastodon.org/apps)
|
- [Browse Mastodon apps](https://joinmastodon.org/apps)
|
||||||
|
|
||||||
|
|
|
@ -49,12 +49,14 @@ module Admin
|
||||||
def approve
|
def approve
|
||||||
authorize @account.user, :approve?
|
authorize @account.user, :approve?
|
||||||
@account.user.approve!
|
@account.user.approve!
|
||||||
|
log_action :approve, @account.user
|
||||||
redirect_to admin_accounts_path(status: 'pending'), 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
|
end
|
||||||
|
|
||||||
def reject
|
def reject
|
||||||
authorize @account.user, :reject?
|
authorize @account.user, :reject?
|
||||||
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
|
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
|
||||||
|
log_action :reject, @account.user
|
||||||
redirect_to admin_accounts_path(status: 'pending'), 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
|
end
|
||||||
|
|
||||||
|
|
|
@ -43,12 +43,8 @@ module Admin
|
||||||
def update
|
def update
|
||||||
authorize :domain_block, :update?
|
authorize :domain_block, :update?
|
||||||
|
|
||||||
@domain_block.update(update_params)
|
if @domain_block.update(update_params)
|
||||||
|
DomainBlockWorker.perform_async(@domain_block.id, @domain_block.severity_previously_changed?)
|
||||||
severity_changed = @domain_block.severity_changed?
|
|
||||||
|
|
||||||
if @domain_block.save
|
|
||||||
DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
|
|
||||||
log_action :update, @domain_block
|
log_action :update, @domain_block
|
||||||
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
|
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
|
||||||
else
|
else
|
||||||
|
|
|
@ -57,7 +57,7 @@ module Admin
|
||||||
end
|
end
|
||||||
|
|
||||||
def preload_delivery_failures!
|
def preload_delivery_failures!
|
||||||
warning_domains_map = DeliveryFailureTracker.warning_domains_map
|
warning_domains_map = DeliveryFailureTracker.warning_domains_map(@instances.map(&:domain))
|
||||||
|
|
||||||
@instances.each do |instance|
|
@instances.each do |instance|
|
||||||
instance.failure_days = warning_domains_map[instance.domain]
|
instance.failure_days = warning_domains_map[instance.domain]
|
||||||
|
|
|
@ -54,12 +54,14 @@ class Api::V1::Admin::AccountsController < Api::BaseController
|
||||||
def approve
|
def approve
|
||||||
authorize @account.user, :approve?
|
authorize @account.user, :approve?
|
||||||
@account.user.approve!
|
@account.user.approve!
|
||||||
|
log_action :approve, @account.user
|
||||||
render json: @account, serializer: REST::Admin::AccountSerializer
|
render json: @account, serializer: REST::Admin::AccountSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject
|
def reject
|
||||||
authorize @account.user, :reject?
|
authorize @account.user, :reject?
|
||||||
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
|
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
|
||||||
|
log_action :reject, @account.user
|
||||||
render json: @account, serializer: REST::Admin::AccountSerializer
|
render json: @account, serializer: REST::Admin::AccountSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,10 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
before_action :set_instance_presenter, only: [:new]
|
before_action :set_instance_presenter, only: [:new]
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
|
|
||||||
|
content_security_policy only: :new do |p|
|
||||||
|
p.form_action(false)
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
super do |resource|
|
super do |resource|
|
||||||
# We only need to call this if this hasn't already been
|
# We only need to call this if this hasn't already been
|
||||||
|
|
27
app/controllers/backups_controller.rb
Normal file
27
app/controllers/backups_controller.rb
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class BackupsController < ApplicationController
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_backup
|
||||||
|
|
||||||
|
def download
|
||||||
|
case Paperclip::Attachment.default_options[:storage]
|
||||||
|
when :s3
|
||||||
|
redirect_to @backup.dump.expiring_url(10)
|
||||||
|
when :fog
|
||||||
|
redirect_to @backup.dump.expiring_url(Time.now.utc + 10)
|
||||||
|
when :filesystem
|
||||||
|
redirect_to full_asset_url(@backup.dump.url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_backup
|
||||||
|
@backup = current_user.backups.find(params[:id])
|
||||||
|
end
|
||||||
|
end
|
|
@ -7,6 +7,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||||
before_action :authenticate_resource_owner!
|
before_action :authenticate_resource_owner!
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
|
||||||
|
content_security_policy do |p|
|
||||||
|
p.form_action(false)
|
||||||
|
end
|
||||||
|
|
||||||
include Localized
|
include Localized
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -19,6 +19,8 @@ class RelationshipsController < ApplicationController
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
# Do nothing
|
# Do nothing
|
||||||
|
rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound
|
||||||
|
flash[:alert] = I18n.t('relationships.follow_failure') if action_from_button == 'follow'
|
||||||
ensure
|
ensure
|
||||||
redirect_to relationships_path(filter_params)
|
redirect_to relationships_path(filter_params)
|
||||||
end
|
end
|
||||||
|
@ -60,8 +62,8 @@ class RelationshipsController < ApplicationController
|
||||||
'unfollow'
|
'unfollow'
|
||||||
elsif params[:remove_from_followers]
|
elsif params[:remove_from_followers]
|
||||||
'remove_from_followers'
|
'remove_from_followers'
|
||||||
elsif params[:block_domains]
|
elsif params[:block_domains] || params[:remove_domains_from_followers]
|
||||||
'block_domains'
|
'remove_domains_from_followers'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,7 @@ module Settings
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
||||||
status = :internal_server_error
|
status = :unprocessable_entity
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
flash[:error] = t('webauthn_credentials.create.error')
|
flash[:error] = t('webauthn_credentials.create.error')
|
||||||
|
|
|
@ -222,7 +222,7 @@ class LanguageDropdownMenu extends React.PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
|
<div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
|
||||||
<span className='language-dropdown__dropdown__results__item__native-name'>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
|
<span className='language-dropdown__dropdown__results__item__native-name' lang={lang[0]}>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,8 +11,8 @@ describe('emoji', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works with unclosed tags', () => {
|
it('works with unclosed tags', () => {
|
||||||
expect(emojify('hello>')).toEqual('hello>');
|
expect(emojify('hello>')).toEqual('hello>');
|
||||||
expect(emojify('<hello')).toEqual('<hello');
|
expect(emojify('<hello')).toEqual('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works with unclosed shortcodes', () => {
|
it('works with unclosed shortcodes', () => {
|
||||||
|
@ -22,23 +22,23 @@ describe('emoji', () => {
|
||||||
|
|
||||||
it('does unicode', () => {
|
it('does unicode', () => {
|
||||||
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
|
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
|
||||||
'<img draggable="false" class="emojione" alt="👩👩👦👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg" />');
|
'<img draggable="false" class="emojione" alt="👩👩👦👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg">');
|
||||||
expect(emojify('👨👩👧👧')).toEqual(
|
expect(emojify('👨👩👧👧')).toEqual(
|
||||||
'<img draggable="false" class="emojione" alt="👨👩👧👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg" />');
|
'<img draggable="false" class="emojione" alt="👨👩👧👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg">');
|
||||||
expect(emojify('👩👩👦')).toEqual('<img draggable="false" class="emojione" alt="👩👩👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg" />');
|
expect(emojify('👩👩👦')).toEqual('<img draggable="false" class="emojione" alt="👩👩👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg">');
|
||||||
expect(emojify('\u2757')).toEqual(
|
expect(emojify('\u2757')).toEqual(
|
||||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
|
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does multiple unicode', () => {
|
it('does multiple unicode', () => {
|
||||||
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
|
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
|
||||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
|
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
|
||||||
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
|
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
|
||||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
|
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
|
||||||
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
|
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
|
||||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
|
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
|
||||||
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
|
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
|
||||||
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> bar');
|
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> bar');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores unicode inside of tags', () => {
|
it('ignores unicode inside of tags', () => {
|
||||||
|
@ -46,16 +46,16 @@ describe('emoji', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does multiple emoji properly (issue 5188)', () => {
|
it('does multiple emoji properly (issue 5188)', () => {
|
||||||
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
|
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
|
||||||
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
|
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does an emoji that has no shortcode', () => {
|
it('does an emoji that has no shortcode', () => {
|
||||||
expect(emojify('👁🗨')).toEqual('<img draggable="false" class="emojione" alt="👁🗨" title="" src="/emoji/1f441-200d-1f5e8.svg" />');
|
expect(emojify('👁🗨')).toEqual('<img draggable="false" class="emojione" alt="👁🗨" title="" src="/emoji/1f441-200d-1f5e8.svg">');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does an emoji whose filename is irregular', () => {
|
it('does an emoji whose filename is irregular', () => {
|
||||||
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg" />');
|
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg">');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('avoid emojifying on invisible text', () => {
|
it('avoid emojifying on invisible text', () => {
|
||||||
|
@ -67,26 +67,26 @@ describe('emoji', () => {
|
||||||
|
|
||||||
it('avoid emojifying on invisible text with nested tags', () => {
|
it('avoid emojifying on invisible text with nested tags', () => {
|
||||||
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
|
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
|
||||||
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
|
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
||||||
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
|
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
|
||||||
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
|
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
||||||
expect(emojify('<span class="invisible">😄<br/>😴</span>😇'))
|
expect(emojify('<span class="invisible">😄<br>😴</span>😇'))
|
||||||
.toEqual('<span class="invisible">😄<br/>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
|
.toEqual('<span class="invisible">😄<br>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips the textual presentation VS15 character', () => {
|
it('skips the textual presentation VS15 character', () => {
|
||||||
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
|
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
|
||||||
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734_border.svg" />');
|
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734_border.svg">');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does an simple emoji properly', () => {
|
it('does an simple emoji properly', () => {
|
||||||
expect(emojify('♀♂'))
|
expect(emojify('♀♂'))
|
||||||
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg" /><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg" />');
|
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg"><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg">');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does an emoji containing ZWJ properly', () => {
|
it('does an emoji containing ZWJ properly', () => {
|
||||||
expect(emojify('💂♀️💂♂️'))
|
expect(emojify('💂♀️💂♂️'))
|
||||||
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg" /><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg" />');
|
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg">');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -19,15 +19,26 @@ const emojiFilename = (filename) => {
|
||||||
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
|
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emojify = (str, customEmojis = {}) => {
|
const domParser = new DOMParser();
|
||||||
const tagCharsWithoutEmojis = '<&';
|
|
||||||
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
|
const emojifyTextNode = (node, customEmojis) => {
|
||||||
let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0;
|
let str = node.textContent;
|
||||||
|
|
||||||
|
const fragment = new DocumentFragment();
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
let match, i = 0, tag;
|
let match, i = 0;
|
||||||
while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) {
|
|
||||||
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
if (customEmojis === null) {
|
||||||
|
while (i < str.length && !(match = trie.search(str.slice(i)))) {
|
||||||
|
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (i < str.length && str[i] !== ':' && !(match = trie.search(str.slice(i)))) {
|
||||||
|
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rend, replacement = '';
|
let rend, replacement = '';
|
||||||
if (i === str.length) {
|
if (i === str.length) {
|
||||||
break;
|
break;
|
||||||
|
@ -35,8 +46,6 @@ const emojify = (str, customEmojis = {}) => {
|
||||||
if (!(() => {
|
if (!(() => {
|
||||||
rend = str.indexOf(':', i + 1) + 1;
|
rend = str.indexOf(':', i + 1) + 1;
|
||||||
if (!rend) return false; // no pair of ':'
|
if (!rend) return false; // no pair of ':'
|
||||||
const lt = str.indexOf('<', i + 1);
|
|
||||||
if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
|
|
||||||
const shortname = str.slice(i, rend);
|
const shortname = str.slice(i, rend);
|
||||||
// now got a replacee as ':shortname:'
|
// now got a replacee as ':shortname:'
|
||||||
// if you want additional emoji handler, add statements below which set replacement and return true.
|
// if you want additional emoji handler, add statements below which set replacement and return true.
|
||||||
|
@ -47,29 +56,6 @@ const emojify = (str, customEmojis = {}) => {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
})()) rend = ++i;
|
})()) rend = ++i;
|
||||||
} else if (tag >= 0) { // <, &
|
|
||||||
rend = str.indexOf('>;'[tag], i + 1) + 1;
|
|
||||||
if (!rend) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (tag === 0) {
|
|
||||||
if (invisible) {
|
|
||||||
if (str[i + 1] === '/') { // closing tag
|
|
||||||
if (!--invisible) {
|
|
||||||
tagChars = tagCharsWithEmojis;
|
|
||||||
}
|
|
||||||
} else if (str[rend - 2] !== '/') { // opening tag
|
|
||||||
invisible++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (str.startsWith('<span class="invisible">', i)) {
|
|
||||||
// avoid emojifying on invisible text
|
|
||||||
invisible = 1;
|
|
||||||
tagChars = tagCharsWithoutEmojis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i = rend;
|
|
||||||
} else { // matched to unicode emoji
|
} else { // matched to unicode emoji
|
||||||
const { filename, shortCode } = unicodeMapping[match];
|
const { filename, shortCode } = unicodeMapping[match];
|
||||||
const title = shortCode ? `:${shortCode}:` : '';
|
const title = shortCode ? `:${shortCode}:` : '';
|
||||||
|
@ -80,10 +66,43 @@ const emojify = (str, customEmojis = {}) => {
|
||||||
rend += 1;
|
rend += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rtn += str.slice(0, i) + replacement;
|
|
||||||
|
fragment.append(document.createTextNode(str.slice(0, i)));
|
||||||
|
if (replacement) {
|
||||||
|
fragment.append(domParser.parseFromString(replacement, 'text/html').documentElement.getElementsByTagName('img')[0]);
|
||||||
|
}
|
||||||
|
node.textContent = str.slice(0, i);
|
||||||
str = str.slice(rend);
|
str = str.slice(rend);
|
||||||
}
|
}
|
||||||
return rtn + str;
|
|
||||||
|
fragment.append(document.createTextNode(str));
|
||||||
|
node.parentElement.replaceChild(fragment, node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emojifyNode = (node, customEmojis) => {
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
switch(child.nodeType) {
|
||||||
|
case Node.TEXT_NODE:
|
||||||
|
emojifyTextNode(child, customEmojis);
|
||||||
|
break;
|
||||||
|
case Node.ELEMENT_NODE:
|
||||||
|
if (!child.classList.contains('invisible'))
|
||||||
|
emojifyNode(child, customEmojis);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emojify = (str, customEmojis = {}) => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = str;
|
||||||
|
|
||||||
|
if (!Object.keys(customEmojis).length)
|
||||||
|
customEmojis = null;
|
||||||
|
|
||||||
|
emojifyNode(wrapper, customEmojis);
|
||||||
|
|
||||||
|
return wrapper.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default emojify;
|
export default emojify;
|
||||||
|
|
|
@ -184,11 +184,12 @@ const ignoreSuggestion = (state, position, token, completion, path) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortHashtagsByUse = (state, tags) => {
|
const sortHashtagsByUse = (state, tags) => {
|
||||||
const personalHistory = state.get('tagHistory');
|
const personalHistory = state.get('tagHistory').map(tag => tag.toLowerCase());
|
||||||
|
|
||||||
return tags.sort((a, b) => {
|
const tagsWithLowercase = tags.map(t => ({ ...t, lowerName: t.name.toLowerCase() }));
|
||||||
const usedA = personalHistory.includes(a.name);
|
const sorted = tagsWithLowercase.sort((a, b) => {
|
||||||
const usedB = personalHistory.includes(b.name);
|
const usedA = personalHistory.includes(a.lowerName);
|
||||||
|
const usedB = personalHistory.includes(b.lowerName);
|
||||||
|
|
||||||
if (usedA === usedB) {
|
if (usedA === usedB) {
|
||||||
return 0;
|
return 0;
|
||||||
|
@ -198,6 +199,8 @@ const sortHashtagsByUse = (state, tags) => {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
sorted.forEach(tag => delete tag.lowerName);
|
||||||
|
return sorted;
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertEmoji = (state, position, emojiData, needsSpace) => {
|
const insertEmoji = (state, position, emojiData, needsSpace) => {
|
||||||
|
|
|
@ -4261,6 +4261,7 @@ a.status-card.compact:hover {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
color: $secondary-text-color;
|
color: $secondary-text-color;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
|
@ -106,7 +106,8 @@ class ActivityPub::Activity
|
||||||
actor_id = value_or_id(first_of_value(@object['attributedTo']))
|
actor_id = value_or_id(first_of_value(@object['attributedTo']))
|
||||||
|
|
||||||
if actor_id == @account.uri
|
if actor_id == @account.uri
|
||||||
return ActivityPub::Activity.factory({ 'type' => 'Create', 'actor' => actor_id, 'object' => @object }, @account).perform
|
virtual_object = { 'type' => 'Create', 'actor' => actor_id, 'object' => @object }
|
||||||
|
return ActivityPub::Activity.factory(virtual_object, @account, request_id: @options[:request_id]).perform
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -152,9 +153,9 @@ class ActivityPub::Activity
|
||||||
def fetch_remote_original_status
|
def fetch_remote_original_status
|
||||||
if object_uri.start_with?('http')
|
if object_uri.start_with?('http')
|
||||||
return if ActivityPub::TagManager.instance.local_uri?(object_uri)
|
return if ActivityPub::TagManager.instance.local_uri?(object_uri)
|
||||||
ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first)
|
ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
|
||||||
elsif @object['url'].present?
|
elsif @object['url'].present?
|
||||||
::FetchRemoteStatusService.new.call(@object['url'])
|
::FetchRemoteStatusService.new.call(@object['url'], request_id: @options[:request_id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -222,7 +222,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
return if tag['href'].blank?
|
return if tag['href'].blank?
|
||||||
|
|
||||||
account = account_from_uri(tag['href'])
|
account = account_from_uri(tag['href'])
|
||||||
account = ActivityPub::FetchRemoteAccountService.new.call(tag['href']) if account.nil?
|
account = ActivityPub::FetchRemoteAccountService.new.call(tag['href'], request_id: @options[:request_id]) if account.nil?
|
||||||
|
|
||||||
return if account.nil?
|
return if account.nil?
|
||||||
|
|
||||||
|
@ -327,18 +327,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
def resolve_thread(status)
|
def resolve_thread(status)
|
||||||
return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri)
|
return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri)
|
||||||
|
|
||||||
ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
|
ThreadResolveWorker.perform_async(status.id, in_reply_to_uri, { 'request_id' => @options[:request_id]})
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_replies(status)
|
def fetch_replies(status)
|
||||||
collection = @object['replies']
|
collection = @object['replies']
|
||||||
return if collection.nil?
|
return if collection.nil?
|
||||||
|
|
||||||
replies = ActivityPub::FetchRepliesService.new.call(status, collection, false)
|
replies = ActivityPub::FetchRepliesService.new.call(status, collection, allow_synchronous_requests: false, request_id: @options[:request_id])
|
||||||
return unless replies.nil?
|
return unless replies.nil?
|
||||||
|
|
||||||
uri = value_or_id(collection)
|
uri = value_or_id(collection)
|
||||||
ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil?
|
ActivityPub::FetchRepliesWorker.perform_async(status.id, uri, { 'request_id' => @options[:request_id]}) unless uri.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_from_uri(uri)
|
def conversation_from_uri(uri)
|
||||||
|
|
|
@ -18,7 +18,7 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||||
def update_account
|
def update_account
|
||||||
return reject_payload! if @account.uri != object_uri
|
return reject_payload! if @account.uri != object_uri
|
||||||
|
|
||||||
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
|
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true, request_id: @options[:request_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_status
|
def update_status
|
||||||
|
@ -28,6 +28,6 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||||
|
|
||||||
return if @status.nil?
|
return if @status.nil?
|
||||||
|
|
||||||
ActivityPub::ProcessStatusUpdateService.new.call(@status, @object)
|
ActivityPub::ProcessStatusUpdateService.new.call(@status, @object, request_id: @options[:request_id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Admin::SystemCheck
|
class Admin::SystemCheck
|
||||||
ACTIVE_CHECKS = [
|
ACTIVE_CHECKS = [
|
||||||
|
Admin::SystemCheck::MediaPrivacyCheck,
|
||||||
Admin::SystemCheck::DatabaseSchemaCheck,
|
Admin::SystemCheck::DatabaseSchemaCheck,
|
||||||
Admin::SystemCheck::SidekiqProcessCheck,
|
Admin::SystemCheck::SidekiqProcessCheck,
|
||||||
Admin::SystemCheck::RulesCheck,
|
Admin::SystemCheck::RulesCheck,
|
||||||
|
|
|
@ -20,7 +20,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
||||||
def running_version
|
def running_version
|
||||||
@running_version ||= begin
|
@running_version ||= begin
|
||||||
Chewy.client.info['version']['number']
|
Chewy.client.info['version']['number']
|
||||||
rescue Faraday::ConnectionFailed
|
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
105
app/lib/admin/system_check/media_privacy_check.rb
Normal file
105
app/lib/admin/system_check/media_privacy_check.rb
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::SystemCheck::MediaPrivacyCheck < Admin::SystemCheck::BaseCheck
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
def skip?
|
||||||
|
!current_user.can?(:view_devops)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pass?
|
||||||
|
check_media_uploads!
|
||||||
|
@failure_message.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
Admin::SystemCheck::Message.new(@failure_message, @failure_value, @failure_action, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_media_uploads!
|
||||||
|
if Rails.configuration.x.use_s3
|
||||||
|
check_media_listing_inaccessible_s3!
|
||||||
|
else
|
||||||
|
check_media_listing_inaccessible!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_media_listing_inaccessible!
|
||||||
|
full_url = full_asset_url(media_attachment.file.url(:original, false))
|
||||||
|
|
||||||
|
# Check if we can list the uploaded file. If true, that's an error
|
||||||
|
directory_url = Addressable::URI.parse(full_url)
|
||||||
|
directory_url.query = nil
|
||||||
|
filename = directory_url.path.gsub(%r{.*/}, '')
|
||||||
|
directory_url.path = directory_url.path.gsub(%r{/[^/]+\Z}, '/')
|
||||||
|
Request.new(:get, directory_url, allow_local: true).perform do |res|
|
||||||
|
if res.truncated_body&.include?(filename)
|
||||||
|
@failure_message = use_storage? ? :upload_check_privacy_error_object_storage : :upload_check_privacy_error
|
||||||
|
@failure_action = 'https://docs.joinmastodon.org/admin/optional/object-storage/#FS'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_media_listing_inaccessible_s3!
|
||||||
|
urls_to_check = []
|
||||||
|
paperclip_options = Paperclip::Attachment.default_options
|
||||||
|
s3_protocol = paperclip_options[:s3_protocol]
|
||||||
|
s3_host_alias = paperclip_options[:s3_host_alias]
|
||||||
|
s3_host_name = paperclip_options[:s3_host_name]
|
||||||
|
bucket_name = paperclip_options.dig(:s3_credentials, :bucket)
|
||||||
|
|
||||||
|
urls_to_check << "#{s3_protocol}://#{s3_host_alias}/" if s3_host_alias.present?
|
||||||
|
urls_to_check << "#{s3_protocol}://#{s3_host_name}/#{bucket_name}/"
|
||||||
|
urls_to_check.uniq.each do |full_url|
|
||||||
|
check_s3_listing!(full_url)
|
||||||
|
break if @failure_message.present?
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_s3_listing!(full_url)
|
||||||
|
bucket_url = Addressable::URI.parse(full_url)
|
||||||
|
bucket_url.path = bucket_url.path.delete_suffix(media_attachment.file.path(:original))
|
||||||
|
bucket_url.query = "max-keys=1&x-random=#{SecureRandom.hex(10)}"
|
||||||
|
Request.new(:get, bucket_url, allow_local: true).perform do |res|
|
||||||
|
if res.truncated_body&.include?('ListBucketResult')
|
||||||
|
@failure_message = :upload_check_privacy_error_object_storage
|
||||||
|
@failure_action = 'https://docs.joinmastodon.org/admin/optional/object-storage/#S3'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def media_attachment
|
||||||
|
@media_attachment ||= begin
|
||||||
|
attachment = Account.representative.media_attachments.first
|
||||||
|
if attachment.present?
|
||||||
|
attachment.touch # rubocop:disable Rails/SkipsModelValidations
|
||||||
|
attachment
|
||||||
|
else
|
||||||
|
create_test_attachment!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_test_attachment!
|
||||||
|
Tempfile.create(%w(test-upload .jpg), binmode: true) do |tmp_file|
|
||||||
|
tmp_file.write(
|
||||||
|
Base64.decode64(
|
||||||
|
'/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' \
|
||||||
|
'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' \
|
||||||
|
'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' \
|
||||||
|
'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/x' \
|
||||||
|
'ABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAA' \
|
||||||
|
'AAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q=='
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tmp_file.flush
|
||||||
|
Account.representative.media_attachments.create!(file: tmp_file)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,11 +1,12 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Admin::SystemCheck::Message
|
class Admin::SystemCheck::Message
|
||||||
attr_reader :key, :value, :action
|
attr_reader :key, :value, :action, :critical
|
||||||
|
|
||||||
def initialize(key, value = nil, action = nil)
|
def initialize(key, value = nil, action = nil, critical = false)
|
||||||
@key = key
|
@key = key
|
||||||
@value = value
|
@value = value
|
||||||
@action = action
|
@action = action
|
||||||
|
@critical = critical
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -65,8 +65,13 @@ class DeliveryFailureTracker
|
||||||
domains - UnavailableDomain.all.pluck(:domain)
|
domains - UnavailableDomain.all.pluck(:domain)
|
||||||
end
|
end
|
||||||
|
|
||||||
def warning_domains_map
|
def warning_domains_map(domains = nil)
|
||||||
warning_domains.index_with { |domain| redis.scard(exhausted_deliveries_key_by(domain)) }
|
if domains.nil?
|
||||||
|
warning_domains.index_with { |domain| redis.scard(exhausted_deliveries_key_by(domain)) }
|
||||||
|
else
|
||||||
|
domains -= UnavailableDomain.where(domain: domains).pluck(:domain)
|
||||||
|
domains.index_with { |domain| redis.scard(exhausted_deliveries_key_by(domain)) }.filter { |_, days| days.positive? }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -23,48 +23,40 @@ class EmojiFormatter
|
||||||
def to_s
|
def to_s
|
||||||
return html if custom_emojis.empty? || html.blank?
|
return html if custom_emojis.empty? || html.blank?
|
||||||
|
|
||||||
i = -1
|
tree = Nokogiri::HTML.fragment(html)
|
||||||
tag_open_index = nil
|
tree.xpath('./text()|.//text()[not(ancestor[@class="invisible"])]').to_a.each do |node|
|
||||||
inside_shortname = false
|
i = -1
|
||||||
shortname_start_index = -1
|
inside_shortname = false
|
||||||
invisible_depth = 0
|
shortname_start_index = -1
|
||||||
last_index = 0
|
last_index = 0
|
||||||
result = ''.dup
|
text = node.content
|
||||||
|
result = Nokogiri::XML::NodeSet.new(tree.document)
|
||||||
|
|
||||||
while i + 1 < html.size
|
while i + 1 < text.size
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
if invisible_depth.zero? && inside_shortname && html[i] == ':'
|
if inside_shortname && text[i] == ':'
|
||||||
inside_shortname = false
|
inside_shortname = false
|
||||||
shortcode = html[shortname_start_index + 1..i - 1]
|
shortcode = text[shortname_start_index + 1..i - 1]
|
||||||
char_after = html[i + 1]
|
char_after = text[i + 1]
|
||||||
|
|
||||||
next unless (char_after.nil? || !DISALLOWED_BOUNDING_REGEX.match?(char_after)) && (emoji = emoji_map[shortcode])
|
next unless (char_after.nil? || !DISALLOWED_BOUNDING_REGEX.match?(char_after)) && (emoji = emoji_map[shortcode])
|
||||||
|
|
||||||
result << html[last_index..shortname_start_index - 1] if shortname_start_index.positive?
|
result << Nokogiri::XML::Text.new(text[last_index..shortname_start_index - 1], tree.document) if shortname_start_index.positive?
|
||||||
result << image_for_emoji(shortcode, emoji)
|
result << Nokogiri::HTML.fragment(image_for_emoji(shortcode, emoji))
|
||||||
last_index = i + 1
|
|
||||||
elsif tag_open_index && html[i] == '>'
|
|
||||||
tag = html[tag_open_index..i]
|
|
||||||
tag_open_index = nil
|
|
||||||
|
|
||||||
if invisible_depth.positive?
|
last_index = i + 1
|
||||||
invisible_depth += count_tag_nesting(tag)
|
elsif text[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(text[i - 1]))
|
||||||
elsif tag == '<span class="invisible">'
|
inside_shortname = true
|
||||||
invisible_depth = 1
|
shortname_start_index = i
|
||||||
end
|
end
|
||||||
elsif html[i] == '<'
|
|
||||||
tag_open_index = i
|
|
||||||
inside_shortname = false
|
|
||||||
elsif !tag_open_index && html[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(html[i - 1]))
|
|
||||||
inside_shortname = true
|
|
||||||
shortname_start_index = i
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
result << Nokogiri::XML::Text.new(text[last_index..-1], tree.document)
|
||||||
|
node.replace(result)
|
||||||
end
|
end
|
||||||
|
|
||||||
result << html[last_index..-1]
|
tree.to_html.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
|
|
||||||
result.html_safe # rubocop:disable Rails/OutputSafety
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -70,7 +70,7 @@ class StatusReachFinder
|
||||||
|
|
||||||
def followers_inboxes
|
def followers_inboxes
|
||||||
if @status.in_reply_to_local_account? && distributable?
|
if @status.in_reply_to_local_account? && distributable?
|
||||||
@status.account.followers.or(@status.thread.account.followers).inboxes
|
@status.account.followers.or(@status.thread.account.followers.not_domain_blocked_by_account(@status.account)).inboxes
|
||||||
elsif @status.direct_visibility? || @status.limited_visibility?
|
elsif @status.direct_visibility? || @status.limited_visibility?
|
||||||
[]
|
[]
|
||||||
else
|
else
|
||||||
|
|
|
@ -7,6 +7,8 @@ class ApplicationMailer < ActionMailer::Base
|
||||||
helper :instance
|
helper :instance
|
||||||
helper :formatting
|
helper :formatting
|
||||||
|
|
||||||
|
after_action :set_autoreply_headers!
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def locale_for_account(account)
|
def locale_for_account(account)
|
||||||
|
@ -14,4 +16,10 @@ class ApplicationMailer < ActionMailer::Base
|
||||||
yield
|
yield
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_autoreply_headers!
|
||||||
|
headers['Precedence'] = 'list'
|
||||||
|
headers['X-Auto-Response-Suppress'] = 'All'
|
||||||
|
headers['Auto-Submitted'] = 'auto-generated'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -106,7 +106,7 @@ class Account < ApplicationRecord
|
||||||
scope :bots, -> { where(actor_type: %w(Application Service)) }
|
scope :bots, -> { where(actor_type: %w(Application Service)) }
|
||||||
scope :groups, -> { where(actor_type: 'Group') }
|
scope :groups, -> { where(actor_type: 'Group') }
|
||||||
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
|
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
|
||||||
scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
|
scope :matches_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") }
|
||||||
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
||||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||||
scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) }
|
scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) }
|
||||||
|
|
|
@ -73,7 +73,7 @@ class Admin::StatusBatchAction
|
||||||
# Can't use a transaction here because UpdateStatusService queues
|
# Can't use a transaction here because UpdateStatusService queues
|
||||||
# Sidekiq jobs
|
# Sidekiq jobs
|
||||||
statuses.includes(:media_attachments, :preview_cards).find_each do |status|
|
statuses.includes(:media_attachments, :preview_cards).find_each do |status|
|
||||||
next unless status.with_media? || status.with_preview_card?
|
next if status.discarded? || !(status.with_media? || status.with_preview_card?)
|
||||||
|
|
||||||
authorize(status, :update?)
|
authorize(status, :update?)
|
||||||
|
|
||||||
|
@ -89,15 +89,15 @@ class Admin::StatusBatchAction
|
||||||
report.resolve!(current_account)
|
report.resolve!(current_account)
|
||||||
log_action(:resolve, report)
|
log_action(:resolve, report)
|
||||||
end
|
end
|
||||||
|
|
||||||
@warning = target_account.strikes.create!(
|
|
||||||
action: :mark_statuses_as_sensitive,
|
|
||||||
account: current_account,
|
|
||||||
report: report,
|
|
||||||
status_ids: status_ids
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@warning = target_account.strikes.create!(
|
||||||
|
action: :mark_statuses_as_sensitive,
|
||||||
|
account: current_account,
|
||||||
|
report: report,
|
||||||
|
status_ids: status_ids
|
||||||
|
)
|
||||||
|
|
||||||
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
|
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,6 @@
|
||||||
class Backup < ApplicationRecord
|
class Backup < ApplicationRecord
|
||||||
belongs_to :user, inverse_of: :backups
|
belongs_to :user, inverse_of: :backups
|
||||||
|
|
||||||
has_attached_file :dump
|
has_attached_file :dump, s3_permissions: 'private'
|
||||||
do_not_validate_attachment_file_type :dump
|
do_not_validate_attachment_file_type :dump
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,11 +3,24 @@
|
||||||
module DomainMaterializable
|
module DomainMaterializable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
include Redisable
|
||||||
|
|
||||||
included do
|
included do
|
||||||
after_create_commit :refresh_instances_view
|
after_create_commit :refresh_instances_view
|
||||||
end
|
end
|
||||||
|
|
||||||
def refresh_instances_view
|
def refresh_instances_view
|
||||||
Instance.refresh unless domain.nil? || Instance.where(domain: domain).exists?
|
return if domain.nil? || Instance.exists?(domain: domain)
|
||||||
|
|
||||||
|
Instance.refresh
|
||||||
|
count_unique_subdomains!
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_unique_subdomains!
|
||||||
|
second_and_top_level_domain = PublicSuffix.domain(domain, ignore_private: true)
|
||||||
|
with_redis do |redis|
|
||||||
|
redis.pfadd("unique_subdomains_for:#{second_and_top_level_domain}", domain)
|
||||||
|
redis.expire("unique_subdomains_for:#{second_and_top_level_domain}", 1.minute.seconds)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,8 +16,8 @@ class Form::AccountBatch
|
||||||
unfollow!
|
unfollow!
|
||||||
when 'remove_from_followers'
|
when 'remove_from_followers'
|
||||||
remove_from_followers!
|
remove_from_followers!
|
||||||
when 'block_domains'
|
when 'remove_domains_from_followers'
|
||||||
block_domains!
|
remove_domains_from_followers!
|
||||||
when 'approve'
|
when 'approve'
|
||||||
approve!
|
approve!
|
||||||
when 'reject'
|
when 'reject'
|
||||||
|
@ -34,9 +34,15 @@ class Form::AccountBatch
|
||||||
private
|
private
|
||||||
|
|
||||||
def follow!
|
def follow!
|
||||||
|
error = nil
|
||||||
|
|
||||||
accounts.each do |target_account|
|
accounts.each do |target_account|
|
||||||
FollowService.new.call(current_account, target_account)
|
FollowService.new.call(current_account, target_account)
|
||||||
|
rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound => e
|
||||||
|
error ||= e
|
||||||
end
|
end
|
||||||
|
|
||||||
|
raise error if error.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def unfollow!
|
def unfollow!
|
||||||
|
@ -49,10 +55,8 @@ class Form::AccountBatch
|
||||||
RemoveFromFollowersService.new.call(current_account, account_ids)
|
RemoveFromFollowersService.new.call(current_account, account_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
def block_domains!
|
def remove_domains_from_followers!
|
||||||
AfterAccountDomainBlockWorker.push_bulk(account_domains) do |domain|
|
RemoveDomainsFromFollowersService.new.call(current_account, account_domains)
|
||||||
[current_account.id, domain]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_domains
|
def account_domains
|
||||||
|
|
|
@ -85,6 +85,7 @@ class Poll < ApplicationRecord
|
||||||
def reset_votes!
|
def reset_votes!
|
||||||
self.cached_tallies = options.map { 0 }
|
self.cached_tallies = options.map { 0 }
|
||||||
self.votes_count = 0
|
self.votes_count = 0
|
||||||
|
self.voters_count = 0
|
||||||
votes.delete_all unless new_record?
|
votes.delete_all unless new_record?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -442,10 +442,13 @@ class User < ApplicationRecord
|
||||||
def prepare_new_user!
|
def prepare_new_user!
|
||||||
BootstrapTimelineWorker.perform_async(account_id)
|
BootstrapTimelineWorker.perform_async(account_id)
|
||||||
ActivityTracker.increment('activity:accounts:local')
|
ActivityTracker.increment('activity:accounts:local')
|
||||||
|
ActivityTracker.record('activity:logins', id)
|
||||||
UserMailer.welcome(self).deliver_later
|
UserMailer.welcome(self).deliver_later
|
||||||
end
|
end
|
||||||
|
|
||||||
def prepare_returning_user!
|
def prepare_returning_user!
|
||||||
|
return unless confirmed?
|
||||||
|
|
||||||
ActivityTracker.record('activity:logins', id)
|
ActivityTracker.record('activity:logins', id)
|
||||||
regenerate_feed! if needs_feed_update?
|
regenerate_feed! if needs_feed_update?
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,6 +15,16 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
||||||
attribute :suspended, if: :suspended?
|
attribute :suspended, if: :suspended?
|
||||||
attribute :silenced, key: :limited, if: :silenced?
|
attribute :silenced, key: :limited, if: :silenced?
|
||||||
|
|
||||||
|
class AccountDecorator < SimpleDelegator
|
||||||
|
def self.model_name
|
||||||
|
Account.model_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def moved?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class FieldSerializer < ActiveModel::Serializer
|
class FieldSerializer < ActiveModel::Serializer
|
||||||
include FormattingHelper
|
include FormattingHelper
|
||||||
|
|
||||||
|
@ -84,7 +94,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def moved_to_account
|
def moved_to_account
|
||||||
object.suspended? ? nil : object.moved_to_account
|
object.suspended? ? nil : AccountDecorator.new(object.moved_to_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def emojis
|
def emojis
|
||||||
|
@ -106,6 +116,6 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
||||||
delegate :suspended?, :silenced?, to: :object
|
delegate :suspended?, :silenced?, to: :object
|
||||||
|
|
||||||
def moved_and_not_nested?
|
def moved_and_not_nested?
|
||||||
object.moved? && object.moved_to_account.moved_to_account_id.nil?
|
object.moved?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,10 +3,11 @@
|
||||||
class ActivityPub::FetchFeaturedCollectionService < BaseService
|
class ActivityPub::FetchFeaturedCollectionService < BaseService
|
||||||
include JsonLdHelper
|
include JsonLdHelper
|
||||||
|
|
||||||
def call(account)
|
def call(account, **options)
|
||||||
return if account.featured_collection_url.blank? || account.suspended? || account.local?
|
return if account.featured_collection_url.blank? || account.suspended? || account.local?
|
||||||
|
|
||||||
@account = account
|
@account = account
|
||||||
|
@options = options
|
||||||
@json = fetch_resource(@account.featured_collection_url, true, local_follower)
|
@json = fetch_resource(@account.featured_collection_url, true, local_follower)
|
||||||
|
|
||||||
return unless supported_context?(@json)
|
return unless supported_context?(@json)
|
||||||
|
@ -38,9 +39,9 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
|
||||||
def process_items(items)
|
def process_items(items)
|
||||||
status_ids = items.filter_map do |item|
|
status_ids = items.filter_map do |item|
|
||||||
uri = value_or_id(item)
|
uri = value_or_id(item)
|
||||||
next if ActivityPub::TagManager.instance.local_uri?(uri)
|
next if ActivityPub::TagManager.instance.local_uri?(uri) || invalid_origin?(uri)
|
||||||
|
|
||||||
status = ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: local_follower)
|
status = ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: local_follower, expected_actor_uri: @account.uri, request_id: @options[:request_id])
|
||||||
next unless status&.account_id == @account.id
|
next unless status&.account_id == @account.id
|
||||||
|
|
||||||
status.id
|
status.id
|
||||||
|
|
|
@ -8,7 +8,7 @@ class ActivityPub::FetchRemoteAccountService < BaseService
|
||||||
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
||||||
|
|
||||||
# Does a WebFinger roundtrip on each call, unless `only_key` is true
|
# Does a WebFinger roundtrip on each call, unless `only_key` is true
|
||||||
def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false)
|
def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, request_id: nil)
|
||||||
return if domain_not_allowed?(uri)
|
return if domain_not_allowed?(uri)
|
||||||
return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri)
|
return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri)
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ class ActivityPub::FetchRemoteAccountService < BaseService
|
||||||
|
|
||||||
return unless only_key || verified_webfinger?
|
return unless only_key || verified_webfinger?
|
||||||
|
|
||||||
ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key)
|
ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key, request_id: request_id)
|
||||||
rescue Oj::ParseError
|
rescue Oj::ParseError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,9 +2,13 @@
|
||||||
|
|
||||||
class ActivityPub::FetchRemoteStatusService < BaseService
|
class ActivityPub::FetchRemoteStatusService < BaseService
|
||||||
include JsonLdHelper
|
include JsonLdHelper
|
||||||
|
include Redisable
|
||||||
|
|
||||||
|
DISCOVERIES_PER_REQUEST = 1000
|
||||||
|
|
||||||
# Should be called when uri has already been checked for locality
|
# Should be called when uri has already been checked for locality
|
||||||
def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil)
|
def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
|
||||||
|
@request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
|
||||||
@json = begin
|
@json = begin
|
||||||
if prefetched_body.nil?
|
if prefetched_body.nil?
|
||||||
fetch_resource(uri, id, on_behalf_of)
|
fetch_resource(uri, id, on_behalf_of)
|
||||||
|
@ -30,6 +34,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
||||||
end
|
end
|
||||||
|
|
||||||
return if activity_json.nil? || object_uri.nil? || !trustworthy_attribution?(@json['id'], actor_uri)
|
return if activity_json.nil? || object_uri.nil? || !trustworthy_attribution?(@json['id'], actor_uri)
|
||||||
|
return if expected_actor_uri.present? && actor_uri != expected_actor_uri
|
||||||
return ActivityPub::TagManager.instance.uri_to_resource(object_uri, Status) if ActivityPub::TagManager.instance.local_uri?(object_uri)
|
return ActivityPub::TagManager.instance.uri_to_resource(object_uri, Status) if ActivityPub::TagManager.instance.local_uri?(object_uri)
|
||||||
|
|
||||||
actor = account_from_uri(actor_uri)
|
actor = account_from_uri(actor_uri)
|
||||||
|
@ -40,7 +45,13 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
||||||
# activity as an update rather than create
|
# activity as an update rather than create
|
||||||
activity_json['type'] = 'Update' if equals_or_includes_any?(activity_json['type'], %w(Create)) && Status.where(uri: object_uri, account_id: actor.id).exists?
|
activity_json['type'] = 'Update' if equals_or_includes_any?(activity_json['type'], %w(Create)) && Status.where(uri: object_uri, account_id: actor.id).exists?
|
||||||
|
|
||||||
ActivityPub::Activity.factory(activity_json, actor).perform
|
with_redis do |redis|
|
||||||
|
discoveries = redis.incr("status_discovery_per_request:#{@request_id}")
|
||||||
|
redis.expire("status_discovery_per_request:#{@request_id}", 5.minutes.seconds)
|
||||||
|
return nil if discoveries > DISCOVERIES_PER_REQUEST
|
||||||
|
end
|
||||||
|
|
||||||
|
ActivityPub::Activity.factory(activity_json, actor, request_id: @request_id).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -52,7 +63,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
||||||
|
|
||||||
def account_from_uri(uri)
|
def account_from_uri(uri)
|
||||||
actor = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
|
actor = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
|
||||||
actor = ActivityPub::FetchRemoteAccountService.new.call(uri, id: true) if actor.nil? || actor.possibly_stale?
|
actor = ActivityPub::FetchRemoteAccountService.new.call(uri, id: true, request_id: @request_id) if actor.nil? || actor.possibly_stale?
|
||||||
actor
|
actor
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3,14 +3,14 @@
|
||||||
class ActivityPub::FetchRepliesService < BaseService
|
class ActivityPub::FetchRepliesService < BaseService
|
||||||
include JsonLdHelper
|
include JsonLdHelper
|
||||||
|
|
||||||
def call(parent_status, collection_or_uri, allow_synchronous_requests = true)
|
def call(parent_status, collection_or_uri, allow_synchronous_requests: true, request_id: nil)
|
||||||
@account = parent_status.account
|
@account = parent_status.account
|
||||||
@allow_synchronous_requests = allow_synchronous_requests
|
@allow_synchronous_requests = allow_synchronous_requests
|
||||||
|
|
||||||
@items = collection_items(collection_or_uri)
|
@items = collection_items(collection_or_uri)
|
||||||
return if @items.nil?
|
return if @items.nil?
|
||||||
|
|
||||||
FetchReplyWorker.push_bulk(filtered_replies)
|
FetchReplyWorker.push_bulk(filtered_replies) { |reply_uri| [reply_uri, { 'request_id' => request_id}] }
|
||||||
|
|
||||||
@items
|
@items
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,6 +6,9 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||||
include Redisable
|
include Redisable
|
||||||
include Lockable
|
include Lockable
|
||||||
|
|
||||||
|
SUBDOMAINS_RATELIMIT = 10
|
||||||
|
DISCOVERIES_PER_REQUEST = 400
|
||||||
|
|
||||||
# Should be called with confirmed valid JSON
|
# Should be called with confirmed valid JSON
|
||||||
# and WebFinger-resolved username and domain
|
# and WebFinger-resolved username and domain
|
||||||
def call(username, domain, json, options = {})
|
def call(username, domain, json, options = {})
|
||||||
|
@ -15,9 +18,12 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||||
@json = json
|
@json = json
|
||||||
@uri = @json['id']
|
@uri = @json['id']
|
||||||
@username = username
|
@username = username
|
||||||
@domain = domain
|
@domain = TagManager.instance.normalize_domain(domain)
|
||||||
@collections = {}
|
@collections = {}
|
||||||
|
|
||||||
|
# The key does not need to be unguessable, it just needs to be somewhat unique
|
||||||
|
@options[:request_id] ||= "#{Time.now.utc.to_i}-#{username}@#{domain}"
|
||||||
|
|
||||||
with_lock("process_account:#{@uri}") do
|
with_lock("process_account:#{@uri}") do
|
||||||
@account = Account.remote.find_by(uri: @uri) if @options[:only_key]
|
@account = Account.remote.find_by(uri: @uri) if @options[:only_key]
|
||||||
@account ||= Account.find_remote(@username, @domain)
|
@account ||= Account.find_remote(@username, @domain)
|
||||||
|
@ -25,7 +31,18 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||||
@old_protocol = @account&.protocol
|
@old_protocol = @account&.protocol
|
||||||
@suspension_changed = false
|
@suspension_changed = false
|
||||||
|
|
||||||
create_account if @account.nil?
|
if @account.nil?
|
||||||
|
with_redis do |redis|
|
||||||
|
return nil if redis.pfcount("unique_subdomains_for:#{PublicSuffix.domain(@domain, ignore_private: true)}") >= SUBDOMAINS_RATELIMIT
|
||||||
|
|
||||||
|
discoveries = redis.incr("discovery_per_request:#{@options[:request_id]}")
|
||||||
|
redis.expire("discovery_per_request:#{@options[:request_id]}", 5.minutes.seconds)
|
||||||
|
return nil if discoveries > DISCOVERIES_PER_REQUEST
|
||||||
|
end
|
||||||
|
|
||||||
|
create_account
|
||||||
|
end
|
||||||
|
|
||||||
update_account
|
update_account
|
||||||
process_tags
|
process_tags
|
||||||
|
|
||||||
|
@ -149,7 +166,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_featured_collection!
|
def check_featured_collection!
|
||||||
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
|
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id, { 'request_id' => @options[:request_id] })
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_links!
|
def check_links!
|
||||||
|
@ -249,7 +266,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||||
|
|
||||||
def moved_account
|
def moved_account
|
||||||
account = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
|
account = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
|
||||||
account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true)
|
account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true, request_id: @options[:request_id])
|
||||||
account
|
account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||||
include Redisable
|
include Redisable
|
||||||
include Lockable
|
include Lockable
|
||||||
|
|
||||||
def call(status, json)
|
def call(status, json, request_id: nil)
|
||||||
raise ArgumentError, 'Status has unsaved changes' if status.changed?
|
raise ArgumentError, 'Status has unsaved changes' if status.changed?
|
||||||
|
|
||||||
@json = json
|
@json = json
|
||||||
|
@ -15,6 +15,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||||
@account = status.account
|
@account = status.account
|
||||||
@media_attachments_changed = false
|
@media_attachments_changed = false
|
||||||
@poll_changed = false
|
@poll_changed = false
|
||||||
|
@request_id = request_id
|
||||||
|
|
||||||
# Only native types can be updated at the moment
|
# Only native types can be updated at the moment
|
||||||
return @status if !expected_type? || already_updated_more_recently?
|
return @status if !expected_type? || already_updated_more_recently?
|
||||||
|
@ -92,7 +93,13 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||||
|
|
||||||
next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
|
next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
|
||||||
|
|
||||||
RedownloadMediaWorker.perform_async(media_attachment.id) if media_attachment.remote_url_previously_changed? || media_attachment.thumbnail_remote_url_previously_changed?
|
begin
|
||||||
|
media_attachment.download_file! if media_attachment.remote_url_previously_changed?
|
||||||
|
media_attachment.download_thumbnail! if media_attachment.thumbnail_remote_url_previously_changed?
|
||||||
|
media_attachment.save
|
||||||
|
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
|
||||||
|
RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
|
||||||
|
end
|
||||||
rescue Addressable::URI::InvalidURIError => e
|
rescue Addressable::URI::InvalidURIError => e
|
||||||
Rails.logger.debug "Invalid URL in attachment: #{e}"
|
Rails.logger.debug "Invalid URL in attachment: #{e}"
|
||||||
end
|
end
|
||||||
|
@ -185,7 +192,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||||
next if href.blank?
|
next if href.blank?
|
||||||
|
|
||||||
account = ActivityPub::TagManager.instance.uri_to_resource(href, Account)
|
account = ActivityPub::TagManager.instance.uri_to_resource(href, Account)
|
||||||
account ||= ActivityPub::FetchRemoteAccountService.new.call(href)
|
account ||= ActivityPub::FetchRemoteAccountService.new.call(href, request_id: @request_id)
|
||||||
|
|
||||||
next if account.nil?
|
next if account.nil?
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class FetchRemoteStatusService < BaseService
|
class FetchRemoteStatusService < BaseService
|
||||||
def call(url, prefetched_body = nil)
|
def call(url, prefetched_body: nil, request_id: nil)
|
||||||
if prefetched_body.nil?
|
if prefetched_body.nil?
|
||||||
resource_url, resource_options = FetchResourceService.new.call(url)
|
resource_url, resource_options = FetchResourceService.new.call(url)
|
||||||
else
|
else
|
||||||
|
@ -9,6 +9,6 @@ class FetchRemoteStatusService < BaseService
|
||||||
resource_options = { prefetched_body: prefetched_body }
|
resource_options = { prefetched_body: prefetched_body }
|
||||||
end
|
end
|
||||||
|
|
||||||
ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options) unless resource_url.nil?
|
ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options.merge(request_id: request_id)) unless resource_url.nil?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
43
app/services/follow_migration_service.rb
Normal file
43
app/services/follow_migration_service.rb
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class FollowMigrationService < FollowService
|
||||||
|
# Follow an account with the same settings as another account, and unfollow the old account once the request is sent
|
||||||
|
# @param [Account] source_account From which to follow
|
||||||
|
# @param [Account] target_account Account to follow
|
||||||
|
# @param [Account] old_target_account Account to unfollow once the follow request has been sent to the new one
|
||||||
|
# @option [Boolean] bypass_locked Whether to immediately follow the new account even if it is locked
|
||||||
|
def call(source_account, target_account, old_target_account, bypass_locked: false)
|
||||||
|
@old_target_account = old_target_account
|
||||||
|
|
||||||
|
follow = source_account.active_relationships.find_by(target_account: old_target_account)
|
||||||
|
reblogs = follow&.show_reblogs?
|
||||||
|
notify = follow&.notify?
|
||||||
|
|
||||||
|
super(source_account, target_account, reblogs: reblogs, notify: notify, bypass_locked: bypass_locked, bypass_limit: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def request_follow!
|
||||||
|
follow_request = @source_account.request_follow!(@target_account, **follow_options.merge(rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]))
|
||||||
|
|
||||||
|
if @target_account.local?
|
||||||
|
LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
|
||||||
|
UnfollowService.new.call(@source_account, @old_target_account, skip_unmerge: true)
|
||||||
|
elsif @target_account.activitypub?
|
||||||
|
ActivityPub::MigratedFollowDeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url, @old_target_account.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
follow_request
|
||||||
|
end
|
||||||
|
|
||||||
|
def direct_follow!
|
||||||
|
follow = super
|
||||||
|
UnfollowService.new.call(@source_account, @old_target_account, skip_unmerge: true)
|
||||||
|
follow
|
||||||
|
end
|
||||||
|
|
||||||
|
def follow_options
|
||||||
|
@options.slice(:reblogs, :notify)
|
||||||
|
end
|
||||||
|
end
|
23
app/services/remove_domains_from_followers_service.rb
Normal file
23
app/services/remove_domains_from_followers_service.rb
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class RemoveDomainsFromFollowersService < BaseService
|
||||||
|
include Payloadable
|
||||||
|
|
||||||
|
def call(source_account, target_domains)
|
||||||
|
source_account.passive_relationships.where(account_id: Account.where(domain: target_domains)).find_each do |follow|
|
||||||
|
follow.destroy
|
||||||
|
|
||||||
|
create_notification(follow) if source_account.local? && !follow.account.local? && follow.account.activitypub?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_notification(follow)
|
||||||
|
ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.target_account_id, follow.account.inbox_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_json(follow)
|
||||||
|
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
|
||||||
|
end
|
||||||
|
end
|
|
@ -57,7 +57,16 @@ class ReportService < BaseService
|
||||||
end
|
end
|
||||||
|
|
||||||
def reported_status_ids
|
def reported_status_ids
|
||||||
AccountStatusesFilter.new(@target_account, @source_account).results.with_discarded.find(Array(@status_ids)).pluck(:id)
|
return AccountStatusesFilter.new(@target_account, @source_account).results.with_discarded.find(Array(@status_ids)).pluck(:id) if @source_account.local?
|
||||||
|
|
||||||
|
# If the account making reports is remote, it is likely anonymized so we have to relax the requirements for attaching statuses.
|
||||||
|
domain = @source_account.domain.to_s.downcase
|
||||||
|
has_followers = @target_account.followers.where(Account.arel_table[:domain].lower.eq(domain)).exists?
|
||||||
|
visibility = has_followers ? %i(public unlisted private) : %i(public unlisted)
|
||||||
|
scope = @target_account.statuses.with_discarded
|
||||||
|
scope.merge!(scope.where(visibility: visibility).or(scope.where('EXISTS (SELECT 1 FROM mentions m JOIN accounts a ON m.account_id = a.id WHERE lower(a.domain) = ?)', domain)))
|
||||||
|
# Allow missing posts to not drop reports that include e.g. a deleted post
|
||||||
|
scope.where(id: Array(@status_ids)).pluck(:id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def payload
|
def payload
|
||||||
|
|
|
@ -23,7 +23,7 @@ class ResolveURLService < BaseService
|
||||||
if equals_or_includes_any?(type, ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES)
|
if equals_or_includes_any?(type, ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES)
|
||||||
ActivityPub::FetchRemoteAccountService.new.call(resource_url, prefetched_body: body)
|
ActivityPub::FetchRemoteAccountService.new.call(resource_url, prefetched_body: body)
|
||||||
elsif equals_or_includes_any?(type, ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
|
elsif equals_or_includes_any?(type, ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
|
||||||
status = FetchRemoteStatusService.new.call(resource_url, body)
|
status = FetchRemoteStatusService.new.call(resource_url, prefetched_body: body)
|
||||||
authorize_with @on_behalf_of, status, :show? unless status.nil?
|
authorize_with @on_behalf_of, status, :show? unless status.nil?
|
||||||
status
|
status
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,10 +3,13 @@
|
||||||
class SuspendAccountService < BaseService
|
class SuspendAccountService < BaseService
|
||||||
include Payloadable
|
include Payloadable
|
||||||
|
|
||||||
|
# Carry out the suspension of a recently-suspended account
|
||||||
|
# @param [Account] account Account to suspend
|
||||||
def call(account)
|
def call(account)
|
||||||
|
return unless account.suspended?
|
||||||
|
|
||||||
@account = account
|
@account = account
|
||||||
|
|
||||||
suspend!
|
|
||||||
reject_remote_follows!
|
reject_remote_follows!
|
||||||
distribute_update_actor!
|
distribute_update_actor!
|
||||||
unmerge_from_home_timelines!
|
unmerge_from_home_timelines!
|
||||||
|
@ -16,10 +19,6 @@ class SuspendAccountService < BaseService
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def suspend!
|
|
||||||
@account.suspend! unless @account.suspended?
|
|
||||||
end
|
|
||||||
|
|
||||||
def reject_remote_follows!
|
def reject_remote_follows!
|
||||||
return if @account.local? || !@account.activitypub?
|
return if @account.local? || !@account.activitypub?
|
||||||
|
|
||||||
|
@ -76,10 +75,15 @@ class SuspendAccountService < BaseService
|
||||||
styles.each do |style|
|
styles.each do |style|
|
||||||
case Paperclip::Attachment.default_options[:storage]
|
case Paperclip::Attachment.default_options[:storage]
|
||||||
when :s3
|
when :s3
|
||||||
|
# Prevent useless S3 calls if ACLs are disabled
|
||||||
|
next if ENV['S3_PERMISSION'] == ''
|
||||||
|
|
||||||
begin
|
begin
|
||||||
attachment.s3_object(style).acl.put(acl: 'private')
|
attachment.s3_object(style).acl.put(acl: 'private')
|
||||||
rescue Aws::S3::Errors::NoSuchKey
|
rescue Aws::S3::Errors::NoSuchKey
|
||||||
Rails.logger.warn "Tried to change acl on non-existent key #{attachment.s3_object(style).key}"
|
Rails.logger.warn "Tried to change acl on non-existent key #{attachment.s3_object(style).key}"
|
||||||
|
rescue Aws::S3::Errors::NotImplemented => e
|
||||||
|
Rails.logger.error "Error trying to change ACL on #{attachment.s3_object(style).key}: #{e.message}"
|
||||||
end
|
end
|
||||||
when :fog
|
when :fog
|
||||||
# Not supported
|
# Not supported
|
||||||
|
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
class UnsuspendAccountService < BaseService
|
class UnsuspendAccountService < BaseService
|
||||||
include Payloadable
|
include Payloadable
|
||||||
|
|
||||||
|
# Restores a recently-unsuspended account
|
||||||
|
# @param [Account] account Account to restore
|
||||||
def call(account)
|
def call(account)
|
||||||
@account = account
|
@account = account
|
||||||
|
|
||||||
unsuspend!
|
|
||||||
refresh_remote_account!
|
refresh_remote_account!
|
||||||
|
|
||||||
return if @account.nil? || @account.suspended?
|
return if @account.nil? || @account.suspended?
|
||||||
|
@ -18,10 +20,6 @@ class UnsuspendAccountService < BaseService
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def unsuspend!
|
|
||||||
@account.unsuspend! if @account.suspended?
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh_remote_account!
|
def refresh_remote_account!
|
||||||
return if @account.local?
|
return if @account.local?
|
||||||
|
|
||||||
|
@ -73,10 +71,15 @@ class UnsuspendAccountService < BaseService
|
||||||
styles.each do |style|
|
styles.each do |style|
|
||||||
case Paperclip::Attachment.default_options[:storage]
|
case Paperclip::Attachment.default_options[:storage]
|
||||||
when :s3
|
when :s3
|
||||||
|
# Prevent useless S3 calls if ACLs are disabled
|
||||||
|
next if ENV['S3_PERMISSION'] == ''
|
||||||
|
|
||||||
begin
|
begin
|
||||||
attachment.s3_object(style).acl.put(acl: Paperclip::Attachment.default_options[:s3_permissions])
|
attachment.s3_object(style).acl.put(acl: Paperclip::Attachment.default_options[:s3_permissions])
|
||||||
rescue Aws::S3::Errors::NoSuchKey
|
rescue Aws::S3::Errors::NoSuchKey
|
||||||
Rails.logger.warn "Tried to change acl on non-existent key #{attachment.s3_object(style).key}"
|
Rails.logger.warn "Tried to change acl on non-existent key #{attachment.s3_object(style).key}"
|
||||||
|
rescue Aws::S3::Errors::NotImplemented => e
|
||||||
|
Rails.logger.error "Error trying to change ACL on #{attachment.s3_object(style).key}: #{e.message}"
|
||||||
end
|
end
|
||||||
when :fog
|
when :fog
|
||||||
# Not supported
|
# Not supported
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
- unless @system_checks.empty?
|
- unless @system_checks.empty?
|
||||||
.flash-message-stack
|
.flash-message-stack
|
||||||
- @system_checks.each do |message|
|
- @system_checks.each do |message|
|
||||||
.flash-message.warning
|
.flash-message{ class: message.critical ? 'alert' : 'warning' }
|
||||||
= t("admin.system_checks.#{message.key}.message_html", value: message.value ? content_tag(:strong, message.value) : nil)
|
= t("admin.system_checks.#{message.key}.message_html", value: message.value ? content_tag(:strong, message.value) : nil)
|
||||||
- if message.action
|
- if message.action
|
||||||
= link_to t("admin.system_checks.#{message.key}.action"), message.action
|
= link_to t("admin.system_checks.#{message.key}.action"), message.action
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
= link_to t('admin.reports.mark_as_resolved'), resolve_admin_report_path(@report), method: :post, class: 'button'
|
= link_to t('admin.reports.mark_as_resolved'), resolve_admin_report_path(@report), method: :post, class: 'button'
|
||||||
.report-actions__item__description
|
.report-actions__item__description
|
||||||
= t('admin.reports.actions.resolve_description_html')
|
= t('admin.reports.actions.resolve_description_html')
|
||||||
- if @statuses.any? { |status| status.with_media? || status.with_preview_card? }
|
- if @statuses.any? { |status| (status.with_media? || status.with_preview_card?) && !status.discarded? }
|
||||||
.report-actions__item
|
.report-actions__item
|
||||||
.report-actions__item__button
|
.report-actions__item__button
|
||||||
= button_tag t('admin.reports.mark_as_sensitive'), name: :mark_as_sensitive, class: 'button'
|
= button_tag t('admin.reports.mark_as_sensitive'), name: :mark_as_sensitive, class: 'button'
|
||||||
|
|
|
@ -50,17 +50,18 @@
|
||||||
.strike-card__statuses-list__item
|
.strike-card__statuses-list__item
|
||||||
- if (status = status_map[status_id.to_i])
|
- if (status = status_map[status_id.to_i])
|
||||||
.one-liner
|
.one-liner
|
||||||
= link_to short_account_status_url(@strike.target_account, status_id), class: 'emojify' do
|
.emojify= one_line_preview(status)
|
||||||
= one_line_preview(status)
|
|
||||||
|
|
||||||
- status.ordered_media_attachments.each do |media_attachment|
|
- status.ordered_media_attachments.each do |media_attachment|
|
||||||
%abbr{ title: media_attachment.description }
|
%abbr{ title: media_attachment.description }
|
||||||
= fa_icon 'link'
|
= fa_icon 'link'
|
||||||
= media_attachment.file_file_name
|
= media_attachment.file_file_name
|
||||||
.strike-card__statuses-list__item__meta
|
.strike-card__statuses-list__item__meta
|
||||||
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
|
= link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank' do
|
||||||
·
|
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
|
||||||
= status.application.name
|
- unless status.application.nil?
|
||||||
|
·
|
||||||
|
= status.application.name
|
||||||
- else
|
- else
|
||||||
.one-liner= t('disputes.strikes.status', id: status_id)
|
.one-liner= t('disputes.strikes.status', id: status_id)
|
||||||
.strike-card__statuses-list__item__meta
|
.strike-card__statuses-list__item__meta
|
||||||
|
|
|
@ -48,7 +48,7 @@
|
||||||
|
|
||||||
= f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_followers')]), name: :remove_from_followers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } unless following_relationship?
|
= f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_followers')]), name: :remove_from_followers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } unless following_relationship?
|
||||||
|
|
||||||
= f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_domains')]), name: :block_domains, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } if followed_by_relationship?
|
= f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_domains')]), name: :remove_domains_from_followers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } if followed_by_relationship?
|
||||||
.batch-table__body
|
.batch-table__body
|
||||||
- if @accounts.empty?
|
- if @accounts.empty?
|
||||||
= nothing_here 'nothing-here--under-tabs'
|
= nothing_here 'nothing-here--under-tabs'
|
||||||
|
|
|
@ -64,6 +64,6 @@
|
||||||
%td= l backup.created_at
|
%td= l backup.created_at
|
||||||
- if backup.processed?
|
- if backup.processed?
|
||||||
%td= number_to_human_size backup.dump_file_size
|
%td= number_to_human_size backup.dump_file_size
|
||||||
%td= table_link_to 'download', t('exports.archive_takeout.download'), backup.dump.url
|
%td= table_link_to 'download', t('exports.archive_takeout.download'), download_backup_url(backup)
|
||||||
- else
|
- else
|
||||||
%td{ colspan: 2 }= t('exports.archive_takeout.in_progress')
|
%td{ colspan: 2 }= t('exports.archive_takeout.in_progress')
|
||||||
|
|
|
@ -55,5 +55,5 @@
|
||||||
%tbody
|
%tbody
|
||||||
%tr
|
%tr
|
||||||
%td.button-primary
|
%td.button-primary
|
||||||
= link_to full_asset_url(@backup.dump.url) do
|
= link_to download_backup_url(@backup) do
|
||||||
%span= t 'exports.archive_takeout.download'
|
%span= t 'exports.archive_takeout.download'
|
||||||
|
|
|
@ -4,4 +4,4 @@
|
||||||
|
|
||||||
<%= t 'user_mailer.backup_ready.explanation' %>
|
<%= t 'user_mailer.backup_ready.explanation' %>
|
||||||
|
|
||||||
=> <%= full_asset_url(@backup.dump.url) %>
|
=> <%= download_backup_url(@backup) %>
|
||||||
|
|
|
@ -6,8 +6,8 @@ class ActivityPub::FetchRepliesWorker
|
||||||
|
|
||||||
sidekiq_options queue: 'pull', retry: 3
|
sidekiq_options queue: 'pull', retry: 3
|
||||||
|
|
||||||
def perform(parent_status_id, replies_uri)
|
def perform(parent_status_id, replies_uri, options = {})
|
||||||
ActivityPub::FetchRepliesService.new.call(Status.find(parent_status_id), replies_uri)
|
ActivityPub::FetchRepliesService.new.call(Status.find(parent_status_id), replies_uri, **options.deep_symbolize_keys)
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
17
app/workers/activitypub/migrated_follow_delivery_worker.rb
Normal file
17
app/workers/activitypub/migrated_follow_delivery_worker.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::MigratedFollowDeliveryWorker < ActivityPub::DeliveryWorker
|
||||||
|
def perform(json, source_account_id, inbox_url, old_target_account_id, options = {})
|
||||||
|
super(json, source_account_id, inbox_url, options)
|
||||||
|
unfollow_old_account!(old_target_account_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def unfollow_old_account!(old_target_account_id)
|
||||||
|
old_target_account = Account.find(old_target_account_id)
|
||||||
|
UnfollowService.new.call(@source_account, old_target_account, skip_unmerge: true)
|
||||||
|
rescue StandardError
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
|
@ -5,8 +5,10 @@ class ActivityPub::SynchronizeFeaturedCollectionWorker
|
||||||
|
|
||||||
sidekiq_options queue: 'pull', lock: :until_executed
|
sidekiq_options queue: 'pull', lock: :until_executed
|
||||||
|
|
||||||
def perform(account_id)
|
def perform(account_id, options = {})
|
||||||
ActivityPub::FetchFeaturedCollectionService.new.call(Account.find(account_id))
|
options = { note: true, hashtag: false }.deep_merge(options.deep_symbolize_keys)
|
||||||
|
|
||||||
|
ActivityPub::FetchFeaturedCollectionService.new.call(Account.find(account_id), **options)
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,7 +6,7 @@ class FetchReplyWorker
|
||||||
|
|
||||||
sidekiq_options queue: 'pull', retry: 3
|
sidekiq_options queue: 'pull', retry: 3
|
||||||
|
|
||||||
def perform(child_url)
|
def perform(child_url, options = {})
|
||||||
FetchRemoteStatusService.new.call(child_url)
|
FetchRemoteStatusService.new.call(child_url, **options.deep_symbolize_keys)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,6 +15,8 @@ class Scheduler::UserCleanupScheduler
|
||||||
|
|
||||||
def clean_unconfirmed_accounts!
|
def clean_unconfirmed_accounts!
|
||||||
User.where('confirmed_at is NULL AND confirmation_sent_at <= ?', 2.days.ago).reorder(nil).find_in_batches do |batch|
|
User.where('confirmed_at is NULL AND confirmation_sent_at <= ?', 2.days.ago).reorder(nil).find_in_batches do |batch|
|
||||||
|
# We have to do it separately because of missing database constraints
|
||||||
|
AccountModerationNote.where(target_account_id: batch.map(&:account_id)).delete_all
|
||||||
Account.where(id: batch.map(&:account_id)).delete_all
|
Account.where(id: batch.map(&:account_id)).delete_all
|
||||||
User.where(id: batch.map(&:id)).delete_all
|
User.where(id: batch.map(&:id)).delete_all
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,9 +6,9 @@ class ThreadResolveWorker
|
||||||
|
|
||||||
sidekiq_options queue: 'pull', retry: 3
|
sidekiq_options queue: 'pull', retry: 3
|
||||||
|
|
||||||
def perform(child_status_id, parent_url)
|
def perform(child_status_id, parent_url, options = {})
|
||||||
child_status = Status.find(child_status_id)
|
child_status = Status.find(child_status_id)
|
||||||
parent_status = FetchRemoteStatusService.new.call(parent_url)
|
parent_status = FetchRemoteStatusService.new.call(parent_url, **options.deep_symbolize_keys)
|
||||||
|
|
||||||
return if parent_status.nil?
|
return if parent_status.nil?
|
||||||
|
|
||||||
|
|
|
@ -10,12 +10,7 @@ class UnfollowFollowWorker
|
||||||
old_target_account = Account.find(old_target_account_id)
|
old_target_account = Account.find(old_target_account_id)
|
||||||
new_target_account = Account.find(new_target_account_id)
|
new_target_account = Account.find(new_target_account_id)
|
||||||
|
|
||||||
follow = follower_account.active_relationships.find_by(target_account: old_target_account)
|
FollowMigrationService.new.call(follower_account, new_target_account, old_target_account, bypass_locked: bypass_locked)
|
||||||
reblogs = follow&.show_reblogs?
|
|
||||||
notify = follow&.notify?
|
|
||||||
|
|
||||||
FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, notify: notify, bypass_locked: bypass_locked, bypass_limit: true)
|
|
||||||
UnfollowService.new.call(follower_account, old_target_account, skip_unmerge: true)
|
|
||||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,9 @@ require_relative '../config/boot'
|
||||||
require_relative '../lib/cli'
|
require_relative '../lib/cli'
|
||||||
|
|
||||||
begin
|
begin
|
||||||
Mastodon::CLI.start(ARGV)
|
Chewy.strategy(:mastodon) do
|
||||||
|
Mastodon::CLI.start(ARGV)
|
||||||
|
end
|
||||||
rescue Interrupt
|
rescue Interrupt
|
||||||
exit(130)
|
exit(130)
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,7 @@ image:
|
||||||
# built from the most recent commit
|
# built from the most recent commit
|
||||||
#
|
#
|
||||||
# tag: latest
|
# tag: latest
|
||||||
tag: v3.5.2
|
tag: v3.5.5
|
||||||
# use `Always` when using `latest` tag
|
# use `Always` when using `latest` tag
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ default: &default
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
encoding: unicode
|
encoding: unicode
|
||||||
sslmode: <%= ENV['DB_SSLMODE'] || "prefer" %>
|
sslmode: <%= ENV['DB_SSLMODE'] || "prefer" %>
|
||||||
|
application_name: ''
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *default
|
<<: *default
|
||||||
|
|
|
@ -19,7 +19,6 @@ Chewy.settings = {
|
||||||
# cycle, which takes care of checking if Elasticsearch is enabled
|
# cycle, which takes care of checking if Elasticsearch is enabled
|
||||||
# or not. However, mind that for the Rails console, the :urgent
|
# or not. However, mind that for the Rails console, the :urgent
|
||||||
# strategy is set automatically with no way to override it.
|
# strategy is set automatically with no way to override it.
|
||||||
Chewy.root_strategy = :mastodon
|
|
||||||
Chewy.request_strategy = :mastodon
|
Chewy.request_strategy = :mastodon
|
||||||
Chewy.use_after_commit_callbacks = false
|
Chewy.use_after_commit_callbacks = false
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ Rails.application.config.content_security_policy do |p|
|
||||||
p.media_src :self, :https, :data, assets_host
|
p.media_src :self, :https, :data, assets_host
|
||||||
p.frame_src :self, :https
|
p.frame_src :self, :https
|
||||||
p.manifest_src :self, assets_host
|
p.manifest_src :self, assets_host
|
||||||
|
p.form_action :self
|
||||||
|
|
||||||
if Rails.env.development?
|
if Rails.env.development?
|
||||||
webpacker_urls = %w(ws http).map { |protocol| "#{protocol}#{Webpacker.dev_server.https? ? 's' : ''}://#{Webpacker.dev_server.host_with_port}" }
|
webpacker_urls = %w(ws http).map { |protocol| "#{protocol}#{Webpacker.dev_server.https? ? 's' : ''}://#{Webpacker.dev_server.host_with_port}" }
|
||||||
|
|
|
@ -17,6 +17,18 @@ class Rack::Attack
|
||||||
@remote_ip ||= (@env["action_dispatch.remote_ip"] || ip).to_s
|
@remote_ip ||= (@env["action_dispatch.remote_ip"] || ip).to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def throttleable_remote_ip
|
||||||
|
@throttleable_remote_ip ||= begin
|
||||||
|
ip = IPAddr.new(remote_ip)
|
||||||
|
|
||||||
|
if ip.ipv6?
|
||||||
|
ip.mask(64)
|
||||||
|
else
|
||||||
|
ip
|
||||||
|
end
|
||||||
|
end.to_s
|
||||||
|
end
|
||||||
|
|
||||||
def authenticated_user_id
|
def authenticated_user_id
|
||||||
authenticated_token&.resource_owner_id
|
authenticated_token&.resource_owner_id
|
||||||
end
|
end
|
||||||
|
@ -29,6 +41,10 @@ class Rack::Attack
|
||||||
path.start_with?('/api')
|
path.start_with?('/api')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def path_matches?(other_path)
|
||||||
|
/\A#{Regexp.escape(other_path)}(\..*)?\z/ =~ path
|
||||||
|
end
|
||||||
|
|
||||||
def web_request?
|
def web_request?
|
||||||
!api_request?
|
!api_request?
|
||||||
end
|
end
|
||||||
|
@ -51,19 +67,19 @@ class Rack::Attack
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_unauthenticated_api', limit: 300, period: 5.minutes) do |req|
|
throttle('throttle_unauthenticated_api', limit: 300, period: 5.minutes) do |req|
|
||||||
req.remote_ip if req.api_request? && req.unauthenticated?
|
req.throttleable_remote_ip if req.api_request? && req.unauthenticated?
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_api_media', limit: 30, period: 30.minutes) do |req|
|
throttle('throttle_api_media', limit: 30, period: 30.minutes) do |req|
|
||||||
req.authenticated_user_id if req.post? && req.path.match?('^/api/v\d+/media')
|
req.authenticated_user_id if req.post? && req.path.match?(/\A\/api\/v\d+\/media\z/i)
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_media_proxy', limit: 30, period: 10.minutes) do |req|
|
throttle('throttle_media_proxy', limit: 30, period: 10.minutes) do |req|
|
||||||
req.remote_ip if req.path.start_with?('/media_proxy')
|
req.throttleable_remote_ip if req.path.start_with?('/media_proxy')
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_api_sign_up', limit: 5, period: 30.minutes) do |req|
|
throttle('throttle_api_sign_up', limit: 5, period: 30.minutes) do |req|
|
||||||
req.remote_ip if req.post? && req.path == '/api/v1/accounts'
|
req.throttleable_remote_ip if req.post? && req.path == '/api/v1/accounts'
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_authenticated_paging', limit: 300, period: 15.minutes) do |req|
|
throttle('throttle_authenticated_paging', limit: 300, period: 15.minutes) do |req|
|
||||||
|
@ -71,39 +87,34 @@ class Rack::Attack
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_unauthenticated_paging', limit: 300, period: 15.minutes) do |req|
|
throttle('throttle_unauthenticated_paging', limit: 300, period: 15.minutes) do |req|
|
||||||
req.remote_ip if req.paging_request? && req.unauthenticated?
|
req.throttleable_remote_ip if req.paging_request? && req.unauthenticated?
|
||||||
end
|
end
|
||||||
|
|
||||||
API_DELETE_REBLOG_REGEX = /\A\/api\/v1\/statuses\/[\d]+\/unreblog/.freeze
|
API_DELETE_REBLOG_REGEX = /\A\/api\/v1\/statuses\/[\d]+\/unreblog\z/.freeze
|
||||||
API_DELETE_STATUS_REGEX = /\A\/api\/v1\/statuses\/[\d]+/.freeze
|
API_DELETE_STATUS_REGEX = /\A\/api\/v1\/statuses\/[\d]+\z/.freeze
|
||||||
|
|
||||||
throttle('throttle_api_delete', limit: 30, period: 30.minutes) do |req|
|
throttle('throttle_api_delete', limit: 30, period: 30.minutes) do |req|
|
||||||
req.authenticated_user_id if (req.post? && req.path.match?(API_DELETE_REBLOG_REGEX)) || (req.delete? && req.path.match?(API_DELETE_STATUS_REGEX))
|
req.authenticated_user_id if (req.post? && req.path.match?(API_DELETE_REBLOG_REGEX)) || (req.delete? && req.path.match?(API_DELETE_STATUS_REGEX))
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_sign_up_attempts/ip', limit: 25, period: 5.minutes) do |req|
|
throttle('throttle_sign_up_attempts/ip', limit: 25, period: 5.minutes) do |req|
|
||||||
if req.post? && req.path == '/auth'
|
req.throttleable_remote_ip if req.post? && req.path_matches?('/auth')
|
||||||
addr = req.remote_ip
|
|
||||||
addr = IPAddr.new(addr) if addr.is_a?(String)
|
|
||||||
addr = addr.mask(64) if addr.ipv6?
|
|
||||||
addr.to_s
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_password_resets/ip', limit: 25, period: 5.minutes) do |req|
|
throttle('throttle_password_resets/ip', limit: 25, period: 5.minutes) do |req|
|
||||||
req.remote_ip if req.post? && req.path == '/auth/password'
|
req.throttleable_remote_ip if req.post? && req.path_matches?('/auth/password')
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_password_resets/email', limit: 5, period: 30.minutes) do |req|
|
throttle('throttle_password_resets/email', limit: 5, period: 30.minutes) do |req|
|
||||||
req.params.dig('user', 'email').presence if req.post? && req.path == '/auth/password'
|
req.params.dig('user', 'email').presence if req.post? && req.path_matches?('/auth/password')
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_email_confirmations/ip', limit: 25, period: 5.minutes) do |req|
|
throttle('throttle_email_confirmations/ip', limit: 25, period: 5.minutes) do |req|
|
||||||
req.remote_ip if req.post? && %w(/auth/confirmation /api/v1/emails/confirmations).include?(req.path)
|
req.throttleable_remote_ip if req.post? && (req.path_matches?('/auth/confirmation') || req.path == '/api/v1/emails/confirmations')
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_email_confirmations/email', limit: 5, period: 30.minutes) do |req|
|
throttle('throttle_email_confirmations/email', limit: 5, period: 30.minutes) do |req|
|
||||||
if req.post? && req.path == '/auth/password'
|
if req.post? && req.path_matches?('/auth/password')
|
||||||
req.params.dig('user', 'email').presence
|
req.params.dig('user', 'email').presence
|
||||||
elsif req.post? && req.path == '/api/v1/emails/confirmations'
|
elsif req.post? && req.path == '/api/v1/emails/confirmations'
|
||||||
req.authenticated_user_id
|
req.authenticated_user_id
|
||||||
|
@ -111,11 +122,11 @@ class Rack::Attack
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_login_attempts/ip', limit: 25, period: 5.minutes) do |req|
|
throttle('throttle_login_attempts/ip', limit: 25, period: 5.minutes) do |req|
|
||||||
req.remote_ip if req.post? && req.path == '/auth/sign_in'
|
req.throttleable_remote_ip if req.post? && req.path_matches?('/auth/sign_in')
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('throttle_login_attempts/email', limit: 25, period: 1.hour) do |req|
|
throttle('throttle_login_attempts/email', limit: 25, period: 1.hour) do |req|
|
||||||
req.session[:attempt_user_id] || req.params.dig('user', 'email').presence if req.post? && req.path == '/auth/sign_in'
|
req.session[:attempt_user_id] || req.params.dig('user', 'email').presence if req.post? && req.path_matches?('/auth/sign_in')
|
||||||
end
|
end
|
||||||
|
|
||||||
self.throttled_responder = lambda do |request|
|
self.throttled_responder = lambda do |request|
|
||||||
|
|
|
@ -783,6 +783,12 @@ en:
|
||||||
message_html: You haven't defined any server rules.
|
message_html: You haven't defined any server rules.
|
||||||
sidekiq_process_check:
|
sidekiq_process_check:
|
||||||
message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration
|
message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration
|
||||||
|
upload_check_privacy_error:
|
||||||
|
action: Check here for more information
|
||||||
|
message_html: "<strong>Your web server is misconfigured. The privacy of your users is at risk.</strong>"
|
||||||
|
upload_check_privacy_error_object_storage:
|
||||||
|
action: Check here for more information
|
||||||
|
message_html: "<strong>Your object storage is misconfigured. The privacy of your users is at risk.</strong>"
|
||||||
tags:
|
tags:
|
||||||
review: Review status
|
review: Review status
|
||||||
updated_msg: Hashtag settings updated successfully
|
updated_msg: Hashtag settings updated successfully
|
||||||
|
@ -1323,6 +1329,7 @@ en:
|
||||||
relationships:
|
relationships:
|
||||||
activity: Account activity
|
activity: Account activity
|
||||||
dormant: Dormant
|
dormant: Dormant
|
||||||
|
follow_failure: Could not follow some of the selected accounts.
|
||||||
follow_selected_followers: Follow selected followers
|
follow_selected_followers: Follow selected followers
|
||||||
followers: Followers
|
followers: Followers
|
||||||
following: Following
|
following: Following
|
||||||
|
|
|
@ -47,7 +47,7 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
devise_for :users, path: 'auth', controllers: {
|
devise_for :users, path: 'auth', format: false, controllers: {
|
||||||
omniauth_callbacks: 'auth/omniauth_callbacks',
|
omniauth_callbacks: 'auth/omniauth_callbacks',
|
||||||
sessions: 'auth/sessions',
|
sessions: 'auth/sessions',
|
||||||
registrations: 'auth/registrations',
|
registrations: 'auth/registrations',
|
||||||
|
@ -182,7 +182,8 @@ Rails.application.routes.draw do
|
||||||
resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update]
|
resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update]
|
||||||
|
|
||||||
get '/public', to: 'public_timelines#show', as: :public_timeline
|
get '/public', to: 'public_timelines#show', as: :public_timeline
|
||||||
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy
|
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy, format: false
|
||||||
|
get '/backups/:id/download', to: 'backups#download', as: :download_backup, format: false
|
||||||
|
|
||||||
resource :authorize_interaction, only: [:show, :create]
|
resource :authorize_interaction, only: [:show, :create]
|
||||||
resource :share, only: [:show, :create]
|
resource :share, only: [:show, :create]
|
||||||
|
@ -353,7 +354,7 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
get '/admin', to: redirect('/admin/dashboard', status: 302)
|
get '/admin', to: redirect('/admin/dashboard', status: 302)
|
||||||
|
|
||||||
namespace :api do
|
namespace :api, format: false do
|
||||||
# OEmbed
|
# OEmbed
|
||||||
get '/oembed', to: 'oembed#show', as: :oembed
|
get '/oembed', to: 'oembed#show', as: :oembed
|
||||||
|
|
||||||
|
@ -394,7 +395,9 @@ Rails.application.routes.draw do
|
||||||
resources :list, only: :show
|
resources :list, only: :show
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :streaming, only: [:index]
|
get '/streaming', to: 'streaming#index'
|
||||||
|
get '/streaming/(*any)', to: 'streaming#index'
|
||||||
|
|
||||||
resources :custom_emojis, only: [:index]
|
resources :custom_emojis, only: [:index]
|
||||||
resources :suggestions, only: [:index, :destroy]
|
resources :suggestions, only: [:index, :destroy]
|
||||||
resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
|
resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
|
||||||
|
|
|
@ -44,7 +44,7 @@ services:
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
image: tootsuite/mastodon
|
image: ghcr.io/mastodon/mastodon:v3.5.7
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
||||||
|
@ -65,7 +65,7 @@ services:
|
||||||
|
|
||||||
streaming:
|
streaming:
|
||||||
build: .
|
build: .
|
||||||
image: tootsuite/mastodon
|
image: ghcr.io/mastodon/mastodon:v3.5.7
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: node ./streaming
|
command: node ./streaming
|
||||||
|
@ -83,7 +83,7 @@ services:
|
||||||
|
|
||||||
sidekiq:
|
sidekiq:
|
||||||
build: .
|
build: .
|
||||||
image: tootsuite/mastodon
|
image: ghcr.io/mastodon/mastodon:v3.5.7
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bundle exec sidekiq
|
command: bundle exec sidekiq
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
class Mastodon::SidekiqMiddleware
|
class Mastodon::SidekiqMiddleware
|
||||||
BACKTRACE_LIMIT = 3
|
BACKTRACE_LIMIT = 3
|
||||||
|
|
||||||
def call(*)
|
def call(*, &block)
|
||||||
yield
|
Chewy.strategy(:mastodon, &block)
|
||||||
rescue Mastodon::HostValidationError
|
rescue Mastodon::HostValidationError
|
||||||
# Do not retry
|
# Do not retry
|
||||||
rescue => e
|
rescue => e
|
||||||
|
|
|
@ -13,7 +13,7 @@ module Mastodon
|
||||||
end
|
end
|
||||||
|
|
||||||
def patch
|
def patch
|
||||||
3
|
7
|
||||||
end
|
end
|
||||||
|
|
||||||
def flags
|
def flags
|
||||||
|
|
|
@ -5,7 +5,7 @@ module Paperclip
|
||||||
def make
|
def make
|
||||||
return @file unless options[:style] == :small || options[:blurhash]
|
return @file unless options[:style] == :small || options[:blurhash]
|
||||||
|
|
||||||
pixels = convert(':source RGB:-', source: File.expand_path(@file.path)).unpack('C*')
|
pixels = convert(':source -depth 8 RGB:-', source: File.expand_path(@file.path)).unpack('C*')
|
||||||
geometry = options.fetch(:file_geometry_parser).from_file(@file)
|
geometry = options.fetch(:file_geometry_parser).from_file(@file)
|
||||||
|
|
||||||
attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, **(options[:blurhash] || {}))
|
attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, **(options[:blurhash] || {}))
|
||||||
|
|
|
@ -49,7 +49,7 @@ class Sanitize
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
current_node.replace(current_node.text) unless LINK_PROTOCOLS.include?(scheme)
|
current_node.replace(Nokogiri::XML::Text.new(current_node.text, current_node.document)) unless LINK_PROTOCOLS.include?(scheme)
|
||||||
end
|
end
|
||||||
|
|
||||||
UNSUPPORTED_ELEMENTS_TRANSFORMER = lambda do |env|
|
UNSUPPORTED_ELEMENTS_TRANSFORMER = lambda do |env|
|
||||||
|
|
|
@ -147,6 +147,87 @@ RSpec.describe Admin::AccountsController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'POST #approve' do
|
||||||
|
subject { post :approve, params: { id: account.id } }
|
||||||
|
|
||||||
|
let(:current_user) { Fabricate(:user, role: role) }
|
||||||
|
let(:account) { user.account }
|
||||||
|
let(:user) { Fabricate(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
account.user.update(approved: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is admin' do
|
||||||
|
let(:role) { 'admin' }
|
||||||
|
|
||||||
|
it 'succeeds in approving account' do
|
||||||
|
is_expected.to redirect_to admin_accounts_path(status: 'pending')
|
||||||
|
expect(user.reload).to be_approved
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs action' do
|
||||||
|
is_expected.to have_http_status :found
|
||||||
|
|
||||||
|
log_item = Admin::ActionLog.last
|
||||||
|
|
||||||
|
expect(log_item).to_not be_nil
|
||||||
|
expect(log_item.action).to eq :approve
|
||||||
|
expect(log_item.account_id).to eq current_user.account_id
|
||||||
|
expect(log_item.target_id).to eq account.user.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not admin' do
|
||||||
|
let(:role) { 'user' }
|
||||||
|
|
||||||
|
it 'fails to approve account' do
|
||||||
|
is_expected.to have_http_status :forbidden
|
||||||
|
expect(user.reload).not_to be_approved
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST #reject' do
|
||||||
|
subject { post :reject, params: { id: account.id } }
|
||||||
|
|
||||||
|
let(:current_user) { Fabricate(:user, role: role) }
|
||||||
|
let(:account) { user.account }
|
||||||
|
let(:user) { Fabricate(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
account.user.update(approved: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is admin' do
|
||||||
|
let(:role) { 'admin' }
|
||||||
|
|
||||||
|
it 'succeeds in rejecting account' do
|
||||||
|
is_expected.to redirect_to admin_accounts_path(status: 'pending')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs action' do
|
||||||
|
is_expected.to have_http_status :found
|
||||||
|
|
||||||
|
log_item = Admin::ActionLog.last
|
||||||
|
|
||||||
|
expect(log_item).to_not be_nil
|
||||||
|
expect(log_item.action).to eq :reject
|
||||||
|
expect(log_item.account_id).to eq current_user.account_id
|
||||||
|
expect(log_item.target_id).to eq account.user.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not admin' do
|
||||||
|
let(:role) { 'user' }
|
||||||
|
|
||||||
|
it 'fails to reject account' do
|
||||||
|
is_expected.to have_http_status :forbidden
|
||||||
|
expect(user.reload).not_to be_approved
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'POST #redownload' do
|
describe 'POST #redownload' do
|
||||||
subject { post :redownload, params: { id: account.id } }
|
subject { post :redownload, params: { id: account.id } }
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,53 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'PUT #update' do
|
||||||
|
let!(:remote_account) { Fabricate(:account, domain: 'example.com') }
|
||||||
|
let(:domain_block) { Fabricate(:domain_block, domain: 'example.com', severity: original_severity) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
BlockDomainService.new.call(domain_block)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:subject) do
|
||||||
|
post :update, params: { id: domain_block.id, domain_block: { domain: 'example.com', severity: new_severity } }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'downgrading a domain suspension to silence' do
|
||||||
|
let(:original_severity) { 'suspend' }
|
||||||
|
let(:new_severity) { 'silence' }
|
||||||
|
|
||||||
|
it 'changes the block severity' do
|
||||||
|
expect { subject }.to change { domain_block.reload.severity }.from('suspend').to('silence')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'undoes individual suspensions' do
|
||||||
|
expect { subject }.to change { remote_account.reload.suspended? }.from(true).to(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'performs individual silences' do
|
||||||
|
expect { subject }.to change { remote_account.reload.silenced? }.from(false).to(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'upgrading a domain silence to suspend' do
|
||||||
|
let(:original_severity) { 'silence' }
|
||||||
|
let(:new_severity) { 'suspend' }
|
||||||
|
|
||||||
|
it 'changes the block severity' do
|
||||||
|
expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'undoes individual silences' do
|
||||||
|
expect { subject }.to change { remote_account.reload.silenced? }.from(true).to(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'performs individual suspends' do
|
||||||
|
expect { subject }.to change { remote_account.reload.suspended? }.from(false).to(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'DELETE #destroy' do
|
describe 'DELETE #destroy' do
|
||||||
it 'unblocks the domain' do
|
it 'unblocks the domain' do
|
||||||
service = double(call: true)
|
service = double(call: true)
|
||||||
|
|
42
spec/controllers/admin/reports/actions_controller_spec.rb
Normal file
42
spec/controllers/admin/reports/actions_controller_spec.rb
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Admin::Reports::ActionsController do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
let(:user) { Fabricate(:user, role: 'moderator') }
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
let!(:status) { Fabricate(:status, account: account) }
|
||||||
|
let(:media_attached_status) { Fabricate(:status, account: account) }
|
||||||
|
let!(:media_attachment) { Fabricate(:media_attachment, account: account, status: media_attached_status) }
|
||||||
|
let(:media_attached_deleted_status) { Fabricate(:status, account: account, deleted_at: 1.day.ago) }
|
||||||
|
let!(:media_attachment2) { Fabricate(:media_attachment, account: account, status: media_attached_deleted_status) }
|
||||||
|
let(:last_media_attached_status) { Fabricate(:status, account: account) }
|
||||||
|
let!(:last_media_attachment) { Fabricate(:media_attachment, account: account, status: last_media_attached_status) }
|
||||||
|
let!(:last_status) { Fabricate(:status, account: account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in user, scope: :user
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST #create' do
|
||||||
|
let(:report) { Fabricate(:report, status_ids: status_ids, account: user.account, target_account: account) }
|
||||||
|
let(:status_ids) { [media_attached_status.id, media_attached_deleted_status.id] }
|
||||||
|
|
||||||
|
before do
|
||||||
|
post :create, params: { report_id: report.id, action => '' }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when action is mark_as_sensitive' do
|
||||||
|
|
||||||
|
let(:action) { 'mark_as_sensitive' }
|
||||||
|
|
||||||
|
it 'resolves the report' do
|
||||||
|
expect(report.reload.action_taken_at).to_not be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'marks the non-deleted as sensitive' do
|
||||||
|
expect(media_attached_status.reload.sensitive).to eq true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -100,6 +100,15 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
|
||||||
it 'approves user' do
|
it 'approves user' do
|
||||||
expect(account.reload.user_approved?).to be true
|
expect(account.reload.user_approved?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'logs action' do
|
||||||
|
log_item = Admin::ActionLog.last
|
||||||
|
|
||||||
|
expect(log_item).to_not be_nil
|
||||||
|
expect(log_item.action).to eq :approve
|
||||||
|
expect(log_item.account_id).to eq user.account_id
|
||||||
|
expect(log_item.target_id).to eq account.user.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #reject' do
|
describe 'POST #reject' do
|
||||||
|
@ -118,6 +127,15 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
|
||||||
it 'removes user' do
|
it 'removes user' do
|
||||||
expect(User.where(id: account.user.id).count).to eq 0
|
expect(User.where(id: account.user.id).count).to eq 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'logs action' do
|
||||||
|
log_item = Admin::ActionLog.last
|
||||||
|
|
||||||
|
expect(log_item).to_not be_nil
|
||||||
|
expect(log_item.action).to eq :reject
|
||||||
|
expect(log_item.account_id).to eq user.account_id
|
||||||
|
expect(log_item.target_id).to eq account.user.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #enable' do
|
describe 'POST #enable' do
|
||||||
|
|
|
@ -55,7 +55,7 @@ describe RelationshipsController do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when select parameter is provided' do
|
context 'when select parameter is provided' do
|
||||||
subject { patch :update, params: { form_account_batch: { account_ids: [poopfeast.id] }, block_domains: '' } }
|
subject { patch :update, params: { form_account_batch: { account_ids: [poopfeast.id] }, remove_domains_from_followers: '' } }
|
||||||
|
|
||||||
it 'soft-blocks followers from selected domains' do
|
it 'soft-blocks followers from selected domains' do
|
||||||
poopfeast.follow!(user.account)
|
poopfeast.follow!(user.account)
|
||||||
|
@ -66,6 +66,15 @@ describe RelationshipsController do
|
||||||
expect(poopfeast.following?(user.account)).to be false
|
expect(poopfeast.following?(user.account)).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'does not unfollow users from selected domains' do
|
||||||
|
user.account.follow!(poopfeast)
|
||||||
|
|
||||||
|
sign_in user, scope: :user
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(user.account.following?(poopfeast)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
include_examples 'authenticate user'
|
include_examples 'authenticate user'
|
||||||
include_examples 'redirects back to followers page'
|
include_examples 'redirects back to followers page'
|
||||||
end
|
end
|
||||||
|
|
|
@ -248,7 +248,7 @@ describe Settings::TwoFactorAuthentication::WebauthnCredentialsController do
|
||||||
|
|
||||||
post :create, params: { credential: new_webauthn_credential, nickname: 'USB Key' }
|
post :create, params: { credential: new_webauthn_credential, nickname: 'USB Key' }
|
||||||
|
|
||||||
expect(response).to have_http_status(500)
|
expect(response).to have_http_status(422)
|
||||||
expect(flash[:error]).to be_present
|
expect(flash[:error]).to be_present
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -268,7 +268,7 @@ describe Settings::TwoFactorAuthentication::WebauthnCredentialsController do
|
||||||
|
|
||||||
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
|
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
|
||||||
|
|
||||||
expect(response).to have_http_status(500)
|
expect(response).to have_http_status(422)
|
||||||
expect(flash[:error]).to be_present
|
expect(flash[:error]).to be_present
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -48,7 +48,7 @@ RSpec.describe ActivityPub::Activity::Add do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'fetches the status and pins it' do
|
it 'fetches the status and pins it' do
|
||||||
allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil|
|
allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil, request_id: nil|
|
||||||
expect(uri).to eq 'https://example.com/unknown'
|
expect(uri).to eq 'https://example.com/unknown'
|
||||||
expect(id).to eq true
|
expect(id).to eq true
|
||||||
expect(on_behalf_of&.following?(sender)).to eq true
|
expect(on_behalf_of&.following?(sender)).to eq true
|
||||||
|
@ -62,7 +62,7 @@ RSpec.describe ActivityPub::Activity::Add do
|
||||||
|
|
||||||
context 'when there is no local follower' do
|
context 'when there is no local follower' do
|
||||||
it 'tries to fetch the status' do
|
it 'tries to fetch the status' do
|
||||||
allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil|
|
allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil, request_id: nil|
|
||||||
expect(uri).to eq 'https://example.com/unknown'
|
expect(uri).to eq 'https://example.com/unknown'
|
||||||
expect(id).to eq true
|
expect(id).to eq true
|
||||||
expect(on_behalf_of).to eq nil
|
expect(on_behalf_of).to eq nil
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe ActivityPub::Activity::Flag do
|
RSpec.describe ActivityPub::Activity::Flag do
|
||||||
let(:sender) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/account') }
|
let(:sender) { Fabricate(:account, username: 'example.com', domain: 'example.com', uri: 'http://example.com/actor') }
|
||||||
let(:flagged) { Fabricate(:account) }
|
let(:flagged) { Fabricate(:account) }
|
||||||
let(:status) { Fabricate(:status, account: flagged, uri: 'foobar') }
|
let(:status) { Fabricate(:status, account: flagged, uri: 'foobar') }
|
||||||
let(:flag_id) { nil }
|
let(:flag_id) { nil }
|
||||||
|
@ -23,16 +23,88 @@ RSpec.describe ActivityPub::Activity::Flag do
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
subject { described_class.new(json, sender) }
|
subject { described_class.new(json, sender) }
|
||||||
|
|
||||||
before do
|
context 'when the reported status is public' do
|
||||||
subject.perform
|
before do
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a report' do
|
||||||
|
report = Report.find_by(account: sender, target_account: flagged)
|
||||||
|
|
||||||
|
expect(report).to_not be_nil
|
||||||
|
expect(report.comment).to eq 'Boo!!'
|
||||||
|
expect(report.status_ids).to eq [status.id]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a report' do
|
context 'when the reported status is private and should not be visible to the remote server' do
|
||||||
report = Report.find_by(account: sender, target_account: flagged)
|
let(:status) { Fabricate(:status, account: flagged, uri: 'foobar', visibility: :private) }
|
||||||
|
|
||||||
expect(report).to_not be_nil
|
before do
|
||||||
expect(report.comment).to eq 'Boo!!'
|
subject.perform
|
||||||
expect(report.status_ids).to eq [status.id]
|
end
|
||||||
|
|
||||||
|
it 'creates a report with no attached status' do
|
||||||
|
report = Report.find_by(account: sender, target_account: flagged)
|
||||||
|
|
||||||
|
expect(report).to_not be_nil
|
||||||
|
expect(report.comment).to eq 'Boo!!'
|
||||||
|
expect(report.status_ids).to eq []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the reported status is private and the author has a follower on the remote instance' do
|
||||||
|
let(:status) { Fabricate(:status, account: flagged, uri: 'foobar', visibility: :private) }
|
||||||
|
let(:follower) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/users/account') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
follower.follow!(flagged)
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a report with the attached status' do
|
||||||
|
report = Report.find_by(account: sender, target_account: flagged)
|
||||||
|
|
||||||
|
expect(report).to_not be_nil
|
||||||
|
expect(report.comment).to eq 'Boo!!'
|
||||||
|
expect(report.status_ids).to eq [status.id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the reported status is private and the author mentions someone else on the remote instance' do
|
||||||
|
let(:status) { Fabricate(:status, account: flagged, uri: 'foobar', visibility: :private) }
|
||||||
|
let(:mentioned) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/users/account') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
status.mentions.create(account: mentioned)
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a report with the attached status' do
|
||||||
|
report = Report.find_by(account: sender, target_account: flagged)
|
||||||
|
|
||||||
|
expect(report).to_not be_nil
|
||||||
|
expect(report.comment).to eq 'Boo!!'
|
||||||
|
expect(report.status_ids).to eq [status.id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the reported status is private and the author mentions someone else on the local instance' do
|
||||||
|
let(:status) { Fabricate(:status, account: flagged, uri: 'foobar', visibility: :private) }
|
||||||
|
let(:mentioned) { Fabricate(:account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
status.mentions.create(account: mentioned)
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a report with no attached status' do
|
||||||
|
report = Report.find_by(account: sender, target_account: flagged)
|
||||||
|
|
||||||
|
expect(report).to_not be_nil
|
||||||
|
expect(report.comment).to eq 'Boo!!'
|
||||||
|
expect(report.status_ids).to eq []
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,10 @@ describe Sanitize::Config do
|
||||||
expect(Sanitize.fragment('<a href="foo://bar">Test</a>', subject)).to eq 'Test'
|
expect(Sanitize.fragment('<a href="foo://bar">Test</a>', subject)).to eq 'Test'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'does not re-interpret HTML when removing unsupported links' do
|
||||||
|
expect(Sanitize.fragment('<a href="foo://bar">Test<a href="https://example.com">test</a></a>', subject)).to eq 'Test<a href="https://example.com">test</a>'
|
||||||
|
end
|
||||||
|
|
||||||
it 'keeps a with href' do
|
it 'keeps a with href' do
|
||||||
expect(Sanitize.fragment('<a href="http://example.com">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
|
expect(Sanitize.fragment('<a href="http://example.com">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
|
||||||
end
|
end
|
||||||
|
|
|
@ -223,4 +223,98 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'statuses referencing other statuses' do
|
||||||
|
before do
|
||||||
|
stub_const 'ActivityPub::FetchRemoteStatusService::DISCOVERIES_PER_REQUEST', 5
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'using inReplyTo' do
|
||||||
|
let(:object) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: "https://foo.bar/@foo/1",
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
inReplyTo: 'https://foo.bar/@foo/2',
|
||||||
|
attributedTo: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
8.times do |i|
|
||||||
|
status_json = {
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: "https://foo.bar/@foo/#{i}",
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
inReplyTo: "https://foo.bar/@foo/#{i + 1}",
|
||||||
|
attributedTo: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
to: 'as:Public',
|
||||||
|
}.with_indifferent_access
|
||||||
|
stub_request(:get, "https://foo.bar/@foo/#{i}").to_return(status: 200, body: status_json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates at least some statuses' do
|
||||||
|
expect { subject.call(object[:id], prefetched_body: Oj.dump(object)) }.to change { sender.statuses.count }.by_at_least(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates no more account than the limit allows' do
|
||||||
|
expect { subject.call(object[:id], prefetched_body: Oj.dump(object)) }.to change { sender.statuses.count }.by_at_most(5)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'using replies' do
|
||||||
|
let(:object) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: "https://foo.bar/@foo/1",
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
replies: {
|
||||||
|
type: 'Collection',
|
||||||
|
id: 'https://foo.bar/@foo/1/replies',
|
||||||
|
first: {
|
||||||
|
type: 'CollectionPage',
|
||||||
|
partOf: 'https://foo.bar/@foo/1/replies',
|
||||||
|
items: ['https://foo.bar/@foo/2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attributedTo: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
8.times do |i|
|
||||||
|
status_json = {
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: "https://foo.bar/@foo/#{i}",
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
replies: {
|
||||||
|
type: 'Collection',
|
||||||
|
id: "https://foo.bar/@foo/#{i}/replies",
|
||||||
|
first: {
|
||||||
|
type: 'CollectionPage',
|
||||||
|
partOf: "https://foo.bar/@foo/#{i}/replies",
|
||||||
|
items: ["https://foo.bar/@foo/#{i+1}"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attributedTo: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
to: 'as:Public',
|
||||||
|
}.with_indifferent_access
|
||||||
|
stub_request(:get, "https://foo.bar/@foo/#{i}").to_return(status: 200, body: status_json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates at least some statuses' do
|
||||||
|
expect { subject.call(object[:id], prefetched_body: Oj.dump(object)) }.to change { sender.statuses.count }.by_at_least(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates no more account than the limit allows' do
|
||||||
|
expect { subject.call(object[:id], prefetched_body: Oj.dump(object)) }.to change { sender.statuses.count }.by_at_most(5)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -109,4 +109,98 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'discovering many subdomains in a short timeframe' do
|
||||||
|
before do
|
||||||
|
stub_const 'ActivityPub::ProcessAccountService::SUBDOMAINS_RATELIMIT', 5
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:subject) do
|
||||||
|
8.times do |i|
|
||||||
|
domain = "test#{i}.testdomain.com"
|
||||||
|
json = {
|
||||||
|
id: "https://#{domain}/users/1",
|
||||||
|
type: 'Actor',
|
||||||
|
inbox: "https://#{domain}/inbox",
|
||||||
|
}.with_indifferent_access
|
||||||
|
described_class.new.call('alice', domain, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates at least some accounts' do
|
||||||
|
expect { subject }.to change { Account.remote.count }.by_at_least(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates no more account than the limit allows' do
|
||||||
|
expect { subject }.to change { Account.remote.count }.by_at_most(5)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'accounts referencing other accounts' do
|
||||||
|
before do
|
||||||
|
stub_const 'ActivityPub::ProcessAccountService::DISCOVERIES_PER_REQUEST', 5
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:payload) do
|
||||||
|
{
|
||||||
|
'@context': ['https://www.w3.org/ns/activitystreams'],
|
||||||
|
id: 'https://foo.test/users/1',
|
||||||
|
type: 'Person',
|
||||||
|
inbox: 'https://foo.test/inbox',
|
||||||
|
featured: 'https://foo.test/users/1/featured',
|
||||||
|
preferredUsername: 'user1',
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
8.times do |i|
|
||||||
|
actor_json = {
|
||||||
|
'@context': ['https://www.w3.org/ns/activitystreams'],
|
||||||
|
id: "https://foo.test/users/#{i}",
|
||||||
|
type: 'Person',
|
||||||
|
inbox: 'https://foo.test/inbox',
|
||||||
|
featured: "https://foo.test/users/#{i}/featured",
|
||||||
|
preferredUsername: "user#{i}",
|
||||||
|
}.with_indifferent_access
|
||||||
|
status_json = {
|
||||||
|
'@context': ['https://www.w3.org/ns/activitystreams'],
|
||||||
|
id: "https://foo.test/users/#{i}/status",
|
||||||
|
attributedTo: "https://foo.test/users/#{i}",
|
||||||
|
type: 'Note',
|
||||||
|
content: "@user#{i + 1} test",
|
||||||
|
tag: [
|
||||||
|
{
|
||||||
|
type: 'Mention',
|
||||||
|
href: "https://foo.test/users/#{i + 1}",
|
||||||
|
name: "@user#{i + 1 }",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
to: [ 'as:Public', "https://foo.test/users/#{i + 1}" ]
|
||||||
|
}.with_indifferent_access
|
||||||
|
featured_json = {
|
||||||
|
'@context': ['https://www.w3.org/ns/activitystreams'],
|
||||||
|
id: "https://foo.test/users/#{i}/featured",
|
||||||
|
type: 'OrderedCollection',
|
||||||
|
totelItems: 1,
|
||||||
|
orderedItems: [status_json],
|
||||||
|
}.with_indifferent_access
|
||||||
|
webfinger = {
|
||||||
|
subject: "acct:user#{i}@foo.test",
|
||||||
|
links: [{ rel: 'self', href: "https://foo.test/users/#{i}" }],
|
||||||
|
}.with_indifferent_access
|
||||||
|
stub_request(:get, "https://foo.test/users/#{i}").to_return(status: 200, body: actor_json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
stub_request(:get, "https://foo.test/users/#{i}/featured").to_return(status: 200, body: featured_json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
stub_request(:get, "https://foo.test/users/#{i}/status").to_return(status: 200, body: status_json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
stub_request(:get, "https://foo.test/.well-known/webfinger?resource=acct:user#{i}@foo.test").to_return(body: webfinger.to_json, headers: { 'Content-Type': 'application/jrd+json' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates at least some accounts' do
|
||||||
|
expect { subject.call('user1', 'foo.test', payload) }.to change { Account.remote.count }.by_at_least(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates no more account than the limit allows' do
|
||||||
|
expect { subject.call('user1', 'foo.test', payload) }.to change { Account.remote.count }.by_at_most(5)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -331,7 +331,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
|
||||||
|
|
||||||
context 'originally without media attachments' do
|
context 'originally without media attachments' do
|
||||||
before do
|
before do
|
||||||
allow(RedownloadMediaWorker).to receive(:perform_async)
|
stub_request(:get, 'https://example.com/foo.png').to_return(body: attachment_fixture('emojo.png'))
|
||||||
subject.call(status, json)
|
subject.call(status, json)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -355,8 +355,8 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
|
||||||
expect(media_attachment.remote_url).to eq 'https://example.com/foo.png'
|
expect(media_attachment.remote_url).to eq 'https://example.com/foo.png'
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'queues download of media attachments' do
|
it 'fetches the attachment' do
|
||||||
expect(RedownloadMediaWorker).to have_received(:perform_async)
|
expect(a_request(:get, 'https://example.com/foo.png')).to have_been_made
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'records media change in edit' do
|
it 'records media change in edit' do
|
||||||
|
|
|
@ -15,7 +15,7 @@ RSpec.describe FetchRemoteStatusService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'protocol is :activitypub' do
|
context 'protocol is :activitypub' do
|
||||||
subject { described_class.new.call(note[:id], prefetched_body) }
|
subject { described_class.new.call(note[:id], prefetched_body: prefetched_body) }
|
||||||
let(:prefetched_body) { Oj.dump(note) }
|
let(:prefetched_body) { Oj.dump(note) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
|
|
@ -28,6 +28,31 @@ RSpec.describe ReportService, type: :service do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when the reported status is a DM' do
|
||||||
|
let(:target_account) { Fabricate(:account) }
|
||||||
|
let(:status) { Fabricate(:status, account: target_account, visibility: :direct) }
|
||||||
|
|
||||||
|
subject do
|
||||||
|
-> { described_class.new.call(source_account, target_account, status_ids: [status.id]) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is addressed to the reporter' do
|
||||||
|
before do
|
||||||
|
status.mentions.create(account: source_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a report' do
|
||||||
|
is_expected.to change { target_account.targeted_reports.count }.from(0).to(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is not addressed to the reporter' do
|
||||||
|
it 'errors out' do
|
||||||
|
is_expected.to raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when other reports already exist for the same target' do
|
context 'when other reports already exist for the same target' do
|
||||||
let!(:target_account) { Fabricate(:account) }
|
let!(:target_account) { Fabricate(:account) }
|
||||||
let!(:other_report) { Fabricate(:report, target_account: target_account) }
|
let!(:other_report) { Fabricate(:report, target_account: target_account) }
|
||||||
|
|
|
@ -13,6 +13,8 @@ RSpec.describe SuspendAccountService, type: :service do
|
||||||
|
|
||||||
local_follower.follow!(account)
|
local_follower.follow!(account)
|
||||||
list.accounts << account
|
list.accounts << account
|
||||||
|
|
||||||
|
account.suspend!
|
||||||
end
|
end
|
||||||
|
|
||||||
it "unmerges from local followers' feeds" do
|
it "unmerges from local followers' feeds" do
|
||||||
|
@ -21,8 +23,8 @@ RSpec.describe SuspendAccountService, type: :service do
|
||||||
expect(FeedManager.instance).to have_received(:unmerge_from_list).with(account, list)
|
expect(FeedManager.instance).to have_received(:unmerge_from_list).with(account, list)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'marks account as suspended' do
|
it 'does not change the “suspended” flag' do
|
||||||
expect { subject }.to change { account.suspended? }.from(false).to(true)
|
expect { subject }.to_not change { account.suspended? }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ RSpec.describe UnsuspendAccountService, type: :service do
|
||||||
local_follower.follow!(account)
|
local_follower.follow!(account)
|
||||||
list.accounts << account
|
list.accounts << account
|
||||||
|
|
||||||
account.suspend!(origin: :local)
|
account.unsuspend!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -30,8 +30,8 @@ RSpec.describe UnsuspendAccountService, type: :service do
|
||||||
stub_request(:post, 'https://bob.com/inbox').to_return(status: 201)
|
stub_request(:post, 'https://bob.com/inbox').to_return(status: 201)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'marks account as unsuspended' do
|
it 'does not change the “suspended” flag' do
|
||||||
expect { subject }.to change { account.suspended? }.from(true).to(false)
|
expect { subject }.to_not change { account.suspended? }
|
||||||
end
|
end
|
||||||
|
|
||||||
include_examples 'common behavior' do
|
include_examples 'common behavior' do
|
||||||
|
@ -83,8 +83,8 @@ RSpec.describe UnsuspendAccountService, type: :service do
|
||||||
expect(FeedManager.instance).to have_received(:merge_into_list).with(account, list)
|
expect(FeedManager.instance).to have_received(:merge_into_list).with(account, list)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'marks account as unsuspended' do
|
it 'does not change the “suspended” flag' do
|
||||||
expect { subject }.to change { account.suspended? }.from(true).to(false)
|
expect { subject }.to_not change { account.suspended? }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -107,8 +107,8 @@ RSpec.describe UnsuspendAccountService, type: :service do
|
||||||
expect(FeedManager.instance).to_not have_received(:merge_into_list).with(account, list)
|
expect(FeedManager.instance).to_not have_received(:merge_into_list).with(account, list)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not mark the account as unsuspended' do
|
it 'marks account as suspended' do
|
||||||
expect { subject }.not_to change { account.suspended? }
|
expect { subject }.to change { account.suspended? }.from(false).to(true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
39
spec/workers/scheduler/user_cleanup_scheduler_spec.rb
Normal file
39
spec/workers/scheduler/user_cleanup_scheduler_spec.rb
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Scheduler::UserCleanupScheduler do
|
||||||
|
subject { described_class.new }
|
||||||
|
|
||||||
|
let!(:new_unconfirmed_user) { Fabricate(:user) }
|
||||||
|
let!(:old_unconfirmed_user) { Fabricate(:user) }
|
||||||
|
let!(:confirmed_user) { Fabricate(:user) }
|
||||||
|
let!(:moderation_note) { Fabricate(:account_moderation_note, account: Fabricate(:account), target_account: old_unconfirmed_user.account) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
before do
|
||||||
|
# Need to update the already-existing users because their initialization overrides confirmation_sent_at
|
||||||
|
new_unconfirmed_user.update!(confirmed_at: nil, confirmation_sent_at: Time.now.utc)
|
||||||
|
old_unconfirmed_user.update!(confirmed_at: nil, confirmation_sent_at: 1.week.ago)
|
||||||
|
confirmed_user.update!(confirmed_at: 1.day.ago)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deletes the old unconfirmed user' do
|
||||||
|
expect { subject.perform }.to change { User.exists?(old_unconfirmed_user.id) }.from(true).to(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "deletes the old unconfirmed user's account" do
|
||||||
|
expect { subject.perform }.to change { Account.exists?(old_unconfirmed_user.account_id) }.from(true).to(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not delete the new unconfirmed user or their account' do
|
||||||
|
subject.perform
|
||||||
|
expect(User.exists?(new_unconfirmed_user.id)).to be true
|
||||||
|
expect(Account.exists?(new_unconfirmed_user.account_id)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not delete the confirmed user or their account' do
|
||||||
|
subject.perform
|
||||||
|
expect(User.exists?(confirmed_user.id)).to be true
|
||||||
|
expect(Account.exists?(confirmed_user.account_id)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue
Block a user