mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 11:11:11 +02:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0b875f9c3 | ||
|
|
f926bf9a42 | ||
|
|
1e5ac0f491 | ||
|
|
e4e5d0cd2d | ||
|
|
19edc6a264 | ||
|
|
5bcbc77e75 | ||
|
|
bae1da6f73 | ||
|
|
b89d6e256b | ||
|
|
7de301922b | ||
|
|
418370d561 | ||
|
|
eb453afe8d | ||
|
|
3871f3a399 | ||
|
|
9eeeb1b31d | ||
|
|
fa51ec5364 | ||
|
|
5419594d3d | ||
|
|
8a46c747db | ||
|
|
f6ac245a84 | ||
|
|
1b0be1a725 | ||
|
|
68c08114b9 | ||
|
|
04903a45ce | ||
|
|
d9bad1c407 | ||
|
|
cbb1085855 | ||
|
|
3920feb8bd | ||
|
|
4dbe15654a | ||
|
|
27e06cdf20 | ||
|
|
6ac8b52ccc | ||
|
|
7ee99bbe81 | ||
|
|
6c1e77ff1f | ||
|
|
2588d1ab47 | ||
|
|
c0f8640252 | ||
|
|
92be0fd12e | ||
|
|
8450ebc7e8 | ||
|
|
3d27ec34ac | ||
|
|
d3551e1ab6 | ||
|
|
0b9c741dac | ||
|
|
a8c9923df9 | ||
|
|
f32067dc56 | ||
|
|
36981fadf0 | ||
|
|
b60edf59d8 | ||
|
|
3a87a63abf | ||
|
|
ef4d722d6a | ||
|
|
68e30985ca | ||
|
|
1702290786 | ||
|
|
abb3a02ce2 | ||
|
|
25c79f526a | ||
|
|
f5890040e1 | ||
|
|
740f262e38 | ||
|
|
4d2b6795a4 | ||
|
|
6558a1e07a | ||
|
|
b64101ee64 | ||
|
|
0bde273b3d | ||
|
|
22fe977ffe | ||
|
|
8e945bef2a | ||
|
|
d5f12debe0 | ||
|
|
5d0ec718fd | ||
|
|
c7aa312307 | ||
|
|
dc1d4eda7c | ||
|
|
931a29b4f3 | ||
|
|
99b2307350 | ||
|
|
375f2e6ebf | ||
|
|
f0a1da78ba | ||
|
|
b554ecfcb4 | ||
|
|
50244ba682 | ||
|
|
01cf5c103d | ||
|
|
5bda54d15a | ||
|
|
07f5573cd6 | ||
|
|
2b0b537152 | ||
|
|
c49e261ad0 | ||
|
|
915bcb267f | ||
|
|
ff37011057 | ||
|
|
8f5e95a159 | ||
|
|
16ee628d24 | ||
|
|
64a0b060a8 | ||
|
|
fa52f4361a | ||
|
|
7f7d6697c1 | ||
|
|
c2fb12d22d | ||
|
|
2dc4552229 | ||
|
|
95868643a2 | ||
|
|
7c46fdfbf1 | ||
|
|
8965e1bfa9 | ||
|
|
1e27ab0885 | ||
|
|
cef2c50a71 |
4
.github/workflows/build-releases.yml
vendored
4
.github/workflows/build-releases.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||
latest=false
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||
latest=false
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
||||
@@ -21,11 +21,11 @@ Metrics/BlockNesting:
|
||||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 25
|
||||
Enabled: false
|
||||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 27
|
||||
Enabled: false
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: AllowedVars, DefaultToNil.
|
||||
|
||||
101
CHANGELOG.md
101
CHANGELOG.md
@@ -2,6 +2,107 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.4.14] - 2026-02-24
|
||||
|
||||
### Security
|
||||
|
||||
- Reject unconfirmed FASPs (#37926 by @oneiros, [GHSA-qgmm-vr4c-ggjg](https://github.com/mastodon/mastodon/security/advisories/GHSA-qgmm-vr4c-ggjg))
|
||||
- Re-use custom socket class for FASP requests (#37925 by @oneiros, [GHSA-46w6-g98f-wxqm](https://github.com/mastodon/mastodon/security/advisories/GHSA-46w6-g98f-wxqm))
|
||||
|
||||
### Added
|
||||
|
||||
- Add `--suspended-only` option to `tootctl emoji purge` (#37828 and #37861 by @ClearlyClaire and @mjankowski)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix custom emojis not being purged on domain suspension (#37808 by @ClearlyClaire)
|
||||
- Fix processing of object updates with duplicate hashtags (#37756 by @ClearlyClaire)
|
||||
|
||||
## [4.4.13] - 2026-02-03
|
||||
|
||||
### Security
|
||||
|
||||
- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire)
|
||||
- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire)
|
||||
- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable)
|
||||
|
||||
## [4.4.12] - 2026-01-20
|
||||
|
||||
### Security
|
||||
|
||||
- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g)
|
||||
- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp)
|
||||
- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3)
|
||||
- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4)
|
||||
|
||||
### Changed
|
||||
|
||||
- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire)
|
||||
- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire)
|
||||
- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire)
|
||||
- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec)
|
||||
- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec)
|
||||
- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable)
|
||||
|
||||
## [4.4.11] - 2026-01-07
|
||||
|
||||
### Security
|
||||
|
||||
- Fix SSRF protection bypass ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-xfrj-c749-jxxq))
|
||||
- Fix missing ownership check in severed relationships controller ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-ww85-x9cp-5v24))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change HTTP Signature verification status from 401 to 503 on temporary failure to get remote actor (#37221 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix mentions of domain-blocked users being processed (#37257 by @ClearlyClaire)
|
||||
|
||||
## [4.4.10] - 2025-12-08
|
||||
|
||||
### Security
|
||||
|
||||
- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima)
|
||||
- Fix YouTube iframe not being able to start at a defined time (#26584 by @BrunoViveiros)
|
||||
- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire)
|
||||
- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire)
|
||||
- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire)
|
||||
|
||||
## [4.4.9] - 2025-11-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `tootctl upgrade storage-schema` failing with `ArgumentError` (#36914 by @shugo)
|
||||
- Fix old previously-undiscovered posts being treated as new when receiving an `Update` (#36848 by @ClearlyClaire)
|
||||
- Fix filters not being applied to quotes in detailed view (#36843 by @ClearlyClaire)
|
||||
|
||||
## [4.4.8] - 2025-10-21
|
||||
|
||||
### Security
|
||||
|
||||
- Fix quote control bypass ([GHSA-8h43-rcqj-wpc6](https://github.com/mastodon/mastodon/security/advisories/GHSA-8h43-rcqj-wpc6))
|
||||
|
||||
## [4.4.7] - 2025-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix forwarder being called with `nil` status when quote post is soft-deleted (#36463 by @ClearlyClaire)
|
||||
- Fix moderation warning e-mails that include posts (#36462 by @ClearlyClaire)
|
||||
- Fix allow_referrer_origin typo (#36460 by @ShadowJonathan)
|
||||
|
||||
## [4.4.6] - 2025-10-13
|
||||
|
||||
### Security
|
||||
|
||||
@@ -48,3 +48,22 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
|
||||
### Additional documentation
|
||||
|
||||
- [Mastodon documentation](https://docs.joinmastodon.org/)
|
||||
|
||||
## Size limits
|
||||
|
||||
Mastodon imposes a few hard limits on federated content.
|
||||
These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons.
|
||||
The following table attempts to summary those limits.
|
||||
|
||||
| Limited property | Size limit | Consequence of exceeding the limit |
|
||||
| ------------------------------------------------------------- | ---------- | ---------------------------------- |
|
||||
| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** |
|
||||
| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated |
|
||||
| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated |
|
||||
| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated |
|
||||
| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** |
|
||||
| Account display name (actor `name`) length | 2048 | Display name will be truncated |
|
||||
| Account note (actor `summary`) length | 20kB | Account note will be truncated |
|
||||
| Account `attributionDomains` | 256 | List will be truncated |
|
||||
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
||||
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
||||
|
||||
@@ -16,6 +16,5 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Until 2026-01-08 |
|
||||
| < 4.2 | No |
|
||||
| 4.3.x | Until 2026-05-06 |
|
||||
| < 4.3 | No |
|
||||
|
||||
@@ -4,17 +4,31 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :check_authorization
|
||||
before_action :set_items
|
||||
before_action :set_size
|
||||
before_action :set_type
|
||||
|
||||
def show
|
||||
expires_in 3.minutes, public: public_fetch_mode?
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
|
||||
if @unauthorized
|
||||
render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
else
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
@unauthorized = authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
end
|
||||
|
||||
def set_items
|
||||
case params[:id]
|
||||
when 'featured'
|
||||
@@ -57,11 +71,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
end
|
||||
|
||||
def for_signed_account
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
if @unauthorized
|
||||
[]
|
||||
else
|
||||
yield
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
include JsonLdHelper
|
||||
|
||||
before_action :skip_large_payload
|
||||
before_action :skip_unknown_actor_activity
|
||||
before_action :require_actor_signature!
|
||||
skip_before_action :authenticate_user!
|
||||
@@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
|
||||
private
|
||||
|
||||
def skip_large_payload
|
||||
head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE
|
||||
end
|
||||
|
||||
def skip_unknown_actor_activity
|
||||
head 202 if unknown_affected_account?
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class Api::Fasp::BaseController < ApplicationController
|
||||
provider = nil
|
||||
|
||||
Linzer.verify!(request.rack_request, no_older_than: 5.minutes) do |keyid|
|
||||
provider = Fasp::Provider.find(keyid)
|
||||
provider = Fasp::Provider.confirmed.find(keyid)
|
||||
Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid)
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
|
||||
def set_poll
|
||||
@poll = Poll.find(params[:poll_id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController
|
||||
def set_poll
|
||||
@poll = Poll.find(params[:id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
|
||||
bookmark&.destroy!
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false })
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class Api::V1::Statuses::FavouritesController < Api::V1::Statuses::BaseControlle
|
||||
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } })
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
|
||||
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } })
|
||||
render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
@@ -45,7 +45,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
|
||||
def set_reblog
|
||||
@reblog = Status.find(params[:status_id])
|
||||
authorize @reblog, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||
end
|
||||
|
||||
def set_push_subscription
|
||||
@push_subscription = ::Web::PushSubscription.find(params[:id])
|
||||
@push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id])
|
||||
end
|
||||
|
||||
def subscription_params
|
||||
|
||||
@@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController
|
||||
def set_resource
|
||||
@resource = located_resource
|
||||
authorize(@resource, :show?) if @resource.is_a?(Status)
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ module CacheConcern
|
||||
# from being used as cache keys, while allowing to `Vary` on them (to not serve
|
||||
# anonymous cached data to authenticated requests when authentication matters)
|
||||
def enforce_cache_control!
|
||||
vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase }
|
||||
vary = response.headers['Vary'].to_s.split(',').map { |x| x.strip.downcase }.reject(&:empty?)
|
||||
return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? }
|
||||
|
||||
response.cache_control.replace(private: true, no_store: true)
|
||||
|
||||
@@ -70,10 +70,13 @@ module SignatureVerification
|
||||
rescue Mastodon::SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! "Failed to fetch remote data: #{e.message}"
|
||||
rescue Mastodon::UnexpectedResponseError
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
|
||||
rescue Stoplight::Error::RedLight
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
||||
end
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class MediaController < ApplicationController
|
||||
|
||||
def verify_permitted_status!
|
||||
authorize @media_attachment.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class SeveredRelationshipsController < ApplicationController
|
||||
private
|
||||
|
||||
def set_event
|
||||
@event = AccountRelationshipSeveranceEvent.find(params[:id])
|
||||
@event = AccountRelationshipSeveranceEvent.where(account: current_account).find(params[:id])
|
||||
end
|
||||
|
||||
def following_data
|
||||
|
||||
@@ -59,7 +59,7 @@ class StatusesController < ApplicationController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export function fetchStatus(id, {
|
||||
dispatch(fetchStatusSuccess(skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
|
||||
if (error.status === 404)
|
||||
dispatch(deleteFromTimelines(id));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,18 +26,23 @@ const getHostname = url => {
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
const addAutoPlay = html => {
|
||||
const handleIframeUrl = (html, url, providerName) => {
|
||||
const document = domParser.parseFromString(html, 'text/html').documentElement;
|
||||
const iframe = document.querySelector('iframe');
|
||||
const startTime = new URL(url).searchParams.get('t')
|
||||
|
||||
if (iframe) {
|
||||
if (iframe.src.indexOf('?') !== -1) {
|
||||
iframe.src += '&';
|
||||
} else {
|
||||
iframe.src += '?';
|
||||
const iframeUrl = new URL(iframe.src)
|
||||
|
||||
iframeUrl.searchParams.set('autoplay', 1)
|
||||
iframeUrl.searchParams.set('auto_play', 1)
|
||||
|
||||
if (providerName === 'YouTube') {
|
||||
iframeUrl.searchParams.set('start', startTime || '');
|
||||
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
||||
}
|
||||
|
||||
iframe.src += 'autoplay=1&auto_play=1';
|
||||
iframe.src = iframeUrl.href
|
||||
|
||||
// DOM parser creates html/body elements around original HTML fragment,
|
||||
// so we need to get innerHTML out of the body and not the entire document
|
||||
@@ -103,7 +108,7 @@ export default class Card extends PureComponent {
|
||||
|
||||
renderVideo () {
|
||||
const { card } = this.props;
|
||||
const content = { __html: addAutoPlay(card.get('html')) };
|
||||
const content = { __html: handleIframeUrl(card.get('html'), card.get('url'), card.get('provider_name')) };
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -421,6 +421,7 @@ export const DetailedStatus: React.FC<{
|
||||
<QuotedStatus
|
||||
quote={status.get('quote')}
|
||||
parentQuotePostId={status.get('id')}
|
||||
contextType='thread'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -64,6 +64,10 @@ const statusTranslateUndo = (state, id) => {
|
||||
});
|
||||
};
|
||||
|
||||
const removeStatusStub = (state, id) => {
|
||||
return state.getIn([id, 'id']) ? state.deleteIn([id, 'isLoading']) : state.delete(id);
|
||||
}
|
||||
|
||||
|
||||
/** @type {ImmutableMap<string, import('flavours/glitch/models/status').Status>} */
|
||||
const initialState = ImmutableMap();
|
||||
@@ -75,11 +79,10 @@ export default function statuses(state = initialState, action) {
|
||||
return state.setIn([action.id, 'isLoading'], true);
|
||||
case STATUS_FETCH_FAIL: {
|
||||
if (action.parentQuotePostId && action.error.status === 404) {
|
||||
return state
|
||||
.delete(action.id)
|
||||
return removeStatusStub(state, action.id)
|
||||
.setIn([action.parentQuotePostId, 'quote', 'state'], 'deleted')
|
||||
} else {
|
||||
return state.delete(action.id);
|
||||
return removeStatusStub(state, action.id);
|
||||
}
|
||||
}
|
||||
case STATUS_IMPORT:
|
||||
|
||||
@@ -78,6 +78,8 @@ export function fetchStatus(id, {
|
||||
dispatch(fetchStatusSuccess(skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
|
||||
if (error.status === 404)
|
||||
dispatch(deleteFromTimelines(id));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,18 +37,23 @@ const getHostname = url => {
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
const addAutoPlay = html => {
|
||||
const handleIframeUrl = (html, url, providerName) => {
|
||||
const document = domParser.parseFromString(html, 'text/html').documentElement;
|
||||
const iframe = document.querySelector('iframe');
|
||||
const startTime = new URL(url).searchParams.get('t')
|
||||
|
||||
if (iframe) {
|
||||
if (iframe.src.indexOf('?') !== -1) {
|
||||
iframe.src += '&';
|
||||
} else {
|
||||
iframe.src += '?';
|
||||
const iframeUrl = new URL(iframe.src)
|
||||
|
||||
iframeUrl.searchParams.set('autoplay', 1)
|
||||
iframeUrl.searchParams.set('auto_play', 1)
|
||||
|
||||
if (providerName === 'YouTube') {
|
||||
iframeUrl.searchParams.set('start', startTime || '');
|
||||
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
||||
}
|
||||
|
||||
iframe.src += 'autoplay=1&auto_play=1';
|
||||
iframe.src = iframeUrl.href
|
||||
|
||||
// DOM parser creates html/body elements around original HTML fragment,
|
||||
// so we need to get innerHTML out of the body and not the entire document
|
||||
@@ -114,7 +119,7 @@ export default class Card extends PureComponent {
|
||||
|
||||
renderVideo () {
|
||||
const { card } = this.props;
|
||||
const content = { __html: addAutoPlay(card.get('html')) };
|
||||
const content = { __html: handleIframeUrl(card.get('html'), card.get('url'), card.get('provider_name')) };
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -384,6 +384,7 @@ export const DetailedStatus: React.FC<{
|
||||
<QuotedStatus
|
||||
quote={status.get('quote')}
|
||||
parentQuotePostId={status.get('id')}
|
||||
contextType='thread'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -64,6 +64,10 @@ const statusTranslateUndo = (state, id) => {
|
||||
});
|
||||
};
|
||||
|
||||
const removeStatusStub = (state, id) => {
|
||||
return state.getIn([id, 'id']) ? state.deleteIn([id, 'isLoading']) : state.delete(id);
|
||||
}
|
||||
|
||||
|
||||
/** @type {ImmutableMap<string, import('mastodon/models/status').Status>} */
|
||||
const initialState = ImmutableMap();
|
||||
@@ -75,11 +79,10 @@ export default function statuses(state = initialState, action) {
|
||||
return state.setIn([action.id, 'isLoading'], true);
|
||||
case STATUS_FETCH_FAIL: {
|
||||
if (action.parentQuotePostId && action.error.status === 404) {
|
||||
return state
|
||||
.delete(action.id)
|
||||
return removeStatusStub(state, action.id)
|
||||
.setIn([action.parentQuotePostId, 'quote', 'state'], 'deleted')
|
||||
} else {
|
||||
return state.delete(action.id);
|
||||
return removeStatusStub(state, action.id);
|
||||
}
|
||||
}
|
||||
case STATUS_IMPORT:
|
||||
|
||||
@@ -5,6 +5,7 @@ class ActivityPub::Activity
|
||||
include Redisable
|
||||
include Lockable
|
||||
|
||||
MAX_JSON_SIZE = 1.megabyte
|
||||
SUPPORTED_TYPES = %w(Note Question).freeze
|
||||
CONVERTED_TYPES = %w(Image Audio Video Article Page Event).freeze
|
||||
|
||||
@@ -21,14 +22,13 @@ class ActivityPub::Activity
|
||||
|
||||
class << self
|
||||
def factory(json, account, **)
|
||||
@json = json
|
||||
klass&.new(json, account, **)
|
||||
klass_for(json)&.new(json, account, **)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def klass
|
||||
case @json['type']
|
||||
def klass_for(json)
|
||||
case json['type']
|
||||
when 'Create'
|
||||
ActivityPub::Activity::Create
|
||||
when 'Announce'
|
||||
|
||||
@@ -56,12 +56,14 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
|
||||
end
|
||||
|
||||
def revoke_quote
|
||||
@quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account)
|
||||
@quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account, state: [:pending, :accepted])
|
||||
return if @quote.nil?
|
||||
|
||||
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward!
|
||||
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward! if @quote.status.present?
|
||||
|
||||
@quote.reject!
|
||||
DistributionWorker.perform_async(@quote.status_id, { 'update' => true })
|
||||
|
||||
DistributionWorker.perform_async(@quote.status_id, { 'update' => true }) if @quote.status.present?
|
||||
end
|
||||
|
||||
def forwarder
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||
# Updates to unknown objects older than that are ignored
|
||||
OBJECT_AGE_THRESHOLD = 1.day
|
||||
|
||||
def perform
|
||||
@account.schedule_refresh_if_stale!
|
||||
|
||||
@@ -28,6 +31,10 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||
|
||||
@status = Status.find_by(uri: object_uri, account_id: @account.id)
|
||||
|
||||
# Ignore updates for old unknown objects, since those are updates we are not interested in
|
||||
# Also ignore unknown objects from suspended users for the same reasons
|
||||
return if @status.nil? && (@account.suspended? || object_too_old?)
|
||||
|
||||
# We may be getting `Create` and `Update` out of order
|
||||
@status ||= ActivityPub::Activity::Create.new(@json, @account, **@options).perform
|
||||
|
||||
@@ -35,4 +42,10 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||
|
||||
ActivityPub::ProcessStatusUpdateService.new.call(@status, @json, @object, request_id: @options[:request_id])
|
||||
end
|
||||
|
||||
def object_too_old?
|
||||
@object['published'].present? && @object['published'].to_datetime < OBJECT_AGE_THRESHOLD.ago
|
||||
rescue Date::Error
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
class ActivityPub::Parser::PollParser
|
||||
include JsonLdHelper
|
||||
|
||||
# Limit the number of items for performance purposes.
|
||||
# We truncate rather than error out to avoid missing the post entirely.
|
||||
MAX_ITEMS = 500
|
||||
|
||||
def initialize(json)
|
||||
@json = json
|
||||
end
|
||||
@@ -48,6 +52,6 @@ class ActivityPub::Parser::PollParser
|
||||
private
|
||||
|
||||
def items
|
||||
@json['anyOf'] || @json['oneOf']
|
||||
(@json['anyOf'] || @json['oneOf'])&.take(MAX_ITEMS)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -112,10 +112,12 @@ class AttachmentBatch
|
||||
keys.each_slice(LIMIT) do |keys_slice|
|
||||
logger.debug { "Deleting #{keys_slice.size} objects" }
|
||||
|
||||
bucket.delete_objects(delete: {
|
||||
objects: keys_slice.map { |key| { key: key } },
|
||||
quiet: true,
|
||||
})
|
||||
with_overridden_timeout(bucket.client, 120) do
|
||||
bucket.delete_objects(delete: {
|
||||
objects: keys_slice.map { |key| { key: key } },
|
||||
quiet: true,
|
||||
})
|
||||
end
|
||||
rescue => e
|
||||
retries += 1
|
||||
|
||||
@@ -134,6 +136,20 @@ class AttachmentBatch
|
||||
@bucket ||= records.first.public_send(@attachment_names.first).s3_bucket
|
||||
end
|
||||
|
||||
# Currently, the aws-sdk-s3 gem does not offer a way to cleanly override the timeout
|
||||
# per-request. So we change the client's config instead. As this client will likely
|
||||
# be re-used for other jobs, restore its original configuration in an `ensure` block.
|
||||
def with_overridden_timeout(s3_client, longer_read_timeout)
|
||||
original_timeout = s3_client.config.http_read_timeout
|
||||
s3_client.config.http_read_timeout = [original_timeout, longer_read_timeout].max
|
||||
|
||||
begin
|
||||
yield
|
||||
ensure
|
||||
s3_client.config.http_read_timeout = original_timeout
|
||||
end
|
||||
end
|
||||
|
||||
def nullified_attributes
|
||||
@attachment_names.flat_map { |attachment_name| NULLABLE_ATTRIBUTES.map { |attribute| "#{attachment_name}_#{attribute}" } & klass.column_names }.index_with(nil)
|
||||
end
|
||||
|
||||
@@ -41,12 +41,17 @@ class ConnectionPool::SharedConnectionPool < ConnectionPool
|
||||
# ConnectionPool 2.4+ calls `checkin(force: true)` after fork.
|
||||
# When this happens, we should remove all connections from Thread.current
|
||||
|
||||
::Thread.current.keys.each do |name| # rubocop:disable Style/HashEachMethods
|
||||
next unless name.to_s.start_with?("#{@key}-")
|
||||
connection_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key}-") && !key.to_s.start_with?("#{@key_count}-") }
|
||||
count_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key_count}-") }
|
||||
|
||||
@available.push(::Thread.current[name])
|
||||
::Thread.current[name] = nil
|
||||
connection_keys.each do |key|
|
||||
@available.push(::Thread.current[key])
|
||||
::Thread.current[key] = nil
|
||||
end
|
||||
count_keys.each do |key|
|
||||
::Thread.current[key] = nil
|
||||
end
|
||||
|
||||
elsif ::Thread.current[key(preferred_tag)]
|
||||
if ::Thread.current[key_count(preferred_tag)] == 1
|
||||
@available.push(::Thread.current[key(preferred_tag)])
|
||||
|
||||
@@ -70,6 +70,7 @@ class ConnectionPool::SharedTimedStack
|
||||
if @created == @max && !@queue.empty?
|
||||
throw_away_connection = @queue.pop
|
||||
@tagged_queue[throw_away_connection.site].delete(throw_away_connection)
|
||||
throw_away_connection.close
|
||||
@create_block.call(preferred_tag)
|
||||
elsif @created != @max
|
||||
connection = @create_block.call(preferred_tag)
|
||||
|
||||
@@ -29,7 +29,7 @@ class Fasp::Request
|
||||
response = HTTP
|
||||
.headers(headers)
|
||||
.use(http_signature: { key:, covered_components: COVERED_COMPONENTS })
|
||||
.send(verb, url, body:)
|
||||
.send(verb, url, body:, socket_class: ::Request::Socket)
|
||||
|
||||
validate!(response)
|
||||
|
||||
|
||||
@@ -500,6 +500,7 @@ class FeedManager
|
||||
return :filter if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
|
||||
return :skip_home if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present?
|
||||
return :filter if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
|
||||
return :filter if status.reblog? && status.reblog.blank?
|
||||
|
||||
check_for_blocks = crutches[:active_mentions][status.id] || []
|
||||
check_for_blocks.push(status.account_id)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PrivateAddressCheck
|
||||
module_function
|
||||
|
||||
CIDR_LIST = [
|
||||
IP4_CIDR_LIST = [
|
||||
IPAddr.new('0.0.0.0/8'), # Current network (only valid as source address)
|
||||
IPAddr.new('100.64.0.0/10'), # Shared Address Space
|
||||
IPAddr.new('172.16.0.0/12'), # Private network
|
||||
@@ -16,6 +14,9 @@ module PrivateAddressCheck
|
||||
IPAddr.new('224.0.0.0/4'), # IP multicast (former Class D network)
|
||||
IPAddr.new('240.0.0.0/4'), # Reserved (former Class E network)
|
||||
IPAddr.new('255.255.255.255'), # Broadcast
|
||||
].freeze
|
||||
|
||||
CIDR_LIST = (IP4_CIDR_LIST + IP4_CIDR_LIST.map(&:ipv4_mapped) + [
|
||||
IPAddr.new('64:ff9b::/96'), # IPv4/IPv6 translation (RFC 6052)
|
||||
IPAddr.new('100::/64'), # Discard prefix (RFC 6666)
|
||||
IPAddr.new('2001::/32'), # Teredo tunneling
|
||||
@@ -25,7 +26,9 @@ module PrivateAddressCheck
|
||||
IPAddr.new('2002::/16'), # 6to4
|
||||
IPAddr.new('fc00::/7'), # Unique local address
|
||||
IPAddr.new('ff00::/8'), # Multicast
|
||||
].freeze
|
||||
]).freeze
|
||||
|
||||
module_function
|
||||
|
||||
def private_address?(address)
|
||||
address.private? || address.loopback? || address.link_local? || CIDR_LIST.any? { |cidr| cidr.include?(address) }
|
||||
|
||||
@@ -349,5 +349,5 @@ class Request
|
||||
end
|
||||
end
|
||||
|
||||
private_constant :ClientLimit, :Socket, :ProxySocket
|
||||
private_constant :ClientLimit
|
||||
end
|
||||
|
||||
@@ -25,9 +25,13 @@ class SignatureParser
|
||||
|
||||
# Use `skip` instead of `scan` as we only care about the subgroups
|
||||
while scanner.skip(PARAM_RE)
|
||||
key = scanner[:key]
|
||||
# Detect a duplicate key
|
||||
raise Mastodon::SignatureVerificationError, 'Error parsing signature with duplicate keys' if params.key?(key)
|
||||
|
||||
# This is not actually correct with regards to quoted pairs, but it's consistent
|
||||
# with our previous implementation, and good enough in practice.
|
||||
params[scanner[:key]] = scanner[:value] || scanner[:quoted_value][1...-1]
|
||||
params[key] = scanner[:value] || scanner[:quoted_value][1...-1]
|
||||
|
||||
scanner.skip(/\s*/)
|
||||
return params if scanner.eos?
|
||||
|
||||
@@ -26,12 +26,7 @@ class StatusCacheHydrator
|
||||
|
||||
def hydrate_non_reblog_payload(empty_payload, account_id, nested: false)
|
||||
empty_payload.tap do |payload|
|
||||
fill_status_payload(payload, @status, account_id, nested:)
|
||||
|
||||
if payload[:poll]
|
||||
payload[:poll][:voted] = @status.account_id == account_id
|
||||
payload[:poll][:own_votes] = []
|
||||
end
|
||||
fill_status_payload(payload, @status, account_id, fresh: !nested, nested:)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,18 +40,7 @@ class StatusCacheHydrator
|
||||
# used to create the status, we need to hydrate it here too
|
||||
payload[:reblog][:application] = payload_reblog_application if payload[:reblog][:application].nil? && @status.reblog.account_id == account_id
|
||||
|
||||
fill_status_payload(payload[:reblog], @status.reblog, account_id, nested:)
|
||||
|
||||
if payload[:reblog][:poll]
|
||||
if @status.reblog.account_id == account_id
|
||||
payload[:reblog][:poll][:voted] = true
|
||||
payload[:reblog][:poll][:own_votes] = []
|
||||
else
|
||||
own_votes = PollVote.where(poll_id: @status.reblog.poll_id, account_id: account_id).pluck(:choice)
|
||||
payload[:reblog][:poll][:voted] = !own_votes.empty?
|
||||
payload[:reblog][:poll][:own_votes] = own_votes
|
||||
end
|
||||
end
|
||||
fill_status_payload(payload[:reblog], @status.reblog, account_id, fresh: false, nested:)
|
||||
|
||||
payload[:filtered] = payload[:reblog][:filtered]
|
||||
payload[:favourited] = payload[:reblog][:favourited]
|
||||
@@ -64,7 +48,7 @@ class StatusCacheHydrator
|
||||
end
|
||||
end
|
||||
|
||||
def fill_status_payload(payload, status, account_id, nested: false)
|
||||
def fill_status_payload(payload, status, account_id, nested: false, fresh: true)
|
||||
payload[:favourited] = Favourite.exists?(account_id: account_id, status_id: status.id)
|
||||
payload[:reblogged] = Status.exists?(account_id: account_id, reblog_of_id: status.id)
|
||||
payload[:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: status.conversation_id)
|
||||
@@ -72,6 +56,30 @@ class StatusCacheHydrator
|
||||
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id
|
||||
payload[:filtered] = mapped_applied_custom_filter(account_id, status)
|
||||
payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id, nested:) if payload[:quote]
|
||||
|
||||
if payload[:poll]
|
||||
if fresh
|
||||
# If the status is brand new, we don't need to look up votes in database
|
||||
payload[:poll][:voted] = status.account_id == account_id
|
||||
payload[:poll][:own_votes] = []
|
||||
elsif status.account_id == account_id
|
||||
payload[:poll][:voted] = true
|
||||
payload[:poll][:own_votes] = []
|
||||
else
|
||||
own_votes = PollVote.where(poll_id: status.poll_id, account_id: account_id).pluck(:choice)
|
||||
payload[:poll][:voted] = !own_votes.empty?
|
||||
payload[:poll][:own_votes] = own_votes
|
||||
end
|
||||
end
|
||||
|
||||
# Nested statuses are more likely to have a stale cache
|
||||
fill_status_stats(payload, status) if nested
|
||||
end
|
||||
|
||||
def fill_status_stats(payload, status)
|
||||
payload[:replies_count] = status.replies_count
|
||||
payload[:reblogs_count] = status.untrusted_reblogs_count || status.reblogs_count
|
||||
payload[:favourites_count] = status.untrusted_favourites_count || status.favourites_count
|
||||
end
|
||||
|
||||
def hydrate_quote_payload(empty_payload, quote, account_id, nested: false)
|
||||
|
||||
@@ -78,6 +78,13 @@ class Account < ApplicationRecord
|
||||
DISPLAY_NAME_LENGTH_LIMIT = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
|
||||
NOTE_LENGTH_LIMIT = (ENV['MAX_BIO_CHARS'] || 500).to_i
|
||||
|
||||
# Hard limits for federated content
|
||||
USERNAME_LENGTH_HARD_LIMIT = 2048
|
||||
DISPLAY_NAME_LENGTH_HARD_LIMIT = 2048
|
||||
NOTE_LENGTH_HARD_LIMIT = 20.kilobytes
|
||||
ATTRIBUTION_DOMAINS_HARD_LIMIT = 256
|
||||
ALSO_KNOWN_AS_HARD_LIMIT = 256
|
||||
|
||||
AUTOMATED_ACTOR_TYPES = %w(Application Service).freeze
|
||||
|
||||
include Attachmentable # Load prior to Avatar & Header concerns
|
||||
@@ -109,7 +116,7 @@ class Account < ApplicationRecord
|
||||
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
|
||||
|
||||
# Remote user validations, also applies to internal actors
|
||||
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (remote? || actor_type_application?) && will_save_change_to_username? }
|
||||
validates :username, format: { with: USERNAME_ONLY_RE }, length: { maximum: USERNAME_LENGTH_HARD_LIMIT }, if: -> { (remote? || actor_type_application?) && will_save_change_to_username? }
|
||||
|
||||
# Remote user validations
|
||||
validates :uri, presence: true, unless: :local?, on: :create
|
||||
|
||||
@@ -27,6 +27,8 @@ class CustomEmoji < ApplicationRecord
|
||||
LOCAL_LIMIT = (ENV['MAX_EMOJI_SIZE'] || 256.kilobytes).to_i
|
||||
LIMIT = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 256.kilobytes).to_i].max
|
||||
MINIMUM_SHORTCODE_SIZE = 2
|
||||
MAX_SHORTCODE_SIZE = 128
|
||||
MAX_FEDERATED_SHORTCODE_SIZE = 2048
|
||||
|
||||
SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
|
||||
|
||||
@@ -48,7 +50,8 @@ class CustomEmoji < ApplicationRecord
|
||||
validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true
|
||||
validates_attachment_size :image, less_than: LIMIT, unless: :local?
|
||||
validates_attachment_size :image, less_than: LOCAL_LIMIT, if: :local?
|
||||
validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: MINIMUM_SHORTCODE_SIZE }
|
||||
validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: MINIMUM_SHORTCODE_SIZE, maximum: MAX_FEDERATED_SHORTCODE_SIZE }
|
||||
validates :shortcode, length: { maximum: MAX_SHORTCODE_SIZE }, if: :local?
|
||||
|
||||
scope :local, -> { where(domain: nil) }
|
||||
scope :remote, -> { where.not(domain: nil) }
|
||||
|
||||
@@ -30,6 +30,8 @@ class CustomFilter < ApplicationRecord
|
||||
|
||||
EXPIRATION_DURATIONS = [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].freeze
|
||||
|
||||
TITLE_LENGTH_LIMIT = 256
|
||||
|
||||
include Expireable
|
||||
include Redisable
|
||||
|
||||
@@ -41,6 +43,7 @@ class CustomFilter < ApplicationRecord
|
||||
accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
|
||||
|
||||
validates :title, :context, presence: true
|
||||
validates :title, length: { maximum: TITLE_LENGTH_LIMIT }
|
||||
validate :context_must_be_valid
|
||||
|
||||
normalizes :context, with: ->(context) { context.map(&:strip).filter_map(&:presence) }
|
||||
|
||||
@@ -17,7 +17,9 @@ class CustomFilterKeyword < ApplicationRecord
|
||||
|
||||
belongs_to :custom_filter
|
||||
|
||||
validates :keyword, presence: true
|
||||
KEYWORD_LENGTH_LIMIT = 512
|
||||
|
||||
validates :keyword, presence: true, length: { maximum: KEYWORD_LENGTH_LIMIT }
|
||||
|
||||
alias_attribute :phrase, :keyword
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class Fasp::Provider < ApplicationRecord
|
||||
before_create :create_keypair
|
||||
after_commit :update_remote_capabilities
|
||||
|
||||
scope :confirmed, -> { where(confirmed: true) }
|
||||
scope :with_capability, lambda { |capability_name|
|
||||
where('fasp_providers.capabilities @> ?::jsonb', "[{\"id\": \"#{capability_name}\", \"enabled\": true}]")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class List < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
PER_ACCOUNT_LIMIT = 50
|
||||
TITLE_LENGTH_LIMIT = 256
|
||||
|
||||
enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show, validate: true
|
||||
|
||||
@@ -26,7 +27,7 @@ class List < ApplicationRecord
|
||||
has_many :accounts, through: :list_accounts
|
||||
has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account
|
||||
|
||||
validates :title, presence: true
|
||||
validates :title, presence: true, length: { maximum: TITLE_LENGTH_LIMIT }
|
||||
|
||||
validate :validate_account_lists_limit, on: :create
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class Quote < ApplicationRecord
|
||||
before_validation :set_activity_uri, only: :create, if: -> { account.local? && quoted_account&.remote? }
|
||||
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
|
||||
validate :validate_visibility
|
||||
validate :validate_original_quoted_status
|
||||
|
||||
def accept!
|
||||
update!(state: :accepted)
|
||||
@@ -41,9 +42,9 @@ class Quote < ApplicationRecord
|
||||
|
||||
def reject!
|
||||
if accepted?
|
||||
update!(state: :revoked)
|
||||
update!(state: :revoked, approval_uri: nil)
|
||||
elsif !revoked?
|
||||
update!(state: :rejected)
|
||||
update!(state: :rejected, approval_uri: nil)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -70,6 +71,10 @@ class Quote < ApplicationRecord
|
||||
errors.add(:quoted_status_id, :visibility_mismatch)
|
||||
end
|
||||
|
||||
def validate_original_quoted_status
|
||||
errors.add(:quoted_status_id, :reblog_unallowed) if quoted_status&.reblog?
|
||||
end
|
||||
|
||||
def set_activity_uri
|
||||
self.activity_uri = [ActivityPub::TagManager.instance.uri_for(account), '/quote_requests/', SecureRandom.uuid].join
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ class REST::BaseQuoteSerializer < ActiveModel::Serializer
|
||||
end
|
||||
|
||||
def quoted_status
|
||||
object.quoted_status if object.accepted? && object.quoted_status.present? && !status_filter.filtered_for_quote?
|
||||
object.quoted_status if object.accepted? && object.quoted_status.present? && !object.quoted_status&.reblog? && !status_filter.filtered_for_quote?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -92,7 +92,6 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
||||
existing_status = Status.remote.find_by(uri: uri)
|
||||
if existing_status&.distributable?
|
||||
Rails.logger.debug { "FetchRemoteStatusService - Got 404 for orphaned status with URI #{uri}, deleting" }
|
||||
Tombstone.find_or_create_by(uri: uri, account: existing_status.account)
|
||||
RemoveStatusService.new.call(existing_status, redraft: false)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||
include Redisable
|
||||
include Lockable
|
||||
|
||||
MAX_PROFILE_FIELDS = 50
|
||||
SUBDOMAINS_RATELIMIT = 10
|
||||
DISCOVERIES_PER_REQUEST = 400
|
||||
|
||||
@@ -122,15 +123,15 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||
|
||||
def set_immediate_attributes!
|
||||
@account.featured_collection_url = valid_collection_uri(@json['featured'])
|
||||
@account.display_name = @json['name'] || ''
|
||||
@account.note = @json['summary'] || ''
|
||||
@account.display_name = (@json['name'] || '')[0...(Account::DISPLAY_NAME_LENGTH_HARD_LIMIT)]
|
||||
@account.note = (@json['summary'] || '')[0...(Account::NOTE_LENGTH_HARD_LIMIT)]
|
||||
@account.locked = @json['manuallyApprovesFollowers'] || false
|
||||
@account.fields = property_values || {}
|
||||
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
|
||||
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).take(Account::ALSO_KNOWN_AS_HARD_LIMIT).map { |item| value_or_id(item) }
|
||||
@account.discoverable = @json['discoverable'] || false
|
||||
@account.indexable = @json['indexable'] || false
|
||||
@account.memorial = @json['memorial'] || false
|
||||
@account.attribution_domains = as_array(@json['attributionDomains'] || []).map { |item| value_or_id(item) }
|
||||
@account.attribution_domains = as_array(@json['attributionDomains'] || []).take(Account::ATTRIBUTION_DOMAINS_HARD_LIMIT).map { |item| value_or_id(item) }
|
||||
end
|
||||
|
||||
def set_fetchable_key!
|
||||
@@ -251,7 +252,10 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||
def property_values
|
||||
return unless @json['attachment'].is_a?(Array)
|
||||
|
||||
as_array(@json['attachment']).select { |attachment| attachment['type'] == 'PropertyValue' }.map { |attachment| attachment.slice('name', 'value') }
|
||||
as_array(@json['attachment'])
|
||||
.select { |attachment| attachment['type'] == 'PropertyValue' }
|
||||
.take(MAX_PROFILE_FIELDS)
|
||||
.map { |attachment| attachment.slice('name', 'value') }
|
||||
end
|
||||
|
||||
def mismatching_origin?(url)
|
||||
|
||||
@@ -205,7 +205,11 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||
|
||||
def update_tags!
|
||||
previous_tags = @status.tags.to_a
|
||||
current_tags = @status.tags = Tag.find_or_create_by_names(@raw_tags)
|
||||
current_tags = @status.tags = @raw_tags.flat_map do |tag|
|
||||
Tag.find_or_create_by_names([tag]).filter(&:valid?)
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
[]
|
||||
end.uniq
|
||||
|
||||
return unless @status.distributable?
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ class ActivityPub::VerifyQuoteService < BaseService
|
||||
|
||||
status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, prefetched_body:, request_id: @request_id, depth: @depth + 1)
|
||||
|
||||
@quote.update(quoted_status: status) if status.present?
|
||||
@quote.update(quoted_status: status) if status.present? && !status.reblog?
|
||||
rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
@fetching_error = e
|
||||
end
|
||||
@@ -90,7 +90,7 @@ class ActivityPub::VerifyQuoteService < BaseService
|
||||
|
||||
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id, depth: @depth)
|
||||
|
||||
if status.present?
|
||||
if status.present? && !status.reblog?
|
||||
@quote.update(quoted_status: status)
|
||||
true
|
||||
else
|
||||
|
||||
@@ -34,7 +34,7 @@ class BatchedRemoveStatusService < BaseService
|
||||
# transaction lock the database, but we use the delete method instead
|
||||
# of destroy to avoid all callbacks. We rely on foreign keys to
|
||||
# cascade the delete faster without loading the associations.
|
||||
statuses_and_reblogs.each_slice(50) { |slice| Status.where(id: slice.map(&:id)).delete_all }
|
||||
statuses_and_reblogs.each_slice(50) { |slice| Status.unscoped.where(id: slice.pluck(:id)).delete_all }
|
||||
|
||||
# Since we skipped all callbacks, we also need to manually
|
||||
# deindex the statuses
|
||||
|
||||
@@ -29,7 +29,12 @@ class BlockDomainService < BaseService
|
||||
suspend_accounts!
|
||||
end
|
||||
|
||||
DomainClearMediaWorker.perform_async(domain_block.id) if domain_block.reject_media?
|
||||
if domain_block.suspend?
|
||||
# Account images and attachments are already handled by `suspend_accounts!`
|
||||
PurgeCustomEmojiWorker.perform_async(blocked_domain)
|
||||
elsif domain_block.reject_media?
|
||||
DomainClearMediaWorker.perform_async(domain_block.id)
|
||||
end
|
||||
end
|
||||
|
||||
def silence_accounts!
|
||||
|
||||
@@ -14,6 +14,8 @@ class FanOutOnWriteService < BaseService
|
||||
@account = status.account
|
||||
@options = options
|
||||
|
||||
return if @status.proper.account.suspended?
|
||||
|
||||
check_race_condition!
|
||||
warm_payload_cache!
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ class ProcessMentionsService < BaseService
|
||||
# Make sure we never mention blocked accounts
|
||||
unless @current_mentions.empty?
|
||||
mentioned_domains = @current_mentions.filter_map { |m| m.account.domain }.uniq
|
||||
blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains))
|
||||
blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains).pluck(:domain))
|
||||
mentioned_account_ids = @current_mentions.map(&:account_id)
|
||||
blocked_account_ids = Set.new(@status.account.block_relationships.where(target_account_id: mentioned_account_ids).pluck(:target_account_id))
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
|
||||
%tr
|
||||
%td.email-status-content
|
||||
= render 'status_content', status: status
|
||||
= render 'notification_mailer/status_content', status: status
|
||||
|
||||
%p.email-status-footer
|
||||
= link_to l(status.created_at.in_time_zone(time_zone.presence), format: :with_time_zone), web_url("@#{status.account.pretty_acct}/#{status.id}")
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
|
||||
%tr
|
||||
%td.email-status-content
|
||||
= render 'status_content', status: status
|
||||
= render 'notification_mailer/status_content', status: status
|
||||
|
||||
- if status.local? && status.quote
|
||||
%table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
|
||||
%tr
|
||||
%td.email-inner-nested-card-td
|
||||
= render 'nested_quote', status: status.quote.quoted_status, time_zone: time_zone
|
||||
= render 'notification_mailer/nested_quote', status: status.quote.quoted_status, time_zone: time_zone
|
||||
%p.email-status-footer
|
||||
= link_to l(status.created_at.in_time_zone(time_zone.presence), format: :with_time_zone), web_url("@#{status.account.pretty_acct}/#{status.id}")
|
||||
|
||||
@@ -64,6 +64,16 @@ class MoveWorker
|
||||
.in_batches do |follows|
|
||||
ListAccount.where(follow: follows).in_batches.update_all(account_id: @target_account.id)
|
||||
num_moved += follows.update_all(target_account_id: @target_account.id)
|
||||
|
||||
# Clear any relationship cache, since callbacks are not called
|
||||
Rails.cache.delete_multi(follows.flat_map do |follow|
|
||||
[
|
||||
['relationship', follow.account_id, follow.target_account_id],
|
||||
['relationship', follow.target_account_id, follow.account_id],
|
||||
['relationship', follow.account_id, @target_account.id],
|
||||
['relationship', @target_account.id, follow.account_id],
|
||||
]
|
||||
end)
|
||||
end
|
||||
|
||||
num_moved
|
||||
|
||||
15
app/workers/purge_custom_emoji_worker.rb
Normal file
15
app/workers/purge_custom_emoji_worker.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PurgeCustomEmojiWorker
|
||||
include Sidekiq::IterableJob
|
||||
|
||||
def build_enumerator(domain, cursor:)
|
||||
return if domain.blank?
|
||||
|
||||
active_record_batches_enumerator(CustomEmoji.by_domain_and_subdomains(domain), cursor:)
|
||||
end
|
||||
|
||||
def each_iteration(custom_emojis, _domain)
|
||||
AttachmentBatch.new(CustomEmoji, custom_emojis).delete
|
||||
end
|
||||
end
|
||||
@@ -58,7 +58,7 @@ defaults: &defaults
|
||||
require_invite_text: false
|
||||
backups_retention_period: 7
|
||||
captcha_enabled: false
|
||||
allow_referer_origin: false
|
||||
allow_referrer_origin: false
|
||||
|
||||
development:
|
||||
<<: *defaults
|
||||
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
|
||||
# es:
|
||||
# restart: always
|
||||
# image: docker.elastic.co/elasticsearch/elasticsearch:7.17.4
|
||||
# image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29
|
||||
# environment:
|
||||
# - "ES_JAVA_OPTS=-Xms512m -Xmx512m -Des.enforce.bootstrap.checks=true"
|
||||
# - "xpack.license.self_generated.type=basic"
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
web:
|
||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||
# build: .
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.4.6
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.4.14
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec puma -C config/puma.rb
|
||||
@@ -83,7 +83,7 @@ services:
|
||||
# build:
|
||||
# dockerfile: ./streaming/Dockerfile
|
||||
# context: .
|
||||
image: ghcr.io/glitch-soc/mastodon-streaming:v4.4.6
|
||||
image: ghcr.io/glitch-soc/mastodon-streaming:v4.4.14
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: node ./streaming/index.js
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
sidekiq:
|
||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||
# build: .
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.4.6
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.4.14
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec sidekiq
|
||||
|
||||
@@ -109,15 +109,27 @@ module Mastodon::CLI
|
||||
end
|
||||
|
||||
option :remote_only, type: :boolean
|
||||
option :suspended_only, type: :boolean
|
||||
desc 'purge', 'Remove all custom emoji'
|
||||
long_desc <<-LONG_DESC
|
||||
Removes all custom emoji.
|
||||
|
||||
With the --remote-only option, only remote emoji will be deleted.
|
||||
|
||||
With the --suspended-only option, only emoji from suspended servers will be deleted.
|
||||
LONG_DESC
|
||||
def purge
|
||||
scope = options[:remote_only] ? CustomEmoji.remote : CustomEmoji
|
||||
scope.in_batches.destroy_all
|
||||
if options[:suspended_only]
|
||||
DomainBlock.where(severity: :suspend).find_each do |domain_block|
|
||||
CustomEmoji.by_domain_and_subdomains(domain_block.domain).find_in_batches do |custom_emojis|
|
||||
AttachmentBatch.new(CustomEmoji, custom_emojis).delete
|
||||
end
|
||||
end
|
||||
else
|
||||
scope = options[:remote_only] ? CustomEmoji.remote : CustomEmoji
|
||||
scope.in_batches.destroy_all
|
||||
end
|
||||
|
||||
say('OK', :green)
|
||||
end
|
||||
|
||||
|
||||
@@ -123,12 +123,12 @@ module Mastodon::CLI
|
||||
progress.log("Moving #{previous_path} to #{upgraded_path}") if options[:verbose]
|
||||
|
||||
begin
|
||||
move_previous_to_upgraded
|
||||
move_previous_to_upgraded(previous_path, upgraded_path)
|
||||
rescue => e
|
||||
progress.log(pastel.red("Error processing #{previous_path}: #{e}"))
|
||||
success = false
|
||||
|
||||
remove_directory
|
||||
remove_directory(upgraded_path)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ module Mastodon
|
||||
end
|
||||
|
||||
def patch
|
||||
6
|
||||
14
|
||||
end
|
||||
|
||||
def default_prerelease
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"http-link-header": "^1.1.1",
|
||||
"immutable": "^4.3.0",
|
||||
"intl-messageformat": "^10.7.16",
|
||||
"js-yaml": "^4.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lande": "^1.0.10",
|
||||
"lodash": "^4.17.21",
|
||||
"marky": "^1.2.5",
|
||||
|
||||
@@ -1096,6 +1096,60 @@ RSpec.describe ActivityPub::Activity::Create do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a quote of a known reblog that is otherwise valid' do
|
||||
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
|
||||
let(:quoted_status) { Fabricate(:status, account: quoted_account, reblog: Fabricate(:status)) }
|
||||
let(:approval_uri) { 'https://quoted.example.com/quote-approval' }
|
||||
|
||||
let(:object_json) do
|
||||
build_object(
|
||||
type: 'Note',
|
||||
content: 'woah what she said is amazing',
|
||||
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
|
||||
quoteAuthorization: approval_uri
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{
|
||||
QuoteAuthorization: 'https://w3id.org/fep/044f#QuoteAuthorization',
|
||||
gts: 'https://gotosocial.org/ns#',
|
||||
interactionPolicy: {
|
||||
'@id': 'gts:interactionPolicy',
|
||||
'@type': '@id',
|
||||
},
|
||||
interactingObject: {
|
||||
'@id': 'gts:interactingObject',
|
||||
'@type': '@id',
|
||||
},
|
||||
interactionTarget: {
|
||||
'@id': 'gts:interactionTarget',
|
||||
'@type': '@id',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'QuoteAuthorization',
|
||||
id: approval_uri,
|
||||
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
|
||||
interactingObject: object_json[:id],
|
||||
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
|
||||
}))
|
||||
end
|
||||
|
||||
it 'creates a status without the verified quote' do
|
||||
expect { subject.perform }.to change(sender.statuses, :count).by(1)
|
||||
|
||||
status = sender.statuses.first
|
||||
expect(status).to_not be_nil
|
||||
expect(status.quote).to_not be_nil
|
||||
expect(status.quote.state).to_not eq 'accepted'
|
||||
expect(status.quote.quoted_status).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a vote to a local poll' do
|
||||
let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) }
|
||||
let!(:local_status) { Fabricate(:status, poll: poll) }
|
||||
|
||||
@@ -34,6 +34,8 @@ RSpec.describe ActivityPub::Activity do
|
||||
}
|
||||
end
|
||||
|
||||
let(:publication_date) { 1.hour.ago.utc }
|
||||
|
||||
let(:create_json) do
|
||||
{
|
||||
'@context': [
|
||||
@@ -52,7 +54,7 @@ RSpec.describe ActivityPub::Activity do
|
||||
'https://www.w3.org/ns/activitystreams#Public',
|
||||
],
|
||||
content: 'foo',
|
||||
published: '2025-05-24T11:03:10Z',
|
||||
published: publication_date.iso8601,
|
||||
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
|
||||
},
|
||||
}.deep_stringify_keys
|
||||
@@ -77,7 +79,7 @@ RSpec.describe ActivityPub::Activity do
|
||||
'https://www.w3.org/ns/activitystreams#Public',
|
||||
],
|
||||
content: 'foo',
|
||||
published: '2025-05-24T11:03:10Z',
|
||||
published: publication_date.iso8601,
|
||||
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
|
||||
quoteAuthorization: approval_uri,
|
||||
},
|
||||
|
||||
@@ -55,6 +55,24 @@ RSpec.describe Fasp::Request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the provider host name resolves to a private address' do
|
||||
around do |example|
|
||||
WebMock.disable!
|
||||
example.run
|
||||
WebMock.enable!
|
||||
end
|
||||
|
||||
it 'raises Mastodon::ValidationError' do
|
||||
resolver = instance_double(Resolv::DNS)
|
||||
|
||||
allow(resolver).to receive(:getaddresses).with('reqprov.example.com').and_return(%w(0.0.0.0 2001:db8::face))
|
||||
allow(resolver).to receive(:timeouts=).and_return(nil)
|
||||
allow(Resolv::DNS).to receive(:open).and_yield(resolver)
|
||||
|
||||
expect { subject.send(method, '/test_path') }.to raise_error(Mastodon::ValidationError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get' do
|
||||
|
||||
@@ -23,6 +23,36 @@ RSpec.describe Mastodon::CLI::Emoji do
|
||||
.to output_results('OK')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with --suspended-only and existing custom emoji on blocked servers' do
|
||||
let(:blocked_domain) { 'evil.com' }
|
||||
let(:blocked_subdomain) { 'subdomain.evil.org' }
|
||||
let(:blocked_domain_without_emoji) { 'blocked.com' }
|
||||
let(:silenced_domain) { 'silenced.com' }
|
||||
|
||||
let(:options) { { suspended_only: true } }
|
||||
|
||||
before do
|
||||
Fabricate(:custom_emoji)
|
||||
Fabricate(:custom_emoji, domain: blocked_domain)
|
||||
Fabricate(:custom_emoji, domain: blocked_subdomain)
|
||||
Fabricate(:custom_emoji, domain: silenced_domain)
|
||||
|
||||
Fabricate(:domain_block, severity: :suspend, domain: blocked_domain)
|
||||
Fabricate(:domain_block, severity: :suspend, domain: 'evil.org')
|
||||
Fabricate(:domain_block, severity: :suspend, domain: blocked_domain_without_emoji)
|
||||
Fabricate(:domain_block, severity: :silence, domain: silenced_domain)
|
||||
end
|
||||
|
||||
it 'reports a successful purge' do
|
||||
expect { subject }
|
||||
.to output_results('OK')
|
||||
.and change { CustomEmoji.by_domain_and_subdomains(blocked_domain).count }.to(0)
|
||||
.and change { CustomEmoji.by_domain_and_subdomains('evil.org').count }.to(0)
|
||||
.and not_change { CustomEmoji.by_domain_and_subdomains(silenced_domain).count }
|
||||
.and(not_change { CustomEmoji.local.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#import' do
|
||||
|
||||
20
spec/lib/private_address_check_spec.rb
Normal file
20
spec/lib/private_address_check_spec.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PrivateAddressCheck do
|
||||
describe 'private_address?' do
|
||||
it 'returns true for private addresses' do
|
||||
# rubocop:disable RSpec/ExpectActual
|
||||
expect(
|
||||
[
|
||||
'192.168.1.7',
|
||||
'0.0.0.0',
|
||||
'127.0.0.1',
|
||||
'::ffff:0.0.0.1',
|
||||
]
|
||||
).to all satisfy('return true') { |addr| described_class.private_address?(IPAddr.new(addr)) }
|
||||
# rubocop:enable RSpec/ExpectActual
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -164,6 +164,28 @@ RSpec.describe StatusCacheHydrator do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the quoted post has a poll authored by the user' do
|
||||
let(:poll) { Fabricate(:poll, account: account) }
|
||||
let(:quoted_status) { Fabricate(:status, poll: poll, account: account) }
|
||||
|
||||
it 'renders the same attributes as a full render' do
|
||||
expect(subject).to eql(compare_to_hash)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the quoted post has been voted in' do
|
||||
let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) }
|
||||
let(:quoted_status) { Fabricate(:status, poll: poll) }
|
||||
|
||||
before do
|
||||
VoteService.new.call(account, poll, [0])
|
||||
end
|
||||
|
||||
it 'renders the same attributes as a full render' do
|
||||
expect(subject).to eql(compare_to_hash)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the quoted post matches account filters' do
|
||||
let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') }
|
||||
|
||||
|
||||
@@ -141,7 +141,9 @@ RSpec.describe UserMailer do
|
||||
end
|
||||
|
||||
describe '#warning' do
|
||||
let(:strike) { Fabricate(:account_warning, target_account: receiver.account, text: 'dont worry its just the testsuite', action: 'suspend') }
|
||||
let(:status) { Fabricate(:status, account: receiver.account) }
|
||||
let(:quote) { Fabricate(:quote, state: :accepted, status: status) }
|
||||
let(:strike) { Fabricate(:account_warning, target_account: receiver.account, text: 'dont worry its just the testsuite', action: 'suspend', status_ids: [quote.status_id]) }
|
||||
let(:mail) { described_class.warning(receiver, strike) }
|
||||
|
||||
it 'renders warning notification' do
|
||||
|
||||
@@ -6,34 +6,33 @@ RSpec.describe 'Api::Fasp::DataSharing::V0::BackfillRequests', feature: :fasp do
|
||||
include ProviderRequestHelper
|
||||
|
||||
describe 'POST /api/fasp/data_sharing/v0/backfill_requests' do
|
||||
let(:provider) { Fabricate(:fasp_provider) }
|
||||
subject do
|
||||
post api_fasp_data_sharing_v0_backfill_requests_path, headers:, params:, as: :json
|
||||
end
|
||||
|
||||
let(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let(:params) { { category: 'content', maxCount: 10 } }
|
||||
let(:headers) do
|
||||
request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_backfill_requests_url,
|
||||
method: :post,
|
||||
body: params)
|
||||
end
|
||||
|
||||
it_behaves_like 'forbidden for unconfirmed provider'
|
||||
|
||||
context 'with valid parameters' do
|
||||
it 'creates a new backfill request' do
|
||||
params = { category: 'content', maxCount: 10 }
|
||||
headers = request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_backfill_requests_url,
|
||||
method: :post,
|
||||
body: params)
|
||||
|
||||
expect do
|
||||
post api_fasp_data_sharing_v0_backfill_requests_path, headers:, params:, as: :json
|
||||
end.to change(Fasp::BackfillRequest, :count).by(1)
|
||||
expect { subject }.to change(Fasp::BackfillRequest, :count).by(1)
|
||||
expect(response).to have_http_status(201)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
it 'does not create a backfill request' do
|
||||
params = { category: 'unknown', maxCount: 10 }
|
||||
headers = request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_backfill_requests_url,
|
||||
method: :post,
|
||||
body: params)
|
||||
let(:params) { { category: 'unknown', maxCount: 10 } }
|
||||
|
||||
expect do
|
||||
post api_fasp_data_sharing_v0_backfill_requests_path, headers:, params:, as: :json
|
||||
end.to_not change(Fasp::BackfillRequest, :count)
|
||||
it 'does not create a backfill request' do
|
||||
expect { subject }.to_not change(Fasp::BackfillRequest, :count)
|
||||
expect(response).to have_http_status(422)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,15 +6,22 @@ RSpec.describe 'Api::Fasp::DataSharing::V0::Continuations', feature: :fasp do
|
||||
include ProviderRequestHelper
|
||||
|
||||
describe 'POST /api/fasp/data_sharing/v0/backfill_requests/:id/continuations' do
|
||||
let(:backfill_request) { Fabricate(:fasp_backfill_request) }
|
||||
let(:provider) { backfill_request.fasp_provider }
|
||||
subject do
|
||||
post api_fasp_data_sharing_v0_backfill_request_continuation_path(backfill_request), headers:, as: :json
|
||||
end
|
||||
|
||||
let(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let(:backfill_request) { Fabricate(:fasp_backfill_request, fasp_provider: provider) }
|
||||
let(:headers) do
|
||||
request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_backfill_request_continuation_url(backfill_request),
|
||||
method: :post)
|
||||
end
|
||||
|
||||
it_behaves_like 'forbidden for unconfirmed provider'
|
||||
|
||||
it 'queues a job to continue the given backfill request' do
|
||||
headers = request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_backfill_request_continuation_url(backfill_request),
|
||||
method: :post)
|
||||
|
||||
post api_fasp_data_sharing_v0_backfill_request_continuation_path(backfill_request), headers:, as: :json
|
||||
subject
|
||||
expect(response).to have_http_status(204)
|
||||
expect(Fasp::BackfillWorker).to have_enqueued_sidekiq_job(backfill_request.id)
|
||||
end
|
||||
|
||||
@@ -6,51 +6,57 @@ RSpec.describe 'Api::Fasp::DataSharing::V0::EventSubscriptions', feature: :fasp
|
||||
include ProviderRequestHelper
|
||||
|
||||
describe 'POST /api/fasp/data_sharing/v0/event_subscriptions' do
|
||||
let(:provider) { Fabricate(:fasp_provider) }
|
||||
subject do
|
||||
post api_fasp_data_sharing_v0_event_subscriptions_path, headers:, params:, as: :json
|
||||
end
|
||||
|
||||
let(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let(:params) { { category: 'content', subscriptionType: 'lifecycle', maxBatchSize: 10 } }
|
||||
let(:headers) do
|
||||
request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_event_subscriptions_url,
|
||||
method: :post,
|
||||
body: params)
|
||||
end
|
||||
|
||||
it_behaves_like 'forbidden for unconfirmed provider'
|
||||
|
||||
context 'with valid parameters' do
|
||||
it 'creates a new subscription' do
|
||||
params = { category: 'content', subscriptionType: 'lifecycle', maxBatchSize: 10 }
|
||||
headers = request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_event_subscriptions_url,
|
||||
method: :post,
|
||||
body: params)
|
||||
|
||||
expect do
|
||||
post api_fasp_data_sharing_v0_event_subscriptions_path, headers:, params:, as: :json
|
||||
subject
|
||||
end.to change(Fasp::Subscription, :count).by(1)
|
||||
expect(response).to have_http_status(201)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
it 'does not create a subscription' do
|
||||
params = { category: 'unknown' }
|
||||
headers = request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_event_subscriptions_url,
|
||||
method: :post,
|
||||
body: params)
|
||||
let(:params) { { category: 'unknown' } }
|
||||
|
||||
expect do
|
||||
post api_fasp_data_sharing_v0_event_subscriptions_path, headers:, params:, as: :json
|
||||
end.to_not change(Fasp::Subscription, :count)
|
||||
it 'does not create a subscription' do
|
||||
expect { subject }.to_not change(Fasp::Subscription, :count)
|
||||
expect(response).to have_http_status(422)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/fasp/data_sharing/v0/event_subscriptions/:id' do
|
||||
let(:subscription) { Fabricate(:fasp_subscription) }
|
||||
let(:provider) { subscription.fasp_provider }
|
||||
subject do
|
||||
delete api_fasp_data_sharing_v0_event_subscription_path(subscription), headers:, as: :json
|
||||
end
|
||||
|
||||
let(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let!(:subscription) { Fabricate(:fasp_subscription, fasp_provider: provider) }
|
||||
let(:headers) do
|
||||
request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_event_subscription_url(subscription),
|
||||
method: :delete)
|
||||
end
|
||||
|
||||
it_behaves_like 'forbidden for unconfirmed provider'
|
||||
|
||||
it 'deletes the subscription' do
|
||||
headers = request_authentication_headers(provider,
|
||||
url: api_fasp_data_sharing_v0_event_subscription_url(subscription),
|
||||
method: :delete)
|
||||
|
||||
expect do
|
||||
delete api_fasp_data_sharing_v0_event_subscription_path(subscription), headers:, as: :json
|
||||
end.to change(Fasp::Subscription, :count).by(-1)
|
||||
expect { subject }.to change(Fasp::Subscription, :count).by(-1)
|
||||
expect(response).to have_http_status(204)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,18 +6,23 @@ RSpec.describe 'Api::Fasp::Debug::V0::Callback::Responses', feature: :fasp do
|
||||
include ProviderRequestHelper
|
||||
|
||||
describe 'POST /api/fasp/debug/v0/callback/responses' do
|
||||
let(:provider) { Fabricate(:debug_fasp) }
|
||||
subject do
|
||||
post api_fasp_debug_v0_callback_responses_path, headers:, params: payload, as: :json
|
||||
end
|
||||
|
||||
let(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let(:payload) { { test: 'call' } }
|
||||
let(:headers) do
|
||||
request_authentication_headers(provider,
|
||||
url: api_fasp_debug_v0_callback_responses_url,
|
||||
method: :post,
|
||||
body: payload)
|
||||
end
|
||||
|
||||
it_behaves_like 'forbidden for unconfirmed provider'
|
||||
|
||||
it 'create a record of the callback' do
|
||||
payload = { test: 'call' }
|
||||
headers = request_authentication_headers(provider,
|
||||
url: api_fasp_debug_v0_callback_responses_url,
|
||||
method: :post,
|
||||
body: payload)
|
||||
|
||||
expect do
|
||||
post api_fasp_debug_v0_callback_responses_path, headers:, params: payload, as: :json
|
||||
end.to change(Fasp::DebugCallback, :count).by(1)
|
||||
expect { subject }.to change(Fasp::DebugCallback, :count).by(1)
|
||||
expect(response).to have_http_status(201)
|
||||
|
||||
debug_callback = Fasp::DebugCallback.last
|
||||
|
||||
@@ -65,9 +65,10 @@ RSpec.describe 'API Web Push Subscriptions' do
|
||||
end
|
||||
|
||||
describe 'PUT /api/web/push_subscriptions/:id' do
|
||||
before { sign_in Fabricate :user }
|
||||
before { sign_in user }
|
||||
|
||||
let(:subscription) { Fabricate :web_push_subscription }
|
||||
let(:user) { Fabricate(:user) }
|
||||
let(:subscription) { Fabricate(:web_push_subscription, user: user) }
|
||||
|
||||
it 'gracefully handles invalid nested params' do
|
||||
put api_web_push_subscription_path(subscription), params: { data: 'invalid' }
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Severed Relationships' do
|
||||
let(:account_rs_event) { Fabricate :account_relationship_severance_event }
|
||||
let(:account_rs_event) { Fabricate(:account_relationship_severance_event) }
|
||||
let(:user) { account_rs_event.account.user }
|
||||
|
||||
before { sign_in Fabricate(:user) }
|
||||
before { sign_in user }
|
||||
|
||||
describe 'GET /severed_relationships/:id/following' do
|
||||
it 'returns a CSV file with correct data' do
|
||||
@@ -22,6 +23,17 @@ RSpec.describe 'Severed Relationships' do
|
||||
expect(response.body)
|
||||
.to include('Account address')
|
||||
end
|
||||
|
||||
context 'when the user is not the subject of the event' do
|
||||
let(:user) { Fabricate(:user) }
|
||||
|
||||
it 'returns a 404' do
|
||||
get following_severed_relationship_path(account_rs_event, format: :csv)
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /severed_relationships/:id/followers' do
|
||||
@@ -39,5 +51,16 @@ RSpec.describe 'Severed Relationships' do
|
||||
expect(response.body)
|
||||
.to include('Account address')
|
||||
end
|
||||
|
||||
context 'when the user is not the subject of the event' do
|
||||
let(:user) { Fabricate(:user) }
|
||||
|
||||
it 'returns a 404' do
|
||||
get followers_severed_relationship_path(account_rs_event, format: :csv)
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -258,6 +258,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
tag: [
|
||||
{ type: 'Hashtag', name: 'foo' },
|
||||
{ type: 'Hashtag', name: 'bar' },
|
||||
{ type: 'Hashtag', name: '#2024' },
|
||||
{ type: 'Hashtag', name: 'Foo Bar' },
|
||||
{ type: 'Hashtag', name: 'FooBar' },
|
||||
],
|
||||
}
|
||||
end
|
||||
@@ -269,7 +272,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
|
||||
it 'updates tags and featured tags' do
|
||||
expect { subject.call(status, json, json) }
|
||||
.to change { status.tags.reload.pluck(:name) }.from(contain_exactly('test', 'foo')).to(contain_exactly('foo', 'bar'))
|
||||
.to change { status.tags.reload.pluck(:name) }.from(contain_exactly('test', 'foo')).to(contain_exactly('foo', 'bar', 'foobar'))
|
||||
.and change { status.account.featured_tags.find_by(name: 'test').statuses_count }.by(-1)
|
||||
.and change { status.account.featured_tags.find_by(name: 'bar').statuses_count }.by(1)
|
||||
.and change { status.account.featured_tags.find_by(name: 'bar').last_status_at }.from(nil).to(be_present)
|
||||
@@ -733,6 +736,72 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the status adds a verifiable quote of a reblog through an explicit update' do
|
||||
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
|
||||
let(:quoted_status) { Fabricate(:status, account: quoted_account, reblog: Fabricate(:status)) }
|
||||
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
|
||||
|
||||
let(:payload) do
|
||||
{
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{
|
||||
'@id': 'https://w3id.org/fep/044f#quote',
|
||||
'@type': '@id',
|
||||
},
|
||||
{
|
||||
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
|
||||
'@type': '@id',
|
||||
},
|
||||
],
|
||||
id: 'foo',
|
||||
type: 'Note',
|
||||
summary: 'Show more',
|
||||
content: 'Hello universe',
|
||||
updated: '2021-09-08T22:39:25Z',
|
||||
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
|
||||
quoteAuthorization: approval_uri,
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{
|
||||
QuoteAuthorization: 'https://w3id.org/fep/044f#QuoteAuthorization',
|
||||
gts: 'https://gotosocial.org/ns#',
|
||||
interactionPolicy: {
|
||||
'@id': 'gts:interactionPolicy',
|
||||
'@type': '@id',
|
||||
},
|
||||
interactingObject: {
|
||||
'@id': 'gts:interactingObject',
|
||||
'@type': '@id',
|
||||
},
|
||||
interactionTarget: {
|
||||
'@id': 'gts:interactionTarget',
|
||||
'@type': '@id',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'QuoteAuthorization',
|
||||
id: approval_uri,
|
||||
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
|
||||
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
|
||||
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
|
||||
}))
|
||||
end
|
||||
|
||||
it 'updates the approval URI but does not verify the quote' do
|
||||
expect { subject.call(status, json, json) }
|
||||
.to change(status, :quote).from(nil)
|
||||
expect(status.quote.approval_uri).to eq approval_uri
|
||||
expect(status.quote.state).to_not eq 'accepted'
|
||||
expect(status.quote.quoted_status).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the status adds a unverifiable quote through an implicit update' do
|
||||
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
|
||||
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
|
||||
|
||||
@@ -8,9 +8,9 @@ RSpec.describe ProcessMentionsService do
|
||||
let(:account) { Fabricate(:account, username: 'alice') }
|
||||
|
||||
context 'when mentions contain blocked accounts' do
|
||||
let(:non_blocked_account) { Fabricate(:account) }
|
||||
let(:individually_blocked_account) { Fabricate(:account) }
|
||||
let(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com') }
|
||||
let!(:non_blocked_account) { Fabricate(:account) }
|
||||
let!(:individually_blocked_account) { Fabricate(:account) }
|
||||
let!(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com', protocol: :activitypub) }
|
||||
let(:status) { Fabricate(:status, account: account, text: "Hello @#{non_blocked_account.acct} @#{individually_blocked_account.acct} @#{domain_blocked_account.acct}", visibility: :public) }
|
||||
|
||||
before do
|
||||
|
||||
13
spec/support/examples/fasp/api.rb
Normal file
13
spec/support/examples/fasp/api.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'forbidden for unconfirmed provider' do
|
||||
context 'when the requesting provider is unconfirmed' do
|
||||
let(:provider) { Fabricate(:fasp_provider) }
|
||||
|
||||
it 'returns http unauthorized' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(401)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,10 +6,11 @@ RSpec.describe Fasp::AnnounceAccountLifecycleEventWorker do
|
||||
include ProviderRequestHelper
|
||||
|
||||
let(:account_uri) { 'https://masto.example.com/accounts/1' }
|
||||
let(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let(:subscription) do
|
||||
Fabricate(:fasp_subscription, category: 'account')
|
||||
Fabricate(:fasp_subscription, fasp_provider: provider, category: 'account')
|
||||
end
|
||||
let(:provider) { subscription.fasp_provider }
|
||||
|
||||
let!(:stubbed_request) do
|
||||
stub_provider_request(provider,
|
||||
method: :post,
|
||||
|
||||
@@ -6,10 +6,11 @@ RSpec.describe Fasp::AnnounceContentLifecycleEventWorker do
|
||||
include ProviderRequestHelper
|
||||
|
||||
let(:status_uri) { 'https://masto.example.com/status/1' }
|
||||
let(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let(:subscription) do
|
||||
Fabricate(:fasp_subscription)
|
||||
Fabricate(:fasp_subscription, fasp_provider: provider)
|
||||
end
|
||||
let(:provider) { subscription.fasp_provider }
|
||||
|
||||
let!(:stubbed_request) do
|
||||
stub_provider_request(provider,
|
||||
method: :post,
|
||||
|
||||
@@ -6,14 +6,15 @@ RSpec.describe Fasp::AnnounceTrendWorker do
|
||||
include ProviderRequestHelper
|
||||
|
||||
let(:status) { Fabricate(:status) }
|
||||
let(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let(:subscription) do
|
||||
Fabricate(:fasp_subscription,
|
||||
fasp_provider: provider,
|
||||
category: 'content',
|
||||
subscription_type: 'trends',
|
||||
threshold_timeframe: 15,
|
||||
threshold_likes: 2)
|
||||
end
|
||||
let(:provider) { subscription.fasp_provider }
|
||||
let!(:stubbed_request) do
|
||||
stub_provider_request(provider,
|
||||
method: :post,
|
||||
|
||||
@@ -5,8 +5,10 @@ require 'rails_helper'
|
||||
RSpec.describe Fasp::BackfillWorker do
|
||||
include ProviderRequestHelper
|
||||
|
||||
let(:backfill_request) { Fabricate(:fasp_backfill_request) }
|
||||
let(:provider) { backfill_request.fasp_provider }
|
||||
subject { described_class.new.perform(backfill_request.id) }
|
||||
|
||||
let(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let(:backfill_request) { Fabricate(:fasp_backfill_request, fasp_provider: provider) }
|
||||
let(:status) { Fabricate(:status) }
|
||||
let!(:stubbed_request) do
|
||||
stub_provider_request(provider,
|
||||
|
||||
33
spec/workers/purge_custom_emoji_worker_spec.rb
Normal file
33
spec/workers/purge_custom_emoji_worker_spec.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PurgeCustomEmojiWorker do
|
||||
let(:worker) { described_class.new }
|
||||
|
||||
let(:domain) { 'evil' }
|
||||
|
||||
before do
|
||||
Fabricate(:custom_emoji)
|
||||
Fabricate(:custom_emoji, domain: 'example.com')
|
||||
Fabricate.times(5, :custom_emoji, domain: domain)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when domain is nil' do
|
||||
it 'does not delete emojis' do
|
||||
expect { worker.perform(nil) }
|
||||
.to_not(change(CustomEmoji, :count))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when passing a domain' do
|
||||
it 'deletes emojis from this domain only' do
|
||||
expect { worker.perform(domain) }
|
||||
.to change { CustomEmoji.where(domain: domain).count }.to(0)
|
||||
.and not_change { CustomEmoji.local.count }
|
||||
.and(not_change { CustomEmoji.where(domain: 'example.com').count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
19
yarn.lock
19
yarn.lock
@@ -2689,7 +2689,7 @@ __metadata:
|
||||
husky: "npm:^9.0.11"
|
||||
immutable: "npm:^4.3.0"
|
||||
intl-messageformat: "npm:^10.7.16"
|
||||
js-yaml: "npm:^4.1.0"
|
||||
js-yaml: "npm:^4.1.1"
|
||||
lande: "npm:^1.0.10"
|
||||
lint-staged: "npm:^16.0.0"
|
||||
lodash: "npm:^4.17.21"
|
||||
@@ -7769,8 +7769,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.1":
|
||||
version: 10.4.5
|
||||
resolution: "glob@npm:10.4.5"
|
||||
version: 10.5.0
|
||||
resolution: "glob@npm:10.5.0"
|
||||
dependencies:
|
||||
foreground-child: "npm:^3.1.0"
|
||||
jackspeak: "npm:^3.1.2"
|
||||
@@ -7780,7 +7780,7 @@ __metadata:
|
||||
path-scurry: "npm:^1.11.1"
|
||||
bin:
|
||||
glob: dist/esm/bin.mjs
|
||||
checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e
|
||||
checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8762,6 +8762,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "js-yaml@npm:4.1.1"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.js
|
||||
checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsdoc-type-pratt-parser@npm:~4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "jsdoc-type-pratt-parser@npm:4.1.0"
|
||||
|
||||
Reference in New Issue
Block a user