Compare commits

...

50 Commits
main ... v3.5.7

Author SHA1 Message Date
Claire
547634dfa6 Bump version to v3.5.7 2023-03-16 22:50:15 +01:00
Claire
f90daf58db Add warning for object storage misconfiguration (#24137) 2023-03-16 22:50:15 +01:00
Eugen Rochko
a42b48ea4e Change user backups to use expiring URLs for download when possible (#24136) 2023-03-16 22:50:15 +01:00
Claire
251dd0b72b Update changelog 2023-03-16 22:05:39 +01:00
Nick Schonning
18840cbc6e Skip pushing containers on forks (#24106) 2023-03-16 13:40:56 +01:00
Renaud Chaput
727126255a Use Github Container Registry as the official container image source (#24113) 2023-03-16 13:40:55 +01:00
Nick Schonning
98d654b8bb Skip Docker CI Login/Push on forks (#23564) 2023-03-16 13:39:59 +01:00
Renaud Chaput
25c517144c Push Docker images to Github Container Registry as well (#24101) 2023-03-16 13:39:58 +01:00
Claire
f036546c22 Fix misleading error code when receiving invalid WebAuthn credentials (#23568) 2023-03-16 12:34:43 +01:00
Claire
9256d653a5 Fix incorrect post links in strikes when the account is remote (#23611) 2023-03-16 12:34:37 +01:00
Jeremy Kescher
d0c0808ad4 Add null check on application in dispute viewer (#19851) 2023-03-16 12:33:09 +01:00
Claire
cb622b23b1 Fix dashboard crash on ElasticSearch server error (#23751) 2023-03-16 12:31:20 +01:00
Claire
fe866f8afb Update changelog 2023-03-14 11:46:12 +01:00
Claire
a1e765991e Add mail headers to avoid auto-replies (#23597) 2023-03-14 11:46:12 +01:00
Claire
76b9f42712 Add lang tag to native language names in language picker (#23749) 2023-03-14 11:46:12 +01:00
Claire
708e590117 Fix sidekiq jobs not triggering Elasticsearch index updates (#24046) 2023-03-14 11:46:12 +01:00
Rodion Borisov
a717aa929c Center the text itself in upload area (#24029) 2023-03-14 11:46:12 +01:00
Claire
bbb7c54367 Fix /api/v1/streaming sub-paths not being redirected (#23988) 2023-03-14 11:46:12 +01:00
Eugen Rochko
282596a66e Fix pgBouncer resetting application name on every transaction (#23958) 2023-03-14 11:46:12 +01:00
Claire
e6f6fe6106 Fix original account being unfollowed on migration before the follow request could be sent (#21957) 2023-03-14 11:46:12 +01:00
Claire
86b1adf7d7 Fix unconfirmed accounts being registered as active users (#23803) 2023-03-14 10:26:38 +01:00
Claire
4beeec4e50 Fix server error when failing to follow back followers from /relationships (#23787) 2023-03-14 10:26:23 +01:00
Claire
3c44ba0411 Fix inefficiency when searching accounts per username in admin interface (#23801) 2023-03-14 10:26:14 +01:00
Dean Bassett
339d4fa61c Fix case-sensitive check for previously used hashtags (#23526) 2023-03-14 10:25:48 +01:00
Claire
62f0eab635 Fix “Remove all followers from the selected domains” being more destructive than it claims (#23805) 2023-03-14 10:25:38 +01:00
Claire
8c8d578e38
Bump version to 3.5.6 (#23493) 2023-02-10 22:18:15 +01:00
Claire
a8a3e86216
Fix unbounded recursion in post discovery (#23507)
* Add a limit to how many posts can get fetched as a result of a single request

* Add tests

* Always pass `request_id` when processing `Announce` activities

---------

Co-authored-by: nametoolong <nametoolong@users.noreply.github.com>
2023-02-10 22:16:47 +01:00
Claire
be1caad933
Fix REST API serializer for Account not including moved when the moved account has itself moved (#22483) (#23492)
Instead of cutting immediately, cut after one recursion.
2023-02-09 21:02:09 +01:00
Claire
84a40824ad
Fix sanitizer parsing link text as HTML when stripping unsupported links (#22558) (#23491) 2023-02-09 21:02:01 +01:00
Claire
533bf92d21
Don't delivery a reply to domains which are blocked by author (#22117) (#23490)
Co-authored-by: Jeong Arm <kjwonmail@gmail.com>
2023-02-09 21:01:53 +01:00
Claire
6a2b48190c
Log admin approve and reject account (#22088) (#23488)
* Log admin approve and reject account

* Add unit tests for approve and reject logging

Co-authored-by: Francis Murillo <evacuee.overlap.vs3op@aleeas.com>
2023-02-09 21:01:45 +01:00
Claire
6cbc589990
Fix UserCleanupScheduler crash when an unconfirmed account has a moderation note (#23318) (#23487)
* Fix `UserCleanupScheduler` crash when an unconfirmed account has a moderation note

* Add tests
2023-02-09 21:01:38 +01:00
Claire
a2bfb16cb8
Fix crash when marking statuses as sensitive while some statuses are deleted (#22134) (#23486)
* Do not offer to mark statuses as sensitive if there is no undeleted status with media attachments

* Fix crash when marking statuses as sensitive while some statuses are deleted

Fixes #21910

* Fix multiple strikes being created for a single report when selecting “Mark as sensitive”

* Add tests
2023-02-09 21:01:21 +01:00
Claire
cfc0507010
Fix attachments of edited statuses not being fetched (#21565) (#23485)
* Fix attachments of edited statuses not being fetched

* Fix tests
2023-02-09 20:57:31 +01:00
Claire
eade64097c
Clear voter count when poll is reset (#21700) (#23484)
When a poll is edited, we reset the poll and remove all previous
votes. However, prior to this commit, the voter count on the poll
was not reset. This leads to incorrect percentages being shown in
poll results.

Fixes #21696

Co-authored-by: afontenot <adam.m.fontenot@gmail.com>
2023-02-09 20:57:24 +01:00
Claire
1f0be21317
Fix some performance issues with /admin/instances (#21907) (#23483)
/admin/instances?availability=failing remains wholly unefficient
2023-02-09 20:57:14 +01:00
Claire
0ca877f084
Fix possible race conditions when suspending/unsuspending accounts (#22363) (#23482)
* Fix possible race conditions when suspending/unsuspending accounts

* Fix tests

Tests were assuming SuspensionWorker and UnsuspensionWorker would do the
suspending/unsuspending themselves, but this has changed.
2023-02-09 20:57:06 +01:00
Claire
cc233af129
Fix suspension worker crashing on S3-compatible setups without ACL support (#22487) (#23481) 2023-02-09 20:56:58 +01:00
Claire
83f1c6460a
Fix changing domain block severity not undoing individual account effects (#22135) (#23480)
* Fix changing domain block severity not undoing individual account effects

Fixes #22133

* Add tests
2023-02-09 20:56:49 +01:00
Claire
e26dd2ea8f
Add form-action CSP directive (#23478)
* Add form-action CSP directive (#20781)

* Fix OAuth flow being broken by recent CSP change (#20958)

* Fix form-action CSP directive for external login (#20962)
2023-02-09 20:56:37 +01:00
Claire
da5d81c90d
Fix CircleCI issues caused by Node and OpenSSL versions (#23489)
Co-authored-by: mhkhung <mhkhung@gmail.com>
2023-02-09 18:34:19 +01:00
Claire
ee66f5790f
Fix unbounded recursion in account discovery (v3.5 backport) (#22026)
* Fix trying to fetch posts from other users when fetching featured posts

* Rate-limit discovery of new subdomains

* Put a limit on recursively discovering new accounts
2022-12-15 19:21:17 +01:00
Claire
696f7b3608 Bump version to 3.5.5 2022-11-14 22:26:24 +01:00
Claire
b22e1476ca Fix nodes order being sometimes mangled when rewriting emoji (#20677)
* Fix front-end emoji tests

* Fix nodes order being sometimes mangled when rewriting emoji
2022-11-14 22:20:29 +01:00
Claire
105ab82425 Bump version to 3.5.4 2022-11-14 20:09:16 +01:00
Claire
2dd8f977e8 Fix emoji substitution not applying only to text nodes in backend code
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2022-11-14 11:20:41 +01:00
Claire
2db06e1d08 Fix emoji substitution not applying only to text nodes in Web UI
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2022-11-14 11:20:41 +01:00
Eugen Rochko
063579373e Fix rate limiting for paths with formats 2022-11-14 11:20:41 +01:00
Pierre Bourdon
1659788de4 blurhash_transcoder: prevent out-of-bound reads with <8bpp images (#20388)
The Blurhash library used by Mastodon requires an input encoded as 24
bits raw RGB data. The conversion to raw RGB using Imagemagick did not
previously specify the desired bit depth. In some situations, this leads
Imagemagick to output in a pixel format using less bpp than expected.
This then manifested as segfaults of the Sidekiq process due to
out-of-bounds read, or potentially a (highly noisy) memory infoleak.

Fixes #19235.
2022-11-14 11:20:41 +01:00
Claire
47eaf85f02 Fix crash when a remote Flag activity mentions a private post (#18760)
* Add tests

* Fix crash when a remote Flag activity mentions a private post
2022-11-14 11:20:41 +01:00
96 changed files with 1257 additions and 274 deletions

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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)

View File

@ -5,13 +5,11 @@
[![Build Status](https://img.shields.io/circleci/project/github/mastodon/mastodon.svg)][circleci] [![Build Status](https://img.shields.io/circleci/project/github/mastodon/mastodon.svg)][circleci]
[![Code Climate](https://img.shields.io/codeclimate/maintainability/mastodon/mastodon.svg)][code_climate] [![Code Climate](https://img.shields.io/codeclimate/maintainability/mastodon/mastodon.svg)][code_climate]
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] [![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
[![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker]
[releases]: https://github.com/mastodon/mastodon/releases [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)

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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>
); );
} }

View File

@ -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&gt;');
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">');
}); });
}); });
}); });

View File

@ -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;

View File

@ -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) => {

View File

@ -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;

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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

View 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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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'

View File

@ -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')

View File

@ -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'

View File

@ -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) %>

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}" }

View File

@ -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|

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -13,7 +13,7 @@ module Mastodon
end end
def patch def patch
3 7
end end
def flags def flags

View File

@ -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] || {}))

View File

@ -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|

View File

@ -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 } }

View File

@ -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)

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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&lt;a href="https://example.com"&gt;test&lt;/a&gt;</a>', subject)).to eq 'Test&lt;a href="https://example.com"&gt;test&lt;/a&gt;'
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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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

View 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