Compare commits

...
Sign in to create a new pull request.

49 commits

Author SHA1 Message Date
KMY(雪あすか)
a60fa38b80
Merge pull request #571 from kmycode/kb-draft-10.5
Release: 10.5
2024-02-16 22:16:27 +09:00
KMY
4f64ba26dc Fix test 2024-02-16 21:49:55 +09:00
KMY
e20c9ad106 Bump version to 10.5 2024-02-16 20:29:22 +09:00
Claire
ed59271078 Bump version to v4.3.0-alpha.3 (#29241) 2024-02-16 20:29:12 +09:00
Claire
5d2f763f47 Merge pull request from GHSA-jhrq-qvrm-qr36
* Fix insufficient Content-Type checking of fetched ActivityStreams objects

* Allow JSON-LD documents with multiple profiles
2024-02-16 20:28:30 +09:00
KMY
eb1094143c Update dependency pg to v1.5.5 (#29230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-16 20:28:23 +09:00
KMY(雪あすか)
fd72d4bed7
Merge pull request #547 from kmycode/kb-draft-10.4
Release: 10.4
2024-02-15 09:35:59 +09:00
Claire
8ce87662b0 Bump version to v4.3.0-alpha.2 (#29200) 2024-02-15 09:12:37 +09:00
Claire
ceff4265e1 Fix user creation failure handling in OAuth paths (#29207) 2024-02-15 08:55:35 +09:00
Claire
d9abcc61ff Fix OmniAuth tests (#29201) 2024-02-15 08:51:52 +09:00
KMY
8658079f0d Bump version to 10.4 2024-02-15 08:34:18 +09:00
Claire
a6997fab01 Merge pull request from GHSA-vm39-j3vx-pch3
* Prevent different identities from a same SSO provider from accessing a same account

* Lock auth provider changes behind `ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH=true`

* Rename methods to avoid confusion between OAuth and OmniAuth
2024-02-15 08:33:55 +09:00
Emelia Smith
d5e14b2865 Merge pull request from GHSA-7w3c-p9j8-mq3x
* Ensure destruction of OAuth Applications notifies streaming

Due to doorkeeper using a dependent: delete_all relationship, the destroy of an OAuth Application bypassed the existing AccessTokenExtension callbacks for announcing destructing of access tokens.

* Ensure password resets revoke access to Streaming API

* Improve performance of deleting OAuth tokens

---------

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2024-02-15 08:33:46 +09:00
Claire
1d546f4388 Add sidekiq_unique_jobs:delete_all_locks task and disable sidekiq-unique-jobs UI by default (#29199) 2024-02-15 08:33:35 +09:00
Emelia Smith
7c3c2d2444 Disable administrative doorkeeper routes (#29187) 2024-02-15 08:33:03 +09:00
renovate[bot]
6a57868e89 Update dependency sidekiq-unique-jobs to v7.1.33 (#29175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-15 08:32:10 +09:00
renovate[bot]
a9f7667900 Update dependency nokogiri to v1.16.2 [SECURITY] (#29106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-15 08:31:20 +09:00
KMY(雪あすか)
6f66145f9d Fix: リモートアカウント情報のNgWord検査でNULLが出る問題 (#541)
* Fix: リモートアカウント情報のNgWord検査でNULLが出る問題

* Add test
2024-02-15 08:17:13 +09:00
KMY(雪あすか)
8b0fd35955
Merge pull request #514 from kmycode/kb-draft-10.3
Release: 10.3
2024-02-02 09:58:54 +09:00
KMY
2e7e260ead Bump version to 10.3 2024-02-02 09:29:30 +09:00
KMY
76cf23dfd6 Fix: リモートからの参照を無限に受け入れる問題 2024-02-02 09:18:36 +09:00
Claire
23faeafe42 Merge pull request from GHSA-3fjr-858r-92rw
* Fix insufficient origin validation

* Bump version to 4.3.0-alpha.1
2024-02-02 07:37:05 +09:00
KMY(雪あすか)
e082a8f4a0
Merge pull request #507 from kmycode/kb-draft-10.2
Release: 10.2
2024-01-26 21:39:26 +09:00
KMY
d468b94158 Bump version to 10.2 2024-01-26 12:34:41 +09:00
KMY
1282a45c54 Fix: #504 docker buildが失敗する問題 2024-01-26 12:22:06 +09:00
KMY(雪あすか)
eac1d8ad5b
Merge pull request #487 from kmycode/kb-draft-10.1
Release: 10.1
2024-01-25 12:22:17 +09:00
KMY
48d091dd18 Set ruby version 2024-01-25 09:58:26 +09:00
Claire
018eb174e8 Bump version to v4.2.4 2024-01-25 08:08:00 +09:00
Claire
48f860945d Change PostgreSQL version check to check for PostgreSQL 10+ 2024-01-25 08:06:42 +09:00
Claire
72367f6848 Ignore the devise-two-factor advisory as we have rate limits in place (#28733) 2024-01-25 08:06:36 +09:00
Claire
fcb0ebdb5b Bump ruby version to 3.2.3 2024-01-25 08:06:28 +09:00
Claire
1ac58c3858 Update dependency puma to v6.4.2 2024-01-25 08:05:30 +09:00
Claire
58267dcfe9 Fix error when processing remote files with unusually long names (#28823) 2024-01-25 08:02:51 +09:00
Claire
1225c22810 Fix processing of compacted single-item JSON-LD collections (#28816) 2024-01-25 08:02:44 +09:00
Claire
8c23a8aa2b Add rate-limit of TOTP authentication attempts at controller level (#28801) 2024-01-25 08:00:45 +09:00
Jonathan de Jong
eec533e8cd Retry 401 errors on replies fetching (#28788)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2024-01-25 08:00:37 +09:00
Jeong Arm
6bc4219f59 Ignore RecordNotUnique errors in LinkCrawlWorker (#28748) 2024-01-25 08:00:30 +09:00
Claire
2338fc4aec Fix potential redirection loop of streaming endpoint (#28665) 2024-01-25 08:00:20 +09:00
MitarashiDango
f40e951d29 Fix Undo Announce activity is not sent, when not followed by the reblogged post author (#18482)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2024-01-25 07:56:50 +09:00
KMY
e83c4c5604 Bump version to 10.1 2024-01-24 21:41:48 +09:00
KMY
7d9d2e2e86 Fix test 2024-01-24 21:41:48 +09:00
KMY
d87a11bc7d Add: Misskeyに相互限定投稿を配送しないオプション 2024-01-24 20:38:19 +09:00
KMY(雪あすか)
201fd37bc3 Fix: ドメインブロックがOutboxにおいて動作しない問題 (#491) 2024-01-24 20:38:19 +09:00
KMY(雪あすか)
65cc1273aa Fix: 投稿ではないリンクを参照したときにリンクプレビューが生成されない問題 (#482) 2024-01-19 08:24:45 +09:00
KMY(雪あすか)
ef3eb48932 Fix: #303 限定投稿のスタンプがストリーミングできていない (#481) 2024-01-19 08:24:45 +09:00
KMY(雪あすか)
0fcf61ee0e Fix: フレンドサーバーにログインユーザーのみ投稿が配送されない問題 (#463) 2024-01-19 08:24:45 +09:00
KMY(雪あすか)
cc408a5e7d Fix: ブースト時に選択できる公開範囲に限定投稿が含まれる問題 (#460) 2024-01-19 08:24:45 +09:00
KMY(雪あすか)
8cd1d7e5d0 Remove: #454 リンクプレビューを生成する設定の削除、無効化 (#458) 2024-01-19 08:24:45 +09:00
KMY(雪あすか)
5bf543ca24 Add: Iceshrimpをスタンプ利用可能サーバーとして登録、襦袢を揃える (#451) 2024-01-19 08:24:45 +09:00
88 changed files with 1028 additions and 290 deletions

6
.bundler-audit.yml Normal file
View file

@ -0,0 +1,6 @@
---
ignore:
# devise-two-factor advisory about brute-forcing TOTP
# We have rate-limits on authentication endpoints in place (including second
# factor verification) since Mastodon v3.2.0
- CVE-2024-0227

View file

@ -1 +1 @@
3.2.2 3.2.3

View file

@ -2,6 +2,101 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.2.7] - 2024-02-16
### Fixed
- Fix OmniAuth tests and edge cases in error handling ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29201), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/29207))
- Fix new installs by upgrading to the latest release of the `nsa` gem, instead of a no longer existing commit ([mjankowski](https://github.com/mastodon/mastodon/pull/29065))
### Security
- Fix insufficient checking of remote posts ([GHSA-jhrq-qvrm-qr36](https://github.com/mastodon/mastodon/security/advisories/GHSA-jhrq-qvrm-qr36))
## [4.2.6] - 2024-02-14
### Security
- Update the `sidekiq-unique-jobs` dependency (see [GHSA-cmh9-rx85-xj38](https://github.com/mhenrixon/sidekiq-unique-jobs/security/advisories/GHSA-cmh9-rx85-xj38))
In addition, we have disabled the web interface for `sidekiq-unique-jobs` out of caution.
If you need it, you can re-enable it by setting `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI=true`.
If you only need to clear all locks, you can now use `bundle exec rake sidekiq_unique_jobs:delete_all_locks`.
- Update the `nokogiri` dependency (see [GHSA-xc9x-jj77-9p9j](https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-xc9x-jj77-9p9j))
- Disable administrative Doorkeeper routes ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/29187))
- Fix ongoing streaming sessions not being invalidated when applications get deleted in some cases ([GHSA-7w3c-p9j8-mq3x](https://github.com/mastodon/mastodon/security/advisories/GHSA-7w3c-p9j8-mq3x))
In some rare cases, the streaming server was not notified of access tokens revocation on application deletion.
- Change external authentication behavior to never reattach a new identity to an existing user by default ([GHSA-vm39-j3vx-pch3](https://github.com/mastodon/mastodon/security/advisories/GHSA-vm39-j3vx-pch3))
Up until now, Mastodon has allowed new identities from external authentication providers to attach to an existing local user based on their verified e-mail address.
This allowed upgrading users from a database-stored password to an external authentication provider, or move from one authentication provider to another.
However, this behavior may be unexpected, and means that when multiple authentication providers are configured, the overall security would be that of the least secure authentication provider.
For these reasons, this behavior is now locked under the `ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH` environment variable.
In addition, regardless of this environment variable, Mastodon will refuse to attach two identities from the same authentication provider to the same account.
## [4.2.5] - 2024-02-01
### Security
- Fix insufficient origin validation (CVE-2024-23832, [GHSA-3fjr-858r-92rw](https://github.com/mastodon/mastodon/security/advisories/GHSA-3fjr-858r-92rw))
## [4.2.4] - 2024-01-24
### Fixed
- Fix error when processing remote files with unusually long names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28823))
- Fix processing of compacted single-item JSON-LD collections ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28816))
- Retry 401 errors on replies fetching ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28788))
- Fix `RecordNotUnique` errors in LinkCrawlWorker ([tribela](https://github.com/mastodon/mastodon/pull/28748))
- Fix Mastodon not correctly processing HTTP Signatures with query strings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28443), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28476))
- Fix potential redirection loop of streaming endpoint ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28665))
- Fix streaming API redirection ignoring the port of `streaming_api_base_url` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28558))
- Fix error when processing link preview with an array as `inLanguage` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28252))
- Fix unsupported time zone or locale preventing sign-up ([Gargron](https://github.com/mastodon/mastodon/pull/28035))
- Fix "Hide these posts from home" list setting not refreshing when switching lists ([brianholley](https://github.com/mastodon/mastodon/pull/27763))
- Fix missing background behind dismissable banner in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/27479))
- Fix line wrapping of language selection button with long locale codes ([gunchleoc](https://github.com/mastodon/mastodon/pull/27100), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27127))
- Fix `Undo Announce` activity not being sent to non-follower authors ([MitarashiDango](https://github.com/mastodon/mastodon/pull/18482))
- Fix N+1s because of association preloaders not actually getting called ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28339))
- Fix empty column explainer getting cropped under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28337))
- Fix `LinkCrawlWorker` error when encountering empty OEmbed response ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28268))
- Fix call to inefficient `delete_matched` cache method in domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28367))
### Security
- Add rate-limit of TOTP authentication attempts at controller level ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28801))
## [4.2.3] - 2023-12-05
### Fixed
- Fix dependency on `json-canonicalization` version that has been made unavailable since last release
## [4.2.2] - 2023-12-04
### Changed
- Change dismissed banners to be stored server-side ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27055))
- Change GIF max matrix size error to explicitly mention GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27927))
- Change `Follow` activities delivery to bypass availability check ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/27586))
- Change single-column navigation notice to be displayed outside of the logo container ([renchap](https://github.com/mastodon/mastodon/pull/27462), [renchap](https://github.com/mastodon/mastodon/pull/27476))
- Change Content-Security-Policy to be tighter on media paths ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26889))
- Change post language code to include country code when relevant ([gunchleoc](https://github.com/mastodon/mastodon/pull/27099), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27207))
### Fixed
- Fix upper border radius of onboarding columns ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27890))
- Fix incoming status creation date not being restricted to standard ISO8601 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27655), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28081))
- Fix some posts from threads received out-of-order sometimes not being inserted into timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27653))
- Fix posts from force-sensitized accounts being able to trend ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27620))
- Fix error when trying to delete already-deleted file with OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27569))
- Fix batch attachment deletion when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27554))
- Fix processing LDSigned activities from actors with unknown public keys ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27474))
- Fix error and incorrect URLs in `/api/v1/accounts/:id/featured_tags` for remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27459))
- Fix report processing notice not mentioning the report number when performing a custom action ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27442))
- Fix handling of `inLanguage` attribute in preview card processing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27423))
- Fix own posts being removed from home timeline when unfollowing a used hashtag ([kmycode](https://github.com/mastodon/mastodon/pull/27391))
- Fix some link anchors being recognized as hashtags ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27271), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27584))
- Fix format-dependent redirects being cached regardless of requested format ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27634))
## [4.2.1] - 2023-10-10 ## [4.2.1] - 2023-10-10
### Added ### Added

View file

@ -7,15 +7,15 @@
ARG TARGETPLATFORM=${TARGETPLATFORM} ARG TARGETPLATFORM=${TARGETPLATFORM}
ARG BUILDPLATFORM=${BUILDPLATFORM} ARG BUILDPLATFORM=${BUILDPLATFORM}
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.2"] # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.3"]
ARG RUBY_VERSION="3.2.2" ARG RUBY_VERSION="3.2.3"
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
ARG NODE_MAJOR_VERSION="20" ARG NODE_MAJOR_VERSION="20"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
ARG DEBIAN_VERSION="bookworm" ARG DEBIAN_VERSION="bookworm"
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim) # Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node
# Ruby image to use for base image based on combined variables (ex: 3.2.2-slim-bookworm) # Ruby image to use for base image based on combined variables (ex: 3.2.3-slim-bookworm)
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA

View file

@ -481,8 +481,9 @@ GEM
timeout timeout
net-smtp (0.4.0) net-smtp (0.4.0)
net-protocol net-protocol
nio4r (2.5.9) net-ssh (7.1.0)
nokogiri (1.16.0) nio4r (2.7.0)
nokogiri (1.16.2)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.3) oj (3.16.3)
@ -523,8 +524,8 @@ GEM
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.5.4) pg (1.5.5)
pghero (3.4.0) pghero (3.4.1)
activerecord (>= 6) activerecord (>= 6)
posix-spawn (0.3.15) posix-spawn (0.3.15)
premailer (1.21.0) premailer (1.21.0)
@ -544,7 +545,7 @@ GEM
psych (5.1.2) psych (5.1.2)
stringio stringio
public_suffix (5.0.4) public_suffix (5.0.4)
puma (6.4.1) puma (6.4.2)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.1) pundit (2.3.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@ -726,7 +727,7 @@ GEM
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8) sidekiq (>= 6, < 8)
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (7.1.30) sidekiq-unique-jobs (7.1.33)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0) brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
redis (< 5.0) redis (< 5.0)

View file

@ -5,8 +5,6 @@ class ActivityPub::ReferencesController < ActivityPub::BaseController
include Authorization include Authorization
include AccountOwnedConcern include AccountOwnedConcern
REFERENCES_LIMIT = 5
before_action :require_signature!, if: :authorized_fetch_mode? before_action :require_signature!, if: :authorized_fetch_mode?
before_action :set_status before_action :set_status
@ -40,17 +38,21 @@ class ActivityPub::ReferencesController < ActivityPub::BaseController
@results ||= begin @results ||= begin
references = @status.reference_objects.order(target_status_id: :asc) references = @status.reference_objects.order(target_status_id: :asc)
references = references.where('target_status_id > ?', page_params[:min_id]) if page_params[:min_id].present? references = references.where('target_status_id > ?', page_params[:min_id]) if page_params[:min_id].present?
references = references.limit(limit_param(REFERENCES_LIMIT)) references = references.limit(limit_param(references_limit))
references.pluck(:target_status_id) references.pluck(:target_status_id)
end end
end end
def references_limit
StatusReference::REFERENCES_LIMIT
end
def pagination_min_id def pagination_min_id
results.last results.last
end end
def records_continue? def records_continue?
results.size == limit_param(REFERENCES_LIMIT) results.size == limit_param(references_limit)
end end
def references_collection_presenter def references_collection_presenter

View file

@ -2,7 +2,7 @@
class Api::V1::StreamingController < Api::BaseController class Api::V1::StreamingController < Api::BaseController
def index def index
if Rails.configuration.x.streaming_api_base_url == request.host if same_host?
not_found not_found
else else
redirect_to streaming_api_url, status: 301, allow_other_host: true redirect_to streaming_api_url, status: 301, allow_other_host: true
@ -11,6 +11,11 @@ class Api::V1::StreamingController < Api::BaseController
private private
def same_host?
base_url = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url)
request.host == base_url.host && request.port == (base_url.port || 80)
end
def streaming_api_url def streaming_api_url
Addressable::URI.parse(request.url).tap do |uri| Addressable::URI.parse(request.url).tap do |uri|
base_url = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url) base_url = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url)

View file

@ -7,7 +7,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def self.provides_callback_for(provider) def self.provides_callback_for(provider)
define_method provider do define_method provider do
@provider = provider @provider = provider
@user = User.find_for_oauth(request.env['omniauth.auth'], current_user) @user = User.find_for_omniauth(request.env['omniauth.auth'], current_user)
if @user.persisted? if @user.persisted?
record_login_activity record_login_activity
@ -17,6 +17,9 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
session["devise.#{provider}_data"] = request.env['omniauth.auth'] session["devise.#{provider}_data"] = request.env['omniauth.auth']
redirect_to new_user_registration_url redirect_to new_user_registration_url
end end
rescue ActiveRecord::RecordInvalid
flash[:alert] = I18n.t('devise.failure.omniauth_user_creation_failure') if is_navigational_format?
redirect_to new_user_session_url
end end
end end

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::SessionsController < Devise::SessionsController class Auth::SessionsController < Devise::SessionsController
include Redisable
MAX_2FA_ATTEMPTS_PER_HOUR = 10
layout 'auth' layout 'auth'
skip_before_action :check_self_destruct! skip_before_action :check_self_destruct!
@ -130,9 +134,23 @@ class Auth::SessionsController < Devise::SessionsController
session.delete(:attempt_user_updated_at) session.delete(:attempt_user_updated_at)
end end
def clear_2fa_attempt_from_user(user)
redis.del(second_factor_attempts_key(user))
end
def check_second_factor_rate_limits(user)
attempts, = redis.multi do |multi|
multi.incr(second_factor_attempts_key(user))
multi.expire(second_factor_attempts_key(user), 1.hour)
end
attempts >= MAX_2FA_ATTEMPTS_PER_HOUR
end
def on_authentication_success(user, security_measure) def on_authentication_success(user, security_measure)
@on_authentication_success_called = true @on_authentication_success_called = true
clear_2fa_attempt_from_user(user)
clear_attempt_from_session clear_attempt_from_session
user.update_sign_in!(new_sign_in: true) user.update_sign_in!(new_sign_in: true)
@ -164,4 +182,8 @@ class Auth::SessionsController < Devise::SessionsController
user_agent: request.user_agent user_agent: request.user_agent
) )
end end
def second_factor_attempts_key(user)
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
end
end end

View file

@ -66,6 +66,11 @@ module Auth::TwoFactorAuthenticationConcern
end end
def authenticate_with_two_factor_via_otp(user) def authenticate_with_two_factor_via_otp(user)
if check_second_factor_rate_limits(user)
flash.now[:alert] = I18n.t('users.rate_limited')
return prompt_for_two_factor(user)
end
if valid_otp_attempt?(user) if valid_otp_attempt?(user)
on_authentication_success(user, :otp) on_authentication_success(user, :otp)
else else

View file

@ -266,7 +266,7 @@ module SignatureVerification
stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) } stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) }
elsif !ActivityPub::TagManager.instance.local_uri?(key_id) elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
account = ActivityPub::TagManager.instance.uri_to_actor(key_id) account = ActivityPub::TagManager.instance.uri_to_actor(key_id)
account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) } account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) }
account account
end end
rescue Mastodon::PrivateNetworkAddressError => e rescue Mastodon::PrivateNetworkAddressError => e

View file

@ -163,8 +163,8 @@ module JsonLdHelper
end end
end end
def fetch_resource(uri, id, on_behalf_of = nil) def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {})
unless id unless id_is_known
json = fetch_resource_without_id_validation(uri, on_behalf_of) json = fetch_resource_without_id_validation(uri, on_behalf_of)
return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id']) return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])
@ -172,17 +172,29 @@ module JsonLdHelper
uri = json['id'] uri = json['id']
end end
json = fetch_resource_without_id_validation(uri, on_behalf_of) json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options)
json.present? && json['id'] == uri ? json : nil json.present? && json['id'] == uri ? json : nil
end end
def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false) def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false, request_options: {})
on_behalf_of ||= Account.representative on_behalf_of ||= Account.representative
build_request(uri, on_behalf_of).perform do |response| build_request(uri, on_behalf_of, options: request_options).perform do |response|
raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error
body_to_json(response.body_with_limit) if response.code == 200 body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response)
end
end
def valid_activitypub_content_type?(response)
return true if response.mime_type == 'application/activity+json'
# When the mime type is `application/ld+json`, we need to check the profile,
# but `http.rb` does not parse it for us.
return false unless response.mime_type == 'application/ld+json'
response.headers[HTTP::Headers::CONTENT_TYPE]&.split(';')&.map(&:strip)&.any? do |str|
str.start_with?('profile="') && str[9...-1].split.include?('https://www.w3.org/ns/activitystreams')
end end
end end
@ -212,8 +224,8 @@ module JsonLdHelper
response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)) response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
end end
def build_request(uri, on_behalf_of = nil) def build_request(uri, on_behalf_of = nil, options: {})
Request.new(:get, uri).tap do |request| Request.new(:get, uri, **options).tap do |request|
request.on_behalf_of(on_behalf_of) if on_behalf_of request.on_behalf_of(on_behalf_of) if on_behalf_of
request.add_headers('Accept' => 'application/activity+json, application/ld+json') request.add_headers('Accept' => 'application/activity+json, application/ld+json')
end end

View file

@ -169,6 +169,7 @@ class PrivacyDropdown extends PureComponent {
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
noDirect: PropTypes.bool, noDirect: PropTypes.bool,
noLimited: PropTypes.bool,
replyToLimited: PropTypes.bool, replyToLimited: PropTypes.bool,
container: PropTypes.func, container: PropTypes.func,
disabled: PropTypes.bool, disabled: PropTypes.bool,
@ -260,6 +261,10 @@ class PrivacyDropdown extends PureComponent {
); );
} }
if (this.props.noLimited) {
this.options = this.options.filter((opt) => !['mutual', 'circle'].includes(opt.value));
}
this.selectableOptions = [...this.options]; this.selectableOptions = [...this.options];
if (!enableLoginPrivacy) { if (!enableLoginPrivacy) {

View file

@ -110,6 +110,7 @@ class BoostModal extends ImmutablePureComponent {
{status.get('visibility') !== 'private' && !status.get('reblogged') && ( {status.get('visibility') !== 'private' && !status.get('reblogged') && (
<PrivacyDropdown <PrivacyDropdown
noDirect noDirect
noLimited
value={privacy} value={privacy}
container={this._findContainer} container={this._findContainer}
onChange={this.props.onChangeBoostPrivacy} onChange={this.props.onChangeBoostPrivacy}

View file

@ -33,7 +33,8 @@ class AccountStatusesFilter
available_visibilities -= [:unlisted] if (domain_block&.detect_invalid_subscription || misskey_software?) && @account.user&.setting_reject_unlisted_subscription available_visibilities -= [:unlisted] if (domain_block&.detect_invalid_subscription || misskey_software?) && @account.user&.setting_reject_unlisted_subscription
available_visibilities -= [:login] if current_account.nil? available_visibilities -= [:login] if current_account.nil?
scope.merge!(scope.where(spoiler_text: ['', nil])) if domain_block&.reject_send_sensitive scope.merge!(scope.where(sensitive: false)) if domain_block&.reject_send_sensitive
scope.merge!(scope.where(searchability: available_searchabilities)) scope.merge!(scope.where(searchability: available_searchabilities))
scope.merge!(scope.where(visibility: available_visibilities)) scope.merge!(scope.where(visibility: available_visibilities))
@ -153,9 +154,9 @@ class AccountStatusesFilter
end end
def domain_block def domain_block
return nil if @account.nil? || @account.local? return nil if @current_account.nil? || @current_account.local?
@domain_block = DomainBlock.find_by(domain: @account.domain) @domain_block = DomainBlock.find_by(domain: @current_account.domain)
end end
def misskey_software? def misskey_software?

View file

@ -154,7 +154,7 @@ class ActivityPub::Activity
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, request_id: @options[:request_id]) ActivityPub::FetchRemoteStatusService.new.call(object_uri, 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'], request_id: @options[:request_id]) ::FetchRemoteStatusService.new.call(@object['url'], request_id: @options[:request_id])
end end

View file

@ -19,7 +19,7 @@ class ActivityPub::LinkedDataSignature
return unless type == 'RsaSignature2017' return unless type == 'RsaSignature2017'
creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri) creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri)
creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) if creator&.public_key.blank? creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if creator&.public_key.blank?
return if creator.nil? return if creator.nil?

View file

@ -4,14 +4,34 @@ module ApplicationExtension
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
include Redisable
has_many :created_users, class_name: 'User', foreign_key: 'created_by_application_id', inverse_of: :created_by_application has_many :created_users, class_name: 'User', foreign_key: 'created_by_application_id', inverse_of: :created_by_application
validates :name, length: { maximum: 60 } validates :name, length: { maximum: 60 }
validates :website, url: true, length: { maximum: 2_000 }, if: :website? validates :website, url: true, length: { maximum: 2_000 }, if: :website?
validates :redirect_uri, length: { maximum: 2_000 } validates :redirect_uri, length: { maximum: 2_000 }
# The relationship used between Applications and AccessTokens is using
# dependent: delete_all, which means the ActiveRecord callback in
# AccessTokenExtension is not run, so instead we manually announce to
# streaming that these tokens are being deleted.
before_destroy :push_to_streaming_api, prepend: true
end end
def confirmation_redirect_uri def confirmation_redirect_uri
redirect_uri.lines.first.strip redirect_uri.lines.first.strip
end end
def push_to_streaming_api
# TODO: #28793 Combine into a single topic
payload = Oj.dump(event: :kill)
access_tokens.in_batches do |tokens|
redis.pipelined do |pipeline|
tokens.ids.each do |id|
pipeline.publish("timeline:access_token:#{id}", payload)
end
end
end
end
end end

View file

@ -38,47 +38,39 @@ class StatusReachFinder
private private
def reached_account_inboxes def reached_account_inboxes
Account.where(id: reached_account_ids).where.not(domain: banned_domains).inboxes
end
def reached_account_inboxes_for_misskey
Account.where(id: reached_account_ids, domain: banned_domains_for_misskey - friend_domains).inboxes
end
def reached_account_inboxes_for_friend
Account.where(id: reached_account_ids, domain: friend_domains).inboxes
end
def reached_account_ids
# When the status is a reblog, there are no interactions with it # When the status is a reblog, there are no interactions with it
# directly, we assume all interactions are with the original one # directly, we assume all interactions are with the original one
if @status.reblog? if @status.reblog?
[] [reblog_of_account_id]
elsif @status.limited_visibility? elsif @status.limited_visibility?
Account.where(id: mentioned_account_ids).where.not(domain: banned_domains).inboxes [mentioned_account_ids]
else else
Account.where(id: reached_account_ids).where.not(domain: banned_domains + friend_domains).inboxes [
end replied_to_account_id,
end reblog_of_account_id,
mentioned_account_ids,
def reached_account_inboxes_for_misskey reblogs_account_ids,
if @status.reblog? || @status.limited_visibility? favourites_account_ids,
[] replies_account_ids,
else quoted_account_id,
Account.where(id: reached_account_ids, domain: banned_domains_for_misskey - friend_domains).inboxes ].tap do |arr|
end arr.flatten!
end arr.compact!
arr.uniq!
def reached_account_inboxes_for_friend end
if @status.reblog? || @status.limited_visibility?
[]
else
Account.where(id: reached_account_ids, domain: friend_domains).inboxes
end
end
def reached_account_ids
[
replied_to_account_id,
reblog_of_account_id,
mentioned_account_ids,
reblogs_account_ids,
favourites_account_ids,
replies_account_ids,
quoted_account_id,
].tap do |arr|
arr.flatten!
arr.compact!
arr.uniq!
end end
end end

View file

@ -15,13 +15,6 @@ module Account::OtherSettings
false false
end end
def link_preview?
return user.setting_link_preview if local? && user.present?
return settings['link_preview'] if settings.present? && settings.key?('link_preview')
true
end
def allow_quote? def allow_quote?
return user.setting_allow_quote if local? && user.present? return user.setting_allow_quote if local? && user.present?
return settings['allow_quote'] if settings.present? && settings.key?('allow_quote') return settings['allow_quote'] if settings.present? && settings.key?('allow_quote')
@ -95,7 +88,6 @@ module Account::OtherSettings
'hide_following_count' => hide_following_count?, 'hide_following_count' => hide_following_count?,
'hide_followers_count' => hide_followers_count?, 'hide_followers_count' => hide_followers_count?,
'translatable_private' => translatable_private?, 'translatable_private' => translatable_private?,
'link_preview' => link_preview?,
'allow_quote' => allow_quote?, 'allow_quote' => allow_quote?,
'emoji_reaction_policy' => Setting.enable_emoji_reaction ? emoji_reaction_policy.to_s : 'block', 'emoji_reaction_policy' => Setting.enable_emoji_reaction ? emoji_reaction_policy.to_s : 'block',
} }

View file

@ -127,6 +127,10 @@ module User::HasSettings
settings['allow_quote'] settings['allow_quote']
end end
def setting_reject_send_limited_to_suspects
settings['reject_send_limited_to_suspects']
end
def setting_noindex def setting_noindex
settings['noindex'] settings['noindex']
end end
@ -135,10 +139,6 @@ module User::HasSettings
settings['translatable_private'] settings['translatable_private']
end end
def setting_link_preview
settings['link_preview']
end
def setting_dtl_force_visibility def setting_dtl_force_visibility
settings['dtl_force_visibility']&.to_sym || :unchange settings['dtl_force_visibility']&.to_sym || :unchange
end end

View file

@ -19,17 +19,18 @@ module User::Omniauthable
end end
class_methods do class_methods do
def find_for_oauth(auth, signed_in_resource = nil) def find_for_omniauth(auth, signed_in_resource = nil)
# EOLE-SSO Patch # EOLE-SSO Patch
auth.uid = (auth.uid[0][:uid] || auth.uid[0][:user]) if auth.uid.is_a? Hashie::Array auth.uid = (auth.uid[0][:uid] || auth.uid[0][:user]) if auth.uid.is_a? Hashie::Array
identity = Identity.find_for_oauth(auth) identity = Identity.find_for_omniauth(auth)
# If a signed_in_resource is provided it always overrides the existing user # If a signed_in_resource is provided it always overrides the existing user
# to prevent the identity being locked with accidentally created accounts. # to prevent the identity being locked with accidentally created accounts.
# Note that this may leave zombie accounts (with no associated identity) which # Note that this may leave zombie accounts (with no associated identity) which
# can be cleaned up at a later date. # can be cleaned up at a later date.
user = signed_in_resource || identity.user user = signed_in_resource || identity.user
user ||= create_for_oauth(auth) user ||= reattach_for_auth(auth)
user ||= create_for_auth(auth)
if identity.user.nil? if identity.user.nil?
identity.user = user identity.user = user
@ -39,19 +40,35 @@ module User::Omniauthable
user user
end end
def create_for_oauth(auth) private
# Check if the user exists with provided email. If no email was provided,
def reattach_for_auth(auth)
# If allowed, check if a user exists with the provided email address,
# and return it if they does not have an associated identity with the
# current authentication provider.
# This can be used to provide a choice of alternative auth providers
# or provide smooth gradual transition between multiple auth providers,
# but this is discouraged because any insecure provider will put *all*
# local users at risk, regardless of which provider they registered with.
return unless ENV['ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH'] == 'true'
email, email_is_verified = email_from_auth(auth)
return unless email_is_verified
user = User.find_by(email: email)
return if user.nil? || Identity.exists?(provider: auth.provider, user_id: user.id)
user
end
def create_for_auth(auth)
# Create a user for the given auth params. If no email was provided,
# we assign a temporary email and ask the user to verify it on # we assign a temporary email and ask the user to verify it on
# the next step via Auth::SetupController.show # the next step via Auth::SetupController.show
strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy email, email_is_verified = email_from_auth(auth)
assume_verified = strategy&.security&.assume_email_is_verified
email_is_verified = auth.info.verified || auth.info.verified_email || auth.info.email_verified || assume_verified
email = auth.info.verified_email || auth.info.email
user = User.find_by(email: email) if email_is_verified
return user unless user.nil?
user = User.new(user_params_from_auth(email, auth)) user = User.new(user_params_from_auth(email, auth))
@ -66,7 +83,14 @@ module User::Omniauthable
user user
end end
private def email_from_auth(auth)
strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy
assume_verified = strategy&.security&.assume_email_is_verified
email_is_verified = auth.info.verified || auth.info.verified_email || auth.info.email_verified || assume_verified
email = auth.info.verified_email || auth.info.email
[email, email_is_verified]
end
def user_params_from_auth(email, auth) def user_params_from_auth(email, auth)
{ {

View file

@ -17,7 +17,7 @@ class Identity < ApplicationRecord
validates :uid, presence: true, uniqueness: { scope: :provider } validates :uid, presence: true, uniqueness: { scope: :provider }
validates :provider, presence: true validates :provider, presence: true
def self.find_for_oauth(auth) def self.find_for_omniauth(auth)
find_or_create_by(uid: auth.uid, provider: auth.provider) find_or_create_by(uid: auth.uid, provider: auth.provider)
end end
end end

View file

@ -17,17 +17,17 @@ class InstanceInfo < ApplicationRecord
after_commit :reset_cache after_commit :reset_cache
EMOJI_REACTION_AVAILABLE_SOFTWARES = %w( EMOJI_REACTION_AVAILABLE_SOFTWARES = %w(
misskey
calckey
cherrypick
meisskey
sharkey
firefish
catodon
renedon
fedibird
pleroma
akkoma akkoma
calckey
catodon
cherrypick
fedibird
firefish
iceshrimp
meisskey
misskey
pleroma
sharkey
).freeze ).freeze
def self.emoji_reaction_available?(domain) def self.emoji_reaction_available?(domain)

View file

@ -618,7 +618,7 @@ class Status < ApplicationRecord
end end
def distributable_friend? def distributable_friend?
public_visibility? || public_unlisted_visibility? || (unlisted_visibility? && (public_searchability? || public_unlisted_searchability?)) public_visibility? || public_unlisted_visibility? || login_visibility? || (unlisted_visibility? && (public_searchability? || public_unlisted_searchability?))
end end
private private

View file

@ -14,6 +14,8 @@
# #
class StatusReference < ApplicationRecord class StatusReference < ApplicationRecord
REFERENCES_LIMIT = 5
belongs_to :status belongs_to :status
belongs_to :target_status, class_name: 'Status' belongs_to :target_status, class_name: 'Status'

View file

@ -361,6 +361,16 @@ class User < ApplicationRecord
Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch| Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
batch.update_all(revoked_at: Time.now.utc) batch.update_all(revoked_at: Time.now.utc)
Web::PushSubscription.where(access_token_id: batch).delete_all Web::PushSubscription.where(access_token_id: batch).delete_all
# Revoke each access token for the Streaming API, since `update_all``
# doesn't trigger ActiveRecord Callbacks:
# TODO: #28793 Combine into a single topic
payload = Oj.dump(event: :kill)
redis.pipelined do |pipeline|
batch.ids.each do |id|
pipeline.publish("timeline:access_token:#{id}", payload)
end
end
end end
end end

View file

@ -12,7 +12,6 @@ class UserSettings
setting :theme, default: -> { ::Setting.theme } setting :theme, default: -> { ::Setting.theme }
setting :noindex, default: -> { ::Setting.noindex } setting :noindex, default: -> { ::Setting.noindex }
setting :translatable_private, default: false setting :translatable_private, default: false
setting :link_preview, default: true
setting :bio_markdown, default: false setting :bio_markdown, default: false
setting :discoverable_local, default: false setting :discoverable_local, default: false
setting :hide_statuses_count, default: false setting :hide_statuses_count, default: false
@ -42,6 +41,7 @@ class UserSettings
setting :dtl_force_subscribable, default: false setting :dtl_force_subscribable, default: false
setting :lock_follow_from_bot, default: false setting :lock_follow_from_bot, default: false
setting :allow_quote, default: true setting :allow_quote, default: true
setting :reject_send_limited_to_suspects, default: false
setting_inverse_alias :indexable, :noindex setting_inverse_alias :indexable, :noindex

View file

@ -23,9 +23,9 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
case collection['type'] case collection['type']
when 'Collection', 'CollectionPage' when 'Collection', 'CollectionPage'
collection['items'] as_array(collection['items'])
when 'OrderedCollection', 'OrderedCollectionPage' when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems'] as_array(collection['orderedItems'])
end end
end end

View file

@ -6,7 +6,7 @@ class ActivityPub::FetchReferencesService < BaseService
def call(status, collection_or_uri) def call(status, collection_or_uri)
@account = status.account @account = status.account
collection_items(collection_or_uri)&.map { |item| value_or_id(item) } collection_items(collection_or_uri)&.take(8)&.map { |item| value_or_id(item) }
end end
private private
@ -20,9 +20,9 @@ class ActivityPub::FetchReferencesService < BaseService
case collection['type'] case collection['type']
when 'Collection', 'CollectionPage' when 'Collection', 'CollectionPage'
collection['items'] as_array(collection['items'])
when 'OrderedCollection', 'OrderedCollectionPage' when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems'] as_array(collection['orderedItems'])
end end
end end
@ -31,6 +31,19 @@ class ActivityPub::FetchReferencesService < BaseService
return if unsupported_uri_scheme?(collection_or_uri) return if unsupported_uri_scheme?(collection_or_uri)
return if ActivityPub::TagManager.instance.local_uri?(collection_or_uri) return if ActivityPub::TagManager.instance.local_uri?(collection_or_uri)
fetch_resource_without_id_validation(collection_or_uri, nil, true) # NOTE: For backward compatibility reasons, Mastodon signs outgoing
# queries incorrectly by default.
#
# While this is relevant for all URLs with query strings, this is
# the only code path where this happens in practice.
#
# Therefore, retry with correct signatures if this fails.
begin
fetch_resource_without_id_validation(collection_or_uri, nil, true)
rescue Mastodon::UnexpectedResponseError => e
raise unless e.response && e.response.code == 401 && Addressable::URI.parse(collection_or_uri).query.present?
fetch_resource_without_id_validation(collection_or_uri, nil, true, request_options: { with_query_string: true })
end
end end
end end

View file

@ -2,7 +2,7 @@
class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService
# 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, suppress_errors: true, request_id: nil) def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
actor = super actor = super
return actor if actor.nil? || actor.is_a?(Account) return actor if actor.nil? || actor.is_a?(Account)

View file

@ -10,15 +10,15 @@ class ActivityPub::FetchRemoteActorService < 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, suppress_errors: true, request_id: nil) def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
return if domain_not_allowed?(uri) return if domain_not_allowed?(uri)
return ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri) return ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri)
@json = begin @json = begin
if prefetched_body.nil? if prefetched_body.nil?
fetch_resource(uri, id) fetch_resource(uri, true)
else else
body_to_json(prefetched_body, compare_id: id ? uri : nil) body_to_json(prefetched_body, compare_id: uri)
end end
rescue Oj::ParseError rescue Oj::ParseError
raise Error, "Error parsing JSON-LD document #{uri}" raise Error, "Error parsing JSON-LD document #{uri}"

View file

@ -6,23 +6,10 @@ class ActivityPub::FetchRemoteKeyService < BaseService
class Error < StandardError; end class Error < StandardError; end
# Returns actor that owns the key # Returns actor that owns the key
def call(uri, id: true, prefetched_body: nil, suppress_errors: true) def call(uri, suppress_errors: true)
raise Error, 'No key URI given' if uri.blank? raise Error, 'No key URI given' if uri.blank?
if prefetched_body.nil? @json = fetch_resource(uri, false)
if id
@json = fetch_resource_without_id_validation(uri)
if actor_type?
@json = fetch_resource(@json['id'], true)
elsif uri != @json['id']
raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}"
end
else
@json = fetch_resource(uri, id)
end
else
@json = body_to_json(prefetched_body, compare_id: id ? uri : nil)
end
raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil? raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil?
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json) raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json)

View file

@ -8,14 +8,14 @@ class ActivityPub::FetchRemoteStatusService < BaseService
DISCOVERIES_PER_REQUEST = 1000 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, expected_actor_uri: nil, request_id: nil) def call(uri, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
return if domain_not_allowed?(uri) return if domain_not_allowed?(uri)
@request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}" @request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
@json = if prefetched_body.nil? @json = if prefetched_body.nil?
fetch_resource(uri, id, on_behalf_of) fetch_resource(uri, true, on_behalf_of)
else else
body_to_json(prefetched_body, compare_id: id ? uri : nil) body_to_json(prefetched_body, compare_id: uri)
end end
return unless supported_context? return unless supported_context?
@ -65,7 +65,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, request_id: @request_id) if actor.nil? || actor.possibly_stale? actor = ActivityPub::FetchRemoteAccountService.new.call(uri, request_id: @request_id) if actor.nil? || actor.possibly_stale?
actor actor
end end

View file

@ -26,9 +26,9 @@ class ActivityPub::FetchRepliesService < BaseService
case collection['type'] case collection['type']
when 'Collection', 'CollectionPage' when 'Collection', 'CollectionPage'
collection['items'] as_array(collection['items'])
when 'OrderedCollection', 'OrderedCollectionPage' when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems'] as_array(collection['orderedItems'])
end end
end end
@ -37,7 +37,20 @@ class ActivityPub::FetchRepliesService < BaseService
return unless @allow_synchronous_requests return unless @allow_synchronous_requests
return if non_matching_uri_hosts?(@account.uri, collection_or_uri) return if non_matching_uri_hosts?(@account.uri, collection_or_uri)
fetch_resource_without_id_validation(collection_or_uri, nil, true) # NOTE: For backward compatibility reasons, Mastodon signs outgoing
# queries incorrectly by default.
#
# While this is relevant for all URLs with query strings, this is
# the only code path where this happens in practice.
#
# Therefore, retry with correct signatures if this fails.
begin
fetch_resource_without_id_validation(collection_or_uri, nil, true)
rescue Mastodon::UnexpectedResponseError => e
raise unless e.response && e.response.code == 401 && Addressable::URI.parse(collection_or_uri).query.present?
fetch_resource_without_id_validation(collection_or_uri, nil, true, request_options: { with_query_string: true })
end
end end
def filtered_replies def filtered_replies

View file

@ -131,8 +131,8 @@ class ActivityPub::ProcessAccountService < BaseService
end end
def valid_account? def valid_account?
display_name = @json['name'] display_name = @json['name'] || ''
note = @json['summary'] note = @json['summary'] || ''
!Admin::NgWord.reject?(display_name) && !Admin::NgWord.reject?(note) !Admin::NgWord.reject?(display_name) && !Admin::NgWord.reject?(note)
end end
@ -392,7 +392,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, request_id: @options[:request_id]) account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], break_on_redirect: true, request_id: @options[:request_id])
account account
end end

View file

@ -59,9 +59,9 @@ class ActivityPub::SynchronizeFollowersService < BaseService
case collection['type'] case collection['type']
when 'Collection', 'CollectionPage' when 'Collection', 'CollectionPage'
collection['items'] as_array(collection['items'])
when 'OrderedCollection', 'OrderedCollectionPage' when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems'] as_array(collection['orderedItems'])
end end
end end

View file

@ -7,6 +7,8 @@ module AccountScope
scope_local scope_local
when :private when :private
scope_account_local_followers(status.account) scope_account_local_followers(status.account)
when :limited
scope_status_all_mentioned(status)
else else
scope_status_mentioned(status) scope_status_mentioned(status)
end end
@ -24,6 +26,10 @@ module AccountScope
Account.local.where(id: status.active_mentions.select(:account_id)).reorder(nil) Account.local.where(id: status.active_mentions.select(:account_id)).reorder(nil)
end end
def scope_status_all_mentioned(status)
Account.local.where(id: status.mentions.select(:account_id)).reorder(nil)
end
# TODO: not work # TODO: not work
def scope_list_following_account(account) def scope_list_following_account(account)
account.lists_for_local_distribution.select(:id).reorder(nil) account.lists_for_local_distribution.select(:id).reorder(nil)

View file

@ -20,7 +20,7 @@ class FetchLinkCardService < BaseService
@status = status @status = status
@original_url = parse_urls @original_url = parse_urls
return if @original_url.nil? || @status.with_preview_card? || !@status.account.link_preview? return if @original_url.nil? || @status.with_preview_card?
@url = @original_url.to_s @url = @original_url.to_s
@ -88,6 +88,10 @@ class FetchLinkCardService < BaseService
end end
def referenced_urls def referenced_urls
referenced_urls_raw.filter { |uri| ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true).present? }
end
def referenced_urls_raw
unless @status.local? unless @status.local?
document = Nokogiri::HTML(@status.text) document = Nokogiri::HTML(@status.text)
document.search('a[href^="http://"]', 'a[href^="https://"]').each do |link| document.search('a[href^="http://"]', 'a[href^="https://"]').each do |link|

View file

@ -44,11 +44,19 @@ class FetchResourceService < BaseService
@response_code = response.code @response_code = response.code
return nil if response.code != 200 return nil if response.code != 200
if ['application/activity+json', 'application/ld+json'].include?(response.mime_type) if valid_activitypub_content_type?(response)
body = response.body_with_limit body = response.body_with_limit
json = body_to_json(body) json = body_to_json(body)
[json['id'], { prefetched_body: body, id: true }] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json)) return unless supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))
if json['id'] != @url
return if terminal
return process(json['id'], terminal: true)
end
[@url, { prefetched_body: body }]
elsif !terminal elsif !terminal
link_header = response['Link'] && parse_link_header(response) link_header = response['Link'] && parse_link_header(response)

View file

@ -69,7 +69,7 @@ class Keys::QueryService < BaseService
return if json['items'].blank? return if json['items'].blank?
@devices = json['items'].map do |device| @devices = as_array(json['items']).map do |device|
Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim']) Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim'])
end end
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e

View file

@ -105,7 +105,10 @@ class ProcessMentionsService < BaseService
def process_mutual! def process_mutual!
mentioned_account_ids = @current_mentions.map(&:account_id) mentioned_account_ids = @current_mentions.map(&:account_id)
@status.account.mutuals.reorder(nil).find_each do |target_account| mutuals = @status.account.mutuals
mutuals = mutuals.where.not(domain: InstanceInfo.where(software: 'misskey').select(:domain)).or(mutuals.where(domain: nil)) if @status.account.user&.setting_reject_send_limited_to_suspects
mutuals.reorder(nil).find_each do |target_account|
@current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id) @current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id)
end end
end end

View file

@ -44,11 +44,7 @@ class ReblogService < BaseService
def create_notification(reblog) def create_notification(reblog)
reblogged_status = reblog.reblog reblogged_status = reblog.reblog
if reblogged_status.account.local? LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, 'reblog') if reblogged_status.account.local?
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, 'reblog')
elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
end
end end
def increment_statistics def increment_statistics

View file

@ -19,10 +19,16 @@
= ff.input :translatable_private, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_translatable_private') = ff.input :translatable_private, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_translatable_private')
.fields-group .fields-group
= ff.input :link_preview, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_link_preview'), hint: I18n.t('simple_form.hints.defaults.setting_link_preview') = f.input :subscription_policy,
as: :radio_buttons,
.fields-group collection: %w(allow followers_only block),
= f.input :subscription_policy, kmyblue: true, collection: %w(allow followers_only block), label_method: ->(item) { safe_join([t("simple_form.labels.subscription_policy.#{item}")]) }, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', wrapper: :with_floating_label, label: t('simple_form.labels.defaults.subscription_policy'), hint: t('simple_form.hints.defaults.subscription_policy') collection_wrapper_tag: 'ul',
hint: t('simple_form.hints.defaults.subscription_policy'),
item_wrapper_tag: 'li',
kmyblue: true,
label: t('simple_form.labels.defaults.subscription_policy'),
label_method: ->(item) { safe_join([t("simple_form.labels.subscription_policy.#{item}")]) },
wrapper: :with_floating_label
.fields-group .fields-group
= ff.input :allow_quote, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_allow_quote'), hint: false = ff.input :allow_quote, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_allow_quote'), hint: false
@ -39,5 +45,8 @@
.fields-group .fields-group
= ff.input :reject_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_unlisted_subscription'), hint: I18n.t('simple_form.hints.defaults.setting_reject_unlisted_subscription') = ff.input :reject_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_unlisted_subscription'), hint: I18n.t('simple_form.hints.defaults.setting_reject_unlisted_subscription')
.fields-group
= ff.input :reject_send_limited_to_suspects, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_send_limited_to_suspects'), hint: I18n.t('simple_form.hints.defaults.setting_reject_send_limited_to_suspects')
.actions .actions
= f.button :button, t('generic.save_changes'), type: :submit = f.button :button, t('generic.save_changes'), type: :submit

View file

@ -7,7 +7,7 @@ class LinkCrawlWorker
def perform(status_id) def perform(status_id)
FetchLinkCardService.new.call(Status.find(status_id)) FetchLinkCardService.new.call(Status.find(status_id))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
true true
end end
end end

View file

@ -21,9 +21,14 @@ Doorkeeper.configure do
user unless user&.otp_required_for_login? user unless user&.otp_required_for_login?
end end
# If you want to restrict access to the web interface for adding oauth authorized applications, you need to declare the block below. # Doorkeeper provides some administrative interfaces for managing OAuth
# Applications, allowing creation, edit, and deletion of applications from the
# server. At present, these administrative routes are not integrated into
# Mastodon, and as such, we've disabled them by always return a 403 forbidden
# response for them. This does not affect the ability for users to manage
# their own OAuth Applications.
admin_authenticator do admin_authenticator do
current_user&.admin? || redirect_to(new_user_session_url) head 403
end end
# Authorization Code expiration time (default 10 minutes). # Authorization Code expiration time (default 10 minutes).

View file

@ -12,6 +12,7 @@ en:
last_attempt: You have one more attempt before your account is locked. last_attempt: You have one more attempt before your account is locked.
locked: Your account is locked. locked: Your account is locked.
not_found_in_database: Invalid %{authentication_keys} or password. not_found_in_database: Invalid %{authentication_keys} or password.
omniauth_user_creation_failure: Error creating an account for this identity.
pending: Your account is still under review. pending: Your account is still under review.
timeout: Your session expired. Please login again to continue. timeout: Your session expired. Please login again to continue.
unauthenticated: You need to login or sign up before continuing. unauthenticated: You need to login or sign up before continuing.

View file

@ -2075,6 +2075,7 @@ en:
go_to_sso_account_settings: Go to your identity provider's account settings go_to_sso_account_settings: Go to your identity provider's account settings
invalid_otp_token: Invalid two-factor code invalid_otp_token: Invalid two-factor code
otp_lost_help_html: If you lost access to both, you may get in touch with %{email} otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
rate_limited: Too many authentication attempts, try again later.
seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available. seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available.
signed_in_as: 'Signed in as:' signed_in_as: 'Signed in as:'
verification: verification:

View file

@ -278,11 +278,11 @@ en:
setting_hide_network: Hide your social graph setting_hide_network: Hide your social graph
setting_hide_recent_emojis: Hide recent emojis setting_hide_recent_emojis: Hide recent emojis
setting_hide_statuses_count: Hide statuses count setting_hide_statuses_count: Hide statuses count
setting_link_preview: Generate post link preview card
setting_lock_follow_from_bot: Request approval about bot follow setting_lock_follow_from_bot: Request approval about bot follow
setting_public_post_to_unlisted: Convert public post to public unlisted if not using Web app setting_public_post_to_unlisted: Convert public post to public unlisted if not using Web app
setting_reduce_motion: Reduce motion in animations setting_reduce_motion: Reduce motion in animations
setting_reject_public_unlisted_subscription: Reject sending public unlisted visibility/non-public searchability posts to Misskey, Calckey setting_reject_public_unlisted_subscription: Reject sending public unlisted visibility/non-public searchability posts to Misskey, Calckey
setting_reject_send_limited_to_suspects: Reject sending mutual posts to Misskey
setting_reject_unlisted_subscription: Reject sending unlisted visibility/non-public searchability posts to Misskey, Calckey setting_reject_unlisted_subscription: Reject sending unlisted visibility/non-public searchability posts to Misskey, Calckey
setting_send_without_domain_blocks: Send your post to all server with administrator set as rejecting-post-server for protect you [DEPRECATED] setting_send_without_domain_blocks: Send your post to all server with administrator set as rejecting-post-server for protect you [DEPRECATED]
setting_show_application: Disclose application used to send posts setting_show_application: Disclose application used to send posts

View file

@ -79,8 +79,8 @@ ja:
setting_emoji_reaction_streaming_notify_impl2: 当該サーバーの独自機能に対応したアプリを利用時に、スタンプ機能を利用できます。動作確認していないため(そもそもそのようなアプリ自体を確認できていないため)正しく動かない場合があります setting_emoji_reaction_streaming_notify_impl2: 当該サーバーの独自機能に対応したアプリを利用時に、スタンプ機能を利用できます。動作確認していないため(そもそもそのようなアプリ自体を確認できていないため)正しく動かない場合があります
setting_enable_emoji_reaction: この機能を無効にしても、他の人はあなたの投稿にスタンプをつけられます setting_enable_emoji_reaction: この機能を無効にしても、他の人はあなたの投稿にスタンプをつけられます
setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします
setting_link_preview: プレビュー生成を停止することは、センシティブなサイトへのリンクを頻繁に投稿する人にも有効かもしれません
setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります
setting_reject_send_limited_to_suspects: これは「相互のみ」投稿に適用されます。サークル投稿は例外なく配送されます。一部のMisskeyサーバーが独自に限定投稿へ対応しましたが、相互のみ投稿を行うたびに相手に通知されるなど複数の問題があるため、気になる人向けの設定です
setting_reject_unlisted_subscription: Misskeyやそのフォークは、フォローしていないアカウントの「非収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに非収載として配信されること、ご理解ください setting_reject_unlisted_subscription: Misskeyやそのフォークは、フォローしていないアカウントの「非収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに非収載として配信されること、ご理解ください
setting_show_application: 投稿するのに使用したアプリが投稿の詳細ビューに表示されるようになります setting_show_application: 投稿するのに使用したアプリが投稿の詳細ビューに表示されるようになります
setting_single_ref_to_quote: 当サーバーがまだ対象投稿を取り込んでいない場合、引用が相手に正常に認識されない場合があります setting_single_ref_to_quote: 当サーバーがまだ対象投稿を取り込んでいない場合、引用が相手に正常に認識されない場合があります
@ -287,7 +287,6 @@ ja:
setting_hide_network: 繋がりを隠す setting_hide_network: 繋がりを隠す
setting_hide_recent_emojis: 絵文字ピッカーで最近使用した絵文字を隠す(絵文字デッキのみを表示する) setting_hide_recent_emojis: 絵文字ピッカーで最近使用した絵文字を隠す(絵文字デッキのみを表示する)
setting_hide_statuses_count: 投稿数を隠す setting_hide_statuses_count: 投稿数を隠す
setting_link_preview: リンクのプレビューを生成する
setting_lock_follow_from_bot: botからのフォローを承認制にする setting_lock_follow_from_bot: botからのフォローを承認制にする
setting_show_quote_in_home: ホーム・リスト・アンテナなどで引用を表示する setting_show_quote_in_home: ホーム・リスト・アンテナなどで引用を表示する
setting_show_quote_in_public: 公開タイムライン(ローカル・連合)で引用を表示する setting_show_quote_in_public: 公開タイムライン(ローカル・連合)で引用を表示する
@ -295,6 +294,7 @@ ja:
setting_public_post_to_unlisted: サードパーティから公開範囲「公開」で投稿した場合、「ローカル公開」に変更する setting_public_post_to_unlisted: サードパーティから公開範囲「公開」で投稿した場合、「ローカル公開」に変更する
setting_reduce_motion: アニメーションの動きを減らす setting_reduce_motion: アニメーションの動きを減らす
setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する
setting_reject_send_limited_to_suspects: Misskey系サーバーに「相互のみ」投稿を配送しない
setting_reject_unlisted_subscription: Misskey系サーバーに「非収載」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する setting_reject_unlisted_subscription: Misskey系サーバーに「非収載」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する
setting_send_without_domain_blocks: 管理人の設定した配送停止設定を拒否する (非推奨) setting_send_without_domain_blocks: 管理人の設定した配送停止設定を拒否する (非推奨)
setting_show_application: 送信したアプリを開示する setting_show_application: 送信したアプリを開示する

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'sidekiq_unique_jobs/web' require 'sidekiq_unique_jobs/web' if ENV['ENABLE_SIDEKIQ_UNIQUE_JOBS_UI'] == true
require 'sidekiq-scheduler/web' require 'sidekiq-scheduler/web'
class RedirectWithVary < ActionDispatch::Routing::PathRedirect class RedirectWithVary < ActionDispatch::Routing::PathRedirect

View file

@ -56,7 +56,7 @@ services:
web: web:
build: . build: .
image: ghcr.io/mastodon/mastodon:v4.2.1 image: ghcr.io/mastodon/mastodon:v4.2.7
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec puma -C config/puma.rb command: bundle exec puma -C config/puma.rb
@ -77,7 +77,7 @@ services:
streaming: streaming:
build: . build: .
image: ghcr.io/mastodon/mastodon:v4.2.1 image: ghcr.io/mastodon/mastodon:v4.2.7
restart: always restart: always
env_file: .env.production env_file: .env.production
command: node ./streaming command: node ./streaming
@ -95,7 +95,7 @@ services:
sidekiq: sidekiq:
build: . build: .
image: ghcr.io/mastodon/mastodon:v4.2.1 image: ghcr.io/mastodon/mastodon:v4.2.7
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec sidekiq command: bundle exec sidekiq

View file

@ -6,8 +6,8 @@ Install Ruby
EOF EOF
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 3.2.2 RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 3.2.3
rbenv global 3.2.2 rbenv global 3.2.3
cat << EOF cat << EOF

View file

@ -6,8 +6,8 @@ Install Ruby
EOF EOF
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 3.2.2 RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 3.2.3
rbenv global 3.2.2 rbenv global 3.2.3
cat << EOF cat << EOF

View file

@ -9,7 +9,7 @@ module Mastodon
end end
def kmyblue_minor def kmyblue_minor
0 5
end end
def kmyblue_flag def kmyblue_flag
@ -29,7 +29,7 @@ module Mastodon
end end
def default_prerelease def default_prerelease
'alpha.0' 'alpha.3'
end end
def prerelease def prerelease

View file

@ -16,7 +16,7 @@ module Paperclip
private private
def cache_current_values def cache_current_values
@original_filename = filename_from_content_disposition.presence || filename_from_path.presence || 'data' @original_filename = truncated_filename
@tempfile = copy_to_tempfile(@target) @tempfile = copy_to_tempfile(@target)
@content_type = ContentTypeDetector.new(@tempfile.path).detect @content_type = ContentTypeDetector.new(@tempfile.path).detect
@size = File.size(@tempfile) @size = File.size(@tempfile)
@ -43,6 +43,13 @@ module Paperclip
source.response.connection.close source.response.connection.close
end end
def truncated_filename
filename = filename_from_content_disposition.presence || filename_from_path.presence || 'data'
extension = File.extname(filename)
basename = File.basename(filename, extension)
[basename[...20], extension[..4]].compact_blank.join
end
def filename_from_content_disposition def filename_from_content_disposition
disposition = @target.response.headers['content-disposition'] disposition = @target.response.headers['content-disposition']
disposition&.match(/filename="([^"]*)"/)&.captures&.first disposition&.match(/filename="([^"]*)"/)&.captures&.first

View file

@ -17,7 +17,7 @@ namespace :db do
task :pre_migration_check do task :pre_migration_check do
version = ActiveRecord::Base.connection.select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i version = ActiveRecord::Base.connection.select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
abort 'This version of Mastodon requires PostgreSQL 9.5 or newer. Please update PostgreSQL before updating Mastodon' if version < 90_500 abort 'This version of Mastodon requires PostgreSQL 10.0 or newer. Please update PostgreSQL before updating Mastodon' if version < 100_000
end end
Rake::Task['db:migrate'].enhance(['db:pre_migration_check']) Rake::Task['db:migrate'].enhance(['db:pre_migration_check'])

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
namespace :sidekiq_unique_jobs do
task delete_all_locks: :environment do
digests = SidekiqUniqueJobs::Digests.new
digests.delete_by_pattern('*', count: digests.count)
expiring_digests = SidekiqUniqueJobs::ExpiringDigests.new
expiring_digests.delete_by_pattern('*', count: expiring_digests.count)
end
end

View file

@ -5,7 +5,7 @@ require 'rails_helper'
describe Api::V1::StreamingController do describe Api::V1::StreamingController do
around do |example| around do |example|
before = Rails.configuration.x.streaming_api_base_url before = Rails.configuration.x.streaming_api_base_url
Rails.configuration.x.streaming_api_base_url = Rails.configuration.x.web_domain Rails.configuration.x.streaming_api_base_url = "wss://#{Rails.configuration.x.web_domain}"
example.run example.run
Rails.configuration.x.streaming_api_base_url = before Rails.configuration.x.streaming_api_base_url = before
end end

View file

@ -262,6 +262,26 @@ RSpec.describe Auth::SessionsController do
end end
end end
context 'when repeatedly using an invalid TOTP code before using a valid code' do
before do
stub_const('Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR', 2)
end
it 'does not log the user in' do
# Travel to the beginning of an hour to avoid crossing rate-limit buckets
travel_to '2023-12-20T10:00:00Z'
Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR.times do
post :create, params: { user: { otp_attempt: '1234' } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
expect(controller.current_user).to be_nil
end
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
expect(controller.current_user).to be_nil
expect(flash[:alert]).to match I18n.t('users.rate_limited')
end
end
context 'when using a valid OTP' do context 'when using a valid OTP' do
before do before do
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s } post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }

View file

@ -56,15 +56,15 @@ describe JsonLdHelper do
describe '#fetch_resource' do describe '#fetch_resource' do
context 'when the second argument is false' do context 'when the second argument is false' do
it 'returns resource even if the retrieved ID and the given URI does not match' do it 'returns resource even if the retrieved ID and the given URI does not match' do
stub_request(:get, 'https://bob.test/').to_return body: '{"id": "https://alice.test/"}' stub_request(:get, 'https://bob.test/').to_return(body: '{"id": "https://alice.test/"}', headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://alice.test/').to_return body: '{"id": "https://alice.test/"}' stub_request(:get, 'https://alice.test/').to_return(body: '{"id": "https://alice.test/"}', headers: { 'Content-Type': 'application/activity+json' })
expect(fetch_resource('https://bob.test/', false)).to eq({ 'id' => 'https://alice.test/' }) expect(fetch_resource('https://bob.test/', false)).to eq({ 'id' => 'https://alice.test/' })
end end
it 'returns nil if the object identified by the given URI and the object identified by the retrieved ID does not match' do it 'returns nil if the object identified by the given URI and the object identified by the retrieved ID does not match' do
stub_request(:get, 'https://mallory.test/').to_return body: '{"id": "https://marvin.test/"}' stub_request(:get, 'https://mallory.test/').to_return(body: '{"id": "https://marvin.test/"}', headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://marvin.test/').to_return body: '{"id": "https://alice.test/"}' stub_request(:get, 'https://marvin.test/').to_return(body: '{"id": "https://alice.test/"}', headers: { 'Content-Type': 'application/activity+json' })
expect(fetch_resource('https://mallory.test/', false)).to be_nil expect(fetch_resource('https://mallory.test/', false)).to be_nil
end end
@ -72,7 +72,7 @@ describe JsonLdHelper do
context 'when the second argument is true' do context 'when the second argument is true' do
it 'returns nil if the retrieved ID and the given URI does not match' do it 'returns nil if the retrieved ID and the given URI does not match' do
stub_request(:get, 'https://mallory.test/').to_return body: '{"id": "https://alice.test/"}' stub_request(:get, 'https://mallory.test/').to_return(body: '{"id": "https://alice.test/"}', headers: { 'Content-Type': 'application/activity+json' })
expect(fetch_resource('https://mallory.test/', true)).to be_nil expect(fetch_resource('https://mallory.test/', true)).to be_nil
end end
end end
@ -80,12 +80,12 @@ describe JsonLdHelper do
describe '#fetch_resource_without_id_validation' do describe '#fetch_resource_without_id_validation' do
it 'returns nil if the status code is not 200' do it 'returns nil if the status code is not 200' do
stub_request(:get, 'https://host.test/').to_return status: 400, body: '{}' stub_request(:get, 'https://host.test/').to_return(status: 400, body: '{}', headers: { 'Content-Type': 'application/activity+json' })
expect(fetch_resource_without_id_validation('https://host.test/')).to be_nil expect(fetch_resource_without_id_validation('https://host.test/')).to be_nil
end end
it 'returns hash' do it 'returns hash' do
stub_request(:get, 'https://host.test/').to_return status: 200, body: '{}' stub_request(:get, 'https://host.test/').to_return(status: 200, body: '{}', headers: { 'Content-Type': 'application/activity+json' })
expect(fetch_resource_without_id_validation('https://host.test/')).to eq({}) expect(fetch_resource_without_id_validation('https://host.test/')).to eq({})
end end
end end

View file

@ -267,6 +267,80 @@ RSpec.describe AccountStatusesFilter do
it_behaves_like 'filter params' it_behaves_like 'filter params'
end end
context 'when accessed by a remote account' do
let(:current_account) { Fabricate(:account, uri: 'https://example.com/', domain: 'example.com') }
let!(:sensitive_status_with_cw) { Fabricate(:status, account: account, visibility: :public, spoiler_text: 'CW', sensitive: true) }
let!(:sensitive_status_with_media) do
Fabricate(:status, account: account, visibility: :public, sensitive: true).tap do |status|
Fabricate(:media_attachment, account: account, status: status)
end
end
shared_examples 'as_like_public_visibility' do
it 'returns private statuses, replies, and reblogs' do
expect(results_unique_visibilities).to match_array %w(login unlisted public_unlisted public)
expect(results_in_reply_to_ids).to_not be_empty
expect(results_reblog_of_ids).to_not be_empty
end
context 'when there is a direct status mentioning the non-follower' do
let!(:direct_status) { status_with_mention!(:direct, current_account) }
it 'returns the direct status' do
expect(results_ids).to include(direct_status.id)
end
end
context 'when there is a direct status mentioning other user' do
let!(:direct_status) { status_with_mention!(:direct) }
it 'not returns the direct status' do
expect(results_ids).to_not include(direct_status.id)
end
end
context 'when there is a limited status mentioning the non-follower' do
let!(:limited_status) { status_with_mention!(:limited, current_account) }
it 'returns the limited status' do
expect(results_ids).to include(limited_status.id)
end
end
context 'when there is a limited status mentioning other user' do
let!(:limited_status) { status_with_mention!(:limited) }
it 'not returns the limited status' do
expect(results_ids).to_not include(limited_status.id)
end
end
end
it_behaves_like 'as_like_public_visibility'
it_behaves_like 'filter params'
it 'returns the sensitive status' do
expect(results_ids).to include(sensitive_status_with_cw.id)
expect(results_ids).to include(sensitive_status_with_media.id)
end
context 'when domain-blocked reject_media' do
before do
Fabricate(:domain_block, domain: 'example.com', severity: :noop, reject_send_sensitive: true)
end
it_behaves_like 'as_like_public_visibility'
it_behaves_like 'filter params'
it 'does not return the sensitive status' do
expect(results_ids).to_not include(sensitive_status_with_cw.id)
expect(results_ids).to_not include(sensitive_status_with_media.id)
end
end
end
private private
def results_unique_visibilities def results_unique_visibilities

View file

@ -35,7 +35,7 @@ RSpec.describe ActivityPub::Activity::Announce do
context 'when sender is followed by a local account' do context 'when sender is followed by a local account' do
before do before do
Fabricate(:account).follow!(sender) Fabricate(:account).follow!(sender)
stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json)) stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json), headers: { 'Content-Type': 'application/activity+json' })
subject.perform subject.perform
end end
@ -120,7 +120,7 @@ RSpec.describe ActivityPub::Activity::Announce do
let(:object_json) { 'https://example.com/actor/hello-world' } let(:object_json) { 'https://example.com/actor/hello-world' }
before do before do
stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json)) stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json), headers: { 'Content-Type': 'application/activity+json' })
end end
context 'when the relay is enabled' do context 'when the relay is enabled' do

View file

@ -30,7 +30,7 @@ RSpec.describe ActivityPub::Activity::Create do
stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png')) stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png'))
stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' }) stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' })
stub_request(:get, 'http://example.com/conversation').to_return(body: Oj.dump(conversation)) stub_request(:get, 'http://example.com/conversation').to_return(body: Oj.dump(conversation), headers: { 'Content-Type' => 'application/activity+json' })
stub_request(:get, 'http://example.com/invalid-conversation').to_return(status: 404) stub_request(:get, 'http://example.com/invalid-conversation').to_return(status: 404)
end end
@ -1158,8 +1158,8 @@ RSpec.describe ActivityPub::Activity::Create do
end end
before do before do
stub_request(:get, 'https://foo.test').to_return(status: 200, body: Oj.dump(actor_json)) stub_request(:get, 'https://foo.test').to_return(status: 200, body: Oj.dump(actor_json), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://foo.test/.well-known/webfinger?resource=acct:actor@foo.test').to_return(status: 200, body: Oj.dump(webfinger)) stub_request(:get, 'https://foo.test/.well-known/webfinger?resource=acct:actor@foo.test').to_return(status: 200, body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:post, 'https://foo.test/inbox').to_return(status: 200) stub_request(:post, 'https://foo.test/inbox').to_return(status: 200)
stub_request(:get, 'https://foo.test/.well-known/nodeinfo').to_return(status: 200) stub_request(:get, 'https://foo.test/.well-known/nodeinfo').to_return(status: 200)
subject.perform subject.perform

View file

@ -60,8 +60,8 @@ RSpec.describe ActivityPub::Activity::Like do
before do before do
stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png')) stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png'))
stub_request(:get, 'http://foo.bar/emoji2.png').to_return(body: attachment_fixture('emojo.png')) stub_request(:get, 'http://foo.bar/emoji2.png').to_return(body: attachment_fixture('emojo.png'))
stub_request(:get, 'https://example.com/aaa').to_return(status: 200, body: Oj.dump(original_emoji)) stub_request(:get, 'https://example.com/aaa').to_return(status: 200, body: Oj.dump(original_emoji), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/invalid').to_return(status: 200, body: Oj.dump(original_invalid_emoji)) stub_request(:get, 'https://example.com/invalid').to_return(status: 200, body: Oj.dump(original_invalid_emoji), headers: { 'Content-Type': 'application/activity+json' })
end end
let(:json) do let(:json) do

View file

@ -56,7 +56,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do
allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub) allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub)
allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do allow(service_stub).to receive(:call).with('http://example.com/alice') do
sender.update!(public_key: old_key) sender.update!(public_key: old_key)
sender sender
end end
@ -64,7 +64,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do
it 'fetches key and returns creator' do it 'fetches key and returns creator' do
expect(subject.verify_actor!).to eq sender expect(subject.verify_actor!).to eq sender
expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once expect(service_stub).to have_received(:call).with('http://example.com/alice').once
end end
end end

View file

@ -139,6 +139,14 @@ RSpec.describe ActivityPub::TagManager do
expect(subject.cc(status)).to include(subject.uri_for(foo)) expect(subject.cc(status)).to include(subject.uri_for(foo))
expect(subject.cc(status)).to_not include(subject.uri_for(alice)) expect(subject.cc(status)).to_not include(subject.uri_for(alice))
end end
it 'returns poster of reblogged post, if reblog' do
bob = Fabricate(:account, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/bob')
alice = Fabricate(:account, username: 'alice')
status = Fabricate(:status, visibility: :public, account: bob)
reblog = Fabricate(:status, visibility: :public, account: alice, reblog: status)
expect(subject.cc(reblog)).to include(subject.uri_for(bob))
end
end end
describe '#cc_for_misskey' do describe '#cc_for_misskey' do

View file

@ -397,13 +397,9 @@ RSpec.describe Account do
describe '#public_settings_for_local' do describe '#public_settings_for_local' do
subject { account.public_settings_for_local } subject { account.public_settings_for_local }
let(:account) { Fabricate(:user, settings: { link_preview: false, allow_quote: true, hide_statuses_count: true, emoji_reaction_policy: :followers_only }).account } let(:account) { Fabricate(:user, settings: { allow_quote: true, hide_statuses_count: true, emoji_reaction_policy: :followers_only }).account }
shared_examples 'some settings' do |permitted, emoji_reaction_policy| shared_examples 'some settings' do |permitted, emoji_reaction_policy|
it 'link_preview is disallowed' do
expect(subject['link_preview']).to be permitted.include?(:link_preview)
end
it 'allow_quote is allowed' do it 'allow_quote is allowed' do
expect(subject['allow_quote']).to be permitted.include?(:allow_quote) expect(subject['allow_quote']).to be permitted.include?(:allow_quote)
end end
@ -423,8 +419,14 @@ RSpec.describe Account do
it_behaves_like 'some settings', %i(allow_quote hide_statuses_count), 'followers_only' it_behaves_like 'some settings', %i(allow_quote hide_statuses_count), 'followers_only'
context 'when default true setting is set false' do
let(:account) { Fabricate(:user, settings: { allow_quote: false, hide_statuses_count: true, emoji_reaction_policy: :followers_only }).account }
it_behaves_like 'some settings', %i(hide_statuses_count), 'followers_only'
end
context 'when remote user' do context 'when remote user' do
let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor', settings: { 'link_preview' => false, 'allow_quote' => true, 'hide_statuses_count' => true, 'emoji_reaction_policy' => 'followers_only' }) } let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor', settings: { 'allow_quote' => true, 'hide_statuses_count' => true, 'emoji_reaction_policy' => 'followers_only' }) }
it_behaves_like 'some settings', %i(allow_quote hide_statuses_count), 'followers_only' it_behaves_like 'some settings', %i(allow_quote hide_statuses_count), 'followers_only'
end end
@ -432,7 +434,7 @@ RSpec.describe Account do
context 'when remote user by server other_settings is not supported' do context 'when remote user by server other_settings is not supported' do
let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') }
it_behaves_like 'some settings', %i(link_preview allow_quote), 'allow' it_behaves_like 'some settings', %i(allow_quote), 'allow'
end end
end end

View file

@ -3,19 +3,19 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Identity do RSpec.describe Identity do
describe '.find_for_oauth' do describe '.find_for_omniauth' do
let(:auth) { Fabricate(:identity, user: Fabricate(:user)) } let(:auth) { Fabricate(:identity, user: Fabricate(:user)) }
it 'calls .find_or_create_by' do it 'calls .find_or_create_by' do
allow(described_class).to receive(:find_or_create_by) allow(described_class).to receive(:find_or_create_by)
described_class.find_for_oauth(auth) described_class.find_for_omniauth(auth)
expect(described_class).to have_received(:find_or_create_by).with(uid: auth.uid, provider: auth.provider) expect(described_class).to have_received(:find_or_create_by).with(uid: auth.uid, provider: auth.provider)
end end
it 'returns an instance of Identity' do it 'returns an instance of Identity' do
expect(described_class.find_for_oauth(auth)).to be_instance_of described_class expect(described_class.find_for_omniauth(auth)).to be_instance_of described_class
end end
end end
end end

View file

@ -412,7 +412,10 @@ RSpec.describe User do
let!(:access_token) { Fabricate(:access_token, resource_owner_id: user.id) } let!(:access_token) { Fabricate(:access_token, resource_owner_id: user.id) }
let!(:web_push_subscription) { Fabricate(:web_push_subscription, access_token: access_token) } let!(:web_push_subscription) { Fabricate(:web_push_subscription, access_token: access_token) }
let(:redis_pipeline_stub) { instance_double(Redis::Namespace, publish: nil) }
before do before do
allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub)
user.reset_password! user.reset_password!
end end
@ -429,6 +432,10 @@ RSpec.describe User do
expect(Doorkeeper::AccessToken.active_for(user).count).to eq 0 expect(Doorkeeper::AccessToken.active_for(user).count).to eq 0
end end
it 'revokes streaming access for all access tokens' do
expect(redis_pipeline_stub).to have_received(:publish).with("timeline:access_token:#{access_token.id}", Oj.dump(event: :kill)).once
end
it 'removes push subscriptions' do it 'removes push subscriptions' do
expect(Web::PushSubscription.where(user: user).or(Web::PushSubscription.where(access_token: access_token)).count).to eq 0 expect(Web::PushSubscription.where(user: user).or(Web::PushSubscription.where(access_token: access_token)).count).to eq 0
expect { web_push_subscription.reload }.to raise_error(ActiveRecord::RecordNotFound) expect { web_push_subscription.reload }.to raise_error(ActiveRecord::RecordNotFound)

View file

@ -0,0 +1,83 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'Disabled OAuth routes' do
# These routes are disabled via the doorkeeper configuration for
# `admin_authenticator`, as these routes should only be accessible by server
# administrators. For now, these routes are not properly designed and
# integrated into Mastodon, so we're disabling them completely
describe 'GET /oauth/applications' do
it 'returns 403 forbidden' do
get oauth_applications_path
expect(response).to have_http_status(403)
end
end
describe 'POST /oauth/applications' do
it 'returns 403 forbidden' do
post oauth_applications_path
expect(response).to have_http_status(403)
end
end
describe 'GET /oauth/applications/new' do
it 'returns 403 forbidden' do
get new_oauth_application_path
expect(response).to have_http_status(403)
end
end
describe 'GET /oauth/applications/:id' do
let(:application) { Fabricate(:application, scopes: 'read') }
it 'returns 403 forbidden' do
get oauth_application_path(application)
expect(response).to have_http_status(403)
end
end
describe 'PATCH /oauth/applications/:id' do
let(:application) { Fabricate(:application, scopes: 'read') }
it 'returns 403 forbidden' do
patch oauth_application_path(application)
expect(response).to have_http_status(403)
end
end
describe 'PUT /oauth/applications/:id' do
let(:application) { Fabricate(:application, scopes: 'read') }
it 'returns 403 forbidden' do
put oauth_application_path(application)
expect(response).to have_http_status(403)
end
end
describe 'DELETE /oauth/applications/:id' do
let(:application) { Fabricate(:application, scopes: 'read') }
it 'returns 403 forbidden' do
delete oauth_application_path(application)
expect(response).to have_http_status(403)
end
end
describe 'GET /oauth/applications/:id/edit' do
let(:application) { Fabricate(:application, scopes: 'read') }
it 'returns 403 forbidden' do
get edit_oauth_application_path(application)
expect(response).to have_http_status(403)
end
end
end

View file

@ -39,16 +39,35 @@ describe 'OmniAuth callbacks' do
Fabricate(:user, email: 'user@host.example') Fabricate(:user, email: 'user@host.example')
end end
it 'matches the existing user, creates an identity, and redirects to root path' do context 'when ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH is set to true' do
expect { subject } around do |example|
.to not_change(User, :count) ClimateControl.modify ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH: 'true' do
.and change(Identity, :count) example.run
.by(1) end
.and change(LoginActivity, :count) end
.by(1)
expect(Identity.find_by(user: User.last).uid).to eq('123') it 'matches the existing user, creates an identity, and redirects to root path' do
expect(response).to redirect_to(root_path) expect { subject }
.to not_change(User, :count)
.and change(Identity, :count)
.by(1)
.and change(LoginActivity, :count)
.by(1)
expect(Identity.find_by(user: User.last).uid).to eq('123')
expect(response).to redirect_to(root_path)
end
end
context 'when ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH is not set to true' do
it 'does not match the existing user or create an identity, and redirects to login page' do
expect { subject }
.to not_change(User, :count)
.and not_change(Identity, :count)
.and not_change(LoginActivity, :count)
expect(response).to redirect_to(new_user_session_url)
end
end end
end end
@ -96,7 +115,7 @@ describe 'OmniAuth callbacks' do
context 'when a user cannot be built' do context 'when a user cannot be built' do
before do before do
allow(User).to receive(:find_for_oauth).and_return(User.new) allow(User).to receive(:find_for_omniauth).and_return(User.new)
end end
it 'redirects to the new user signup page' do it 'redirects to the new user signup page' do

View file

@ -31,7 +31,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
} }
end end
let(:status_json_pinned_unknown_unreachable) do let(:status_json_pinned_unknown_reachable) do
{ {
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
type: 'Note', type: 'Note',
@ -72,11 +72,11 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
shared_examples 'sets pinned posts' do shared_examples 'sets pinned posts' do
before do before do
stub_request(:get, 'https://example.com/account/pinned/known').to_return(status: 200, body: Oj.dump(status_json_pinned_known)) stub_request(:get, 'https://example.com/account/pinned/known').to_return(status: 200, body: Oj.dump(status_json_pinned_known), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/account/pinned/unknown-inlined').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_inlined)) stub_request(:get, 'https://example.com/account/pinned/unknown-inlined').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_inlined), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/account/pinned/unknown-unreachable').to_return(status: 404) stub_request(:get, 'https://example.com/account/pinned/unknown-unreachable').to_return(status: 404)
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_unreachable)) stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/account/collections/featured').to_return(status: 200, body: Oj.dump(featured_with_null)) stub_request(:get, 'https://example.com/account/collections/featured').to_return(status: 200, body: Oj.dump(featured_with_null), headers: { 'Content-Type': 'application/activity+json' })
subject.call(actor, note: true, hashtag: false) subject.call(actor, note: true, hashtag: false)
end end
@ -94,7 +94,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
describe '#call' do describe '#call' do
context 'when the endpoint is a Collection' do context 'when the endpoint is a Collection' do
before do before do
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it_behaves_like 'sets pinned posts' it_behaves_like 'sets pinned posts'
@ -111,10 +111,25 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
end end
before do before do
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it_behaves_like 'sets pinned posts' it_behaves_like 'sets pinned posts'
context 'when there is a single item, with the array compacted away' do
let(:items) { 'https://example.com/account/pinned/unknown-reachable' }
before do
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable), headers: { 'Content-Type': 'application/activity+json' })
subject.call(actor, note: true, hashtag: false)
end
it 'sets expected posts as pinned posts' do
expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly(
'https://example.com/account/pinned/unknown-reachable'
)
end
end
end end
context 'when the endpoint is a paginated Collection' do context 'when the endpoint is a paginated Collection' do
@ -132,10 +147,25 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
end end
before do before do
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it_behaves_like 'sets pinned posts' it_behaves_like 'sets pinned posts'
context 'when there is a single item, with the array compacted away' do
let(:items) { 'https://example.com/account/pinned/unknown-reachable' }
before do
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable), headers: { 'Content-Type': 'application/activity+json' })
subject.call(actor, note: true, hashtag: false)
end
it 'sets expected posts as pinned posts' do
expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly(
'https://example.com/account/pinned/unknown-reachable'
)
end
end
end end
end end
end end

View file

@ -38,7 +38,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
describe '#call' do describe '#call' do
context 'when the endpoint is a Collection' do context 'when the endpoint is a Collection' do
before do before do
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it_behaves_like 'sets featured tags' it_behaves_like 'sets featured tags'
@ -46,7 +46,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
context 'when the account already has featured tags' do context 'when the account already has featured tags' do
before do before do
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
actor.featured_tags.create!(name: 'FoO') actor.featured_tags.create!(name: 'FoO')
actor.featured_tags.create!(name: 'baz') actor.featured_tags.create!(name: 'baz')
@ -67,7 +67,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
end end
before do before do
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it_behaves_like 'sets featured tags' it_behaves_like 'sets featured tags'
@ -88,7 +88,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
end end
before do before do
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it_behaves_like 'sets featured tags' it_behaves_like 'sets featured tags'

View file

@ -0,0 +1,128 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::FetchReferencesService, type: :service do
subject { described_class.new.call(status, payload) }
let(:actor) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/account') }
let(:status) { Fabricate(:status, account: actor) }
let(:collection_uri) { 'http://example.com/references/1' }
let(:items) do
[
'http://example.com/self-references-1',
'http://example.com/self-references-2',
'http://example.com/self-references-3',
'http://other.com/other-references-1',
'http://other.com/other-references-2',
'http://other.com/other-references-3',
'http://example.com/self-references-4',
'http://example.com/self-references-5',
'http://example.com/self-references-6',
'http://example.com/self-references-7',
'http://example.com/self-references-8',
]
end
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
id: collection_uri,
items: items,
}.with_indifferent_access
end
describe '#call' do
context 'when the payload is a Collection with inlined replies' do
context 'when there is a single reference, with the array compacted away' do
let(:items) { 'http://example.com/self-references-1' }
it 'a item is returned' do
expect(subject).to eq ['http://example.com/self-references-1']
end
end
context 'when passing the collection itself' do
it 'first 8 items are returned' do
expect(subject).to eq items.take(8)
end
end
context 'when passing the URL to the collection' do
subject { described_class.new.call(status, collection_uri) }
before do
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end
it 'first 8 items are returned' do
expect(subject).to eq items.take(8)
end
end
end
context 'when the payload is an OrderedCollection with inlined references' do
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: collection_uri,
orderedItems: items,
}.with_indifferent_access
end
context 'when passing the collection itself' do
it 'first 8 items are returned' do
expect(subject).to eq items.take(8)
end
end
context 'when passing the URL to the collection' do
subject { described_class.new.call(status, collection_uri) }
before do
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end
it 'first 8 items are returned' do
expect(subject).to eq items.take(8)
end
end
end
context 'when the payload is a paginated Collection with inlined references' do
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
id: collection_uri,
first: {
type: 'CollectionPage',
partOf: collection_uri,
items: items,
},
}.with_indifferent_access
end
context 'when passing the collection itself' do
it 'first 8 items are returned' do
expect(subject).to eq items.take(8)
end
end
context 'when passing the URL to the collection' do
subject { described_class.new.call(status, collection_uri) }
before do
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end
it 'first 8 items are returned' do
expect(subject).to eq items.take(8)
end
end
end
end
end

View file

@ -18,7 +18,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
end end
describe '#call' do describe '#call' do
let(:account) { subject.call('https://example.com/alice', id: true) } let(:account) { subject.call('https://example.com/alice') }
shared_examples 'sets profile data' do shared_examples 'sets profile data' do
it 'returns an account' do it 'returns an account' do
@ -44,7 +44,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
before do before do
actor[:inbox] = nil actor[:inbox] = nil
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}')
end end
@ -68,7 +68,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
before do before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}')
end end
@ -95,7 +95,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
before do before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}')
@ -128,7 +128,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
before do before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}')
end end
@ -152,7 +152,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
before do before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}')

View file

@ -18,7 +18,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
end end
describe '#call' do describe '#call' do
let(:account) { subject.call('https://example.com/alice', id: true) } let(:account) { subject.call('https://example.com/alice') }
shared_examples 'sets profile data' do shared_examples 'sets profile data' do
it 'returns an account' do it 'returns an account' do
@ -44,7 +44,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
before do before do
actor[:inbox] = nil actor[:inbox] = nil
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}')
end end
@ -68,7 +68,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
before do before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}')
end end
@ -95,7 +95,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
before do before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}')
@ -128,7 +128,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
before do before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}')
end end
@ -152,7 +152,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
before do before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}')

View file

@ -50,17 +50,17 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do
end end
before do before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}')
end end
describe '#call' do describe '#call' do
let(:account) { subject.call(public_key_id, id: false) } let(:account) { subject.call(public_key_id) }
context 'when the key is a sub-object from the actor' do context 'when the key is a sub-object from the actor' do
before do before do
stub_request(:get, public_key_id).to_return(body: Oj.dump(actor)) stub_request(:get, public_key_id).to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
end end
it 'returns the expected account' do it 'returns the expected account' do
@ -72,7 +72,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do
let(:public_key_id) { 'https://example.com/alice-public-key.json' } let(:public_key_id) { 'https://example.com/alice-public-key.json' }
before do before do
stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] }))) stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] })), headers: { 'Content-Type': 'application/activity+json' })
end end
it 'returns the expected account' do it 'returns the expected account' do
@ -85,7 +85,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do
let(:actor_public_key) { 'https://example.com/alice-public-key.json' } let(:actor_public_key) { 'https://example.com/alice-public-key.json' }
before do before do
stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] }))) stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] })), headers: { 'Content-Type': 'application/activity+json' })
end end
it 'returns the nil' do it 'returns the nil' do

View file

@ -34,6 +34,18 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
describe '#call' do describe '#call' do
context 'when the payload is a Collection with inlined replies' do context 'when the payload is a Collection with inlined replies' do
context 'when there is a single reply, with the array compacted away' do
let(:items) { 'http://example.com/self-reply-1' }
it 'queues the expected worker' do
allow(FetchReplyWorker).to receive(:push_bulk)
subject.call(status, payload)
expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1'])
end
end
context 'when passing the collection itself' do context 'when passing the collection itself' do
it 'spawns workers for up to 5 replies on the same server' do it 'spawns workers for up to 5 replies on the same server' do
allow(FetchReplyWorker).to receive(:push_bulk) allow(FetchReplyWorker).to receive(:push_bulk)
@ -46,7 +58,7 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
context 'when passing the URL to the collection' do context 'when passing the URL to the collection' do
before do before do
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it 'spawns workers for up to 5 replies on the same server' do it 'spawns workers for up to 5 replies on the same server' do
@ -81,7 +93,7 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
context 'when passing the URL to the collection' do context 'when passing the URL to the collection' do
before do before do
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it 'spawns workers for up to 5 replies on the same server' do it 'spawns workers for up to 5 replies on the same server' do
@ -120,7 +132,7 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
context 'when passing the URL to the collection' do context 'when passing the URL to the collection' do
before do before do
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it 'spawns workers for up to 5 replies on the same server' do it 'spawns workers for up to 5 replies on the same server' do

View file

@ -282,6 +282,33 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do
end end
end end
context 'when account is using note contains ng words' do
subject { described_class.new.call(account.username, account.domain, payload) }
let!(:account) { Fabricate(:account, username: 'alice', domain: 'example.com') }
let(:payload) do
{
id: 'https://foo.test',
type: 'Actor',
inbox: 'https://foo.test/inbox',
name: 'Ohagi',
}.with_indifferent_access
end
it 'creates account when ng word is not set' do
Setting.ng_words = ['Amazon']
subject
expect(account.reload.display_name).to eq 'Ohagi'
end
it 'does not create account when ng word is set' do
Setting.ng_words = ['Ohagi']
subject
expect(account.reload.display_name).to_not eq 'Ohagi'
end
end
context 'when account is not suspended' do context 'when account is not suspended' do
subject { described_class.new.call(account.username, account.domain, payload) } subject { described_class.new.call(account.username, account.domain, payload) }

View file

@ -60,7 +60,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do
describe '#call' do describe '#call' do
context 'when the endpoint is a Collection of actor URIs' do context 'when the endpoint is a Collection of actor URIs' do
before do before do
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it_behaves_like 'synchronizes followers' it_behaves_like 'synchronizes followers'
@ -77,7 +77,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do
end end
before do before do
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it_behaves_like 'synchronizes followers' it_behaves_like 'synchronizes followers'
@ -98,7 +98,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do
end end
before do before do
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end end
it_behaves_like 'synchronizes followers' it_behaves_like 'synchronizes followers'

View file

@ -7,6 +7,7 @@ RSpec.describe FetchLinkCardService, type: :service do
let(:html) { '<!doctype html><title>Hello world</title>' } let(:html) { '<!doctype html><title>Hello world</title>' }
let(:oembed_cache) { nil } let(:oembed_cache) { nil }
let(:custom_before) { false }
before do before do
stub_request(:get, 'http://example.com/html').to_return(headers: { 'Content-Type' => 'text/html' }, body: html) stub_request(:get, 'http://example.com/html').to_return(headers: { 'Content-Type' => 'text/html' }, body: html)
@ -30,7 +31,7 @@ RSpec.describe FetchLinkCardService, type: :service do
Rails.cache.write('oembed_endpoint:example.com', oembed_cache) if oembed_cache Rails.cache.write('oembed_endpoint:example.com', oembed_cache) if oembed_cache
subject.call(status) subject.call(status) unless custom_before
end end
context 'with a local status' do context 'with a local status' do
@ -236,32 +237,53 @@ RSpec.describe FetchLinkCardService, type: :service do
end end
end end
context 'with URL of reference' do context 'with URI of reference and normal page' do
let(:status) { Fabricate(:status, text: 'RT http://example.com/html') }
it 'creates preview card' do
expect(status.preview_card).to be_nil
end
end
context 'with URL of reference and normal page' do
let(:status) { Fabricate(:status, text: 'RT http://example.com/text http://example.com/html') } let(:status) { Fabricate(:status, text: 'RT http://example.com/text http://example.com/html') }
let(:custom_before) { true }
before { Fabricate(:status, uri: 'http://example.com/text') }
it 'creates preview card' do it 'creates preview card' do
subject.call(status)
expect(status.preview_card).to_not be_nil expect(status.preview_card).to_not be_nil
expect(status.preview_card.url).to eq 'http://example.com/html' expect(status.preview_card.url).to eq 'http://example.com/html'
expect(status.preview_card.title).to eq 'Hello world' expect(status.preview_card.title).to eq 'Hello world'
end end
end end
context 'with URL but author is not allow preview card' do context 'with URI of reference' do
let(:account) { Fabricate(:user, settings: { link_preview: false }).account } let(:status) { Fabricate(:status, text: 'RT http://example.com/text') }
let(:status) { Fabricate(:status, text: 'http://example.com/html', account: account) } let(:custom_before) { true }
it 'not create preview card' do before { Fabricate(:status, uri: 'http://example.com/text') }
it 'does not create preview card' do
subject.call(status)
expect(status.preview_card).to be_nil expect(status.preview_card).to be_nil
end end
end end
context 'with URL of reference' do
let(:status) { Fabricate(:status, text: 'RT http://example.com/text') }
let(:custom_before) { true }
before { Fabricate(:status, uri: 'http://example.com/text/activity', url: 'http://example.com/text') }
it 'does not create preview card' do
subject.call(status)
expect(status.preview_card).to be_nil
end
end
context 'with reference normal URL' do
let(:status) { Fabricate(:status, text: 'RT http://example.com/html') }
it 'creates preview card' do
expect(status.preview_card).to_not be_nil
expect(status.preview_card.url).to eq 'http://example.com/html'
expect(status.preview_card.title).to eq 'Hello world'
end
end
end end
context 'with a remote status' do context 'with a remote status' do
@ -282,14 +304,6 @@ RSpec.describe FetchLinkCardService, type: :service do
it 'ignores URLs to hashtags' do it 'ignores URLs to hashtags' do
expect(a_request(:get, 'https://quitter.se/tag/wannacry')).to_not have_been_made expect(a_request(:get, 'https://quitter.se/tag/wannacry')).to_not have_been_made
end end
context 'with URL but author is not allow preview card' do
let(:account) { Fabricate(:account, domain: 'example.com', settings: { link_preview: false }) }
it 'not create link preview' do
expect(status.preview_card).to be_nil
end
end
end end
context 'with a remote status of reference' do context 'with a remote status of reference' do
@ -298,8 +312,12 @@ RSpec.describe FetchLinkCardService, type: :service do
RT <a href="http://example.com/html" target="_blank" rel="noopener noreferrer" title="http://example.com/html">Hello</a>&nbsp; RT <a href="http://example.com/html" target="_blank" rel="noopener noreferrer" title="http://example.com/html">Hello</a>&nbsp;
TEXT TEXT
end end
let(:custom_before) { true }
before { Fabricate(:status, uri: 'http://example.com/html') }
it 'creates preview card' do it 'creates preview card' do
subject.call(status)
expect(status.preview_card).to be_nil expect(status.preview_card).to be_nil
end end
end end
@ -311,8 +329,12 @@ RSpec.describe FetchLinkCardService, type: :service do
<a href="http://example.com/html_sub" target="_blank" rel="noopener noreferrer" title="http://example.com/html_sub">Hello</a>&nbsp; <a href="http://example.com/html_sub" target="_blank" rel="noopener noreferrer" title="http://example.com/html_sub">Hello</a>&nbsp;
TEXT TEXT
end end
let(:custom_before) { true }
before { Fabricate(:status, uri: 'http://example.com/html') }
it 'creates preview card' do it 'creates preview card' do
subject.call(status)
expect(status.preview_card).to_not be_nil expect(status.preview_card).to_not be_nil
expect(status.preview_card.url).to eq 'http://example.com/html_sub' expect(status.preview_card.url).to eq 'http://example.com/html_sub'
expect(status.preview_card.title).to eq 'Hello world' expect(status.preview_card.title).to eq 'Hello world'

View file

@ -57,7 +57,7 @@ RSpec.describe FetchResourceService, type: :service do
let(:json) do let(:json) do
{ {
id: 1, id: 'http://example.com/foo',
'@context': ActivityPub::TagManager::CONTEXT, '@context': ActivityPub::TagManager::CONTEXT,
type: 'Note', type: 'Note',
}.to_json }.to_json
@ -83,27 +83,27 @@ RSpec.describe FetchResourceService, type: :service do
let(:content_type) { 'application/activity+json; charset=utf-8' } let(:content_type) { 'application/activity+json; charset=utf-8' }
let(:body) { json } let(:body) { json }
it { is_expected.to eq [1, { prefetched_body: body, id: true }] } it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
end end
context 'when content type is ld+json with profile' do context 'when content type is ld+json with profile' do
let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' } let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }
let(:body) { json } let(:body) { json }
it { is_expected.to eq [1, { prefetched_body: body, id: true }] } it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
end end
context 'when link header is present' do context 'when link header is present' do
let(:headers) { { 'Link' => '<http://example.com/foo>; rel="alternate"; type="application/activity+json"' } } let(:headers) { { 'Link' => '<http://example.com/foo>; rel="alternate"; type="application/activity+json"' } }
it { is_expected.to eq [1, { prefetched_body: json, id: true }] } it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
end end
context 'when content type is text/html' do context 'when content type is text/html' do
let(:content_type) { 'text/html' } let(:content_type) { 'text/html' }
let(:body) { '<html><head><link rel="alternate" href="http://example.com/foo" type="application/activity+json"/></head></html>' } let(:body) { '<html><head><link rel="alternate" href="http://example.com/foo" type="application/activity+json"/></head></html>' }
it { is_expected.to eq [1, { prefetched_body: json, id: true }] } it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
end end
end end
end end

View file

@ -192,21 +192,50 @@ RSpec.describe PostStatusService, type: :service do
expect(mention_service).to have_received(:call).with(status, limited_type: '', circle: nil, save_records: false) expect(mention_service).to have_received(:call).with(status, limited_type: '', circle: nil, save_records: false)
end end
it 'mutual visibility' do context 'with mutual visibility' do
account = Fabricate(:account) let(:sender) { Fabricate(:user).account }
mutual_account = Fabricate(:account) let(:io_account) { Fabricate(:account, domain: 'misskey.io', uri: 'https://misskey.io/actor', inbox_url: 'https://misskey.io/inbox') }
other_account = Fabricate(:account) let(:local_account) { Fabricate(:account) }
text = 'This is an English text.' let(:remote_account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor', inbox_url: 'https://example.com/inbox') }
let(:follower) { Fabricate(:account) }
let(:followee) { Fabricate(:account) }
mutual_account.follow!(account) before do
account.follow!(mutual_account) stub_request(:post, 'https://misskey.io/inbox').to_return(status: 200)
other_account.follow!(account) stub_request(:post, 'https://example.com/inbox').to_return(status: 200)
status = subject.call(account, text: text, visibility: 'mutual') Fabricate(:instance_info, domain: 'misskey.io', software: 'misskey')
io_account.follow!(sender)
local_account.follow!(sender)
remote_account.follow!(sender)
follower.follow!(sender)
sender.follow!(io_account)
sender.follow!(local_account)
sender.follow!(remote_account)
sender.follow!(followee)
end
expect(status.visibility).to eq 'limited' it 'visibility is set' do
expect(status.limited_scope).to eq 'mutual' status = subject.call(sender, text: 'text', visibility: 'mutual')
expect(status.mentioned_accounts.count).to eq 1
expect(status.mentioned_accounts.first.id).to eq mutual_account.id expect(status.visibility).to eq 'limited'
expect(status.limited_scope).to eq 'mutual'
end
it 'sent to mutuals' do
status = subject.call(sender, text: 'text', visibility: 'mutual')
expect(status.mentioned_accounts.count).to eq 3
expect(status.mentioned_accounts.pluck(:id)).to contain_exactly(io_account.id, local_account.id, remote_account.id)
end
it 'sent to mutuals without misskey.io users' do
sender.user.update!(settings: { reject_send_limited_to_suspects: true })
status = subject.call(sender, text: 'text', visibility: 'mutual')
expect(status.mentioned_accounts.count).to eq 2
expect(status.mentioned_accounts.pluck(:id)).to contain_exactly(local_account.id, remote_account.id)
end
end end
it 'limited visibility and direct searchability' do it 'limited visibility and direct searchability' do

View file

@ -86,9 +86,5 @@ RSpec.describe ReblogService, type: :service do
it 'distributes to followers' do it 'distributes to followers' do
expect(ActivityPub::DistributionWorker).to have_received(:perform_async) expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
end end
it 'sends an announce activity to the author' do
expect(a_request(:post, bob.inbox_url)).to have_been_made.once
end
end end
end end

View file

@ -160,4 +160,22 @@ RSpec.describe RemoveStatusService, type: :service do
)).to have_been_made.once )).to have_been_made.once
end end
end end
context 'when removed status is a reblog of a non-follower' do
let!(:original_status) { Fabricate(:status, account: bill, text: 'Hello ThisIsASecret', visibility: :public) }
let!(:status) { ReblogService.new.call(alice, original_status) }
it 'sends Undo activity to followers' do
subject.call(status)
expect(a_request(:post, bill.shared_inbox_url).with(
body: hash_including({
'type' => 'Undo',
'object' => hash_including({
'type' => 'Announce',
'object' => ActivityPub::TagManager.instance.uri_for(original_status),
}),
})
)).to have_been_made.once
end
end
end end

View file

@ -139,6 +139,7 @@ describe ResolveURLService, type: :service do
stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url }) stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url })
body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json
stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' }) stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
stub_request(:get, uri).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
end end
it 'returns status by url' do it 'returns status by url' do

View file

@ -21,7 +21,7 @@ describe ActivityPub::FetchRepliesWorker do
describe 'perform' do describe 'perform' do
it 'performs a request if the collection URI is from the same host' do it 'performs a request if the collection URI is from the same host' do
stub_request(:get, 'https://example.com/statuses_replies/1').to_return(status: 200, body: json) stub_request(:get, 'https://example.com/statuses_replies/1').to_return(status: 200, body: json, headers: { 'Content-Type': 'application/activity+json' })
subject.perform(status.id, 'https://example.com/statuses_replies/1') subject.perform(status.id, 'https://example.com/statuses_replies/1')
expect(a_request(:get, 'https://example.com/statuses_replies/1')).to have_been_made.once expect(a_request(:get, 'https://example.com/statuses_replies/1')).to have_been_made.once
end end