Compare commits
67 commits
chinwag-4.
...
master
Author | SHA1 | Date | |
---|---|---|---|
Mike Barnes | b7ffe81568 | ||
1cad857f14 | |||
95ebcff98e | |||
d770b61a74 | |||
020228ddba | |||
e292a28933 | |||
ba240cea0c | |||
257f9abd56 | |||
b4e3a789b1 | |||
b39fbe7c83 | |||
c717b7da99 | |||
13bbcdf4d4 | |||
3aec33f5a2 | |||
984d7d3dc8 | |||
33a50884e5 | |||
70c4d70dbe | |||
a6089cdfca | |||
5973d7a4b6 | |||
ba5551fd1d | |||
8ce403a85b | |||
3ff575f54c | |||
affbb10566 | |||
209632a0fd | |||
079d3e5189 | |||
57b72cccc4 | |||
37adb144db | |||
142dd34b68 | |||
c2d8666bbf | |||
d3c4441af8 | |||
f0541adbd4 | |||
3fecb36739 | |||
c7312411b8 | |||
2fc87611be | |||
1629ac4c81 | |||
54ae3d5ca5 | |||
Mike Barnes | 372e262c4d | ||
b7b03e8d26 | |||
a07fff079b | |||
6f29d50aa5 | |||
9e5af6bb58 | |||
6499850ac4 | |||
6f36b633a7 | |||
d807b3960e | |||
2f6518cae2 | |||
cdbe2855f3 | |||
fdde3cdb4e | |||
ce9c641d9a | |||
Mike Barnes | 1064e395cb | ||
5799bc4af7 | |||
Mike Barnes | bd7ce0d5f9 | ||
fc4e2eca9f | |||
2e8943aecd | |||
e6072a8d13 | |||
460e4fbdd6 | |||
de60322711 | |||
90bb870680 | |||
9292d998fe | |||
92643f48de | |||
458620bdd4 | |||
a1a71263e0 | |||
4c5575e8e0 | |||
a2ddd849e2 | |||
2e4d43933d | |||
Mike Barnes | 52d2fb8560 | ||
Mike Barnes | 30367cc957 | ||
89f98f4b63 | |||
481e1d4e0e |
153
.github/workflows/test-ruby.yml
vendored
Normal file
153
.github/workflows/test-ruby.yml
vendored
Normal file
|
@ -0,0 +1,153 @@
|
|||
name: Ruby Testing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'dependabot/**'
|
||||
- 'renovate/**'
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
BUNDLE_CLEAN: true
|
||||
BUNDLE_FROZEN: true
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
mode:
|
||||
- production
|
||||
- test
|
||||
env:
|
||||
RAILS_ENV: ${{ matrix.mode }}
|
||||
BUNDLE_WITH: ${{ matrix.mode }}
|
||||
OTP_SECRET: precompile_placeholder
|
||||
SECRET_KEY_BASE: precompile_placeholder
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: yarn
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install native Ruby dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libicu-dev libidn11-dev
|
||||
|
||||
- name: Set up bundler cache
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- run: yarn --frozen-lockfile --production
|
||||
- name: Precompile assets
|
||||
# Previously had set this, but it's not supported
|
||||
# export NODE_OPTIONS=--openssl-legacy-provider
|
||||
run: |-
|
||||
./bin/rails assets:precompile
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: matrix.mode == 'test'
|
||||
with:
|
||||
path: |-
|
||||
./public/assets
|
||||
./public/packs-test
|
||||
name: ${{ github.sha }}
|
||||
retention-days: 0
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs:
|
||||
- build
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
env:
|
||||
DB_HOST: localhost
|
||||
DB_USER: postgres
|
||||
DB_PASS: postgres
|
||||
DISABLE_SIMPLECOV: true
|
||||
RAILS_ENV: test
|
||||
ALLOW_NOPAM: true
|
||||
PAM_ENABLED: true
|
||||
PAM_DEFAULT_SERVICE: pam_test
|
||||
PAM_CONTROLLED_SERVICE: pam_test_controlled
|
||||
OIDC_ENABLED: true
|
||||
OIDC_SCOPE: read
|
||||
SAML_ENABLED: true
|
||||
CAS_ENABLED: true
|
||||
BUNDLE_WITH: 'pam_authentication test'
|
||||
CI_JOBS: ${{ matrix.ci_job }}/4
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
ruby-version:
|
||||
- '.ruby-version'
|
||||
ci_job:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: './public'
|
||||
name: ${{ github.sha }}
|
||||
|
||||
- name: Update package index
|
||||
run: sudo apt-get update
|
||||
|
||||
- name: Install native Ruby dependencies
|
||||
run: sudo apt-get install -y libicu-dev libidn11-dev
|
||||
|
||||
- name: Install additional system dependencies
|
||||
run: sudo apt-get install -y ffmpeg imagemagick libpam-dev
|
||||
|
||||
- name: Set up bundler cache
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: ${{ matrix.ruby-version}}
|
||||
bundler-cache: true
|
||||
|
||||
- name: Load database schema
|
||||
run: './bin/rails db:create db:schema:load db:seed'
|
||||
|
||||
- run: bin/rspec
|
106
CHANGELOG.md
106
CHANGELOG.md
|
@ -3,6 +3,112 @@ Changelog
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.1.17] - 2024-05-30
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
- Fix private mention filtering ([GHSA-5fq7-3p3j-9vrf](https://github.com/mastodon/mastodon/security/advisories/GHSA-5fq7-3p3j-9vrf))
|
||||
- Fix password change endpoint not being rate-limited ([GHSA-q3rg-xx5v-4mxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-q3rg-xx5v-4mxh))
|
||||
- Add hardening around rate-limit bypass ([GHSA-c2r5-cfqr-c553](https://github.com/mastodon/mastodon/security/advisories/GHSA-c2r5-cfqr-c553))
|
||||
|
||||
### Added
|
||||
|
||||
- Add fallback redirection when getting a webfinger query `WEB_DOMAIN@WEB_DOMAIN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28592))
|
||||
- Add `digest` attribute to `Admin::DomainBlock` entity in REST API ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/29092))
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove superfluous application-level caching in some controllers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29862))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix leaking Elasticsearch connections in Sidekiq processes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30450))
|
||||
- Fix language of remote posts not being recognized when using unusual casing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30403))
|
||||
- Fix off-by-one in `tootctl media` commands ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30306))
|
||||
- Fix removal of allowed domains (in `LIMITED_FEDERATION_MODE`) not being recorded in the audit log ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/30125))
|
||||
- Fix not being able to block a subdomain of an already-blocked domain through the API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30119))
|
||||
- Fix `Idempotency-Key` being ignored when scheduling a post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30084))
|
||||
- Fix crash when supplying the `FFMPEG_BINARY` environment variable ([timothyjrogers](https://github.com/mastodon/mastodon/pull/30022))
|
||||
- Fix improper email address validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29838))
|
||||
- Fix results/query in `api/v1/featured_tags/suggestions` ([mjankowski](https://github.com/mastodon/mastodon/pull/29597))
|
||||
- Fix unblocking internationalized domain names under certain conditions ([tribela](https://github.com/mastodon/mastodon/pull/29530))
|
||||
- Fix admin account created by `mastodon:setup` not being auto-approved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29379))
|
||||
- Fix reference to non-existent var in CLI maintenance command ([mjankowski](https://github.com/mastodon/mastodon/pull/28363))
|
||||
|
||||
## [4.1.16] - 2024-02-23
|
||||
|
||||
### Added
|
||||
|
||||
- Add hourly task to automatically require approval for new registrations in the absence of moderators ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29318), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/29355))
|
||||
In order to prevent future abandoned Mastodon servers from being used for spam, harassment and other malicious activity, Mastodon will now automatically switch new user registrations to require moderator approval whenever they are left open and no activity (including non-moderation actions from apps) from any logged-in user with permission to access moderation reports has been detected in a full week.
|
||||
When this happens, users with the permission to change server settings will receive an email notification.
|
||||
This feature is disabled when `EMAIL_DOMAIN_ALLOWLIST` is used, and can also be disabled with `DISABLE_AUTOMATIC_SWITCHING_TO_APPROVED_REGISTRATIONS=true`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Change registrations to be closed by default on new installations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29280))
|
||||
If you are running a server and never changed your registrations mode from the default, updating will automatically close your registrations.
|
||||
Simply re-enable them through the administration interface or using `tootctl settings registrations open` if you want to enable them again.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix processing of remote ActivityPub actors making use of `Link` objects as `Image` `url` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29335))
|
||||
- Fix link verifications when page size exceeds 1MB ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29358))
|
||||
|
||||
## [4.1.15] - 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))
|
||||
|
||||
### Security
|
||||
|
||||
- Fix insufficient checking of remote posts ([GHSA-jhrq-qvrm-qr36](https://github.com/mastodon/mastodon/security/advisories/GHSA-jhrq-qvrm-qr36))
|
||||
|
||||
## [4.1.14] - 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.1.13] - 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.1.12] - 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 `Undo Announce` activity not being sent to non-follower authors ([MitarashiDango](https://github.com/mastodon/mastodon/pull/18482))
|
||||
- Fix `LinkCrawlWorker` error when encountering empty OEmbed response ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28268))
|
||||
|
||||
### Security
|
||||
|
||||
- Add rate-limit of TOTP authentication attempts at controller level ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28801))
|
||||
|
||||
## [4.1.11] - 2023-12-04
|
||||
|
||||
### Changed
|
||||
|
|
1
Gemfile
1
Gemfile
|
@ -158,3 +158,4 @@ gem 'concurrent-ruby', require: false
|
|||
gem 'connection_pool', require: false
|
||||
gem 'xorcist', '~> 1.1'
|
||||
gem 'cocoon', '~> 1.2'
|
||||
gem 'mail', '~> 2.8'
|
||||
|
|
141
Gemfile.lock
141
Gemfile.lock
|
@ -10,40 +10,40 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (6.1.7.6)
|
||||
actionpack (= 6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
actioncable (6.1.7.7)
|
||||
actionpack (= 6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (6.1.7.6)
|
||||
actionpack (= 6.1.7.6)
|
||||
activejob (= 6.1.7.6)
|
||||
activerecord (= 6.1.7.6)
|
||||
activestorage (= 6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
actionmailbox (6.1.7.7)
|
||||
actionpack (= 6.1.7.7)
|
||||
activejob (= 6.1.7.7)
|
||||
activerecord (= 6.1.7.7)
|
||||
activestorage (= 6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
mail (>= 2.7.1)
|
||||
actionmailer (6.1.7.6)
|
||||
actionpack (= 6.1.7.6)
|
||||
actionview (= 6.1.7.6)
|
||||
activejob (= 6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
actionmailer (6.1.7.7)
|
||||
actionpack (= 6.1.7.7)
|
||||
actionview (= 6.1.7.7)
|
||||
activejob (= 6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.1.7.6)
|
||||
actionview (= 6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
actionpack (6.1.7.7)
|
||||
actionview (= 6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
rack (~> 2.0, >= 2.0.9)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (6.1.7.6)
|
||||
actionpack (= 6.1.7.6)
|
||||
activerecord (= 6.1.7.6)
|
||||
activestorage (= 6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
actiontext (6.1.7.7)
|
||||
actionpack (= 6.1.7.7)
|
||||
activerecord (= 6.1.7.7)
|
||||
activestorage (= 6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
actionview (6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
|
@ -54,22 +54,22 @@ GEM
|
|||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
activejob (6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
activerecord (6.1.7.6)
|
||||
activemodel (= 6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
activestorage (6.1.7.6)
|
||||
actionpack (= 6.1.7.6)
|
||||
activejob (= 6.1.7.6)
|
||||
activerecord (= 6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
activemodel (6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
activerecord (6.1.7.7)
|
||||
activemodel (= 6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
activestorage (6.1.7.7)
|
||||
actionpack (= 6.1.7.7)
|
||||
activejob (= 6.1.7.7)
|
||||
activerecord (= 6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (6.1.7.6)
|
||||
activesupport (6.1.7.7)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
|
@ -173,7 +173,7 @@ GEM
|
|||
cocoon (1.2.15)
|
||||
coderay (1.1.3)
|
||||
color_diff (0.1)
|
||||
concurrent-ruby (1.2.2)
|
||||
concurrent-ruby (1.2.3)
|
||||
connection_pool (2.3.0)
|
||||
cose (1.2.1)
|
||||
cbor (~> 0.5.9)
|
||||
|
@ -183,7 +183,7 @@ GEM
|
|||
crass (1.0.6)
|
||||
css_parser (1.12.0)
|
||||
addressable
|
||||
date (3.3.3)
|
||||
date (3.3.4)
|
||||
debug_inspector (1.0.0)
|
||||
devise (4.8.1)
|
||||
bcrypt (~> 3.0)
|
||||
|
@ -330,7 +330,7 @@ GEM
|
|||
jmespath (1.6.2)
|
||||
json (2.6.3)
|
||||
json-canonicalization (0.3.0)
|
||||
json-jwt (1.15.3)
|
||||
json-jwt (1.15.3.1)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
bindata
|
||||
|
@ -405,7 +405,7 @@ GEM
|
|||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2022.0105)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.4)
|
||||
mini_portile2 (2.8.5)
|
||||
minitest (5.17.0)
|
||||
msgpack (1.6.0)
|
||||
multi_json (1.15.0)
|
||||
|
@ -416,16 +416,16 @@ GEM
|
|||
net-ldap (0.17.1)
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.1)
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-scp (4.0.0.rc1)
|
||||
net-ssh (>= 2.6.5, < 8.0.0)
|
||||
net-smtp (0.3.3)
|
||||
net-smtp (0.3.4)
|
||||
net-protocol
|
||||
net-ssh (7.0.1)
|
||||
nio4r (2.5.9)
|
||||
nokogiri (1.14.5)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
nokogiri (1.16.5)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nsa (0.2.8)
|
||||
activesupport (>= 4.2, < 7)
|
||||
|
@ -468,7 +468,7 @@ GEM
|
|||
parslet (2.0.0)
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.4.5)
|
||||
pg (1.4.6)
|
||||
pghero (3.1.0)
|
||||
activerecord (>= 6)
|
||||
pkg-config (1.5.1)
|
||||
|
@ -491,13 +491,13 @@ GEM
|
|||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (5.0.1)
|
||||
puma (5.6.7)
|
||||
puma (5.6.8)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.6.2)
|
||||
rack (2.2.8)
|
||||
racc (1.7.3)
|
||||
rack (2.2.8.1)
|
||||
rack-attack (6.6.1)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-cors (1.1.1)
|
||||
|
@ -512,20 +512,20 @@ GEM
|
|||
rack
|
||||
rack-test (2.0.2)
|
||||
rack (>= 1.3)
|
||||
rails (6.1.7.6)
|
||||
actioncable (= 6.1.7.6)
|
||||
actionmailbox (= 6.1.7.6)
|
||||
actionmailer (= 6.1.7.6)
|
||||
actionpack (= 6.1.7.6)
|
||||
actiontext (= 6.1.7.6)
|
||||
actionview (= 6.1.7.6)
|
||||
activejob (= 6.1.7.6)
|
||||
activemodel (= 6.1.7.6)
|
||||
activerecord (= 6.1.7.6)
|
||||
activestorage (= 6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
rails (6.1.7.7)
|
||||
actioncable (= 6.1.7.7)
|
||||
actionmailbox (= 6.1.7.7)
|
||||
actionmailer (= 6.1.7.7)
|
||||
actionpack (= 6.1.7.7)
|
||||
actiontext (= 6.1.7.7)
|
||||
actionview (= 6.1.7.7)
|
||||
activejob (= 6.1.7.7)
|
||||
activemodel (= 6.1.7.7)
|
||||
activerecord (= 6.1.7.7)
|
||||
activestorage (= 6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 6.1.7.6)
|
||||
railties (= 6.1.7.7)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
|
@ -541,9 +541,9 @@ GEM
|
|||
railties (>= 6.0.0, < 7)
|
||||
rails-settings-cached (0.6.6)
|
||||
rails (>= 4.2.0)
|
||||
railties (6.1.7.6)
|
||||
actionpack (= 6.1.7.6)
|
||||
activesupport (= 6.1.7.6)
|
||||
railties (6.1.7.7)
|
||||
actionpack (= 6.1.7.7)
|
||||
activesupport (= 6.1.7.7)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
|
@ -565,7 +565,8 @@ GEM
|
|||
responders (3.0.1)
|
||||
actionpack (>= 5.0)
|
||||
railties (>= 5.0)
|
||||
rexml (3.2.5)
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rotp (6.2.0)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (2.1.2)
|
||||
|
@ -634,7 +635,7 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
semantic_range (3.0.0)
|
||||
sidekiq (6.5.11)
|
||||
sidekiq (6.5.12)
|
||||
connection_pool (>= 2.2.5, < 3)
|
||||
rack (~> 2.0)
|
||||
redis (>= 4.5.0, < 5)
|
||||
|
@ -645,7 +646,7 @@ GEM
|
|||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 4, < 7)
|
||||
tilt (>= 1.4.0)
|
||||
sidekiq-unique-jobs (7.1.29)
|
||||
sidekiq-unique-jobs (7.1.33)
|
||||
brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
redis (< 5.0)
|
||||
|
@ -679,6 +680,7 @@ GEM
|
|||
redlock (~> 1.0)
|
||||
strong_migrations (0.7.9)
|
||||
activerecord (>= 5)
|
||||
strscan (3.1.0)
|
||||
swd (1.3.0)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
|
@ -753,7 +755,7 @@ GEM
|
|||
xorcist (1.1.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.12)
|
||||
zeitwerk (2.6.13)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
@ -816,6 +818,7 @@ DEPENDENCIES
|
|||
letter_opener_web (~> 2.0)
|
||||
link_header (~> 0.0)
|
||||
lograge (~> 0.12)
|
||||
mail (~> 2.8)
|
||||
makara (~> 0.5)
|
||||
mario-redis-lock (~> 1.2)
|
||||
memory_profiler
|
||||
|
|
|
@ -14,6 +14,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
|||
| ------- | ---------------- |
|
||||
| 4.2.x | Yes |
|
||||
| 4.1.x | Yes |
|
||||
| 4.0.x | No |
|
||||
| 3.5.x | Until 2023-12-31 |
|
||||
| < 3.5 | No |
|
||||
| < 4.1 | No |
|
||||
|
|
|
@ -25,6 +25,8 @@ class Admin::DomainAllowsController < Admin::BaseController
|
|||
def destroy
|
||||
authorize @domain_allow, :destroy?
|
||||
UnallowDomainService.new.call(@domain_allow)
|
||||
log_action :destroy, @domain_allow
|
||||
|
||||
redirect_to admin_instances_path, notice: I18n.t('admin.domain_allows.destroyed_msg')
|
||||
end
|
||||
|
||||
|
|
|
@ -19,10 +19,11 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
|
|||
def create
|
||||
authorize :domain_block, :create?
|
||||
|
||||
@domain_block = DomainBlock.new(resource_params)
|
||||
existing_domain_block = resource_params[:domain].present? ? DomainBlock.rule_for(resource_params[:domain]) : nil
|
||||
return render json: existing_domain_block, serializer: REST::Admin::ExistingDomainBlockErrorSerializer, status: 422 if existing_domain_block.present?
|
||||
return render json: existing_domain_block, serializer: REST::Admin::ExistingDomainBlockErrorSerializer, status: 422 if conflicts_with_existing_block?(@domain_block, existing_domain_block)
|
||||
|
||||
@domain_block = DomainBlock.create!(resource_params)
|
||||
@domain_block.save!
|
||||
DomainBlockWorker.perform_async(@domain_block.id)
|
||||
log_action :create, @domain_block
|
||||
render json: @domain_block, serializer: REST::Admin::DomainBlockSerializer
|
||||
|
@ -55,6 +56,10 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
|
|||
|
||||
private
|
||||
|
||||
def conflicts_with_existing_block?(domain_block, existing_domain_block)
|
||||
existing_domain_block.present? && (existing_domain_block.domain == TagManager.instance.normalize_domain(domain_block.domain) || !domain_block.stricter_than?(existing_domain_block))
|
||||
end
|
||||
|
||||
def set_domain_blocks
|
||||
@domain_blocks = filtered_domain_blocks.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
|
||||
end
|
||||
|
|
|
@ -12,6 +12,10 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController
|
|||
private
|
||||
|
||||
def set_recently_used_tags
|
||||
@recently_used_tags = Tag.recently_used(current_account).where.not(id: current_account.featured_tags).limit(10)
|
||||
@recently_used_tags = Tag.recently_used(current_account).where.not(id: featured_tag_ids).limit(10)
|
||||
end
|
||||
|
||||
def featured_tag_ids
|
||||
current_account.featured_tags.pluck(:tag_id)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
class Api::V1::StreamingController < Api::BaseController
|
||||
def index
|
||||
if Rails.configuration.x.streaming_api_base_url == request.host
|
||||
if same_host?
|
||||
not_found
|
||||
else
|
||||
redirect_to streaming_api_url, status: 301
|
||||
|
@ -11,9 +11,16 @@ class Api::V1::StreamingController < Api::BaseController
|
|||
|
||||
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
|
||||
Addressable::URI.parse(request.url).tap do |uri|
|
||||
uri.host = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url).host
|
||||
base_url = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url)
|
||||
uri.host = base_url.host
|
||||
uri.port = base_url.port
|
||||
end.to_s
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,7 +5,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
|
||||
def self.provides_callback_for(provider)
|
||||
define_method provider do
|
||||
@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?
|
||||
LoginActivity.create(
|
||||
|
@ -24,6 +24,9 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
session["devise.#{provider}_data"] = request.env['omniauth.auth']
|
||||
redirect_to new_user_registration_url
|
||||
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
|
||||
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Auth::SessionsController < Devise::SessionsController
|
||||
include Redisable
|
||||
|
||||
MAX_2FA_ATTEMPTS_PER_HOUR = 10
|
||||
|
||||
layout 'auth'
|
||||
|
||||
skip_before_action :require_no_authentication, only: [:create]
|
||||
|
@ -136,9 +140,23 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
session.delete(:attempt_user_updated_at)
|
||||
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)
|
||||
@on_authentication_success_called = true
|
||||
|
||||
clear_2fa_attempt_from_user(user)
|
||||
clear_attempt_from_session
|
||||
|
||||
user.update_sign_in!(new_sign_in: true)
|
||||
|
@ -170,4 +188,8 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
user_agent: request.user_agent
|
||||
)
|
||||
end
|
||||
|
||||
def second_factor_attempts_key(user)
|
||||
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -28,29 +28,19 @@ module CacheConcern
|
|||
response.headers['Vary'] = public_fetch_mode? ? 'Accept' : 'Accept, Signature'
|
||||
end
|
||||
|
||||
# TODO: Rename this method, as it does not perform any caching anymore.
|
||||
def cache_collection(raw, klass)
|
||||
return raw unless klass.respond_to?(:with_includes)
|
||||
return raw unless klass.respond_to?(:preload_cacheable_associations)
|
||||
|
||||
raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation)
|
||||
return [] if raw.empty?
|
||||
records = raw.to_a
|
||||
|
||||
cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id)
|
||||
uncached_ids = raw.map(&:id) - cached_keys_with_value.keys
|
||||
klass.preload_cacheable_associations(records)
|
||||
|
||||
klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!)
|
||||
|
||||
unless uncached_ids.empty?
|
||||
uncached = klass.where(id: uncached_ids).with_includes.index_by(&:id)
|
||||
|
||||
uncached.each_value do |item|
|
||||
Rails.cache.write(item, item)
|
||||
end
|
||||
end
|
||||
|
||||
raw.filter_map { |item| cached_keys_with_value[item.id] || uncached[item.id] }
|
||||
records
|
||||
end
|
||||
|
||||
# TODO: Rename this method, as it does not perform any caching anymore.
|
||||
def cache_collection_paginated_by_id(raw, klass, limit, options)
|
||||
cache_collection raw.cache_ids.to_a_paginated_by_id(limit, options), klass
|
||||
cache_collection raw.to_a_paginated_by_id(limit, options), klass
|
||||
end
|
||||
end
|
||||
|
|
|
@ -91,14 +91,23 @@ module SignatureVerification
|
|||
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
||||
|
||||
signature = Base64.decode64(signature_params['signature'])
|
||||
compare_signed_string = build_signed_string
|
||||
compare_signed_string = build_signed_string(include_query_string: true)
|
||||
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
# Compatibility quirk with older Mastodon versions
|
||||
compare_signed_string = build_signed_string(include_query_string: false)
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
|
||||
|
||||
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
||||
|
||||
compare_signed_string = build_signed_string(include_query_string: true)
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
# Compatibility quirk with older Mastodon versions
|
||||
compare_signed_string = build_signed_string(include_query_string: false)
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
||||
|
@ -177,16 +186,24 @@ module SignatureVerification
|
|||
nil
|
||||
end
|
||||
|
||||
def build_signed_string
|
||||
def build_signed_string(include_query_string: true)
|
||||
signed_headers.map do |signed_header|
|
||||
if signed_header == Request::REQUEST_TARGET
|
||||
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||
elsif signed_header == '(created)'
|
||||
case signed_header
|
||||
when Request::REQUEST_TARGET
|
||||
if include_query_string
|
||||
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
|
||||
else
|
||||
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
|
||||
# Therefore, temporarily support such incorrect signatures for compatibility.
|
||||
# TODO: remove eventually some time after release of the fixed version
|
||||
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||
end
|
||||
when '(created)'
|
||||
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||
|
||||
"(created): #{signature_params['created']}"
|
||||
elsif signed_header == '(expires)'
|
||||
when '(expires)'
|
||||
raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
||||
|
||||
|
@ -246,7 +263,7 @@ module SignatureVerification
|
|||
stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, ''), suppress_errors: false) }
|
||||
elsif !ActivityPub::TagManager.instance.local_uri?(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
|
||||
end
|
||||
rescue Mastodon::PrivateNetworkAddressError => e
|
||||
|
|
|
@ -65,6 +65,11 @@ module TwoFactorAuthenticationConcern
|
|||
end
|
||||
|
||||
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)
|
||||
on_authentication_success(user, :otp)
|
||||
else
|
||||
|
|
|
@ -20,7 +20,7 @@ module WellKnown
|
|||
def set_account
|
||||
username = username_from_resource
|
||||
@account = begin
|
||||
if username == Rails.configuration.x.local_domain
|
||||
if username == Rails.configuration.x.local_domain || username == Rails.configuration.x.web_domain
|
||||
Account.representative
|
||||
else
|
||||
Account.find_local!(username)
|
||||
|
|
|
@ -157,8 +157,8 @@ module JsonLdHelper
|
|||
end
|
||||
end
|
||||
|
||||
def fetch_resource(uri, id, on_behalf_of = nil)
|
||||
unless id
|
||||
def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {})
|
||||
unless id_is_known
|
||||
json = fetch_resource_without_id_validation(uri, on_behalf_of)
|
||||
|
||||
return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])
|
||||
|
@ -166,17 +166,29 @@ module JsonLdHelper
|
|||
uri = json['id']
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
@ -206,8 +218,8 @@ module JsonLdHelper
|
|||
response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
|
||||
end
|
||||
|
||||
def build_request(uri, on_behalf_of = nil)
|
||||
Request.new(:get, uri).tap do |request|
|
||||
def build_request(uri, on_behalf_of = nil, options: {})
|
||||
Request.new(:get, uri, **options).tap do |request|
|
||||
request.on_behalf_of(on_behalf_of) if on_behalf_of
|
||||
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
|
||||
end
|
||||
|
|
|
@ -153,7 +153,8 @@ class ActivityPub::Activity
|
|||
def fetch_remote_original_status
|
||||
if object_uri.start_with?('http')
|
||||
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?
|
||||
::FetchRemoteStatusService.new.call(@object['url'], request_id: @options[:request_id])
|
||||
end
|
||||
|
|
|
@ -19,7 +19,7 @@ class ActivityPub::LinkedDataSignature
|
|||
return unless type == 'RsaSignature2017'
|
||||
|
||||
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?
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
class ActivityPub::Parser::StatusParser
|
||||
include JsonLdHelper
|
||||
|
||||
NORMALIZED_LOCALE_NAMES = LanguagesHelper::SUPPORTED_LOCALES.keys.index_by(&:downcase).freeze
|
||||
|
||||
# @param [Hash] json
|
||||
# @param [Hash] magic_values
|
||||
# @option magic_values [String] :followers_collection
|
||||
|
@ -86,6 +88,13 @@ class ActivityPub::Parser::StatusParser
|
|||
end
|
||||
|
||||
def language
|
||||
lang = raw_language_code
|
||||
lang.presence && NORMALIZED_LOCALE_NAMES.fetch(lang.downcase.to_sym, lang)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def raw_language_code
|
||||
if content_language_map?
|
||||
@object['contentMap'].keys.first
|
||||
elsif name_language_map?
|
||||
|
@ -95,8 +104,6 @@ class ActivityPub::Parser::StatusParser
|
|||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def audience_to
|
||||
as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
|
||||
end
|
||||
|
|
|
@ -4,12 +4,32 @@ module ApplicationExtension
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Redisable
|
||||
|
||||
validates :name, length: { maximum: 60 }
|
||||
validates :website, url: true, length: { maximum: 2_000 }, if: :website?
|
||||
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
|
||||
|
||||
def confirmation_redirect_uri
|
||||
redirect_uri.lines.first.strip
|
||||
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
|
||||
|
|
|
@ -77,6 +77,7 @@ class Request
|
|||
@url = Addressable::URI.parse(url).normalize
|
||||
@http_client = options.delete(:http_client)
|
||||
@allow_local = options.delete(:allow_local)
|
||||
@full_path = options.delete(:with_query_string)
|
||||
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
|
||||
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
|
||||
@options = @options.merge(proxy_url) if use_proxy?
|
||||
|
@ -146,7 +147,7 @@ class Request
|
|||
private
|
||||
|
||||
def set_common_headers!
|
||||
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
|
||||
@headers[REQUEST_TARGET] = request_target
|
||||
@headers['User-Agent'] = Mastodon::Version.user_agent
|
||||
@headers['Host'] = @url.host
|
||||
@headers['Date'] = Time.now.utc.httpdate
|
||||
|
@ -157,6 +158,14 @@ class Request
|
|||
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
|
||||
end
|
||||
|
||||
def request_target
|
||||
if @url.query.nil? || !@full_path
|
||||
"#{@verb} #{@url.path}"
|
||||
else
|
||||
"#{@verb} #{@url.path}?#{@url.query}"
|
||||
end
|
||||
end
|
||||
|
||||
def signature
|
||||
algorithm = 'rsa-sha256'
|
||||
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
|
||||
|
|
|
@ -16,28 +16,28 @@ class StatusReachFinder
|
|||
private
|
||||
|
||||
def reached_account_inboxes
|
||||
Account.where(id: reached_account_ids).inboxes
|
||||
end
|
||||
|
||||
def reached_account_ids
|
||||
# When the status is a reblog, there are no interactions with it
|
||||
# directly, we assume all interactions are with the original one
|
||||
|
||||
if @status.reblog?
|
||||
[]
|
||||
[reblog_of_account_id]
|
||||
else
|
||||
Account.where(id: reached_account_ids).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,
|
||||
].tap do |arr|
|
||||
arr.flatten!
|
||||
arr.compact!
|
||||
arr.uniq!
|
||||
[
|
||||
replied_to_account_id,
|
||||
reblog_of_account_id,
|
||||
mentioned_account_ids,
|
||||
reblogs_account_ids,
|
||||
favourites_account_ids,
|
||||
replies_account_ids,
|
||||
].tap do |arr|
|
||||
arr.flatten!
|
||||
arr.compact!
|
||||
arr.uniq!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ class VideoMetadataExtractor
|
|||
private
|
||||
|
||||
def ffmpeg_command_output
|
||||
command = Terrapin::CommandLine.new('ffprobe', '-i :path -print_format :format -show_format -show_streams -show_error -loglevel :loglevel')
|
||||
command = Terrapin::CommandLine.new(Rails.configuration.x.ffprobe_binary, '-i :path -print_format :format -show_format -show_streams -show_error -loglevel :loglevel')
|
||||
command.run(path: @path, format: 'json', loglevel: 'fatal')
|
||||
end
|
||||
|
||||
|
|
|
@ -47,4 +47,13 @@ class AdminMailer < ApplicationMailer
|
|||
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.subject', instance: @instance)
|
||||
end
|
||||
end
|
||||
|
||||
def auto_close_registrations(recipient)
|
||||
@me = recipient
|
||||
@instance = Rails.configuration.x.local_domain
|
||||
|
||||
locale_for_account(@me) do
|
||||
mail to: @me.user_email, subject: I18n.t('admin_mailer.auto_close_registrations.subject', instance: @instance)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -112,7 +112,7 @@ class Account < ApplicationRecord
|
|||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||
scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) }
|
||||
scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
|
||||
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
|
||||
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat) }
|
||||
scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) }
|
||||
scope :by_recent_status, -> { includes(:account_stat).merge(AccountStat.order('last_status_at DESC NULLS LAST')).references(:account_stat) }
|
||||
scope :by_recent_sign_in, -> { order(Arel.sql('users.current_sign_in_at DESC NULLS LAST')) }
|
||||
|
|
|
@ -187,7 +187,7 @@ module AccountInteractions
|
|||
end
|
||||
|
||||
def unblock_domain!(other_domain)
|
||||
block = domain_blocks.find_by(domain: other_domain)
|
||||
block = domain_blocks.find_by(domain: normalized_domain(other_domain))
|
||||
block&.destroy
|
||||
end
|
||||
|
||||
|
@ -299,4 +299,8 @@ module AccountInteractions
|
|||
def remove_potential_friendship(other_account)
|
||||
PotentialFriendshipTracker.remove(id, other_account.id)
|
||||
end
|
||||
|
||||
def normalized_domain(domain)
|
||||
TagManager.instance.normalize_domain(domain)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,6 +14,10 @@ module Cacheable
|
|||
includes(@cache_associated)
|
||||
end
|
||||
|
||||
def preload_cacheable_associations(records)
|
||||
ActiveRecord::Associations::Preloader.new.preload(records, @cache_associated)
|
||||
end
|
||||
|
||||
def cache_ids
|
||||
select(:id, :updated_at)
|
||||
end
|
||||
|
|
|
@ -19,17 +19,18 @@ module Omniauthable
|
|||
end
|
||||
|
||||
class_methods do
|
||||
def find_for_oauth(auth, signed_in_resource = nil)
|
||||
def find_for_omniauth(auth, signed_in_resource = nil)
|
||||
# EOLE-SSO Patch
|
||||
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
|
||||
# to prevent the identity being locked with accidentally created accounts.
|
||||
# Note that this may leave zombie accounts (with no associated identity) which
|
||||
# can be cleaned up at a later date.
|
||||
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?
|
||||
identity.user = user
|
||||
|
@ -39,19 +40,35 @@ module Omniauthable
|
|||
user
|
||||
end
|
||||
|
||||
def create_for_oauth(auth)
|
||||
# Check if the user exists with provided email. If no email was provided,
|
||||
private
|
||||
|
||||
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
|
||||
# the next step via Auth::SetupController.show
|
||||
|
||||
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
|
||||
|
||||
user = User.find_by(email: email) if email_is_verified
|
||||
|
||||
return user unless user.nil?
|
||||
email, email_is_verified = email_from_auth(auth)
|
||||
|
||||
user = User.new(user_params_from_auth(email, auth))
|
||||
|
||||
|
@ -68,7 +85,14 @@ module Omniauthable
|
|||
user
|
||||
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)
|
||||
{
|
||||
|
|
|
@ -28,7 +28,7 @@ class Feed
|
|||
unhydrated = redis.zrangebyscore(key, "(#{min_id}", "(#{max_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i)
|
||||
end
|
||||
|
||||
Status.where(id: unhydrated).cache_ids
|
||||
Status.where(id: unhydrated)
|
||||
end
|
||||
|
||||
def key
|
||||
|
|
|
@ -16,7 +16,7 @@ class Identity < ApplicationRecord
|
|||
validates :uid, presence: true, uniqueness: { scope: :provider }
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -29,7 +29,7 @@ class PublicFeed
|
|||
scope.merge!(media_only_scope) if media_only?
|
||||
scope.merge!(language_scope) if account&.chosen_languages.present?
|
||||
|
||||
scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
|
||||
scope.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -344,38 +344,6 @@ class Status < ApplicationRecord
|
|||
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
|
||||
end
|
||||
|
||||
def reload_stale_associations!(cached_items)
|
||||
account_ids = []
|
||||
|
||||
cached_items.each do |item|
|
||||
account_ids << item.account_id
|
||||
account_ids << item.reblog.account_id if item.reblog?
|
||||
end
|
||||
|
||||
account_ids.uniq!
|
||||
|
||||
status_ids = cached_items.map { |item| item.reblog? ? item.reblog_of_id : item.id }.uniq
|
||||
|
||||
return if account_ids.empty?
|
||||
|
||||
accounts = Account.where(id: account_ids).includes(:account_stat, :user).index_by(&:id)
|
||||
|
||||
status_stats = StatusStat.where(status_id: status_ids).index_by(&:status_id)
|
||||
|
||||
cached_items.each do |item|
|
||||
item.account = accounts[item.account_id]
|
||||
item.reblog.account = accounts[item.reblog.account_id] if item.reblog?
|
||||
|
||||
if item.reblog?
|
||||
status_stat = status_stats[item.reblog.id]
|
||||
item.reblog.status_stat = status_stat if status_stat.present?
|
||||
else
|
||||
status_stat = status_stats[item.id]
|
||||
item.status_stat = status_stat if status_stat.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def from_text(text)
|
||||
return [] if text.blank?
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ class TagFeed < PublicFeed
|
|||
scope.merge!(account_filters_scope) if account?
|
||||
scope.merge!(media_only_scope) if media_only?
|
||||
|
||||
scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
|
||||
scope.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -94,6 +94,9 @@ class User < ApplicationRecord
|
|||
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
|
||||
|
||||
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
|
||||
|
||||
validates :email, presence: true, email_address: true
|
||||
|
||||
validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
|
||||
validates_with EmailMxValidator, if: :validate_email_dns?
|
||||
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
|
||||
|
@ -399,6 +402,16 @@ class User < ApplicationRecord
|
|||
Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
|
||||
batch.update_all(revoked_at: Time.now.utc)
|
||||
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
|
||||
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::Admin::DomainBlockSerializer < ActiveModel::Serializer
|
||||
attributes :id, :domain, :created_at, :severity,
|
||||
attributes :id, :domain, :digest, :created_at, :severity,
|
||||
:reject_media, :reject_reports,
|
||||
:private_comment, :public_comment, :obfuscate
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
|
||||
def digest
|
||||
object.domain_digest
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,9 +23,9 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
|
|||
|
||||
case collection['type']
|
||||
when 'Collection', 'CollectionPage'
|
||||
collection['items']
|
||||
as_array(collection['items'])
|
||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||
collection['orderedItems']
|
||||
as_array(collection['orderedItems'])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService
|
||||
# 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
|
||||
return actor if actor.nil? || actor.is_a?(Account)
|
||||
|
||||
|
|
|
@ -10,15 +10,15 @@ class ActivityPub::FetchRemoteActorService < BaseService
|
|||
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
||||
|
||||
# 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 ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri)
|
||||
|
||||
@json = begin
|
||||
if prefetched_body.nil?
|
||||
fetch_resource(uri, id)
|
||||
fetch_resource(uri, true)
|
||||
else
|
||||
body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
||||
body_to_json(prefetched_body, compare_id: uri)
|
||||
end
|
||||
rescue Oj::ParseError
|
||||
raise Error, "Error parsing JSON-LD document #{uri}"
|
||||
|
|
|
@ -6,23 +6,10 @@ class ActivityPub::FetchRemoteKeyService < BaseService
|
|||
class Error < StandardError; end
|
||||
|
||||
# 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?
|
||||
|
||||
if prefetched_body.nil?
|
||||
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
|
||||
@json = fetch_resource(uri, false)
|
||||
|
||||
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)
|
||||
|
|
|
@ -7,13 +7,13 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
|||
DISCOVERIES_PER_REQUEST = 1000
|
||||
|
||||
# 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)
|
||||
@request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
|
||||
@json = begin
|
||||
if prefetched_body.nil?
|
||||
fetch_resource(uri, id, on_behalf_of)
|
||||
fetch_resource(uri, true, on_behalf_of)
|
||||
else
|
||||
body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
||||
body_to_json(prefetched_body, compare_id: uri)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -63,7 +63,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
|||
|
||||
def account_from_uri(uri)
|
||||
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
|
||||
end
|
||||
|
||||
|
|
|
@ -26,9 +26,9 @@ class ActivityPub::FetchRepliesService < BaseService
|
|||
|
||||
case collection['type']
|
||||
when 'Collection', 'CollectionPage'
|
||||
collection['items']
|
||||
as_array(collection['items'])
|
||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||
collection['orderedItems']
|
||||
as_array(collection['orderedItems'])
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -36,7 +36,21 @@ class ActivityPub::FetchRepliesService < BaseService
|
|||
return collection_or_uri if collection_or_uri.is_a?(Hash)
|
||||
return unless @allow_synchronous_requests
|
||||
return if invalid_origin?(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
|
||||
|
||||
def filtered_replies
|
||||
|
|
|
@ -199,10 +199,15 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
value = first_of_value(@json[key])
|
||||
|
||||
return if value.nil?
|
||||
return value['url'] if value.is_a?(Hash)
|
||||
|
||||
image = fetch_resource_without_id_validation(value)
|
||||
image['url'] if image
|
||||
if value.is_a?(String)
|
||||
value = fetch_resource_without_id_validation(value)
|
||||
return if value.nil?
|
||||
end
|
||||
|
||||
value = first_of_value(value['url']) if value.is_a?(Hash) && value['type'] == 'Image'
|
||||
value = value['href'] if value.is_a?(Hash)
|
||||
value if value.is_a?(String)
|
||||
end
|
||||
|
||||
def public_key
|
||||
|
@ -274,7 +279,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
|
||||
def moved_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
|
||||
end
|
||||
|
||||
|
|
|
@ -59,9 +59,9 @@ class ActivityPub::SynchronizeFollowersService < BaseService
|
|||
|
||||
case collection['type']
|
||||
when 'Collection', 'CollectionPage'
|
||||
collection['items']
|
||||
as_array(collection['items'])
|
||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||
collection['orderedItems']
|
||||
as_array(collection['orderedItems'])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -100,7 +100,7 @@ class FetchOEmbedService
|
|||
end
|
||||
|
||||
def validate(oembed)
|
||||
oembed if oembed[:version].to_s == '1.0' && oembed[:type].present?
|
||||
oembed if oembed.present? && oembed[:version].to_s == '1.0' && oembed[:type].present?
|
||||
end
|
||||
|
||||
def html
|
||||
|
|
|
@ -43,11 +43,19 @@ class FetchResourceService < BaseService
|
|||
@response_code = response.code
|
||||
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
|
||||
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
|
||||
link_header = response['Link'] && parse_link_header(response)
|
||||
|
||||
|
|
|
@ -69,7 +69,7 @@ class Keys::QueryService < BaseService
|
|||
|
||||
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'])
|
||||
end
|
||||
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
|
||||
|
|
|
@ -62,16 +62,17 @@ class NotifyService < BaseService
|
|||
LEFT JOIN mentions m ON m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id
|
||||
WHERE s.id = :id
|
||||
UNION ALL
|
||||
SELECT s.id, s.in_reply_to_id, m.id, st.path || s.id, st.depth + 1
|
||||
FROM ancestors st
|
||||
JOIN statuses s ON s.id = st.in_reply_to_id
|
||||
LEFT JOIN mentions m ON m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id
|
||||
WHERE st.mention_id IS NULL AND NOT s.id = ANY(path) AND st.depth < :depth_limit
|
||||
SELECT s.id, s.in_reply_to_id, m.id, ancestors.path || s.id, ancestors.depth + 1
|
||||
FROM ancestors
|
||||
JOIN statuses s ON s.id = ancestors.in_reply_to_id
|
||||
/* early exit if we already have a mention matching our requirements */
|
||||
LEFT JOIN mentions m ON m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id AND s.account_id = :recipient_id
|
||||
WHERE ancestors.mention_id IS NULL AND NOT s.id = ANY(path) AND ancestors.depth < :depth_limit
|
||||
)
|
||||
SELECT COUNT(*)
|
||||
FROM ancestors st
|
||||
JOIN statuses s ON s.id = st.id
|
||||
WHERE st.mention_id IS NOT NULL AND s.visibility = 3
|
||||
FROM ancestors
|
||||
JOIN statuses s ON s.id = ancestors.id
|
||||
WHERE ancestors.mention_id IS NOT NULL AND s.account_id = :recipient_id AND s.visibility = 3
|
||||
SQL
|
||||
end
|
||||
|
||||
|
|
|
@ -137,7 +137,7 @@ class PostStatusService < BaseService
|
|||
|
||||
def idempotency_duplicate
|
||||
if scheduled?
|
||||
@account.schedule_statuses.find(@idempotency_duplicate)
|
||||
@account.scheduled_statuses.find(@idempotency_duplicate)
|
||||
else
|
||||
@account.statuses.find(@idempotency_duplicate)
|
||||
end
|
||||
|
@ -189,7 +189,7 @@ class PostStatusService < BaseService
|
|||
end
|
||||
|
||||
def scheduled_options
|
||||
@options.tap do |options_hash|
|
||||
@options.dup.tap do |options_hash|
|
||||
options_hash[:in_reply_to_id] = options_hash.delete(:thread)&.id
|
||||
options_hash[:application_id] = options_hash.delete(:application)&.id
|
||||
options_hash[:scheduled_at] = nil
|
||||
|
|
|
@ -45,11 +45,7 @@ class ReblogService < BaseService
|
|||
def create_notification(reblog)
|
||||
reblogged_status = reblog.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
|
||||
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, 'reblog') if reblogged_status.account.local?
|
||||
end
|
||||
|
||||
def bump_potential_friendship(account, reblog)
|
||||
|
|
|
@ -19,7 +19,7 @@ class VerifyLinkService < BaseService
|
|||
|
||||
def perform_request!
|
||||
@body = Request.new(:get, @url).add_headers('Accept' => 'text/html').perform do |res|
|
||||
res.code == 200 ? res.body_with_limit : nil
|
||||
res.code == 200 ? res.truncated_body : nil
|
||||
end
|
||||
end
|
||||
|
||||
|
|
18
app/validators/email_address_validator.rb
Normal file
18
app/validators/email_address_validator.rb
Normal file
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# NOTE: I initially wrote this as `EmailValidator` but it ended up clashing
|
||||
# with an indirect dependency of ours, `validate_email`, which, turns out,
|
||||
# has the same approach as we do, but with an extra check disallowing
|
||||
# single-label domains. Decided to not switch to `validate_email` because
|
||||
# we do want to allow at least `localhost`.
|
||||
|
||||
class EmailAddressValidator < ActiveModel::EachValidator
|
||||
def validate_each(record, attribute, value)
|
||||
value = value.strip
|
||||
|
||||
address = Mail::Address.new(value)
|
||||
record.errors.add(attribute, :invalid) if address.address != value
|
||||
rescue Mail::Field::FieldError
|
||||
record.errors.add(attribute, :invalid)
|
||||
end
|
||||
end
|
|
@ -13,6 +13,8 @@
|
|||
|
||||
%p.lead= t('admin.settings.registrations.preamble')
|
||||
|
||||
.flash-message= t('admin.settings.registrations.moderation_recommandation')
|
||||
|
||||
.fields-row
|
||||
.fields-row__column.fields-row__column-6.fields-group
|
||||
= f.input :registrations_mode, collection: %w(open approved none), wrapper: :with_label, include_blank: false, label_method: lambda { |mode| I18n.t("admin.settings.registrations_mode.modes.#{mode}") }
|
||||
|
|
3
app/views/admin_mailer/auto_close_registrations.text.erb
Normal file
3
app/views/admin_mailer/auto_close_registrations.text.erb
Normal file
|
@ -0,0 +1,3 @@
|
|||
<%= raw t('admin_mailer.auto_close_registrations.body', instance: @instance) %>
|
||||
|
||||
<%= raw t('application_mailer.view')%> <%= admin_settings_registrations_url %>
|
|
@ -7,7 +7,7 @@ class LinkCrawlWorker
|
|||
|
||||
def perform(status_id)
|
||||
FetchLinkCardService.new.call(Status.find(status_id))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
|
||||
true
|
||||
end
|
||||
end
|
||||
|
|
33
app/workers/scheduler/auto_close_registrations_scheduler.rb
Normal file
33
app/workers/scheduler/auto_close_registrations_scheduler.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Scheduler::AutoCloseRegistrationsScheduler
|
||||
include Sidekiq::Worker
|
||||
include Redisable
|
||||
|
||||
sidekiq_options retry: 0
|
||||
|
||||
# Automatically switch away from open registrations if no
|
||||
# moderator had any activity in that period of time
|
||||
OPEN_REGISTRATIONS_MODERATOR_THRESHOLD = 1.week + UserTrackingConcern::SIGN_IN_UPDATE_FREQUENCY
|
||||
|
||||
def perform
|
||||
return if Rails.configuration.x.email_domains_whitelist.present? || ENV['DISABLE_AUTOMATIC_SWITCHING_TO_APPROVED_REGISTRATIONS'] == 'true'
|
||||
return unless Setting.registrations_mode == 'open'
|
||||
|
||||
switch_to_approval_mode! unless active_moderators?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def active_moderators?
|
||||
User.those_who_can(:manage_reports).exists?(current_sign_in_at: OPEN_REGISTRATIONS_MODERATOR_THRESHOLD.ago...)
|
||||
end
|
||||
|
||||
def switch_to_approval_mode!
|
||||
Setting.registrations_mode = 'approved'
|
||||
|
||||
User.those_who_can(:manage_settings).includes(:account).find_each do |user|
|
||||
AdminMailer.auto_close_registrations(user.account).deliver_later
|
||||
end
|
||||
end
|
||||
end
|
|
@ -44,6 +44,7 @@ require_relative '../lib/chewy/strategy/bypass_with_warning'
|
|||
require_relative '../lib/webpacker/manifest_extensions'
|
||||
require_relative '../lib/webpacker/helper_extensions'
|
||||
require_relative '../lib/rails/engine_extensions'
|
||||
require_relative '../lib/action_dispatch/remote_ip_extensions'
|
||||
require_relative '../lib/active_record/database_tasks_extensions'
|
||||
require_relative '../lib/active_record/batches'
|
||||
require_relative '../lib/simple_navigation/item_extensions'
|
||||
|
|
|
@ -19,9 +19,14 @@ Doorkeeper.configure do
|
|||
user unless user&.otp_required_for_login?
|
||||
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
|
||||
current_user&.admin? || redirect_to(new_user_session_url)
|
||||
head 403
|
||||
end
|
||||
|
||||
# Authorization Code expiration time (default 10 minutes).
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
if ENV['FFMPEG_BINARY'].present?
|
||||
FFMPEG.ffmpeg_binary = ENV['FFMPEG_BINARY']
|
||||
# frozen_string_literal: true
|
||||
|
||||
Rails.application.configure do
|
||||
config.x.ffmpeg_binary = ENV['FFMPEG_BINARY'] || 'ffmpeg'
|
||||
config.x.ffprobe_binary = ENV['FFPROBE_BINARY'] || 'ffprobe'
|
||||
end
|
||||
|
|
|
@ -37,6 +37,10 @@ class Rack::Attack
|
|||
authenticated_token&.id
|
||||
end
|
||||
|
||||
def warden_user_id
|
||||
@env['warden']&.user&.id
|
||||
end
|
||||
|
||||
def unauthenticated?
|
||||
!authenticated_user_id
|
||||
end
|
||||
|
@ -58,10 +62,6 @@ class Rack::Attack
|
|||
end
|
||||
end
|
||||
|
||||
Rack::Attack.safelist('allow from localhost') do |req|
|
||||
req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
|
||||
end
|
||||
|
||||
Rack::Attack.blocklist('deny from blocklist') do |req|
|
||||
IpBlock.blocked?(req.remote_ip)
|
||||
end
|
||||
|
@ -137,6 +137,10 @@ class Rack::Attack
|
|||
req.session[:attempt_user_id] || req.params.dig('user', 'email').presence if req.post? && req.path_matches?('/auth/sign_in')
|
||||
end
|
||||
|
||||
throttle('throttle_password_change/account', limit: 10, period: 10.minutes) do |req|
|
||||
req.warden_user_id if req.put? || (req.patch? && req.path_matches?('/auth'))
|
||||
end
|
||||
|
||||
self.throttled_responder = lambda do |request|
|
||||
now = Time.now.utc
|
||||
match_data = request.env['rack.attack.match_data']
|
||||
|
|
|
@ -12,6 +12,7 @@ en:
|
|||
last_attempt: You have one more attempt before your account is locked.
|
||||
locked: Your account is locked.
|
||||
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.
|
||||
timeout: Your session expired. Please sign in again to continue.
|
||||
unauthenticated: You need to sign in or sign up before continuing.
|
||||
|
|
|
@ -745,6 +745,7 @@ en:
|
|||
disabled: To no one
|
||||
users: To logged-in local users
|
||||
registrations:
|
||||
moderation_recommandation: Please make sure you have an adequate and reactive moderation team before you open registrations to everyone!
|
||||
preamble: Control who can create an account on your server.
|
||||
title: Registrations
|
||||
registrations_mode:
|
||||
|
@ -905,6 +906,9 @@ en:
|
|||
title: Webhooks
|
||||
webhook: Webhook
|
||||
admin_mailer:
|
||||
auto_close_registrations:
|
||||
body: Due to a lack of recent moderator activity, registrations on %{instance} have been automatically switched to requiring manual review, to prevent %{instance} from being used as a platform for potential bad actors. You can switch it back to open registrations at any time.
|
||||
subject: Registrations for %{instance} have been automatically switched to requiring approval
|
||||
new_appeal:
|
||||
actions:
|
||||
delete_statuses: to delete their posts
|
||||
|
@ -1684,6 +1688,7 @@ en:
|
|||
follow_limit_reached: You cannot follow more than %{limit} people
|
||||
invalid_otp_token: Invalid two-factor code
|
||||
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.
|
||||
signed_in_as: 'Signed in as:'
|
||||
verification:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# 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'
|
||||
|
||||
Rails.application.routes.draw do
|
||||
|
|
|
@ -9,7 +9,7 @@ defaults: &defaults
|
|||
site_terms: ''
|
||||
site_contact_username: ''
|
||||
site_contact_email: ''
|
||||
registrations_mode: 'open'
|
||||
registrations_mode: 'none'
|
||||
profile_directory: true
|
||||
closed_registrations_message: ''
|
||||
open_deletion: true
|
||||
|
|
|
@ -58,3 +58,7 @@
|
|||
interval: 1 minute
|
||||
class: Scheduler::SuspendedUserCleanupScheduler
|
||||
queue: scheduler
|
||||
auto_close_registrations_scheduler:
|
||||
interval: 1 hour
|
||||
class: Scheduler::AutoCloseRegistrationsScheduler
|
||||
queue: scheduler
|
||||
|
|
|
@ -56,7 +56,7 @@ services:
|
|||
|
||||
web:
|
||||
build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.11
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.17
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
||||
|
@ -77,7 +77,7 @@ services:
|
|||
|
||||
streaming:
|
||||
build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.11
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.17
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: node ./streaming
|
||||
|
@ -95,7 +95,7 @@ services:
|
|||
|
||||
sidekiq:
|
||||
build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.11
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.17
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec sidekiq
|
||||
|
|
72
lib/action_dispatch/remote_ip_extensions.rb
Normal file
72
lib/action_dispatch/remote_ip_extensions.rb
Normal file
|
@ -0,0 +1,72 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Mastodon is not made to be directly accessed without a reverse proxy.
|
||||
# This monkey-patch prevents remote IP address spoofing when being accessed
|
||||
# directly.
|
||||
#
|
||||
# See PR: https://github.com/rails/rails/pull/51610
|
||||
|
||||
# In addition to the PR above, it also raises an error if a request with
|
||||
# `X-Forwarded-For` or `Client-Ip` comes directly from a client without
|
||||
# going through a trusted proxy.
|
||||
|
||||
# rubocop:disable all -- This is a mostly vendored file
|
||||
|
||||
module ActionDispatch
|
||||
class RemoteIp
|
||||
module GetIpExtensions
|
||||
def calculate_ip
|
||||
# Set by the Rack web server, this is a single value.
|
||||
remote_addr = ips_from(@req.remote_addr).last
|
||||
|
||||
# Could be a CSV list and/or repeated headers that were concatenated.
|
||||
client_ips = ips_from(@req.client_ip).reverse!
|
||||
forwarded_ips = ips_from(@req.x_forwarded_for).reverse!
|
||||
|
||||
# `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they
|
||||
# are both set, it means that either:
|
||||
#
|
||||
# 1) This request passed through two proxies with incompatible IP header
|
||||
# conventions.
|
||||
#
|
||||
# 2) The client passed one of `Client-Ip` or `X-Forwarded-For`
|
||||
# (whichever the proxy servers weren't using) themselves.
|
||||
#
|
||||
# Either way, there is no way for us to determine which header is the right one
|
||||
# after the fact. Since we have no idea, if we are concerned about IP spoofing
|
||||
# we need to give up and explode. (If you're not concerned about IP spoofing you
|
||||
# can turn the `ip_spoofing_check` option off.)
|
||||
should_check_ip = @check_ip && client_ips.last && forwarded_ips.last
|
||||
if should_check_ip && !forwarded_ips.include?(client_ips.last)
|
||||
# We don't know which came from the proxy, and which from the user
|
||||
raise IpSpoofAttackError, "IP spoofing attack?! " \
|
||||
"HTTP_CLIENT_IP=#{@req.client_ip.inspect} " \
|
||||
"HTTP_X_FORWARDED_FOR=#{@req.x_forwarded_for.inspect}"
|
||||
end
|
||||
|
||||
# NOTE: Mastodon addition to make sure we don't get requests from a non-trusted client
|
||||
if @check_ip && (forwarded_ips.last || client_ips.last) && !@proxies.any? { |proxy| proxy === remote_addr }
|
||||
raise IpSpoofAttackError, "IP spoofing attack?! client #{remote_addr} is not a trusted proxy " \
|
||||
"HTTP_CLIENT_IP=#{@req.client_ip.inspect} " \
|
||||
"HTTP_X_FORWARDED_FOR=#{@req.x_forwarded_for.inspect}"
|
||||
end
|
||||
|
||||
# We assume these things about the IP headers:
|
||||
#
|
||||
# - X-Forwarded-For will be a list of IPs, one per proxy, or blank
|
||||
# - Client-Ip is propagated from the outermost proxy, or is blank
|
||||
# - REMOTE_ADDR will be the IP that made the request to Rack
|
||||
ips = forwarded_ips + client_ips
|
||||
ips.compact!
|
||||
|
||||
# If every single IP option is in the trusted list, return the IP that's
|
||||
# furthest away
|
||||
filter_proxies([remote_addr] + ips).first || ips.last || remote_addr
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ActionDispatch::RemoteIp::GetIp.prepend(ActionDispatch::RemoteIp::GetIpExtensions)
|
||||
|
||||
# rubocop:enable all
|
|
@ -234,7 +234,7 @@ module Mastodon
|
|||
users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse
|
||||
ref_user = users.shift
|
||||
@prompt.warn "Multiple users registered with e-mail address #{ref_user.email}."
|
||||
@prompt.warn "e-mail will be disabled for the following accounts: #{user.map(&:account).map(&:acct).join(', ')}"
|
||||
@prompt.warn "e-mail will be disabled for the following accounts: #{users.map(&:account).map(&:acct).join(', ')}"
|
||||
@prompt.warn 'Please reach out to them and set another address with `tootctl account modify` or delete them.'
|
||||
|
||||
i = 0
|
||||
|
|
|
@ -143,7 +143,7 @@ module Mastodon
|
|||
|
||||
model_name = path_segments.first.classify
|
||||
attachment_name = path_segments[1].singularize
|
||||
record_id = path_segments[2..-2].join.to_i
|
||||
record_id = path_segments[2...-2].join.to_i
|
||||
file_name = path_segments.last
|
||||
record = record_map.dig(model_name, record_id)
|
||||
attachment = record&.public_send(attachment_name)
|
||||
|
@ -186,7 +186,7 @@ module Mastodon
|
|||
end
|
||||
|
||||
model_name = path_segments.first.classify
|
||||
record_id = path_segments[2..-2].join.to_i
|
||||
record_id = path_segments[2...-2].join.to_i
|
||||
attachment_name = path_segments[1].singularize
|
||||
file_name = path_segments.last
|
||||
|
||||
|
@ -322,7 +322,7 @@ module Mastodon
|
|||
end
|
||||
|
||||
model_name = path_segments.first.classify
|
||||
record_id = path_segments[2..-2].join.to_i
|
||||
record_id = path_segments[2...-2].join.to_i
|
||||
|
||||
unless PRELOAD_MODEL_WHITELIST.include?(model_name)
|
||||
say("Cannot find corresponding model: #{model_name}", :red)
|
||||
|
@ -372,7 +372,7 @@ module Mastodon
|
|||
next unless [7, 10].include?(segments.size)
|
||||
|
||||
model_name = segments.first.classify
|
||||
record_id = segments[2..-2].join.to_i
|
||||
record_id = segments[2...-2].join.to_i
|
||||
|
||||
next unless PRELOAD_MODEL_WHITELIST.include?(model_name)
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ class Mastodon::SidekiqMiddleware
|
|||
rescue Mastodon::HostValidationError
|
||||
# Do not retry
|
||||
rescue => e
|
||||
clean_up_elasticsearch_connections!
|
||||
limit_backtrace_and_raise(e)
|
||||
ensure
|
||||
clean_up_sockets!
|
||||
|
@ -25,6 +26,32 @@ class Mastodon::SidekiqMiddleware
|
|||
clean_up_statsd_socket!
|
||||
end
|
||||
|
||||
# This is a hack to immediately free up unused Elasticsearch connections.
|
||||
#
|
||||
# Indeed, Chewy creates one `Elasticsearch::Client` instance per thread,
|
||||
# and each such client manages its long-lasting connection to
|
||||
# Elasticsearch.
|
||||
#
|
||||
# As far as I know, neither `chewy`, `elasticsearch-transport` or even
|
||||
# `faraday` provide a reliable way to immediately close a connection, and
|
||||
# rely on the underlying object to be garbage-collected instead.
|
||||
#
|
||||
# Furthermore, `sidekiq` creates a new thread each time a job throws an
|
||||
# exception, meaning that each failure will create a new connection, and
|
||||
# the old one will only be closed on full garbage collection.
|
||||
def clean_up_elasticsearch_connections!
|
||||
return unless Chewy.enabled? && Chewy.current[:chewy_client].present?
|
||||
|
||||
Chewy.client.transport.connections.each do |connection|
|
||||
# NOTE: This bit of code is tailored for the HTTPClient Faraday adapter
|
||||
connection.connection.app.instance_variable_get(:@client)&.reset_all
|
||||
end
|
||||
|
||||
Chewy.current.delete(:chewy_client)
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
|
||||
def clean_up_redis_socket!
|
||||
RedisConfiguration.pool.checkin if Thread.current[:redis]
|
||||
Thread.current[:redis] = nil
|
||||
|
|
|
@ -13,7 +13,7 @@ module Mastodon
|
|||
end
|
||||
|
||||
def patch
|
||||
11
|
||||
17
|
||||
end
|
||||
|
||||
def flags
|
||||
|
|
|
@ -35,7 +35,7 @@ module Paperclip
|
|||
dst.binmode
|
||||
|
||||
begin
|
||||
command = Terrapin::CommandLine.new('ffmpeg', '-i :source -loglevel :loglevel -y :destination', logger: Paperclip.logger)
|
||||
command = Terrapin::CommandLine.new(Rails.configuration.x.ffmpeg_binary, '-i :source -loglevel :loglevel -y :destination', logger: Paperclip.logger)
|
||||
command.run(source: @file.path, destination: dst.path, loglevel: 'fatal')
|
||||
rescue Terrapin::ExitStatusError
|
||||
dst.close(true)
|
||||
|
|
|
@ -16,7 +16,7 @@ module Paperclip
|
|||
private
|
||||
|
||||
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)
|
||||
@content_type = ContentTypeDetector.new(@tempfile.path).detect
|
||||
@size = File.size(@tempfile)
|
||||
|
@ -43,6 +43,13 @@ module Paperclip
|
|||
source.response.connection.close
|
||||
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
|
||||
disposition = @target.response.headers['content-disposition']
|
||||
disposition&.match(/filename="([^"]*)"/)&.captures&.first
|
||||
|
|
|
@ -51,7 +51,7 @@ module Paperclip
|
|||
command_arguments, interpolations = prepare_command(destination)
|
||||
|
||||
begin
|
||||
command = Terrapin::CommandLine.new('ffmpeg', command_arguments.join(' '), logger: Paperclip.logger)
|
||||
command = Terrapin::CommandLine.new(Rails.configuration.x.ffmpeg_binary, command_arguments.join(' '), logger: Paperclip.logger)
|
||||
command.run(interpolations)
|
||||
rescue Terrapin::ExitStatusError => e
|
||||
raise Paperclip::Error, "Error while transcoding #{@basename}: #{e}"
|
||||
|
|
|
@ -516,6 +516,7 @@ namespace :mastodon do
|
|||
owner_role = UserRole.find_by(name: 'Owner')
|
||||
user = User.new(email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username }, bypass_invite_request_check: true, role: owner_role)
|
||||
user.save(validate: false)
|
||||
user.approve!
|
||||
|
||||
Setting.site_contact_username = username
|
||||
|
||||
|
|
11
lib/tasks/sidekiq_unique_jobs.rake
Normal file
11
lib/tasks/sidekiq_unique_jobs.rake
Normal 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
|
|
@ -1,8 +1,6 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe Rack::Attack do
|
||||
include Rack::Test::Methods
|
||||
|
||||
describe Rack::Attack, type: :request do
|
||||
def app
|
||||
Rails.application
|
||||
end
|
||||
|
@ -12,7 +10,7 @@ describe Rack::Attack do
|
|||
it 'does not change the request status' do
|
||||
limit.times do
|
||||
request.call
|
||||
expect(last_response.status).to_not eq(429)
|
||||
expect(response.status).to_not eq(429)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -21,7 +19,7 @@ describe Rack::Attack do
|
|||
it 'returns http too many requests' do
|
||||
(limit * 2).times do |i|
|
||||
request.call
|
||||
expect(last_response.status).to eq(429) if i > limit
|
||||
expect(response.status).to eq(429) if i > limit
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -32,7 +30,7 @@ describe Rack::Attack do
|
|||
describe 'throttle excessive sign-up requests by IP address' do
|
||||
context 'through the website' do
|
||||
let(:limit) { 25 }
|
||||
let(:request) { ->() { post path, {}, 'REMOTE_ADDR' => remote_ip } }
|
||||
let(:request) { ->() { post path, headers: { 'REMOTE_ADDR' => remote_ip } } }
|
||||
|
||||
context 'for exact path' do
|
||||
let(:path) { '/auth' }
|
||||
|
@ -47,7 +45,7 @@ describe Rack::Attack do
|
|||
|
||||
context 'through the API' do
|
||||
let(:limit) { 5 }
|
||||
let(:request) { ->() { post path, {}, 'REMOTE_ADDR' => remote_ip } }
|
||||
let(:request) { ->() { post path, headers: { 'REMOTE_ADDR' => remote_ip } } }
|
||||
|
||||
context 'for exact path' do
|
||||
let(:path) { '/api/v1/accounts' }
|
||||
|
@ -59,7 +57,7 @@ describe Rack::Attack do
|
|||
|
||||
it 'returns http not found' do
|
||||
request.call
|
||||
expect(last_response.status).to eq(404)
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -67,7 +65,7 @@ describe Rack::Attack do
|
|||
|
||||
describe 'throttle excessive sign-in requests by IP address' do
|
||||
let(:limit) { 25 }
|
||||
let(:request) { ->() { post path, {}, 'REMOTE_ADDR' => remote_ip } }
|
||||
let(:request) { ->() { post path, headers: { 'REMOTE_ADDR' => remote_ip } } }
|
||||
|
||||
context 'for exact path' do
|
||||
let(:path) { '/auth/sign_in' }
|
||||
|
@ -79,4 +77,28 @@ describe Rack::Attack do
|
|||
it_behaves_like 'throttled endpoint'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'throttle excessive password change requests by account' do
|
||||
let(:user) { Fabricate(:user, email: 'user@host.example') }
|
||||
let(:limit) { 10 }
|
||||
let(:period) { 10.minutes }
|
||||
let(:request) { -> { put path, headers: { 'REMOTE_ADDR' => remote_ip } } }
|
||||
let(:path) { '/auth' }
|
||||
|
||||
before do
|
||||
sign_in user, scope: :user
|
||||
|
||||
# Unfortunately, devise's `sign_in` helper causes the `session` to be
|
||||
# loaded in the next request regardless of whether it's actually accessed
|
||||
# by the client code.
|
||||
#
|
||||
# So, we make an extra query to clear issue a session cookie instead.
|
||||
#
|
||||
# A less resource-intensive way to deal with that would be to generate the
|
||||
# session cookie manually, but this seems pretty involved.
|
||||
get '/'
|
||||
end
|
||||
|
||||
it_behaves_like 'throttled endpoint'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -16,6 +16,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
|
|||
let(:scopes) { wrong_scope }
|
||||
|
||||
it 'returns http forbidden' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
@ -24,6 +26,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
|
|||
let(:role) { UserRole.find_by(name: wrong_role) }
|
||||
|
||||
it 'returns http forbidden' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
@ -140,39 +144,70 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
|
|||
|
||||
describe 'POST #create' do
|
||||
let(:existing_block_domain) { 'example.com' }
|
||||
let(:params) { { domain: 'foo.bar.com', severity: :silence } }
|
||||
let!(:block) { Fabricate(:domain_block, domain: existing_block_domain, severity: :suspend) }
|
||||
|
||||
before do
|
||||
post :create, params: { domain: 'foo.bar.com', severity: :silence }
|
||||
subject do
|
||||
post :create, params: params
|
||||
end
|
||||
|
||||
it_behaves_like 'forbidden for wrong scope', 'write:statuses'
|
||||
it_behaves_like 'forbidden for wrong role', ''
|
||||
it_behaves_like 'forbidden for wrong role', 'Moderator'
|
||||
|
||||
it 'returns http success' do
|
||||
it 'creates a domain block and returns expected domain name', :aggregate_failures do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'returns expected domain name' do
|
||||
json = body_as_json
|
||||
expect(json[:domain]).to eq 'foo.bar.com'
|
||||
end
|
||||
|
||||
it 'creates a domain block' do
|
||||
expect(body_as_json[:domain]).to eq 'foo.bar.com'
|
||||
expect(DomainBlock.find_by(domain: 'foo.bar.com')).to_not be_nil
|
||||
end
|
||||
|
||||
context 'when a stricter domain block already exists' do
|
||||
let(:existing_block_domain) { 'bar.com' }
|
||||
context 'when a looser domain block already exists on a higher level domain' do
|
||||
let(:params) { { domain: 'foo.bar.com', severity: :suspend } }
|
||||
|
||||
it 'returns http unprocessable entity' do
|
||||
expect(response).to have_http_status(422)
|
||||
before do
|
||||
Fabricate(:domain_block, domain: 'bar.com', severity: :silence)
|
||||
end
|
||||
|
||||
it 'renders existing domain block in error' do
|
||||
json = body_as_json
|
||||
expect(json[:existing_domain_block][:domain]).to eq existing_block_domain
|
||||
it 'creates a domain block with the expected domain name and severity', :aggregate_failures do
|
||||
subject
|
||||
|
||||
body = body_as_json
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body).to match a_hash_including(
|
||||
{
|
||||
domain: 'foo.bar.com',
|
||||
severity: 'suspend',
|
||||
}
|
||||
)
|
||||
|
||||
expect(DomainBlock.find_by(domain: 'foo.bar.com')).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a domain block already exists on the same domain' do
|
||||
before do
|
||||
Fabricate(:domain_block, domain: 'foo.bar.com', severity: :silence)
|
||||
end
|
||||
|
||||
it 'returns existing domain block in error', :aggregate_failures do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(422)
|
||||
expect(body_as_json[:existing_domain_block][:domain]).to eq('foo.bar.com')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a stricter domain block already exists on a higher level domain' do
|
||||
let(:existing_block_domain) { 'bar.com' }
|
||||
|
||||
it 'returns http unprocessable entity with existing domain block in error', :aggregate_reblogs do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(422)
|
||||
expect(body_as_json[:existing_domain_block][:domain]).to eq existing_block_domain
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Api::V1::FeaturedTags::SuggestionsController do
|
||||
render_views
|
||||
|
||||
let(:user) { Fabricate(:user) }
|
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
|
||||
let(:account) { Fabricate(:account, user: user) }
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:doorkeeper_token) { token }
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
let!(:unused_featured_tag) { Fabricate(:tag, name: 'unused_featured_tag') }
|
||||
let!(:used_tag) { Fabricate(:tag, name: 'used_tag') }
|
||||
let!(:used_featured_tag) { Fabricate(:tag, name: 'used_featured_tag') }
|
||||
|
||||
before do
|
||||
_unused_tag = Fabricate(:tag, name: 'unused_tag')
|
||||
|
||||
# Make relevant tags used by account
|
||||
status = Fabricate(:status, account: account)
|
||||
status.tags << used_tag
|
||||
status.tags << used_featured_tag
|
||||
|
||||
# Feature the relevant tags
|
||||
Fabricate :featured_tag, account: account, name: unused_featured_tag.name
|
||||
Fabricate :featured_tag, account: account, name: used_featured_tag.name
|
||||
end
|
||||
|
||||
it 'returns http success and recently used but not featured tags', :aggregate_failures do
|
||||
get :index, params: { account_id: account.id, limit: 2 }
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
expect(body_as_json)
|
||||
.to contain_exactly(
|
||||
include(name: used_tag.name)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -5,7 +5,7 @@ require 'rails_helper'
|
|||
describe Api::V1::StreamingController do
|
||||
around(:each) do |example|
|
||||
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
|
||||
Rails.configuration.x.streaming_api_base_url = before
|
||||
end
|
||||
|
|
|
@ -242,37 +242,4 @@ describe ApplicationController, type: :controller do
|
|||
|
||||
include_examples 'respond_with_error', 422
|
||||
end
|
||||
|
||||
describe 'cache_collection' do
|
||||
class C < ApplicationController
|
||||
public :cache_collection
|
||||
end
|
||||
|
||||
shared_examples 'receives :with_includes' do |fabricator, klass|
|
||||
it 'uses raw if it is not an ActiveRecord::Relation' do
|
||||
record = Fabricate(fabricator)
|
||||
expect(C.new.cache_collection([record], klass)).to eq [record]
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'cacheable' do |fabricator, klass|
|
||||
include_examples 'receives :with_includes', fabricator, klass
|
||||
|
||||
it 'calls cache_ids of raw if it is an ActiveRecord::Relation' do
|
||||
record = Fabricate(fabricator)
|
||||
relation = klass.none
|
||||
allow(relation).to receive(:cache_ids).and_return([record])
|
||||
expect(C.new.cache_collection(relation, klass)).to eq [record]
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns raw unless class responds to :with_includes' do
|
||||
raw = Object.new
|
||||
expect(C.new.cache_collection(raw, Object)).to eq raw
|
||||
end
|
||||
|
||||
context 'Status' do
|
||||
include_examples 'cacheable', :status, Status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -262,7 +262,27 @@ RSpec.describe Auth::SessionsController, type: :controller do
|
|||
end
|
||||
end
|
||||
|
||||
context 'using a valid OTP' do
|
||||
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
|
||||
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 }
|
||||
end
|
||||
|
|
|
@ -1,303 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe ApplicationController, type: :controller do
|
||||
class WrappedActor
|
||||
attr_reader :wrapped_account
|
||||
|
||||
def initialize(wrapped_account)
|
||||
@wrapped_account = wrapped_account
|
||||
end
|
||||
|
||||
delegate :uri, :keypair, to: :wrapped_account
|
||||
end
|
||||
|
||||
controller do
|
||||
include SignatureVerification
|
||||
|
||||
before_action :require_actor_signature!, only: [:signature_required]
|
||||
|
||||
def success
|
||||
head 200
|
||||
end
|
||||
|
||||
def alternative_success
|
||||
head 200
|
||||
end
|
||||
|
||||
def signature_required
|
||||
head 200
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
routes.draw do
|
||||
match via: [:get, :post], 'success' => 'anonymous#success'
|
||||
match via: [:get, :post], 'signature_required' => 'anonymous#signature_required'
|
||||
end
|
||||
end
|
||||
|
||||
context 'without signature header' do
|
||||
before do
|
||||
get :success
|
||||
end
|
||||
|
||||
describe '#signed_request?' do
|
||||
it 'returns false' do
|
||||
expect(controller.signed_request?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with signature header' do
|
||||
let!(:author) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') }
|
||||
|
||||
context 'without body' do
|
||||
before do
|
||||
get :success
|
||||
|
||||
fake_request = Request.new(:get, request.url)
|
||||
fake_request.on_behalf_of(author)
|
||||
|
||||
request.headers.merge!(fake_request.headers)
|
||||
end
|
||||
|
||||
describe '#signed_request?' do
|
||||
it 'returns true' do
|
||||
expect(controller.signed_request?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns an account' do
|
||||
expect(controller.signed_request_account).to eq author
|
||||
end
|
||||
|
||||
it 'returns nil when path does not match' do
|
||||
request.path = '/alternative-path'
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when method does not match' do
|
||||
post :success
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid actor that is not an Account' do
|
||||
let(:actor) { WrappedActor.new(author) }
|
||||
|
||||
before do
|
||||
get :success
|
||||
|
||||
fake_request = Request.new(:get, request.url)
|
||||
fake_request.on_behalf_of(author)
|
||||
|
||||
request.headers.merge!(fake_request.headers)
|
||||
|
||||
allow(ActivityPub::TagManager.instance).to receive(:uri_to_actor).with(anything) do
|
||||
actor
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request?' do
|
||||
it 'returns true' do
|
||||
expect(controller.signed_request?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request_actor' do
|
||||
it 'returns the expected actor' do
|
||||
expect(controller.signed_request_actor).to eq actor
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with request with unparseable Date header' do
|
||||
before do
|
||||
get :success
|
||||
|
||||
fake_request = Request.new(:get, request.url)
|
||||
fake_request.add_headers({ 'Date' => 'wrong date' })
|
||||
fake_request.on_behalf_of(author)
|
||||
|
||||
request.headers.merge!(fake_request.headers)
|
||||
end
|
||||
|
||||
describe '#signed_request?' do
|
||||
it 'returns true' do
|
||||
expect(controller.signed_request?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signature_verification_failure_reason' do
|
||||
it 'contains an error description' do
|
||||
controller.signed_request_account
|
||||
expect(controller.signature_verification_failure_reason[:error]).to eq 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with request older than a day' do
|
||||
before do
|
||||
get :success
|
||||
|
||||
fake_request = Request.new(:get, request.url)
|
||||
fake_request.add_headers({ 'Date' => 2.days.ago.utc.httpdate })
|
||||
fake_request.on_behalf_of(author)
|
||||
|
||||
request.headers.merge!(fake_request.headers)
|
||||
end
|
||||
|
||||
describe '#signed_request?' do
|
||||
it 'returns true' do
|
||||
expect(controller.signed_request?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signature_verification_failure_reason' do
|
||||
it 'contains an error description' do
|
||||
controller.signed_request_account
|
||||
expect(controller.signature_verification_failure_reason[:error]).to eq 'Signed request date outside acceptable time window'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with inaccessible key' do
|
||||
before do
|
||||
get :success
|
||||
|
||||
author = Fabricate(:account, domain: 'localhost:5000', uri: 'http://localhost:5000/actor')
|
||||
fake_request = Request.new(:get, request.url)
|
||||
fake_request.on_behalf_of(author)
|
||||
author.destroy
|
||||
|
||||
request.headers.merge!(fake_request.headers)
|
||||
|
||||
stub_request(:get, 'http://localhost:5000/actor#main-key').to_raise(Mastodon::HostValidationError)
|
||||
end
|
||||
|
||||
describe '#signed_request?' do
|
||||
it 'returns true' do
|
||||
expect(controller.signed_request?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with body' do
|
||||
before do
|
||||
allow(controller).to receive(:actor_refresh_key!).and_return(author)
|
||||
post :success, body: 'Hello world'
|
||||
|
||||
fake_request = Request.new(:post, request.url, body: 'Hello world')
|
||||
fake_request.on_behalf_of(author)
|
||||
|
||||
request.headers.merge!(fake_request.headers)
|
||||
end
|
||||
|
||||
describe '#signed_request?' do
|
||||
it 'returns true' do
|
||||
expect(controller.signed_request?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns an account' do
|
||||
expect(controller.signed_request_account).to eq author
|
||||
end
|
||||
end
|
||||
|
||||
context 'when path does not match' do
|
||||
before do
|
||||
request.path = '/alternative-path'
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signature_verification_failure_reason' do
|
||||
it 'contains an error description' do
|
||||
controller.signed_request_account
|
||||
expect(controller.signature_verification_failure_reason[:error]).to include('using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)')
|
||||
expect(controller.signature_verification_failure_reason[:signed_string]).to include("(request-target): post /alternative-path\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when method does not match' do
|
||||
before do
|
||||
get :success
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when body has been tampered' do
|
||||
before do
|
||||
post :success, body: 'doo doo doo'
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil when body has been tampered' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a signature is required' do
|
||||
before do
|
||||
get :signature_required
|
||||
end
|
||||
|
||||
context 'without signature header' do
|
||||
it 'returns HTTP 401' do
|
||||
expect(response).to have_http_status(401)
|
||||
end
|
||||
|
||||
it 'returns an error' do
|
||||
expect(Oj.load(response.body)['error']).to eq 'Request not signed'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -56,15 +56,15 @@ describe JsonLdHelper do
|
|||
describe '#fetch_resource' 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
|
||||
stub_request(:get, 'https://bob.test/').to_return body: '{"id": "https://alice.test/"}'
|
||||
stub_request(:get, 'https://alice.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/"}', headers: { 'Content-Type': 'application/activity+json' })
|
||||
|
||||
expect(fetch_resource('https://bob.test/', false)).to eq({ 'id' => 'https://alice.test/' })
|
||||
end
|
||||
|
||||
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://marvin.test/').to_return body: '{"id": "https://alice.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/"}', headers: { 'Content-Type': 'application/activity+json' })
|
||||
|
||||
expect(fetch_resource('https://mallory.test/', false)).to eq nil
|
||||
end
|
||||
|
@ -72,7 +72,7 @@ describe JsonLdHelper do
|
|||
|
||||
context 'when the second argument is true' 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 eq nil
|
||||
end
|
||||
end
|
||||
|
@ -80,12 +80,12 @@ describe JsonLdHelper do
|
|||
|
||||
describe '#fetch_resource_without_id_validation' 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 eq nil
|
||||
end
|
||||
|
||||
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({})
|
||||
end
|
||||
end
|
||||
|
|
|
@ -33,7 +33,7 @@ RSpec.describe ActivityPub::Activity::Announce do
|
|||
context 'when sender is followed by a local account' do
|
||||
before do
|
||||
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
|
||||
end
|
||||
|
||||
|
@ -118,7 +118,7 @@ RSpec.describe ActivityPub::Activity::Announce do
|
|||
subject { described_class.new(json, sender, relayed_through_actor: relay_account) }
|
||||
|
||||
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
|
||||
|
||||
context 'and the relay is enabled' do
|
||||
|
|
|
@ -58,7 +58,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do
|
|||
|
||||
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
|
||||
end
|
||||
|
@ -66,7 +66,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do
|
|||
|
||||
it 'fetches key and returns creator' do
|
||||
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
|
||||
|
||||
|
|
50
spec/lib/activitypub/parser/status_parser_spec.rb
Normal file
50
spec/lib/activitypub/parser/status_parser_spec.rb
Normal file
|
@ -0,0 +1,50 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ActivityPub::Parser::StatusParser do
|
||||
subject { described_class.new(json) }
|
||||
|
||||
let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers', domain: 'example.com', uri: 'https://example.com/actor') }
|
||||
let(:follower) { Fabricate(:account, username: 'bob') }
|
||||
|
||||
let(:json) do
|
||||
{
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join,
|
||||
type: 'Create',
|
||||
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||
object: object_json,
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:object_json) do
|
||||
{
|
||||
id: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'),
|
||||
type: 'Note',
|
||||
to: [
|
||||
'https://www.w3.org/ns/activitystreams#Public',
|
||||
ActivityPub::TagManager.instance.uri_for(follower),
|
||||
],
|
||||
content: '@bob lorem ipsum',
|
||||
contentMap: {
|
||||
EN: '@bob lorem ipsum',
|
||||
},
|
||||
published: 1.hour.ago.utc.iso8601,
|
||||
updated: 1.hour.ago.utc.iso8601,
|
||||
tag: {
|
||||
type: 'Mention',
|
||||
href: ActivityPub::TagManager.instance.uri_for(follower),
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
it 'correctly parses status' do
|
||||
expect(subject).to have_attributes(
|
||||
text: '@bob lorem ipsum',
|
||||
uri: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'),
|
||||
reply: false,
|
||||
language: :en
|
||||
)
|
||||
end
|
||||
end
|
|
@ -110,6 +110,14 @@ RSpec.describe ActivityPub::TagManager do
|
|||
expect(subject.cc(status)).to include(subject.uri_for(foo))
|
||||
expect(subject.cc(status)).to_not include(subject.uri_for(alice))
|
||||
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
|
||||
|
||||
describe '#local_uri?' do
|
||||
|
|
|
@ -248,6 +248,24 @@ describe AccountInteractions do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#block_idna_domain!' do
|
||||
subject do
|
||||
[
|
||||
account.block_domain!(idna_domain),
|
||||
account.block_domain!(punycode_domain),
|
||||
]
|
||||
end
|
||||
|
||||
let(:idna_domain) { '대한민국.한국' }
|
||||
let(:punycode_domain) { 'xn--3e0bs9hfvinn1a.xn--3e0b707e' }
|
||||
|
||||
it 'creates single AccountDomainBlock' do
|
||||
expect do
|
||||
expect(subject).to all(be_a AccountDomainBlock)
|
||||
end.to change { account.domain_blocks.count }.by 1
|
||||
end
|
||||
end
|
||||
|
||||
describe '#unfollow!' do
|
||||
subject { account.unfollow!(target_account) }
|
||||
|
||||
|
@ -343,6 +361,28 @@ describe AccountInteractions do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#unblock_idna_domain!' do
|
||||
subject { account.unblock_domain!(punycode_domain) }
|
||||
|
||||
let(:idna_domain) { '대한민국.한국' }
|
||||
let(:punycode_domain) { 'xn--3e0bs9hfvinn1a.xn--3e0b707e' }
|
||||
|
||||
context 'when blocking the domain' do
|
||||
it 'returns destroyed AccountDomainBlock' do
|
||||
account_domain_block = Fabricate(:account_domain_block, domain: idna_domain)
|
||||
account.domain_blocks << account_domain_block
|
||||
expect(subject).to be_a AccountDomainBlock
|
||||
expect(subject).to be_destroyed
|
||||
end
|
||||
end
|
||||
|
||||
context 'when unblocking idna domain' do
|
||||
it 'returns nil' do
|
||||
expect(subject).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#following?' do
|
||||
subject { account.following?(target_account) }
|
||||
|
||||
|
|
|
@ -25,7 +25,6 @@ RSpec.describe HomeFeed, type: :model do
|
|||
results = subject.get(3)
|
||||
|
||||
expect(results.map(&:id)).to eq [3, 2]
|
||||
expect(results.first.attributes.keys).to eq %w(id updated_at)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Identity, type: :model do
|
||||
describe '.find_for_oauth' do
|
||||
describe '.find_for_omniauth' do
|
||||
let(:auth) { Fabricate(:identity, user: Fabricate(:user)) }
|
||||
|
||||
it 'calls .find_or_create_by' do
|
||||
expect(described_class).to receive(:find_or_create_by).with(uid: auth.uid, provider: auth.provider)
|
||||
described_class.find_for_oauth(auth)
|
||||
described_class.find_for_omniauth(auth)
|
||||
end
|
||||
|
||||
it 'returns an instance of Identity' do
|
||||
expect(described_class.find_for_oauth(auth)).to be_instance_of Identity
|
||||
expect(described_class.find_for_omniauth(auth)).to be_instance_of Identity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -142,22 +142,12 @@ RSpec.describe Setting, type: :model do
|
|||
context 'records includes nothing' do
|
||||
let(:records) { [] }
|
||||
|
||||
context 'default_value is not a Hash' do
|
||||
it 'includes Setting with value of default_value' do
|
||||
setting = described_class.all_as_records[key]
|
||||
it 'includes Setting with value of default_value' do
|
||||
setting = described_class.all_as_records[key]
|
||||
|
||||
expect(setting).to be_kind_of Setting
|
||||
expect(setting).to have_attributes(var: key)
|
||||
expect(setting).to have_attributes(value: 'default_value')
|
||||
end
|
||||
end
|
||||
|
||||
context 'default_value is a Hash' do
|
||||
let(:default_value) { { 'foo' => 'fuga' } }
|
||||
|
||||
it 'returns {}' do
|
||||
expect(described_class.all_as_records).to eq({})
|
||||
end
|
||||
expect(setting).to be_a described_class
|
||||
expect(setting).to have_attributes(var: key)
|
||||
expect(setting).to have_attributes(value: default_value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -40,6 +40,12 @@ RSpec.describe User, type: :model do
|
|||
expect(user.valid?).to be true
|
||||
end
|
||||
|
||||
it 'is valid with a localhost e-mail address' do
|
||||
user = Fabricate.build(:user, email: 'admin@localhost')
|
||||
user.valid?
|
||||
expect(user.valid?).to be true
|
||||
end
|
||||
|
||||
it 'cleans out empty string from languages' do
|
||||
user = Fabricate.build(:user, chosen_languages: [''])
|
||||
user.valid?
|
||||
|
@ -439,7 +445,10 @@ RSpec.describe User, type: :model do
|
|||
let!(:access_token) { Fabricate(:access_token, resource_owner_id: user.id) }
|
||||
let!(:web_push_subscription) { Fabricate(:web_push_subscription, access_token: access_token) }
|
||||
|
||||
let(:redis_pipeline_stub) { instance_double(Redis::Namespace, publish: nil) }
|
||||
|
||||
before do
|
||||
allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub)
|
||||
user.reset_password!
|
||||
end
|
||||
|
||||
|
@ -455,6 +464,10 @@ RSpec.describe User, type: :model do
|
|||
expect(Doorkeeper::AccessToken.active_for(user).count).to eq 0
|
||||
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
|
||||
expect(Web::PushSubscription.where(user: user).or(Web::PushSubscription.where(access_token: access_token)).count).to eq 0
|
||||
end
|
||||
|
|
|
@ -41,6 +41,7 @@ RSpec.configure do |config|
|
|||
|
||||
config.include Devise::Test::ControllerHelpers, type: :controller
|
||||
config.include Devise::Test::ControllerHelpers, type: :view
|
||||
config.include Devise::Test::IntegrationHelpers, type: :request
|
||||
config.include Paperclip::Shoulda::Matchers
|
||||
config.include ActiveSupport::Testing::TimeHelpers
|
||||
config.include Redisable
|
||||
|
|
139
spec/requests/api/v1/directories_spec.rb
Normal file
139
spec/requests/api/v1/directories_spec.rb
Normal file
|
@ -0,0 +1,139 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Directories API' do
|
||||
let(:user) { Fabricate(:user, confirmed_at: nil) }
|
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
|
||||
let(:scopes) { 'read:follows' }
|
||||
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
|
||||
|
||||
describe 'GET /api/v1/directories' do
|
||||
context 'with no params' do
|
||||
before do
|
||||
local_unconfirmed_account = Fabricate(
|
||||
:account,
|
||||
domain: nil,
|
||||
user: Fabricate(:user, confirmed_at: nil, approved: true),
|
||||
username: 'local_unconfirmed'
|
||||
)
|
||||
local_unconfirmed_account.create_account_stat!
|
||||
|
||||
local_unapproved_account = Fabricate(
|
||||
:account,
|
||||
domain: nil,
|
||||
user: Fabricate(:user, confirmed_at: 10.days.ago),
|
||||
username: 'local_unapproved'
|
||||
)
|
||||
local_unapproved_account.create_account_stat!
|
||||
local_unapproved_account.user.update(approved: false)
|
||||
|
||||
local_undiscoverable_account = Fabricate(
|
||||
:account,
|
||||
domain: nil,
|
||||
user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true),
|
||||
discoverable: false,
|
||||
username: 'local_undiscoverable'
|
||||
)
|
||||
local_undiscoverable_account.create_account_stat!
|
||||
|
||||
excluded_from_timeline_account = Fabricate(
|
||||
:account,
|
||||
domain: 'host.example',
|
||||
discoverable: true,
|
||||
username: 'remote_excluded_from_timeline'
|
||||
)
|
||||
excluded_from_timeline_account.create_account_stat!
|
||||
Fabricate(:block, account: user.account, target_account: excluded_from_timeline_account)
|
||||
|
||||
domain_blocked_account = Fabricate(
|
||||
:account,
|
||||
domain: 'test.example',
|
||||
discoverable: true,
|
||||
username: 'remote_domain_blocked'
|
||||
)
|
||||
domain_blocked_account.create_account_stat!
|
||||
Fabricate(:account_domain_block, account: user.account, domain: 'test.example')
|
||||
|
||||
local_discoverable_account.create_account_stat!
|
||||
eligible_remote_account.create_account_stat!
|
||||
end
|
||||
|
||||
let(:local_discoverable_account) do
|
||||
Fabricate(
|
||||
:account,
|
||||
domain: nil,
|
||||
user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true),
|
||||
discoverable: true,
|
||||
username: 'local_discoverable'
|
||||
)
|
||||
end
|
||||
|
||||
let(:eligible_remote_account) do
|
||||
Fabricate(
|
||||
:account,
|
||||
domain: 'host.example',
|
||||
discoverable: true,
|
||||
username: 'eligible_remote'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the local discoverable account and the remote discoverable account' do
|
||||
get '/api/v1/directory', headers: headers
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json.size).to eq(2)
|
||||
expect(body_as_json.pluck(:id)).to contain_exactly(eligible_remote_account.id.to_s, local_discoverable_account.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when asking for local accounts only' do
|
||||
let(:user) { Fabricate(:user, confirmed_at: 10.days.ago, approved: true) }
|
||||
let(:local_account) { Fabricate(:account, domain: nil, user: user) }
|
||||
let(:remote_account) { Fabricate(:account, domain: 'host.example') }
|
||||
|
||||
before do
|
||||
local_account.create_account_stat!
|
||||
remote_account.create_account_stat!
|
||||
end
|
||||
|
||||
it 'returns only the local accounts' do
|
||||
get '/api/v1/directory', headers: headers, params: { local: '1' }
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json.size).to eq(1)
|
||||
expect(body_as_json.first[:id]).to include(local_account.id.to_s)
|
||||
expect(response.body).to_not include(remote_account.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ordered by active' do
|
||||
it 'returns accounts in order of most recent status activity' do
|
||||
old_stat = Fabricate(:account_stat, last_status_at: 1.day.ago)
|
||||
new_stat = Fabricate(:account_stat, last_status_at: 1.minute.ago)
|
||||
|
||||
get '/api/v1/directory', headers: headers, params: { order: 'active' }
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json.size).to eq(2)
|
||||
expect(body_as_json.first[:id]).to include(new_stat.account_id.to_s)
|
||||
expect(body_as_json.second[:id]).to include(old_stat.account_id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ordered by new' do
|
||||
it 'returns accounts in order of creation' do
|
||||
account_old = Fabricate(:account_stat).account
|
||||
travel_to 10.seconds.from_now
|
||||
account_new = Fabricate(:account_stat).account
|
||||
|
||||
get '/api/v1/directory', headers: headers, params: { order: 'new' }
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json.size).to eq(2)
|
||||
expect(body_as_json.first[:id]).to include(account_new.id.to_s)
|
||||
expect(body_as_json.second[:id]).to include(account_old.id.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
83
spec/requests/disabled_oauth_endpoints_spec.rb
Normal file
83
spec/requests/disabled_oauth_endpoints_spec.rb
Normal 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
|
143
spec/requests/omniauth_callbacks_spec.rb
Normal file
143
spec/requests/omniauth_callbacks_spec.rb
Normal file
|
@ -0,0 +1,143 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'OmniAuth callbacks' do
|
||||
shared_examples 'omniauth provider callbacks' do |provider|
|
||||
subject { post send :"user_#{provider}_omniauth_callback_path" }
|
||||
|
||||
context 'with full information in response' do
|
||||
before do
|
||||
mock_omniauth(provider, {
|
||||
provider: provider.to_s,
|
||||
uid: '123',
|
||||
info: {
|
||||
verified: 'true',
|
||||
email: 'user@host.example',
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
context 'without a matching user' do
|
||||
it 'creates a user and an identity and redirects to root path' do
|
||||
expect { subject }
|
||||
.to change(User, :count)
|
||||
.by(1)
|
||||
.and change(Identity, :count)
|
||||
.by(1)
|
||||
.and change(LoginActivity, :count)
|
||||
.by(1)
|
||||
|
||||
expect(User.last.email).to eq('user@host.example')
|
||||
expect(Identity.find_by(user: User.last).uid).to eq('123')
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a matching user and no matching identity' do
|
||||
before do
|
||||
Fabricate(:user, email: 'user@host.example')
|
||||
end
|
||||
|
||||
context 'when ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH is set to true' do
|
||||
around do |example|
|
||||
ClimateControl.modify ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH: 'true' do
|
||||
example.run
|
||||
end
|
||||
end
|
||||
|
||||
it 'matches the existing user, creates an identity, and redirects to root path' do
|
||||
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
|
||||
|
||||
context 'with a matching user and a matching identity' do
|
||||
before do
|
||||
user = Fabricate(:user, email: 'user@host.example')
|
||||
Fabricate(:identity, user: user, uid: '123', provider: provider)
|
||||
end
|
||||
|
||||
it 'matches the existing records and redirects to root path' do
|
||||
expect { subject }
|
||||
.to not_change(User, :count)
|
||||
.and not_change(Identity, :count)
|
||||
.and change(LoginActivity, :count)
|
||||
.by(1)
|
||||
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a response missing email address' do
|
||||
before do
|
||||
mock_omniauth(provider, {
|
||||
provider: provider.to_s,
|
||||
uid: '123',
|
||||
info: {
|
||||
verified: 'true',
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
it 'redirects to the auth setup page' do
|
||||
expect { subject }
|
||||
.to change(User, :count)
|
||||
.by(1)
|
||||
.and change(Identity, :count)
|
||||
.by(1)
|
||||
.and change(LoginActivity, :count)
|
||||
.by(1)
|
||||
|
||||
expect(response).to redirect_to(auth_setup_path(missing_email: '1'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a user cannot be built' do
|
||||
before do
|
||||
allow(User).to receive(:find_for_omniauth).and_return(User.new)
|
||||
end
|
||||
|
||||
it 'redirects to the new user signup 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_registration_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#openid_connect', if: ENV['OIDC_ENABLED'] == 'true' && ENV['OIDC_SCOPE'].present? do
|
||||
include_examples 'omniauth provider callbacks', :openid_connect
|
||||
end
|
||||
|
||||
describe '#cas', if: ENV['CAS_ENABLED'] == 'true' do
|
||||
include_examples 'omniauth provider callbacks', :cas
|
||||
end
|
||||
|
||||
describe '#saml', if: ENV['SAML_ENABLED'] == 'true' do
|
||||
include_examples 'omniauth provider callbacks', :saml
|
||||
end
|
||||
end
|
398
spec/requests/signature_verification_spec.rb
Normal file
398
spec/requests/signature_verification_spec.rb
Normal file
|
@ -0,0 +1,398 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'signature verification concern' do
|
||||
before do
|
||||
stub_tests_controller
|
||||
|
||||
# Signature checking is time-dependent, so travel to a fixed date
|
||||
travel_to '2023-12-20T10:00:00Z'
|
||||
end
|
||||
|
||||
after { Rails.application.reload_routes! }
|
||||
|
||||
# Include the private key so the tests can be easily adjusted and reviewed
|
||||
let(:actor_keypair) do
|
||||
OpenSSL::PKey.read(<<~PEM_TEXT)
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAqIAYvNFGbZ5g4iiK6feSdXD4bDStFM58A7tHycYXaYtzZQpI
|
||||
eHXAmaXuZzXIwtrP4N0gIk8JNwZvXj2UPS+S07t0V9wNK94he01LV5EMz/GN4eNn
|
||||
FmDL64HIEuKLvV8TvgjbUPRD6Y5X0UpKi2ZIFLSb96Q5w0Z/k7ntpVKV52y8kz5F
|
||||
jr/O/0JuHryZe0yItzJh8kzFfeMf0EXzfSnaKvT7P9jhgC6uTre+jXyvVZjiHDrn
|
||||
qvvucdI3I7DRfXo1OqARBrLjy+TdseUAjNYJ+OuPRI1URIWQI01DCHqcohVu9+Ar
|
||||
+BiCjFp3ua+XMuJvrvbD61d1Fvig/9nbBRR+8QIDAQABAoIBAAgySHnFWI6gItR3
|
||||
fkfiqIm80cHCN3Xk1C6iiVu+3oBOZbHpW9R7vl9e/WOA/9O+LPjiSsQOegtWnVvd
|
||||
RRjrl7Hj20VDlZKv5Mssm6zOGAxksrcVbqwdj+fUJaNJCL0AyyseH0x/IE9T8rDC
|
||||
I1GH+3tB3JkhkIN/qjipdX5ab8MswEPu8IC4ViTpdBgWYY/xBcAHPw4xuL0tcwzh
|
||||
FBlf4DqoEVQo8GdK5GAJ2Ny0S4xbXHUURzx/R4y4CCts7niAiLGqd9jmLU1kUTMk
|
||||
QcXfQYK6l+unLc7wDYAz7sFEHh04M48VjWwiIZJnlCqmQbLda7uhhu8zkF1DqZTu
|
||||
ulWDGQECgYEA0TIAc8BQBVab979DHEEmMdgqBwxLY3OIAk0b+r50h7VBGWCDPRsC
|
||||
STD73fQY3lNet/7/jgSGwwAlAJ5PpMXxXiZAE3bUwPmHzgF7pvIOOLhA8O07tHSO
|
||||
L2mvQe6NPzjZ+6iAO2U9PkClxcvGvPx2OBvisfHqZLmxC9PIVxzruQECgYEAzjM6
|
||||
BTUXa6T/qHvLFbN699BXsUOGmHBGaLRapFDBfVvgZrwqYQcZpBBhesLdGTGSqwE7
|
||||
gWsITPIJ+Ldo+38oGYyVys+w/V67q6ud7hgSDTW3hSvm+GboCjk6gzxlt9hQ0t9X
|
||||
8vfDOYhEXvVUJNv3mYO60ENqQhILO4bQ0zi+VfECgYBb/nUccfG+pzunU0Cb6Dp3
|
||||
qOuydcGhVmj1OhuXxLFSDG84Tazo7juvHA9mp7VX76mzmDuhpHPuxN2AzB2SBEoE
|
||||
cSW0aYld413JRfWukLuYTc6hJHIhBTCRwRQFFnae2s1hUdQySm8INT2xIc+fxBXo
|
||||
zrp+Ljg5Wz90SAnN5TX0AQKBgDaatDOq0o/r+tPYLHiLtfWoE4Dau+rkWJDjqdk3
|
||||
lXWn/e3WyHY3Vh/vQpEqxzgju45TXjmwaVtPATr+/usSykCxzP0PMPR3wMT+Rm1F
|
||||
rIoY/odij+CaB7qlWwxj0x/zRbwB7x1lZSp4HnrzBpxYL+JUUwVRxPLIKndSBTza
|
||||
GvVRAoGBAIVBcNcRQYF4fvZjDKAb4fdBsEuHmycqtRCsnkGOz6ebbEQznSaZ0tZE
|
||||
+JuouZaGjyp8uPjNGD5D7mIGbyoZ3KyG4mTXNxDAGBso1hrNDKGBOrGaPhZx8LgO
|
||||
4VXJ+ybXrATf4jr8ccZYsZdFpOphPzz+j55Mqg5vac5P1XjmsGTb
|
||||
-----END RSA PRIVATE KEY-----
|
||||
PEM_TEXT
|
||||
end
|
||||
|
||||
context 'without a Signature header' do
|
||||
it 'does not treat the request as signed' do
|
||||
get '/activitypub/success'
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to match(
|
||||
signed_request: false,
|
||||
signature_actor_id: nil,
|
||||
error: 'Request not signed'
|
||||
)
|
||||
end
|
||||
|
||||
context 'when a signature is required' do
|
||||
it 'returns http unauthorized with appropriate error' do
|
||||
get '/activitypub/signature_required'
|
||||
|
||||
expect(response).to have_http_status(401)
|
||||
expect(body_as_json).to match(
|
||||
error: 'Request not signed'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an HTTP Signature from a known account' do
|
||||
let!(:actor) { Fabricate(:account, domain: 'remote.domain', uri: 'https://remote.domain/users/bob', private_key: nil, public_key: actor_keypair.public_key.to_pem) }
|
||||
|
||||
context 'with a valid signature on a GET request' do
|
||||
let(:signature_header) do
|
||||
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'successfuly verifies signature', :aggregate_failures do
|
||||
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||
|
||||
get '/activitypub/success', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: actor.id.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid signature on a GET request that has a query string' do
|
||||
let(:signature_header) do
|
||||
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'successfuly verifies signature', :aggregate_failures do
|
||||
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||
|
||||
get '/activitypub/success?foo=42', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: actor.id.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the query string is missing from the signature verification (compatibility quirk)' do
|
||||
let(:signature_header) do
|
||||
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'successfuly verifies signature', :aggregate_failures do
|
||||
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||
|
||||
get '/activitypub/success?foo=42', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: actor.id.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mismatching query string' do
|
||||
let(:signature_header) do
|
||||
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||
|
||||
get '/activitypub/success?foo=43', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: anything
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a mismatching path' do
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
get '/activitypub/alternative-path', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength
|
||||
}
|
||||
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: anything
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a mismatching method' do
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
post '/activitypub/success', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength
|
||||
}
|
||||
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: anything
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an unparsable date' do
|
||||
let(:signature_header) do
|
||||
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="d4B7nfx8RJcfdJDu1J//5WzPzK/hgtPkdzZx49lu5QhnE7qdV3lgyVimmhCFrO16bwvzIp9iRMyRLkNFxLiEeVaa1gqeKbldGSnU0B0OMjx7rFBa65vLuzWQOATDitVGiBEYqoK4v0DMuFCz2DtFaA/DIUZ3sty8bZ/Ea3U1nByLOO6MacARA3zhMSI0GNxGqsSmZmG0hPLavB3jIXoE3IDoQabMnC39jrlcO/a8h1iaxBm2WD8TejrImJullgqlJIFpKhIHI3ipQkvTGPlm9dx0y+beM06qBvWaWQcmT09eRIUefVsOAzIhUtS/7FVb/URhZvircIJDa7vtiFcmZQ=="' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'wrong date', 'Host' => 'www.example.com' })
|
||||
|
||||
get '/activitypub/success', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'wrong date',
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a request older than a day' do
|
||||
let(:signature_header) do
|
||||
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="G1NuJv4zgoZ3B/ZIjzDWZHK4RC+5pYee74q8/LJEMCWXhcnAomcb9YHaqk1QYfQvcBUIXw3UZ3Q9xO8F9y0i8G5mzJHfQ+OgHqCoJk8EmGwsUXJMh5s1S5YFCRt8TT12TmJZz0VMqLq85ubueSYBM7QtUE/FzFIVLvz4RysgXxaXQKzdnM6+gbUEEKdCURpXdQt2NXQhp4MAmZH3+0lQoR6VxdsK0hx0Ji2PNp1nuqFTlYqNWZazVdLBN+9rETLRmvGXknvg9jOxTTppBVWnkAIl26HtLS3wwFVvz4pJzi9OQDOvLziehVyLNbU61hky+oJ215e2HuKSe2hxHNl1MA=="' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||
|
||||
get '/activitypub/success', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT',
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: 'Signed request date outside acceptable time window'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid signature on a POST request' do
|
||||
let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' }
|
||||
let(:signature_header) do
|
||||
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'successfuly verifies signature', :aggregate_failures do
|
||||
expect(digest_header).to eq digest_value('Hello world')
|
||||
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header })
|
||||
|
||||
post '/activitypub/success', params: 'Hello world', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Digest' => digest_header,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: actor.id.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the Digest of a POST request is not signed' do
|
||||
let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' }
|
||||
let(:signature_header) do
|
||||
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date (request-target)",signature="CPD704CG8aCm8X8qIP8kkkiGp1qwFLk/wMVQHOGP0Txxan8c2DZtg/KK7eN8RG8tHx8br/yS2hJs51x4kXImYukGzNJd7ihE3T8lp+9RI1tCcdobTzr/VcVJHDFySdQkg266GCMijRQRZfNvqlJLiisr817PI+gNVBI5qV+vnVd1XhWCEZ+YSmMe8UqYARXAYNqMykTheojqGpTeTFGPUpTQA2Fmt2BipwIjcFDm2Hpihl2kB0MUS0x3zPmHDuadvzoBbN6m3usPDLgYrpALlh+wDs1dYMntcwdwawRKY1oE1XNtgOSum12wntDq3uYL4gya2iPdcw3c929b4koUzw=="' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
expect(digest_header).to eq digest_value('Hello world')
|
||||
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT' })
|
||||
|
||||
post '/activitypub/success', params: 'Hello world', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Digest' => digest_header,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: 'Mastodon requires the Digest header to be signed when doing a POST request'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a tampered body on a POST request' do
|
||||
let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' }
|
||||
let(:signature_header) do
|
||||
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
expect(digest_header).to_not eq digest_value('Hello world!')
|
||||
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header })
|
||||
|
||||
post '/activitypub/success', params: 'Hello world!', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=',
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: 'Invalid Digest value. Computed SHA-256 digest: wFNeS+K3n/2TKRMFQ2v4iTFOSj+uwF7P/Lt98xrZ5Ro=; given: ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw='
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a tampered path in a POST request' do
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
post '/activitypub/alternative-path', params: 'Hello world', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=',
|
||||
'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="', # rubocop:disable Layout/LineLength
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: anything
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an inaccessible key' do
|
||||
before do
|
||||
stub_request(:get, 'https://remote.domain/users/alice#main-key').to_return(status: 404)
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
get '/activitypub/success', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||
'Signature' => 'keyId="https://remote.domain/users/alice#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength
|
||||
}
|
||||
|
||||
expect(body_as_json).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: 'Unable to fetch key JSON at https://remote.domain/users/alice#main-key'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_tests_controller
|
||||
stub_const('ActivityPub::TestsController', activitypub_tests_controller)
|
||||
|
||||
Rails.application.routes.draw do
|
||||
# NOTE: RouteSet#draw removes all routes, so we need to re-insert one
|
||||
resource :instance_actor, path: 'actor', only: [:show]
|
||||
|
||||
match :via => [:get, :post], '/activitypub/success' => 'activitypub/tests#success'
|
||||
match :via => [:get, :post], '/activitypub/alternative-path' => 'activitypub/tests#alternative_success'
|
||||
match :via => [:get, :post], '/activitypub/signature_required' => 'activitypub/tests#signature_required'
|
||||
end
|
||||
end
|
||||
|
||||
def activitypub_tests_controller
|
||||
Class.new(ApplicationController) do
|
||||
include SignatureVerification
|
||||
|
||||
before_action :require_actor_signature!, only: [:signature_required]
|
||||
|
||||
def success
|
||||
render json: {
|
||||
signed_request: signed_request?,
|
||||
signature_actor_id: signed_request_actor&.id&.to_s,
|
||||
}.merge(signature_verification_failure_reason || {})
|
||||
end
|
||||
|
||||
alias_method :alternative_success, :success
|
||||
alias_method :signature_required, :success
|
||||
end
|
||||
end
|
||||
|
||||
def digest_value(body)
|
||||
"SHA-256=#{Digest::SHA256.base64digest(body)}"
|
||||
end
|
||||
|
||||
def build_signature_string(keypair, key_id, request_target, headers)
|
||||
algorithm = 'rsa-sha256'
|
||||
signed_headers = headers.merge({ '(request-target)' => request_target })
|
||||
signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
|
||||
signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
|
||||
|
||||
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
|
||||
end
|
||||
end
|
|
@ -60,10 +60,10 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
|||
|
||||
shared_examples 'sets pinned posts' do
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/account/pinned/1').to_return(status: 200, body: Oj.dump(status_json_1))
|
||||
stub_request(:get, 'https://example.com/account/pinned/2').to_return(status: 200, body: Oj.dump(status_json_2))
|
||||
stub_request(:get, 'https://example.com/account/pinned/1').to_return(status: 200, body: Oj.dump(status_json_1), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/account/pinned/2').to_return(status: 200, body: Oj.dump(status_json_2), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/account/pinned/3').to_return(status: 404)
|
||||
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4))
|
||||
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4), headers: { 'Content-Type': 'application/activity+json' })
|
||||
|
||||
subject.call(actor, note: true, hashtag: false)
|
||||
end
|
||||
|
@ -76,7 +76,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
|||
describe '#call' do
|
||||
context 'when the endpoint is a Collection' 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
|
||||
|
||||
it_behaves_like 'sets pinned posts'
|
||||
|
@ -93,10 +93,25 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
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/4' }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4), 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/4'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the endpoint is a paginated Collection' do
|
||||
|
@ -114,10 +129,25 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
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/4' }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4), 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/4'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -36,7 +36,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
|
|||
describe '#call' do
|
||||
context 'when the endpoint is a Collection' 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
|
||||
|
||||
it_behaves_like 'sets featured tags'
|
||||
|
@ -44,7 +44,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
|
|||
|
||||
context 'when the account already has featured tags' 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: 'baz')
|
||||
|
@ -65,7 +65,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
it_behaves_like 'sets featured tags'
|
||||
|
@ -86,7 +86,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
it_behaves_like 'sets featured tags'
|
||||
|
|
|
@ -16,7 +16,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
end
|
||||
|
||||
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
|
||||
it 'returns an account' do
|
||||
|
@ -42,7 +42,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
before do
|
||||
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' })
|
||||
end
|
||||
|
||||
|
@ -65,7 +65,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
||||
|
||||
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' })
|
||||
end
|
||||
|
||||
|
@ -91,7 +91,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
||||
|
||||
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://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
@ -123,7 +123,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
||||
|
||||
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' })
|
||||
end
|
||||
|
||||
|
@ -146,7 +146,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
||||
|
||||
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://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue