diff --git a/.bundler-audit.yml b/.bundler-audit.yml index a457fc41e8..c867b1abf0 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -1,4 +1,10 @@ --- ignore: - # Sidekiq security issue, fixes in the latest Sidekiq 7 but we can not upgrade. Will be fixed in Sidekiq 6.5.10 - - CVE-2023-26141 + # devise-two-factor advisory about brute-forcing TOTP + # We have rate-limits on authentication endpoints in place (including second + # factor verification) since Mastodon v3.2.0 + - CVE-2024-0227 + # devise-two-factor advisory about generated secrets being weaker than expected + # We call `generate_otp_secret` ourselves with a requested length of 32 characters, + # which exceeds the recommended remediation of 26 characters, so we're safe + - CVE-2024-8796 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 3e93f4432b..831e518af5 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -130,6 +130,7 @@ Naming/VariableNumber: - 'db/migrate/20190820003045_update_statuses_index.rb' - 'db/migrate/20190823221802_add_local_index_to_statuses.rb' - 'db/migrate/20200119112504_add_public_index_to_statuses.rb' + - 'db/migrate/20231212225737_improve_index_for_public_timeline_speed.rb' - 'spec/models/account_spec.rb' - 'spec/models/domain_block_spec.rb' - 'spec/models/user_spec.rb' @@ -441,6 +442,7 @@ Rails/SkipsModelValidations: - 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb' - 'db/migrate/20191007013357_update_pt_locales.rb' - 'db/migrate/20220316233212_update_kurdish_locales.rb' + - 'db/migrate/20240109035435_remove_hidden_anonymous_from_domain_blocks.rb' - 'db/post_migrate/20190511152737_remove_suspended_silenced_account_fields.rb' - 'db/post_migrate/20200917193528_migrate_notifications_type.rb' - 'db/post_migrate/20201017234926_fill_account_suspension_origin.rb' diff --git a/.ruby-version b/.ruby-version index be94e6f53d..b347b11eac 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.2.3 diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz new file mode 100644 index 0000000000..4dc538b2ca Binary files /dev/null and b/.yarn/install-state.gz differ diff --git a/CHANGELOG.md b/CHANGELOG.md index f9303f0115..f3e364320b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,247 @@ All notable changes to this project will be documented in this file. +## [4.2.13] - 2024-09-30 + +### Security + +- Fix ReDoS vulnerability on some Ruby versions ([GHSA-jpxp-r43f-rhvx](https://github.com/mastodon/mastodon/security/advisories/GHSA-jpxp-r43f-rhvx)) +- Update dependencies + +### Added + +- Add “A Mastodon update is available.” message on admin dashboard for non-bugfix updates (#32106 by @ClearlyClaire) + +### Changed + +- Change Mastodon to issue correct HTTP signatures by default (#31994 by @ClearlyClaire) + +### Fixed + +- Fix replies collection being cached improperly +- Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire) +- Fix error when encountering reblog of deleted post in feed rebuild (#32001 by @ClearlyClaire) + +## [4.2.12] - 2024-08-19 + +### Fixed + +- Fix broken notifications for mentions from local moderators ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31484)) + +## [4.2.11] - 2024-08-16 + +### Added + +- Add support for incoming `` tag ([mediaformat](https://github.com/mastodon/mastodon/pull/31375)) + +### Changed + +- Change logic of block/mute bypass for mentions from moderators to only apply to visible roles with moderation powers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31271)) + +### Fixed + +- Fix incorrect rate limit on PUT requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31356)) +- Fix presence of `ß` in adjacent word preventing mention and hashtag matching ([adamniedzielski](https://github.com/mastodon/mastodon/pull/31122)) +- Fix processing of webfinger responses with multiple `self` links ([adamniedzielski](https://github.com/mastodon/mastodon/pull/31110)) +- Fix duplicate `orderedItems` in user archive's `outbox.json` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31099)) +- Fix click event handling when clicking outside of an open dropdown menu ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31251)) +- Fix status processing failing halfway when a remote post has a malformed `replies` attribute ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31246)) +- Fix `--verbose` option of `tootctl media remove`, which was previously erroneously removed ([mjankowski](https://github.com/mastodon/mastodon/pull/30536)) +- Fix division by zero on some video/GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30600)) +- Fix Web UI trying to save user settings despite being logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30324)) +- Fix hashtag regexp matching some link anchors ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30190)) +- Fix local account search on LDAP login being case-sensitive ([raucao](https://github.com/mastodon/mastodon/pull/30113)) +- Fix development environment admin account not being auto-approved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29958)) +- Fix report reason selector in moderation interface not unselecting rules when changing category ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29026)) +- Fix already-invalid reports failing to resolve ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29027)) +- Fix OCR when using S3/CDN for assets ([vmstan](https://github.com/mastodon/mastodon/pull/28551)) +- Fix error when encountering malformed `Tag` objects from Kbin ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28235)) +- Fix not all allowed image formats showing in file picker when uploading custom emoji ([june128](https://github.com/mastodon/mastodon/pull/28076)) +- Fix search popout listing unusable search options when logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27918)) +- Fix processing of featured collections lacking an `items` attribute ([tribela](https://github.com/mastodon/mastodon/pull/27581)) +- Fix `mastodon:stats` decoration of stats rake task ([mjankowski](https://github.com/mastodon/mastodon/pull/31104)) + +## [4.2.10] - 2024-07-04 + +### Security + +- Fix incorrect permission checking on multiple API endpoints ([GHSA-58x8-3qxw-6hm7](https://github.com/mastodon/mastodon/security/advisories/GHSA-58x8-3qxw-6hm7)) +- Fix incorrect authorship checking when processing some activities (CVE-2024-37903, [GHSA-xjvf-fm67-4qc3](https://github.com/mastodon/mastodon/security/advisories/GHSA-xjvf-fm67-4qc3)) +- Fix ongoing streaming sessions not being invalidated when application tokens get revoked ([GHSA-vp5r-5pgw-jwqx](https://github.com/mastodon/mastodon/security/advisories/GHSA-vp5r-5pgw-jwqx)) +- Update dependencies + +### Added + +- Add yarn version specification to avoid confusion with Yarn 3 and Yarn 4 + +### Changed + +- Change preview cards generation to skip unusually long URLs ([oneiros](https://github.com/mastodon/mastodon/pull/30854)) +- Change search modifiers to be case-insensitive ([Gargron](https://github.com/mastodon/mastodon/pull/30865)) +- Change `STATSD_ADDR` handling to emit a warning rather than crashing if the address is unreachable ([timothyjrogers](https://github.com/mastodon/mastodon/pull/30691)) +- Change PWA start URL from `/home` to `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27377)) + +### Removed + +- Removed dependency on `posix-spawn` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18559)) + +### Fixed + +- Fix scheduled statuses scheduled in less than 5 minutes being immediately published ([danielmbrasil](https://github.com/mastodon/mastodon/pull/30584)) +- Fix encoding detection for link cards ([oneiros](https://github.com/mastodon/mastodon/pull/30780)) +- Fix `/admin/accounts/:account_id/statuses/:id` for edited posts with media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30819)) +- Fix duplicate `@context` attribute in user archive export ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30653)) + +## [4.2.9] - 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 rate-limit on OAuth application registration ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/30316)) +- 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)) +- Remove aggressive OAuth application vacuuming ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/30316)) + +### 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.2.8] - 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.2.7] - 2024-02-16 + +### Fixed + +- Fix OmniAuth tests and edge cases in error handling ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29201), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/29207)) +- Fix new installs by upgrading to the latest release of the `nsa` gem, instead of a no longer existing commit ([mjankowski](https://github.com/mastodon/mastodon/pull/29065)) + +### Security + +- Fix insufficient checking of remote posts ([GHSA-jhrq-qvrm-qr36](https://github.com/mastodon/mastodon/security/advisories/GHSA-jhrq-qvrm-qr36)) + +## [4.2.6] - 2024-02-14 + +### Security + +- Update the `sidekiq-unique-jobs` dependency (see [GHSA-cmh9-rx85-xj38](https://github.com/mhenrixon/sidekiq-unique-jobs/security/advisories/GHSA-cmh9-rx85-xj38)) + In addition, we have disabled the web interface for `sidekiq-unique-jobs` out of caution. + If you need it, you can re-enable it by setting `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI=true`. + If you only need to clear all locks, you can now use `bundle exec rake sidekiq_unique_jobs:delete_all_locks`. +- Update the `nokogiri` dependency (see [GHSA-xc9x-jj77-9p9j](https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-xc9x-jj77-9p9j)) +- Disable administrative Doorkeeper routes ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/29187)) +- Fix ongoing streaming sessions not being invalidated when applications get deleted in some cases ([GHSA-7w3c-p9j8-mq3x](https://github.com/mastodon/mastodon/security/advisories/GHSA-7w3c-p9j8-mq3x)) + In some rare cases, the streaming server was not notified of access tokens revocation on application deletion. +- Change external authentication behavior to never reattach a new identity to an existing user by default ([GHSA-vm39-j3vx-pch3](https://github.com/mastodon/mastodon/security/advisories/GHSA-vm39-j3vx-pch3)) + Up until now, Mastodon has allowed new identities from external authentication providers to attach to an existing local user based on their verified e-mail address. + This allowed upgrading users from a database-stored password to an external authentication provider, or move from one authentication provider to another. + However, this behavior may be unexpected, and means that when multiple authentication providers are configured, the overall security would be that of the least secure authentication provider. + For these reasons, this behavior is now locked under the `ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH` environment variable. + In addition, regardless of this environment variable, Mastodon will refuse to attach two identities from the same authentication provider to the same account. + +## [4.2.5] - 2024-02-01 + +### Security + +- Fix insufficient origin validation (CVE-2024-23832, [GHSA-3fjr-858r-92rw](https://github.com/mastodon/mastodon/security/advisories/GHSA-3fjr-858r-92rw)) + +## [4.2.4] - 2024-01-24 + +### Fixed + +- Fix error when processing remote files with unusually long names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28823)) +- Fix processing of compacted single-item JSON-LD collections ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28816)) +- Retry 401 errors on replies fetching ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28788)) +- Fix `RecordNotUnique` errors in LinkCrawlWorker ([tribela](https://github.com/mastodon/mastodon/pull/28748)) +- Fix Mastodon not correctly processing HTTP Signatures with query strings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28443), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28476)) +- Fix potential redirection loop of streaming endpoint ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28665)) +- Fix streaming API redirection ignoring the port of `streaming_api_base_url` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28558)) +- Fix error when processing link preview with an array as `inLanguage` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28252)) +- Fix unsupported time zone or locale preventing sign-up ([Gargron](https://github.com/mastodon/mastodon/pull/28035)) +- Fix "Hide these posts from home" list setting not refreshing when switching lists ([brianholley](https://github.com/mastodon/mastodon/pull/27763)) +- Fix missing background behind dismissable banner in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/27479)) +- Fix line wrapping of language selection button with long locale codes ([gunchleoc](https://github.com/mastodon/mastodon/pull/27100), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27127)) +- Fix `Undo Announce` activity not being sent to non-follower authors ([MitarashiDango](https://github.com/mastodon/mastodon/pull/18482)) +- Fix N+1s because of association preloaders not actually getting called ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28339)) +- Fix empty column explainer getting cropped under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28337)) +- Fix `LinkCrawlWorker` error when encountering empty OEmbed response ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28268)) +- Fix call to inefficient `delete_matched` cache method in domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28367)) + +### Security + +- Add rate-limit of TOTP authentication attempts at controller level ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28801)) + +## [4.2.3] - 2023-12-05 + +### Fixed + +- Fix dependency on `json-canonicalization` version that has been made unavailable since last release + +## [4.2.2] - 2023-12-04 + +### Changed + +- Change dismissed banners to be stored server-side ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27055)) +- Change GIF max matrix size error to explicitly mention GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27927)) +- Change `Follow` activities delivery to bypass availability check ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/27586)) +- Change single-column navigation notice to be displayed outside of the logo container ([renchap](https://github.com/mastodon/mastodon/pull/27462), [renchap](https://github.com/mastodon/mastodon/pull/27476)) +- Change Content-Security-Policy to be tighter on media paths ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26889)) +- Change post language code to include country code when relevant ([gunchleoc](https://github.com/mastodon/mastodon/pull/27099), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27207)) + +### Fixed + +- Fix upper border radius of onboarding columns ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27890)) +- Fix incoming status creation date not being restricted to standard ISO8601 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27655), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28081)) +- Fix some posts from threads received out-of-order sometimes not being inserted into timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27653)) +- Fix posts from force-sensitized accounts being able to trend ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27620)) +- Fix error when trying to delete already-deleted file with OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27569)) +- Fix batch attachment deletion when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27554)) +- Fix processing LDSigned activities from actors with unknown public keys ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27474)) +- Fix error and incorrect URLs in `/api/v1/accounts/:id/featured_tags` for remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27459)) +- Fix report processing notice not mentioning the report number when performing a custom action ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27442)) +- Fix handling of `inLanguage` attribute in preview card processing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27423)) +- Fix own posts being removed from home timeline when unfollowing a used hashtag ([kmycode](https://github.com/mastodon/mastodon/pull/27391)) +- Fix some link anchors being recognized as hashtags ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27271), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27584)) +- Fix format-dependent redirects being cached regardless of requested format ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27634)) + ## [4.2.1] - 2023-10-10 ### Added diff --git a/Dockerfile b/Dockerfile index f73bdcf786..980d705092 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # This needs to be bookworm-slim because the Ruby image is built on bookworm-slim ARG NODE_VERSION="20.6-bookworm-slim" -FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby +FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.3-slim as ruby FROM node:${NODE_VERSION} as build COPY --link --from=ruby /opt/ruby /opt/ruby diff --git a/Gemfile b/Gemfile index 18452b1ce9..78f8716f78 100644 --- a/Gemfile +++ b/Gemfile @@ -61,11 +61,10 @@ gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' gem 'nokogiri', '~> 1.15' -gem 'nsa', github: 'jhawthorn/nsa', ref: 'e020fcc3a54d993ab45b7194d89ab720296c111b' +gem 'nsa' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' -gem 'posix-spawn' gem 'public_suffix', '~> 5.0' gem 'pundit', '~> 2.3' gem 'premailer-rails' @@ -206,3 +205,5 @@ gem 'net-http', '~> 0.3.2' gem 'rubyzip', '~> 2.3' gem 'hcaptcha', '~> 7.1' + +gem 'mail', '~> 2.8' diff --git a/Gemfile.lock b/Gemfile.lock index d348811d3a..4ccccc52c2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,17 +7,6 @@ GIT hkdf (~> 0.2) jwt (~> 2.0) -GIT - remote: https://github.com/jhawthorn/nsa.git - revision: e020fcc3a54d993ab45b7194d89ab720296c111b - ref: e020fcc3a54d993ab45b7194d89ab720296c111b - specs: - nsa (0.2.8) - activesupport (>= 4.2, < 7.2) - concurrent-ruby (~> 1.0, >= 1.0.2) - sidekiq (>= 3.5) - statsd-ruby (~> 1.4, >= 1.4.0) - GIT remote: https://github.com/mastodon/rails-settings-cached.git revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab @@ -39,47 +28,47 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.0.8) - actionpack (= 7.0.8) - activesupport (= 7.0.8) + actioncable (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8) - actionpack (= 7.0.8) - activejob (= 7.0.8) - activerecord (= 7.0.8) - activestorage (= 7.0.8) - activesupport (= 7.0.8) + actionmailbox (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8) - actionpack (= 7.0.8) - actionview (= 7.0.8) - activejob (= 7.0.8) - activesupport (= 7.0.8) + actionmailer (7.0.8.4) + actionpack (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activesupport (= 7.0.8.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8) - actionview (= 7.0.8) - activesupport (= 7.0.8) + actionpack (7.0.8.4) + actionview (= 7.0.8.4) + activesupport (= 7.0.8.4) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8) - actionpack (= 7.0.8) - activerecord (= 7.0.8) - activestorage (= 7.0.8) - activesupport (= 7.0.8) + actiontext (7.0.8.4) + actionpack (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8) - activesupport (= 7.0.8) + actionview (7.0.8.4) + activesupport (= 7.0.8.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -89,22 +78,22 @@ GEM activemodel (>= 4.1, < 7.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (7.0.8) - activesupport (= 7.0.8) + activejob (7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.3.6) - activemodel (7.0.8) - activesupport (= 7.0.8) - activerecord (7.0.8) - activemodel (= 7.0.8) - activesupport (= 7.0.8) - activestorage (7.0.8) - actionpack (= 7.0.8) - activejob (= 7.0.8) - activerecord (= 7.0.8) - activesupport (= 7.0.8) + activemodel (7.0.8.4) + activesupport (= 7.0.8.4) + activerecord (7.0.8.4) + activemodel (= 7.0.8.4) + activesupport (= 7.0.8.4) + activestorage (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activesupport (= 7.0.8.4) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.8) + activesupport (7.0.8.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -148,6 +137,7 @@ GEM net-http-persistent (~> 4.0) nokogiri (~> 1, >= 1.10.8) base64 (0.1.1) + bcp47_spec (0.2.1) bcrypt (3.1.18) better_errors (2.10.1) erubi (>= 1.0.0) @@ -201,8 +191,8 @@ GEM xpath (~> 3.2) case_transform (0.2) activesupport - cbor (0.5.9.6) - charlock_holmes (0.7.7) + cbor (0.5.9.8) + charlock_holmes (0.7.8) chewy (7.3.4) activesupport (>= 5.2) elasticsearch (>= 7.12.0, < 7.14.0) @@ -211,7 +201,7 @@ GEM climate_control (0.2.0) cocoon (1.2.15) color_diff (0.1) - concurrent-ruby (1.2.2) + concurrent-ruby (1.3.4) connection_pool (2.4.1) cose (1.3.0) cbor (~> 0.5.9) @@ -225,7 +215,7 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.3.3) + date (3.3.4) debug_inspector (1.1.0) devise (4.9.2) bcrypt (~> 3.0) @@ -266,7 +256,7 @@ GEM multi_json encryptor (3.0.0) erubi (1.12.0) - et-orbi (1.2.7) + et-orbi (1.2.11) tzinfo excon (0.100.0) fabrication (2.30.0) @@ -298,7 +288,7 @@ GEM faraday_middleware (1.2.0) faraday (~> 1.0) fast_blank (1.0.1) - fastimage (2.2.7) + fastimage (2.3.1) ffi (1.15.5) ffi-compiler (1.0.1) ffi (>= 1.0.0) @@ -316,8 +306,8 @@ GEM fog-json (>= 1.0) ipaddress (>= 0.8) formatador (0.3.0) - fugit (1.8.1) - et-orbi (~> 1, >= 1.2.7) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) fuubar (2.5.1) rspec-core (~> 3.0) @@ -360,7 +350,7 @@ GEM httplog (1.6.2) rack (>= 2.0) rainbow (>= 2.0.0) - i18n (1.14.1) + i18n (1.14.5) concurrent-ruby (~> 1.0) i18n-tasks (1.0.12) activesupport (>= 4.0.2) @@ -377,19 +367,19 @@ GEM ipaddress (0.8.3) jmespath (1.6.2) json (2.6.3) - json-canonicalization (0.3.2) - json-jwt (1.15.3) + json-canonicalization (1.0.0) + json-jwt (1.15.3.1) activesupport (>= 4.2) aes_key_wrap bindata httpclient - json-ld (3.2.5) + json-ld (3.3.1) htmlentities (~> 4.3) - json-canonicalization (~> 0.3, >= 0.3.2) + json-canonicalization (~> 1.0) link_header (~> 0.0, >= 0.0.8) multi_json (~> 1.15) rack (>= 2.2, < 4) - rdf (~> 3.2, >= 3.2.10) + rdf (~> 3.3) json-ld-preloaded (3.2.2) json-ld (~> 3.2) rdf (~> 3.2) @@ -434,7 +424,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.21.3) + loofah (2.21.4) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -442,7 +432,7 @@ GEM net-imap net-pop net-smtp - marcel (1.0.2) + marcel (1.0.4) mario-redis-lock (1.2.1) redis (>= 3.0.5) matrix (0.4.2) @@ -456,7 +446,7 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2023.0808) mini_mime (1.1.5) - mini_portile2 (2.8.4) + mini_portile2 (2.8.7) minitest (5.19.0) msgpack (1.7.1) multi_json (1.15.0) @@ -471,28 +461,33 @@ GEM net-ldap (0.18.0) net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout net-scp (4.0.0) net-ssh (>= 2.6.5, < 8.0.0) - net-smtp (0.3.3) + net-smtp (0.3.4) net-protocol net-ssh (7.1.0) - nio4r (2.5.9) - nokogiri (1.15.4) + nio4r (2.7.3) + nokogiri (1.16.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nsa (0.3.0) + activesupport (>= 4.2, < 7.2) + concurrent-ruby (~> 1.0, >= 1.0.2) + sidekiq (>= 3.5) + statsd-ruby (~> 1.4, >= 1.4.0) oj (3.16.1) - omniauth (2.1.1) + omniauth (2.1.2) hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection omniauth-rails_csrf_protection (1.0.1) actionpack (>= 4.2) omniauth (~> 2.0) - omniauth-saml (2.1.0) - omniauth (~> 2.0) - ruby-saml (~> 1.12) + omniauth-saml (2.1.2) + omniauth (~> 2.1) + ruby-saml (~> 1.17) omniauth_openid_connect (0.6.1) omniauth (>= 1.9, < 3) openid_connect (~> 1.1) @@ -519,10 +514,9 @@ GEM parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.4) + pg (1.5.5) pghero (3.3.4) activerecord (>= 6) - posix-spawn (0.3.15) premailer (1.21.0) addressable css_parser (>= 1.12.0) @@ -533,16 +527,16 @@ GEM premailer (~> 1.7, >= 1.7.9) private_address_check (0.5.0) public_suffix (5.0.3) - puma (6.3.1) + puma (6.4.3) nio4r (~> 2.0) pundit (2.3.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.7.1) - rack (2.2.8) + racc (1.8.1) + rack (2.2.9) rack-attack (6.7.0) rack (>= 1.0, < 4) - rack-cors (2.0.1) + rack-cors (2.0.2) rack (>= 2.0.0) rack-oauth2 (1.21.3) activesupport @@ -550,26 +544,26 @@ GEM httpclient json-jwt (>= 1.11.0) rack (>= 2.1.0) - rack-protection (3.0.5) + rack-protection (3.0.6) rack rack-proxy (0.7.6) rack rack-test (2.1.0) rack (>= 1.3) - rails (7.0.8) - actioncable (= 7.0.8) - actionmailbox (= 7.0.8) - actionmailer (= 7.0.8) - actionpack (= 7.0.8) - actiontext (= 7.0.8) - actionview (= 7.0.8) - activejob (= 7.0.8) - activemodel (= 7.0.8) - activerecord (= 7.0.8) - activestorage (= 7.0.8) - activesupport (= 7.0.8) + rails (7.0.8.4) + actioncable (= 7.0.8.4) + actionmailbox (= 7.0.8.4) + actionmailer (= 7.0.8.4) + actionpack (= 7.0.8.4) + actiontext (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activemodel (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) bundler (>= 1.15.0) - railties (= 7.0.8) + railties (= 7.0.8.4) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -584,16 +578,17 @@ GEM rails-i18n (7.0.7) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.0.8) - actionpack (= 7.0.8) - activesupport (= 7.0.8) + railties (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) method_source rake (>= 12.2) thor (~> 1.0) zeitwerk (~> 2.5) rainbow (3.1.1) rake (13.0.6) - rdf (3.2.11) + rdf (3.3.1) + bcp47_spec (~> 0.2) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.6.1) rdf (~> 3.2) @@ -609,8 +604,8 @@ GEM responders (3.1.0) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.6) - rotp (6.2.2) + rexml (3.3.7) + rotp (6.3.0) rouge (4.1.2) rpam2 (4.0.2) rqrcode (2.2.0) @@ -673,7 +668,7 @@ GEM rubocop-factory_bot (~> 2.22) ruby-prof (1.6.3) ruby-progressbar (1.13.0) - ruby-saml (1.15.0) + ruby-saml (1.17.0) nokogiri (>= 1.13.10) rexml ruby2_keywords (0.0.5) @@ -693,7 +688,7 @@ GEM rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) semantic_range (3.0.0) - sidekiq (6.5.10) + sidekiq (6.5.12) connection_pool (>= 2.2.5, < 3) rack (~> 2.0) redis (>= 4.5.0, < 5) @@ -703,7 +698,7 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) 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) @@ -748,9 +743,9 @@ GEM terrapin (0.6.0) climate_control (>= 0.0.3, < 1.0) test-prof (1.2.3) - thor (1.2.2) + thor (1.3.1) tilt (2.2.0) - timeout (0.4.0) + timeout (0.4.1) tpm-key_attestation (0.12.0) bindata (~> 2.4) openssl (> 2.0) @@ -814,7 +809,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.11) + zeitwerk (2.6.16) PLATFORMS ruby @@ -877,6 +872,7 @@ DEPENDENCIES letter_opener_web (~> 2.0) link_header (~> 0.0) lograge (~> 0.12) + mail (~> 2.8) mario-redis-lock (~> 1.2) md-paperclip-azure (~> 2.2) memory_profiler @@ -884,7 +880,7 @@ DEPENDENCIES net-http (~> 0.3.2) net-ldap (~> 0.18) nokogiri (~> 1.15) - nsa! + nsa oj (~> 3.14) omniauth (~> 2.0) omniauth-cas! @@ -895,7 +891,6 @@ DEPENDENCIES parslet pg (~> 1.5) pghero - posix-spawn premailer-rails private_address_check (~> 0.5) public_suffix (~> 5.0) diff --git a/SECURITY.md b/SECURITY.md index 3e13377db6..81472b01b4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,10 +13,8 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through ## Supported Versions -| Version | Supported | -| ------- | ---------------- | -| 4.2.x | Yes | -| 4.1.x | Yes | -| 4.0.x | Until 2023-10-31 | -| 3.5.x | Until 2023-12-31 | -| < 3.5 | No | +| Version | Supported | +| ------- | --------- | +| 4.2.x | Yes | +| 4.1.x | Yes | +| < 4.1 | No | diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb index 9503fb85a1..fa89ac06a8 100644 --- a/app/chewy/accounts_index.rb +++ b/app/chewy/accounts_index.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class AccountsIndex < Chewy::Index + include DatetimeClampingConcern + DEVELOPMENT_SETTINGS = { filter: { english_stop: { @@ -153,7 +155,7 @@ class AccountsIndex < Chewy::Index field(:following_count, type: 'long', value: ->(account) { account.public_following_count }) field(:followers_count, type: 'long', value: ->(account) { account.public_followers_count }) field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties }) - field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }) + field(:last_status_at, type: 'date', value: ->(account) { clamp_date(account.last_status_at || account.created_at) }) field(:domain, type: 'keyword', value: ->(account) { account.domain || '' }) field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } diff --git a/app/chewy/concerns/datetime_clamping_concern.rb b/app/chewy/concerns/datetime_clamping_concern.rb new file mode 100644 index 0000000000..7f176b6e54 --- /dev/null +++ b/app/chewy/concerns/datetime_clamping_concern.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module DatetimeClampingConcern + extend ActiveSupport::Concern + + MIN_ISO8601_DATETIME = '0000-01-01T00:00:00Z'.to_datetime.freeze + MAX_ISO8601_DATETIME = '9999-12-31T23:59:59Z'.to_datetime.freeze + + class_methods do + def clamp_date(datetime) + datetime.clamp(MIN_ISO8601_DATETIME, MAX_ISO8601_DATETIME) + end + end +end diff --git a/app/chewy/public_statuses_index.rb b/app/chewy/public_statuses_index.rb index fc58f8f097..0d53eb2f41 100644 --- a/app/chewy/public_statuses_index.rb +++ b/app/chewy/public_statuses_index.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PublicStatusesIndex < Chewy::Index + include DatetimeClampingConcern + DEVELOPMENT_SETTINGS = { filter: { english_stop: { @@ -154,6 +156,6 @@ class PublicStatusesIndex < Chewy::Index field(:language, type: 'keyword') field(:domain, type: 'keyword', value: ->(status) { status.account.domain || '' }) field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) - field(:created_at, type: 'date') + field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) }) end end diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index 6a6621fb8c..647b35c001 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class StatusesIndex < Chewy::Index + include DatetimeClampingConcern + DEVELOPMENT_SETTINGS = { filter: { english_stop: { @@ -184,6 +186,6 @@ class StatusesIndex < Chewy::Index field(:language, type: 'keyword') field(:domain, type: 'keyword', value: ->(status) { status.account.domain || '' }) field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) - field(:created_at, type: 'date') + field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) }) end end diff --git a/app/chewy/tags_index.rb b/app/chewy/tags_index.rb index 5b6349a964..c99218a47f 100644 --- a/app/chewy/tags_index.rb +++ b/app/chewy/tags_index.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class TagsIndex < Chewy::Index + include DatetimeClampingConcern + settings index: index_preset(refresh_interval: '30s'), analysis: { analyzer: { content: { @@ -42,6 +44,6 @@ class TagsIndex < Chewy::Index field(:name, type: 'text', analyzer: 'content', value: :display_name) { field(:edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content') } field(:reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }) field(:usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts }) - field(:last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }) + field(:last_status_at, type: 'date', value: ->(tag) { clamp_date(tag.last_status_at || tag.created_at) }) end end diff --git a/app/controllers/activitypub/references_controller.rb b/app/controllers/activitypub/references_controller.rb index 7cd903eaf4..58c70e2771 100644 --- a/app/controllers/activitypub/references_controller.rb +++ b/app/controllers/activitypub/references_controller.rb @@ -5,8 +5,6 @@ class ActivityPub::ReferencesController < ActivityPub::BaseController include Authorization include AccountOwnedConcern - REFERENCES_LIMIT = 5 - before_action :require_signature!, if: :authorized_fetch_mode? before_action :set_status @@ -40,17 +38,21 @@ class ActivityPub::ReferencesController < ActivityPub::BaseController @results ||= begin references = @status.reference_objects.order(target_status_id: :asc) references = references.where('target_status_id > ?', page_params[:min_id]) if page_params[:min_id].present? - references = references.limit(limit_param(REFERENCES_LIMIT)) + references = references.limit(limit_param(references_limit)) references.pluck(:target_status_id) end end + def references_limit + StatusReference::REFERENCES_LIMIT + end + def pagination_min_id results.last end def records_continue? - results.size == limit_param(REFERENCES_LIMIT) + results.size == limit_param(references_limit) end def references_collection_presenter diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index f7afc9ba31..fc93c02cfb 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -14,7 +14,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController before_action :set_replies def index - expires_in 0, public: public_fetch_mode? + expires_in 0, public: @status.distributable? && public_fetch_mode? render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true end diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index e89404b609..e674bf55a0 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -21,7 +21,7 @@ module Admin account_action.save! if account_action.with_report? - redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id]) + redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id]) else redirect_to admin_account_path(@account.id) end diff --git a/app/controllers/admin/domain_allows_controller.rb b/app/controllers/admin/domain_allows_controller.rb index 31be1978bb..b0f139e3a8 100644 --- a/app/controllers/admin/domain_allows_controller.rb +++ b/app/controllers/admin/domain_allows_controller.rb @@ -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 diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index c91b9b7163..11ea8fb566 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -88,18 +88,18 @@ module Admin end def update_params - params.require(:domain_block).permit(:severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, - :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) + params.require(:domain_block).permit(:severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, + :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) end def resource_params - params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, - :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) + params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, + :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) end def form_domain_block_batch_params - params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, - :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous]) + params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, + :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden]) end def action_from_button diff --git a/app/controllers/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb index 032e807d11..6d115631a2 100644 --- a/app/controllers/api/v1/accounts/notes_controller.rb +++ b/app/controllers/api/v1/accounts/notes_controller.rb @@ -25,6 +25,6 @@ class Api::V1::Accounts::NotesController < Api::BaseController end def relationships_presenter - AccountRelationshipsPresenter.new([@account.id], current_user.account_id) + AccountRelationshipsPresenter.new([@account], current_user.account_id) end end diff --git a/app/controllers/api/v1/accounts/pins_controller.rb b/app/controllers/api/v1/accounts/pins_controller.rb index 73f845c614..0eb13c048c 100644 --- a/app/controllers/api/v1/accounts/pins_controller.rb +++ b/app/controllers/api/v1/accounts/pins_controller.rb @@ -25,6 +25,6 @@ class Api::V1::Accounts::PinsController < Api::BaseController end def relationships_presenter - AccountRelationshipsPresenter.new([@account.id], current_user.account_id) + AccountRelationshipsPresenter.new([@account], current_user.account_id) end end diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index 503f85c97d..038d6700f5 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -5,11 +5,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController before_action :require_user! def index - accounts = Account.without_suspended.where(id: account_ids).select('id') + @accounts = Account.without_suspended.where(id: account_ids).select(:id, :domain).to_a # .where doesn't guarantee that our results are in the same order # we requested them, so return the "right" order to the requestor. - @accounts = accounts.index_by(&:id).values_at(*account_ids).compact - render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships + render json: @accounts.index_by(&:id).values_at(*account_ids).compact, each_serializer: REST::RelationshipSerializer, relationships: relationships end private diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 0addcbe215..5601d54094 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -86,7 +86,7 @@ class Api::V1::AccountsController < Api::BaseController end def relationships(**options) - AccountRelationshipsPresenter.new([@account.id], current_user.account_id, **options) + AccountRelationshipsPresenter.new([@account], current_user.account_id, **options) end def account_params diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb index bd0660dbaa..765ea9572b 100644 --- a/app/controllers/api/v1/admin/domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb @@ -29,10 +29,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 @@ -69,8 +74,8 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController end def domain_block_params - params.permit(:severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_reports, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, - :reject_new_follow, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) + params.permit(:severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_reports, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, + :reject_new_follow, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate, :hidden) end def insert_pagination_headers @@ -102,7 +107,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController end def resource_params - params.permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, - :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) + params.permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, + :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) end end diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb index 76633210a1..4f732ed2d5 100644 --- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -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 diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index 7c197ce6ba..ee717ebbcc 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -25,11 +25,11 @@ class Api::V1::FollowRequestsController < Api::BaseController private def account - Account.find(params[:id]) + @account ||= Account.find(params[:id]) end def relationships(**options) - AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, **options) + AccountRelationshipsPresenter.new([account], current_user.account_id, **options) end def load_accounts diff --git a/app/controllers/api/v1/instances/domain_blocks_controller.rb b/app/controllers/api/v1/instances/domain_blocks_controller.rb index e91f48e1db..c91234e088 100644 --- a/app/controllers/api/v1/instances/domain_blocks_controller.rb +++ b/app/controllers/api/v1/instances/domain_blocks_controller.rb @@ -26,6 +26,5 @@ class Api::V1::Instances::DomainBlocksController < Api::BaseController def set_domain_blocks @domain_blocks = DomainBlock.with_user_facing_limitations.by_severity - @domain_blocks = @domain_blocks.filter { |block| !block.hidden_anonymous } unless user_signed_in? end end diff --git a/app/controllers/api/v1/scheduled_statuses_controller.rb b/app/controllers/api/v1/scheduled_statuses_controller.rb index 2220b6d22e..b33b534ebb 100644 --- a/app/controllers/api/v1/scheduled_statuses_controller.rb +++ b/app/controllers/api/v1/scheduled_statuses_controller.rb @@ -6,6 +6,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, except: [:update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:update, :destroy] + before_action :require_user! before_action :set_statuses, only: :index before_action :set_status, except: :index diff --git a/app/controllers/api/v1/statuses/translations_controller.rb b/app/controllers/api/v1/statuses/translations_controller.rb index ec5ea5b85b..5e5ee7d38e 100644 --- a/app/controllers/api/v1/statuses/translations_controller.rb +++ b/app/controllers/api/v1/statuses/translations_controller.rb @@ -4,6 +4,7 @@ class Api::V1::Statuses::TranslationsController < Api::BaseController include Authorization before_action -> { doorkeeper_authorize! :read, :'read:statuses' } + before_action :require_user! before_action :set_status before_action :set_translation diff --git a/app/controllers/api/v1/streaming_controller.rb b/app/controllers/api/v1/streaming_controller.rb index 0cdd00d62f..adb14676e1 100644 --- a/app/controllers/api/v1/streaming_controller.rb +++ b/app/controllers/api/v1/streaming_controller.rb @@ -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, allow_other_host: true @@ -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 diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 9c320e688e..b71a26719c 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Api::V1::Timelines::PublicController < Api::BaseController + before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :require_user!, only: [:show], if: :require_auth? after_action :insert_pagination_headers, unless: -> { @statuses.empty? } diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 1de00f50c8..1084a3a7d9 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class Api::V1::Timelines::TagController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth? + before_action -> { authorize_if_got_token! :read, :'read:statuses' } + before_action :require_user!, if: :require_auth? before_action :load_tag after_action :insert_pagination_headers, unless: -> { @statuses.empty? } diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb index 4723806b92..b8570d0bfa 100644 --- a/app/controllers/auth/omniauth_callbacks_controller.rb +++ b/app/controllers/auth/omniauth_callbacks_controller.rb @@ -6,7 +6,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController def self.provides_callback_for(provider) define_method provider do @provider = provider - @user = User.find_for_oauth(request.env['omniauth.auth'], current_user) + @user = User.find_for_omniauth(request.env['omniauth.auth'], current_user) if @user.persisted? record_login_activity @@ -16,6 +16,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 diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 06a3deee2b..8212e2e630 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -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] @@ -134,9 +138,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) @@ -168,4 +186,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 diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index 55ebe1bd64..e4e32cdad9 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -180,6 +180,16 @@ module CacheConcern def render_with_cache(**options) raise ArgumentError, 'Only JSON render calls are supported' unless options.key?(:json) || block_given? + if options.delete(:cancel_cache) + if block_given? + options[:json] = yield + elsif options[:json].is_a?(Symbol) + options[:json] = send(options[:json]) + end + + return render(options) + end + key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':') expires_in = options.delete(:expires_in) || 3.minutes body = Rails.cache.read(key, raw: true) @@ -198,34 +208,19 @@ module CacheConcern end 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 = begin - Rails.cache.read_multi(*raw).transform_keys(&:id).transform_values { |r| ActiveRecordCoder.load(r) } - rescue ActiveRecordCoder::Error - {} # The serialization format may have changed, let's pretend it's a cache miss. - end + klass.preload_cacheable_associations(records) - uncached_ids = raw.map(&:id) - cached_keys_with_value.keys - - 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, ActiveRecordCoder.dump(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 diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index f0a344f1c9..92f1eb5a16 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -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'] @@ -180,11 +189,18 @@ module SignatureVerification nil end - def build_signed_string + def build_signed_string(include_query_string: true) signed_headers.map do |signed_header| case signed_header when Request::REQUEST_TARGET - "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + 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? @@ -250,7 +266,7 @@ module SignatureVerification stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), 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 diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb index 9eb45b90d6..90fa392a13 100644 --- a/app/controllers/concerns/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/two_factor_authentication_concern.rb @@ -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 diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 350ae2e906..17f1be23de 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -17,6 +17,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio def destroy Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner) + Doorkeeper::Application.find_by(id: params[:id])&.close_streaming_sessions(current_resource_owner) super end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index e87b5a656f..dd794f3199 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -33,7 +33,7 @@ class RelationshipsController < ApplicationController end def set_relationships - @relationships = AccountRelationshipsPresenter.new(@accounts.pluck(:id), current_user.account_id) + @relationships = AccountRelationshipsPresenter.new(@accounts, current_user.account_id) end def form_account_batch_params diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 50a8763b72..9ae15a6ed0 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -30,15 +30,15 @@ class StatusesController < ApplicationController end format.json do - expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? - render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter + expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? && !misskey_software? + render_with_cache json: @status, content_type: 'application/activity+json', serializer: status_activity_serializer, adapter: ActivityPub::Adapter, cancel_cache: misskey_software? end end end def activity - expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? - render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter + expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? && !misskey_software? + render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status, for_misskey: misskey_software?), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, cancel_cache: misskey_software? end def embed @@ -76,6 +76,29 @@ class StatusesController < ApplicationController @instance_presenter = InstancePresenter.new end + def misskey_software? + return @misskey_software if defined?(@misskey_software) + + @misskey_software = false + + return false if !@status.local? || signed_request_account&.domain.blank? + + info = InstanceInfo.find_by(domain: signed_request_account.domain) + return false if info.nil? + + @misskey_software = %w(misskey calckey cherrypick sharkey).include?(info.software) && + ((@status.public_unlisted_visibility? && @status.account.user&.setting_reject_public_unlisted_subscription) || + (@status.unlisted_visibility? && @status.account.user&.setting_reject_unlisted_subscription)) + end + + def status_activity_serializer + if misskey_software? + ActivityPub::NoteForMisskeySerializer + else + ActivityPub::NoteSerializer + end + end + def redirect_to_original redirect_to(ActivityPub::TagManager.instance.url_for(@status.reblog), allow_other_host: true) if @status.reblog? end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 4748940f7c..6cf37c2ff0 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -21,7 +21,7 @@ module WellKnown 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) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 1c976bce57..2db421d10e 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -155,8 +155,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']) @@ -164,17 +164,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 @@ -204,8 +216,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 diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index a8c66552cf..c42c4c23ef 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -254,6 +254,7 @@ module LanguagesHelper def valid_locale_or_nil(str) return if str.blank? + return str if valid_locale?(str) code, = str.to_s.split(/[_-]/) # Strip out the region from e.g. en_US or ja-JP diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js index 3685b0684e..fbd89f9d4b 100644 --- a/app/javascript/mastodon/actions/settings.js +++ b/app/javascript/mastodon/actions/settings.js @@ -20,7 +20,7 @@ export function changeSetting(path, value) { } const debouncedSave = debounce((dispatch, getState) => { - if (getState().getIn(['settings', 'saved'])) { + if (getState().getIn(['settings', 'saved']) || !getState().getIn(['meta', 'me'])) { return; } diff --git a/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx b/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx index ecce92b309..90f4334a6e 100644 --- a/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx +++ b/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx @@ -124,7 +124,7 @@ class ReportReasonSelector extends PureComponent { api().put(`/api/v1/admin/reports/${id}`, { category, - rule_ids, + rule_ids: category === 'violation' ? rule_ids : [], }).catch(err => { console.error(err); }); diff --git a/app/javascript/mastodon/components/dismissable_banner.tsx b/app/javascript/mastodon/components/dismissable_banner.tsx index 04a28e3cbe..6984691409 100644 --- a/app/javascript/mastodon/components/dismissable_banner.tsx +++ b/app/javascript/mastodon/components/dismissable_banner.tsx @@ -1,9 +1,16 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, + @typescript-eslint/no-unsafe-return, + @typescript-eslint/no-unsafe-assignment, + @typescript-eslint/no-unsafe-member-access + -- the settings store is not yet typed */ import type { PropsWithChildren } from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { changeSetting } from 'mastodon/actions/settings'; import { bannerSettings } from 'mastodon/settings'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { IconButton } from './icon_button'; @@ -19,13 +26,25 @@ export const DismissableBanner: React.FC> = ({ id, children, }) => { - const [visible, setVisible] = useState(!bannerSettings.get(id)); + const dismissed = useAppSelector((state) => + state.settings.getIn(['dismissed_banners', id], false), + ); + const dispatch = useAppDispatch(); + + const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed); const intl = useIntl(); const handleDismiss = useCallback(() => { setVisible(false); bannerSettings.set(id, true); - }, [id]); + dispatch(changeSetting(['dismissed_banners', id], true)); + }, [id, dispatch]); + + useEffect(() => { + if (!visible && !dismissed) { + dispatch(changeSetting(['dismissed_banners', id], true)); + } + }, [id, dispatch, visible, dismissed]); if (!visible) { return null; diff --git a/app/javascript/mastodon/components/dropdown_menu.jsx b/app/javascript/mastodon/components/dropdown_menu.jsx index fd66310e85..2457692cd8 100644 --- a/app/javascript/mastodon/components/dropdown_menu.jsx +++ b/app/javascript/mastodon/components/dropdown_menu.jsx @@ -40,6 +40,7 @@ class DropdownMenu extends PureComponent { if (this.node && !this.node.contains(e.target)) { this.props.onClose(); e.stopPropagation(); + e.preventDefault(); } }; diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx index b01c0ede8b..bacb43de2a 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx @@ -156,6 +156,7 @@ class PrivacyDropdown extends PureComponent { value: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, noDirect: PropTypes.bool, + noLimited: PropTypes.bool, container: PropTypes.func, disabled: PropTypes.bool, intl: PropTypes.object.isRequired, @@ -249,6 +250,10 @@ class PrivacyDropdown extends PureComponent { if (this.props.noDirect) { this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'direct'); } + + if (this.props.noLimited) { + this.selectableOptions = this.selectableOptions.filter((opt) => !['mutual', 'circle'].includes(opt.value)); + } } setTargetRef = c => { diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx index 7584dbc819..3e4c5d2be4 100644 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ b/app/javascript/mastodon/features/compose/components/search.jsx @@ -277,6 +277,7 @@ class Search extends PureComponent { } _calculateOptions (value) { + const { signedIn } = this.context.identity; const trimmedValue = value.trim(); const options = []; @@ -301,7 +302,7 @@ class Search extends PureComponent { const couldBeStatusSearch = searchEnabled; - if (couldBeStatusSearch) { + if (couldBeStatusSearch && signedIn) { options.push({ key: 'status-search', label: {trimmedValue} }} />, action: this.handleStatusSearch }); } @@ -378,7 +379,7 @@ class Search extends PureComponent {

- {searchEnabled ? ( + {searchEnabled && signedIn ? (
{this.defaultOptions.map(({ key, label, action }, i) => (
) : (
- + {searchEnabled ? ( + + ) : ( + + )}
)} diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.jsx b/app/javascript/mastodon/features/compose/components/text_icon_button.jsx index 46b5d7fada..166d022b88 100644 --- a/app/javascript/mastodon/features/compose/components/text_icon_button.jsx +++ b/app/javascript/mastodon/features/compose/components/text_icon_button.jsx @@ -4,7 +4,7 @@ import { PureComponent } from 'react'; const iconStyle = { height: null, lineHeight: '27px', - width: `${18 * 1.28571429}px`, + minWidth: `${18 * 1.28571429}px`, }; export default class TextIconButton extends PureComponent { diff --git a/app/javascript/mastodon/features/explore/statuses.jsx b/app/javascript/mastodon/features/explore/statuses.jsx index f32a4a5368..0d8d212b25 100644 --- a/app/javascript/mastodon/features/explore/statuses.jsx +++ b/app/javascript/mastodon/features/explore/statuses.jsx @@ -45,24 +45,20 @@ class Statuses extends PureComponent { const emptyMessage = ; return ( - <> - - - - - - + } + alwaysPrepend + timelineId='explore' + statusIds={statusIds} + scrollKey='explore-statuses' + hasMore={hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + withCounters + /> ); } diff --git a/app/javascript/mastodon/features/list_timeline/index.jsx b/app/javascript/mastodon/features/list_timeline/index.jsx index cb5a912130..edeab76923 100644 --- a/app/javascript/mastodon/features/list_timeline/index.jsx +++ b/app/javascript/mastodon/features/list_timeline/index.jsx @@ -207,7 +207,7 @@ class ListTimeline extends PureComponent {
- + diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.jsx b/app/javascript/mastodon/features/ui/components/boost_modal.jsx index fed1ef69df..68527d77d1 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/boost_modal.jsx @@ -140,6 +140,7 @@ class BoostModal extends ImmutablePureComponent { {status.get('visibility_ex') !== 'private' && !status.get('reblogged') && ( { if (status === 'recognizing text') { this.setState({ ocrStatus: 'detecting', progress }); diff --git a/app/javascript/mastodon/features/ui/components/link_footer.jsx b/app/javascript/mastodon/features/ui/components/link_footer.jsx index 9585df2ec4..6b1555243b 100644 --- a/app/javascript/mastodon/features/ui/components/link_footer.jsx +++ b/app/javascript/mastodon/features/ui/components/link_footer.jsx @@ -100,7 +100,7 @@ class LinkFooter extends PureComponent { {DividingCircle} {DividingCircle} - v{version} + v{version}

); diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx index 5b412ba147..b0c445cdd6 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx @@ -66,25 +66,30 @@ class NavigationPanel extends Component { ) : ( )); + let banner = undefined; + + if(transientSingleColumn) + banner = (
+ {intl.formatMessage(messages.openedInClassicInterface)} + {" "} + + {intl.formatMessage(messages.advancedInterface)} + +
); return (
- - {transientSingleColumn ? ( -
- {intl.formatMessage(messages.openedInClassicInterface)} - {" "} - - {intl.formatMessage(messages.advancedInterface)} - -
- ) : ( -
- )} + {!banner &&
}
+ {banner && + + } + {signedIn && ( <> diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 6cbb18c9b8..77b8f27ff1 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -625,6 +625,7 @@ "searchability.unlisted.short": "Followers and reactionners", "search_popout.domain": "domain", "search_popout.full_text_search_disabled_message": "Not available on {domain}.", + "search_popout.full_text_search_logged_out_message": "Only available when logged in.", "search_popout.language_code": "ISO language code", "search_popout.options": "Search options", "search_popout.quick_actions": "Quick actions", diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 5cdba8008f..f3450a74d6 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -110,6 +110,15 @@ const initialState = ImmutableMap({ body: '', }), }), + + dismissed_banners: ImmutableMap({ + 'public_timeline': false, + 'community_timeline': false, + 'home.explore_prompt': false, + 'explore/links': false, + 'explore/statuses': false, + 'explore/tags': false, + }), }); const defaultColumns = fromJS([ diff --git a/app/javascript/packs/admin.jsx b/app/javascript/packs/admin.jsx index f6d5bcd72a..b737560b41 100644 --- a/app/javascript/packs/admin.jsx +++ b/app/javascript/packs/admin.jsx @@ -155,6 +155,10 @@ delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'c const onChangeRegistrationMode = (target) => { const enabled = target.value === 'approved'; + [].forEach.call(document.querySelectorAll('.form_admin_settings_registrations_mode .warning-hint'), (warning_hint) => { + warning_hint.style.display = target.value === 'open' ? 'inline' : 'none'; + }); + [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => { input.disabled = !enabled; if (enabled) { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index d6ee4f18f9..ddebab878f 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -284,6 +284,7 @@ font-size: 11px; padding: 0 3px; line-height: 27px; + white-space: nowrap; &:hover, &:active, @@ -2303,8 +2304,7 @@ $ui-header-height: 55px; > .scrollable { background: $ui-base-color; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; + border-radius: 0 0 4px 4px; } } @@ -2530,6 +2530,7 @@ $ui-header-height: 55px; .navigation-panel__sign-in-banner, .navigation-panel__logo, + .navigation-panel__banner, .getting-started__trends { display: none; } @@ -4690,11 +4691,6 @@ a.status-card { align-items: center; justify-content: center; - @supports (display: grid) { - // hack to fix Chrome <57 - contain: strict; - } - & > span { max-width: 500px; } diff --git a/app/lib/account_statuses_filter.rb b/app/lib/account_statuses_filter.rb index 9a4a2b5d6e..9b279066d8 100644 --- a/app/lib/account_statuses_filter.rb +++ b/app/lib/account_statuses_filter.rb @@ -29,12 +29,12 @@ class AccountStatusesFilter available_searchabilities = [:public, :unlisted, :private, :direct, :limited, nil] available_visibilities = [:public, :public_unlisted, :login, :unlisted, :private, :direct, :limited] - available_searchabilities = [:public] if domain_block&.reject_send_not_public_searchability - available_visibilities -= [:public_unlisted] if domain_block&.reject_send_public_unlisted || (domain_block&.detect_invalid_subscription && @account.user&.setting_reject_public_unlisted_subscription) - available_visibilities -= [:unlisted] if domain_block&.detect_invalid_subscription && @account.user&.setting_reject_unlisted_subscription + available_visibilities -= [:public_unlisted] if (domain_block&.detect_invalid_subscription || misskey_software?) && @account.user&.setting_reject_public_unlisted_subscription + available_visibilities -= [:unlisted] if (domain_block&.detect_invalid_subscription || misskey_software?) && @account.user&.setting_reject_unlisted_subscription available_visibilities -= [:login] if current_account.nil? - scope.merge!(scope.where(spoiler_text: ['', nil])) if domain_block&.reject_send_sensitive + scope.merge!(scope.where(sensitive: false)) if domain_block&.reject_send_sensitive + scope.merge!(scope.where(searchability: available_searchabilities)) scope.merge!(scope.where(visibility: available_visibilities)) @@ -44,7 +44,7 @@ class AccountStatusesFilter private def initial_scope - if (suspended? || (domain_block&.reject_send_dissubscribable && @account.dissubscribable)) || domain_block&.reject_send_media || blocked? + if suspended? || blocked? Status.none elsif anonymous? account.statuses.where(visibility: %i(public unlisted public_unlisted)) @@ -156,6 +156,21 @@ class AccountStatusesFilter end def domain_block - @domain_block = DomainBlock.find_by(domain: @account&.domain) + return nil if @current_account.nil? || @current_account.local? + + @domain_block = DomainBlock.find_by(domain: @current_account.domain) + end + + def misskey_software? + return false if @account.nil? || @account.local? + return false if instance_info.nil? + + %w(misskey cherrypick).include?(instance_info.software) + end + + def instance_info + return @instance_info if defined?(@instance_info) + + @instance_info = InstanceInfo.find_by(domain: @account.domain) end end diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 45ce7252f4..96a0836aa0 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -154,7 +154,7 @@ class ActivityPub::Activity 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 diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 49c822f5dd..7a80133165 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -116,7 +116,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def find_existing_status status = status_from_uri(object_uri) status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present? - status + status if status&.account_id == @account.id end def process_status_params @@ -365,13 +365,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def fetch_replies(status) collection = @object['replies'] - return if collection.nil? + return if collection.blank? replies = ActivityPub::FetchRepliesService.new.call(status, collection, allow_synchronous_requests: false, request_id: @options[:request_id]) return unless replies.nil? uri = value_or_id(collection) ActivityPub::FetchRepliesWorker.perform_async(status.id, uri, { 'request_id' => @options[:request_id] }) unless uri.nil? + rescue => e + Rails.logger.warn "Error fetching replies: #{e}" end def conversation_from_uri(uri) @@ -505,15 +507,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def searchability_from_audience - if audience_searchable_by.nil? - nil - elsif audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) } + return nil if audience_searchable_by.blank? + + if audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) } :public elsif audience_searchable_by.include?('kmyblue:Limited') || audience_searchable_by.include?('as:Limited') :limited elsif audience_searchable_by.include?(@account.followers_url) :private - else + elsif audience_searchable_by.include?(@account.uri) || audience_searchable_by.include?(@account.url) :direct end end diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 098b6296fb..5b9437eb8d 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -20,6 +20,6 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base serialized_hash = serialized_hash.select { |k, _| options[:fields].include?(k) } if options[:fields] serialized_hash = self.class.transform_key_casing!(serialized_hash, instance_options) - { '@context' => serialized_context(named_contexts, context_extensions) }.merge(serialized_hash) + { '@context': serialized_context(named_contexts, context_extensions) }.merge(serialized_hash) end end diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb index 891c5a5d43..5f2430600e 100644 --- a/app/lib/activitypub/case_transform.rb +++ b/app/lib/activitypub/case_transform.rb @@ -21,6 +21,8 @@ module ActivityPub::CaseTransform value elsif value.start_with?('_:') "_:#{value.delete_prefix('_:').underscore.camelize(:lower)}" + elsif LanguagesHelper::ISO_639_1_REGIONAL.key?(value.to_sym) # rubocop:disable Lint/DuplicateBranch + value else value.underscore.camelize(:lower) end diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb index ea59879f3b..c42313b05e 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -4,6 +4,7 @@ class ActivityPub::LinkedDataSignature include JsonLdHelper CONTEXT = 'https://w3id.org/identity/v1' + SIGNATURE_CONTEXT = 'https://w3id.org/security/v1' def initialize(json) @json = json.with_indifferent_access @@ -18,8 +19,8 @@ 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) + creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri) + creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if creator&.public_key.blank? return if creator.nil? @@ -28,6 +29,8 @@ class ActivityPub::LinkedDataSignature to_be_verified = options_hash + document_hash creator if creator.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), Base64.decode64(signature), to_be_verified) + rescue OpenSSL::PKey::RSAError + false end def sign!(creator, sign_with: nil) @@ -44,7 +47,13 @@ class ActivityPub::LinkedDataSignature signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), to_be_signed)) - @json.merge('signature' => options.merge('signatureValue' => signature)) + # Mastodon's context is either an array or a single URL + context_with_security = Array(@json['@context']) + context_with_security << 'https://w3id.org/security/v1' + context_with_security.uniq! + context_with_security = context_with_security.first if context_with_security.size == 1 + + @json.merge('signature' => options.merge('signatureValue' => signature), '@context' => context_with_security) end private diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index a9ec958195..322e372c13 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -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 @@ -53,7 +55,8 @@ class ActivityPub::Parser::StatusParser end def created_at - @object['published']&.to_datetime + datetime = @object['published']&.to_datetime + datetime if datetime.present? && (0..9999).cover?(datetime.year) rescue ArgumentError nil end @@ -98,6 +101,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? @@ -107,8 +117,6 @@ class ActivityPub::Parser::StatusParser end end - private - def audience_to as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) } end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index f9b67867ef..b8a969c558 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -119,10 +119,7 @@ class ActivityPub::TagManager end.compact end when 'limited' - status.mentions.each_with_object([]) do |mention, result| - result << uri_for(mention.account) - result << followers_uri_for(mention.account) if mention.account.group? - end.compact + ['kmyblue:Limited'] # to avoid Fedibird personal visibility end end @@ -240,12 +237,10 @@ class ActivityPub::TagManager [COLLECTIONS[:public]] when 'private' [account_followers_url(status.account)] - when 'direct' - status.conversation_id.present? ? [uri_for(status.conversation)] : [] when 'limited' ['as:Limited', 'kmyblue:Limited'] else - [] + status.conversation_id.present? ? [uri_for(status.conversation), account_url(status.account)] : [account_url(status.account)] end searchable_by.concat(mentions_uris(status)).compact @@ -260,7 +255,7 @@ class ActivityPub::TagManager when 'limited' ['as:Limited', 'kmyblue:Limited'] else - [] + [account_url(account)] end end diff --git a/app/lib/admin/system_check/software_version_check.rb b/app/lib/admin/system_check/software_version_check.rb index e142feddf0..e5cacfe354 100644 --- a/app/lib/admin/system_check/software_version_check.rb +++ b/app/lib/admin/system_check/software_version_check.rb @@ -14,14 +14,16 @@ class Admin::SystemCheck::SoftwareVersionCheck < Admin::SystemCheck::BaseCheck def message if software_updates.any?(&:urgent?) Admin::SystemCheck::Message.new(:software_version_critical_check, nil, admin_software_updates_path, true) - else + elsif software_updates.any?(&:patch_type?) Admin::SystemCheck::Message.new(:software_version_patch_check, nil, admin_software_updates_path) + else + Admin::SystemCheck::Message.new(:software_version_check, nil, admin_software_updates_path) end end private def software_updates - @software_updates ||= SoftwareUpdate.pending_to_a.filter { |update| update.urgent? || update.patch_type? } + @software_updates ||= SoftwareUpdate.pending_to_a end end diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb index fb442e2c2d..f226b99cd7 100644 --- a/app/lib/application_extension.rb +++ b/app/lib/application_extension.rb @@ -4,14 +4,36 @@ module ApplicationExtension extend ActiveSupport::Concern included do + include Redisable + has_many :created_users, class_name: 'User', foreign_key: 'created_by_application_id', inverse_of: :created_by_application 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 :close_streaming_sessions, prepend: true end def confirmation_redirect_uri redirect_uri.lines.first.strip end + + def close_streaming_sessions(resource_owner = nil) + # TODO: #28793 Combine into a single topic + payload = Oj.dump(event: :kill) + scope = access_tokens + scope = scope.where(resource_owner_id: resource_owner.id) unless resource_owner.nil? + scope.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 diff --git a/app/lib/attachment_batch.rb b/app/lib/attachment_batch.rb index b75938bdd7..13a9da828f 100644 --- a/app/lib/attachment_batch.rb +++ b/app/lib/attachment_batch.rb @@ -75,7 +75,12 @@ class AttachmentBatch end when :fog logger.debug { "Deleting #{attachment.path(style)}" } - attachment.directory.files.new(key: attachment.path(style)).destroy + + begin + attachment.send(:directory).files.new(key: attachment.path(style)).destroy + rescue Fog::Storage::OpenStack::NotFound + # Ignore failure to delete a file that has already been deleted + end when :azure logger.debug { "Deleting #{attachment.path(style)}" } attachment.destroy diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index ff2ad7a01d..bc6bcbbc98 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -594,7 +594,7 @@ class FeedManager arr = crutches[:active_mentions][s.id] || [] arr.push(s.account_id) - if s.reblog? + if s.reblog? && s.reblog.present? arr.push(s.reblog.account_id) arr.concat(crutches[:active_mentions][s.reblog_of_id] || []) end diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb index ebdd1d189f..b42734dd2c 100644 --- a/app/lib/inline_renderer.rb +++ b/app/lib/inline_renderer.rb @@ -42,13 +42,13 @@ class InlineRenderer private def preload_associations_for_status - ActiveRecord::Associations::Preloader.new(records: @object, associations: { + ActiveRecord::Associations::Preloader.new(records: [@object], associations: { active_mentions: :account, reblog: { active_mentions: :account, }, - }) + }).call end def current_user diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb index b95ec80519..48a592a404 100644 --- a/app/lib/link_details_extractor.rb +++ b/app/lib/link_details_extractor.rb @@ -36,7 +36,9 @@ class LinkDetailsExtractor end def language - json['inLanguage'] + lang = json['inLanguage'] + lang = lang.first if lang.is_a?(Array) + lang.is_a?(Hash) ? (lang['alternateName'] || lang['name']) : lang end def type @@ -263,16 +265,21 @@ class LinkDetailsExtractor end def document - @document ||= Nokogiri::HTML(@html, nil, encoding) + @document ||= detect_encoding_and_parse_document end - def encoding - @encoding ||= begin - guess = detector.detect(@html, @html_charset) - guess&.fetch(:confidence, 0).to_i > 60 ? guess&.fetch(:encoding, nil) : nil + def detect_encoding_and_parse_document + [detect_encoding, nil, @html_charset, 'UTF-8'].uniq.each do |encoding| + document = Nokogiri::HTML(@html, nil, encoding) + return document if document.to_s.valid_encoding? end end + def detect_encoding + guess = detector.detect(@html, @html_charset) + guess&.fetch(:confidence, 0).to_i > 60 ? guess&.fetch(:encoding, nil) : nil + end + def detector @detector ||= CharlockHolmes::EncodingDetector.new.tap do |detector| detector.strip_tags = true diff --git a/app/lib/request.rb b/app/lib/request.rb index 5f128af734..4f3f3ff43c 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -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(:omit_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)) diff --git a/app/lib/search_query_transformer.rb b/app/lib/search_query_transformer.rb index ba4383f674..c7554504a3 100644 --- a/app/lib/search_query_transformer.rb +++ b/app/lib/search_query_transformer.rb @@ -380,7 +380,7 @@ class SearchQueryTransformer < Parslet::Transform end rule(clause: subtree(:clause)) do - prefix = clause[:prefix][:term].to_s if clause[:prefix] + prefix = clause[:prefix][:term].to_s.downcase if clause[:prefix] operator = clause[:operator]&.to_s term = clause[:phrase] ? clause[:phrase].map { |term| term[:term].to_s }.join(' ') : clause[:term].to_s diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb index 8639a30a9f..a0acf56aac 100644 --- a/app/lib/status_reach_finder.rb +++ b/app/lib/status_reach_finder.rb @@ -21,43 +21,43 @@ class StatusReachFinder end end + def inboxes_for_limited + DeliveryFailureTracker.without_unavailable( + @status.mentioned_accounts.where.not(domain: nil).pluck(:inbox_url).compact.uniq + ) + end + private def reached_account_inboxes + Account.where(id: reached_account_ids).where.not(domain: banned_domains).inboxes + end + + def reached_account_inboxes_for_misskey + Account.where(id: reached_account_ids).where(domain: banned_domains_for_misskey).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] elsif @status.limited_visibility? - Account.where(id: mentioned_account_ids).where.not(domain: banned_domains).inboxes + [mentioned_account_ids] else - Account.where(id: reached_account_ids).where.not(domain: banned_domains).inboxes - end - end - - def reached_account_inboxes_for_misskey - if @status.reblog? - [] - elsif @status.limited_visibility? - Account.where(id: mentioned_account_ids).where(domain: banned_domains_for_misskey).inboxes - else - Account.where(id: reached_account_ids).where(domain: banned_domains_for_misskey).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 @@ -137,10 +137,6 @@ class StatusReachFinder [] else blocks = DomainBlock.where(domain: nil) - blocks = blocks.or(DomainBlock.where(reject_send_not_public_searchability: true)) if status.compute_searchability != 'public' - blocks = blocks.or(DomainBlock.where(reject_send_public_unlisted: true)) if status.public_unlisted_visibility? - blocks = blocks.or(DomainBlock.where(reject_send_dissubscribable: true)) if status.account.dissubscribable - blocks = blocks.or(DomainBlock.where(reject_send_media: true)) if status.with_media? blocks = blocks.or(DomainBlock.where(reject_send_sensitive: true)) if (status.with_media? && status.sensitive) || status.spoiler_text? blocks.pluck(:domain).uniq end diff --git a/app/lib/vacuum/applications_vacuum.rb b/app/lib/vacuum/applications_vacuum.rb deleted file mode 100644 index ba88655f16..0000000000 --- a/app/lib/vacuum/applications_vacuum.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class Vacuum::ApplicationsVacuum - def perform - Doorkeeper::Application.where(owner_id: nil) - .where.missing(:created_users, :access_tokens, :access_grants) - .where(created_at: ...1.day.ago) - .in_batches.delete_all - end -end diff --git a/app/lib/vacuum/feeds_vacuum.rb b/app/lib/vacuum/feeds_vacuum.rb index fb0b8a8472..a9e96accdd 100644 --- a/app/lib/vacuum/feeds_vacuum.rb +++ b/app/lib/vacuum/feeds_vacuum.rb @@ -4,6 +4,7 @@ class Vacuum::FeedsVacuum def perform vacuum_inactive_home_feeds! vacuum_inactive_list_feeds! + vacuum_inactive_antenna_feeds! end private @@ -20,6 +21,12 @@ class Vacuum::FeedsVacuum end end + def vacuum_inactive_antenna_feeds! + inactive_users_antennas.select(:id).in_batches do |antennas| + feed_manager.clean_feeds!(:antenna, antennas.ids) + end + end + def inactive_users User.confirmed.inactive end @@ -28,6 +35,10 @@ class Vacuum::FeedsVacuum List.where(account_id: inactive_users.select(:account_id)) end + def inactive_users_antennas + Antenna.where(account_id: inactive_users.select(:account_id)) + end + def feed_manager FeedManager.instance end diff --git a/app/lib/video_metadata_extractor.rb b/app/lib/video_metadata_extractor.rb index f27d34868a..2155766251 100644 --- a/app/lib/video_metadata_extractor.rb +++ b/app/lib/video_metadata_extractor.rb @@ -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 @@ -41,8 +41,8 @@ class VideoMetadataExtractor @colorspace = video_stream[:pix_fmt] @width = video_stream[:width] @height = video_stream[:height] - @frame_rate = video_stream[:avg_frame_rate] == '0/0' ? nil : Rational(video_stream[:avg_frame_rate]) - @r_frame_rate = video_stream[:r_frame_rate] == '0/0' ? nil : Rational(video_stream[:r_frame_rate]) + @frame_rate = parse_framerate(video_stream[:avg_frame_rate]) + @r_frame_rate = parse_framerate(video_stream[:r_frame_rate]) # For some video streams the frame_rate reported by `ffprobe` will be 0/0, but for these streams we # should use `r_frame_rate` instead. Video screencast generated by Gnome Screencast have this issue. @frame_rate ||= @r_frame_rate @@ -55,4 +55,10 @@ class VideoMetadataExtractor @invalid = true if @metadata.key?(:error) end + + def parse_framerate(raw) + Rational(raw) + rescue ZeroDivisionError + nil + end end diff --git a/app/lib/webfinger.rb b/app/lib/webfinger.rb index ae8a3b1eae..01a5dbc21d 100644 --- a/app/lib/webfinger.rb +++ b/app/lib/webfinger.rb @@ -6,6 +6,8 @@ class Webfinger class RedirectError < Error; end class Response + ACTIVITYPUB_READY_TYPE = ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].freeze + attr_reader :uri def initialize(uri, body) @@ -20,17 +22,28 @@ class Webfinger end def link(rel, attribute) - links.dig(rel, attribute) + links.dig(rel, 0, attribute) + end + + def self_link_href + self_link.fetch('href') end private def links - @links ||= @json['links'].index_by { |link| link['rel'] } + @links ||= @json.fetch('links', []).group_by { |link| link['rel'] } + end + + def self_link + links.fetch('self', []).find do |link| + ACTIVITYPUB_READY_TYPE.include?(link['type']) + end end def validate_response! raise Webfinger::Error, "Missing subject in response for #{@uri}" if subject.blank? + raise Webfinger::Error, "Missing self link in response for #{@uri}" if self_link.blank? end end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index 990b92c337..6b08aa8ccf 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -61,6 +61,12 @@ class AdminMailer < ApplicationMailer end end + def auto_close_registrations + locale_for_account(@me) do + mail subject: default_i18n_subject(instance: @instance) + end + end + private def process_params diff --git a/app/models/account.rb b/app/models/account.rb index 51f2e69971..e14da5d0de 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -69,8 +69,8 @@ class Account < ApplicationRecord BACKGROUND_REFRESH_INTERVAL = 1.week.freeze - USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i - MENTION_RE = %r{(? hide_following_count?, 'hide_followers_count' => hide_followers_count?, 'translatable_private' => translatable_private?, - 'link_preview' => link_preview?, } if Setting.enable_emoji_reaction config = config.merge({ diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb index af1e6a68dc..db2e37184f 100644 --- a/app/models/account_domain_block.rb +++ b/app/models/account_domain_block.rb @@ -18,16 +18,12 @@ class AccountDomainBlock < ApplicationRecord belongs_to :account validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true - after_commit :remove_blocking_cache - after_commit :remove_relationship_cache + after_commit :invalidate_domain_blocking_cache private - def remove_blocking_cache + def invalidate_domain_blocking_cache Rails.cache.delete("exclude_domains_for:#{account_id}") - end - - def remove_relationship_cache - Rails.cache.delete_matched("relationship:#{account_id}:*") + Rails.cache.delete(['exclude_domains', account_id, domain]) end end diff --git a/app/models/announcement.rb b/app/models/announcement.rb index 7b3d24aadb..132ddecb5b 100644 --- a/app/models/announcement.rb +++ b/app/models/announcement.rb @@ -78,9 +78,9 @@ class Announcement < ApplicationRecord else scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from announcement_reactions r where r.account_id = #{account.id} and r.announcement_id = announcement_reactions.announcement_id and r.name = announcement_reactions.name) as me") end - end + end.to_a - ActiveRecord::Associations::Preloader.new(records: records, associations: :custom_emoji) + ActiveRecord::Associations::Preloader.new(records: records, associations: :custom_emoji).call records end diff --git a/app/models/antenna.rb b/app/models/antenna.rb index a35fa6dd17..c64e66bde2 100644 --- a/app/models/antenna.rb +++ b/app/models/antenna.rb @@ -55,11 +55,15 @@ class Antenna < ApplicationRecord scope :available_stls, -> { where(available: true, stl: true) } scope :available_ltls, -> { where(available: true, stl: false, ltl: true) } + validates :title, presence: true + validate :list_owner validate :validate_limit validate :validate_stl_limit validate :validate_ltl_limit + before_destroy :clean_feed_manager + def list_owner raise Mastodon::ValidationError, I18n.t('antennas.errors.invalid_list_owner') if !list_id.zero? && list.present? && list.account != account end @@ -121,4 +125,8 @@ class Antenna < ApplicationRecord ltls.any? { |tl| !tl.insert_feeds } end end + + def clean_feed_manager + FeedManager.instance.clean_feeds!(:antenna, [id]) + end end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 143e91feb8..fd849c9af2 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -60,12 +60,6 @@ module AccountInteractions end end - def domain_blocking_map(target_account_ids, account_id) - accounts_map = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain } - blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id) - accounts_map.reduce({}) { |h, (id, domain)| h.merge(id => blocked_domains[domain]) } - end - def domain_blocking_map_by_domain(target_domains, account_id) follow_mapping(AccountDomainBlock.where(account_id: account_id, domain: target_domains), :domain) end @@ -191,7 +185,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 @@ -339,4 +333,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 diff --git a/app/models/concerns/account_search.rb b/app/models/concerns/account_search.rb index ae882b158b..aea221c666 100644 --- a/app/models/concerns/account_search.rb +++ b/app/models/concerns/account_search.rb @@ -145,7 +145,7 @@ module AccountSearch tsquery = generate_query_for_search(terms) find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records| - ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat) + ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call end end @@ -158,7 +158,7 @@ module AccountSearch end find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records| - ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat) + ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call end end diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb index c0ee1bdce7..4cdbdeb473 100644 --- a/app/models/concerns/attachmentable.rb +++ b/app/models/concerns/attachmentable.rb @@ -52,9 +52,13 @@ module Attachmentable return if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank? width, height = FastImage.size(attachment.queued_for_write[:original].path) - matrix_limit = attachment.content_type == 'image/gif' ? GIF_MATRIX_LIMIT : MAX_MATRIX_LIMIT + return unless width.present? && height.present? - raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height > matrix_limit) + if attachment.content_type == 'image/gif' && width * height > GIF_MATRIX_LIMIT + raise Mastodon::DimensionsValidationError, "#{width}x#{height} GIF files are not supported" + elsif width * height > MAX_MATRIX_LIMIT + raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" + end end def appropriate_extension(attachment) diff --git a/app/models/concerns/cacheable.rb b/app/models/concerns/cacheable.rb index d7524cdfd0..0633f20c77 100644 --- a/app/models/concerns/cacheable.rb +++ b/app/models/concerns/cacheable.rb @@ -14,6 +14,10 @@ module Cacheable includes(@cache_associated) end + def preload_cacheable_associations(records) + ActiveRecord::Associations::Preloader.new(records: records, associations: @cache_associated).call + end + def cache_ids select(:id, :updated_at) end diff --git a/app/models/concerns/has_user_settings.rb b/app/models/concerns/has_user_settings.rb index 780c6345bb..c30e5c038f 100644 --- a/app/models/concerns/has_user_settings.rb +++ b/app/models/concerns/has_user_settings.rb @@ -123,10 +123,6 @@ module HasUserSettings settings['translatable_private'] end - def setting_link_preview - settings['link_preview'] - end - def setting_single_ref_to_quote settings['single_ref_to_quote'] end diff --git a/app/models/concerns/ldap_authenticable.rb b/app/models/concerns/ldap_authenticable.rb index 775df08176..1a46b4e80e 100644 --- a/app/models/concerns/ldap_authenticable.rb +++ b/app/models/concerns/ldap_authenticable.rb @@ -22,7 +22,7 @@ module LdapAuthenticable safe_username = safe_username.gsub(keys, replacement) end - resource = joins(:account).find_by(accounts: { username: safe_username }) + resource = joins(:account).merge(Account.where(Account.arel_table[:username].lower.eq safe_username.downcase)).take if resource.blank? resource = new(email: attributes[Devise.ldap_mail.to_sym].first, agreement: true, account_attributes: { username: safe_username }, admin: false, external: true, confirmed_at: Time.now.utc) diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb index 3983fbcda8..9c004a308c 100644 --- a/app/models/concerns/omniauthable.rb +++ b/app/models/concerns/omniauthable.rb @@ -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)) @@ -66,7 +83,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) { diff --git a/app/models/concerns/relationship_cacheable.rb b/app/models/concerns/relationship_cacheable.rb index 0d9359f7e7..c32a8d62c6 100644 --- a/app/models/concerns/relationship_cacheable.rb +++ b/app/models/concerns/relationship_cacheable.rb @@ -10,7 +10,7 @@ module RelationshipCacheable private def remove_relationship_cache - Rails.cache.delete("relationship:#{account_id}:#{target_account_id}") - Rails.cache.delete("relationship:#{target_account_id}:#{account_id}") + Rails.cache.delete(['relationship', account_id, target_account_id]) + Rails.cache.delete(['relationship', target_account_id, account_id]) end end diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index b05fa19476..d7ac1287e3 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -25,7 +25,6 @@ # reject_straight_follow :boolean default(FALSE), not null # reject_new_follow :boolean default(FALSE), not null # hidden :boolean default(FALSE), not null -# hidden_anonymous :boolean default(FALSE), not null # detect_invalid_subscription :boolean default(FALSE), not null # reject_reply_exclude_followers :boolean default(FALSE), not null # @@ -60,10 +59,6 @@ class DomainBlock < ApplicationRecord reject_favourite? ? :reject_favourite : nil, reject_reply? ? :reject_reply : nil, reject_reply_exclude_followers? ? :reject_reply_exclude_followers : nil, - reject_send_not_public_searchability? ? :reject_send_not_public_searchability : nil, - reject_send_public_unlisted? ? :reject_send_public_unlisted : nil, - reject_send_dissubscribable? ? :reject_send_dissubscribable : nil, - reject_send_media? ? :reject_send_media : nil, reject_send_sensitive? ? :reject_send_sensitive : nil, reject_hashtag? ? :reject_hashtag : nil, reject_straight_follow? ? :reject_straight_follow : nil, diff --git a/app/models/feed.rb b/app/models/feed.rb index f51dcfab1d..30073fed4b 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -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 diff --git a/app/models/identity.rb b/app/models/identity.rb index c95a68a6f6..77821b78fa 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -17,7 +17,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 diff --git a/app/models/notification.rb b/app/models/notification.rb index 50e45f05b3..2ec17e7860 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -128,7 +128,7 @@ class Notification < ApplicationRecord # Instead of using the usual `includes`, manually preload each type. # If polymorphic associations are loaded with the usual `includes`, other types of associations will be loaded more. - ActiveRecord::Associations::Preloader.new(records: grouped_notifications, associations: associations) + ActiveRecord::Associations::Preloader.new(records: grouped_notifications, associations: associations).call end unique_target_statuses = notifications.filter_map(&:target_status).uniq diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb index a641e77039..fcd006d5b8 100644 --- a/app/models/public_feed.rb +++ b/app/models/public_feed.rb @@ -31,7 +31,7 @@ class PublicFeed # scope.merge!(anonymous_scope) unless account? scope = to_anonymous_scope(scope) unless account? - 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 diff --git a/app/models/report.rb b/app/models/report.rb index eaf662d1e2..d0f19e1234 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -44,9 +44,9 @@ class Report < ApplicationRecord delegate :local?, to: :account validates :comment, length: { maximum: 1_000 }, if: :local? - validates :rule_ids, absence: true, unless: :violation? + validates :rule_ids, absence: true, if: -> { (category_changed? || rule_ids_changed?) && !violation? } - validate :validate_rule_ids + validate :validate_rule_ids, if: -> { (category_changed? || rule_ids_changed?) && violation? } # entries here need to be kept in sync with the front-end: # - app/javascript/mastodon/features/notifications/components/report.jsx @@ -154,8 +154,6 @@ class Report < ApplicationRecord end def validate_rule_ids - return unless violation? - errors.add(:rule_ids, I18n.t('reports.errors.invalid_rules')) unless rules.size == rule_ids&.size end diff --git a/app/models/status.rb b/app/models/status.rb index a5501fcbf6..244247b978 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -387,6 +387,8 @@ class Status < ApplicationRecord end public_emoji_reactions + else + emoji_reactions end end end @@ -488,38 +490,6 @@ class Status < ApplicationRecord Status.where(id: status_ids).pluck(:account_id).uniq.index_with { |a| Account.find_by(id: a).show_emoji_reaction?(my_account) } 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? diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb index 6841ac87c9..7e0afdc74a 100644 --- a/app/models/status_edit.rb +++ b/app/models/status_edit.rb @@ -42,8 +42,8 @@ class StatusEdit < ApplicationRecord default_scope { order(id: :asc) } - delegate :local?, :application, :edited?, :edited_at, :language, - :discarded?, :visibility, to: :status + delegate :local?, :application, :edited?, :edited_at, + :discarded?, :visibility, :language, to: :status def emojis return @emojis if defined?(@emojis) diff --git a/app/models/status_reference.rb b/app/models/status_reference.rb index 8d5d6eba8b..585539f4cc 100644 --- a/app/models/status_reference.rb +++ b/app/models/status_reference.rb @@ -13,6 +13,8 @@ # class StatusReference < ApplicationRecord + REFERENCES_LIMIT = 5 + belongs_to :status belongs_to :target_status, class_name: 'Status' diff --git a/app/models/tag.rb b/app/models/tag.rb index 4a9c289b37..c707abe10a 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -37,7 +37,7 @@ class Tag < ApplicationRecord HASHTAG_LAST_SEQUENCE = '([[:word:]_]*[[:alpha:]][[:word:]_]*)' HASHTAG_NAME_PAT = "#{HASHTAG_FIRST_SEQUENCE}|#{HASHTAG_LAST_SEQUENCE}" - HASHTAG_RE = %r{(?:^|[^/)\w])#(#{HASHTAG_NAME_PAT})}i + HASHTAG_RE = %r{(?(attributes) { attributes['text'].blank? && !Setting.require_invite_text } 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 - validates :time_zone, inclusion: { in: ActiveSupport::TimeZone.all.map { |tz| tz.tzinfo.name } }, allow_blank: true # Honeypot/anti-spam fields attr_accessor :registration_form_time, :website, :confirm_password @@ -126,6 +126,8 @@ class User < ApplicationRecord before_validation :sanitize_languages before_validation :sanitize_role + before_validation :sanitize_time_zone + before_validation :sanitize_locale before_create :set_approved after_commit :send_pending_devise_notifications after_create_commit :trigger_webhooks @@ -362,6 +364,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 @@ -453,9 +465,15 @@ class User < ApplicationRecord end def sanitize_role - return if role.nil? + self.role = nil if role.present? && role.everyone? + end - self.role = nil if role.everyone? + def sanitize_time_zone + self.time_zone = nil if time_zone.present? && ActiveSupport::TimeZone[time_zone].nil? + end + + def sanitize_locale + self.locale = nil if locale.present? && I18n.available_locales.exclude?(locale.to_sym) end def prepare_new_user! diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb index 93f7f4a64f..9e07dac0fb 100644 --- a/app/models/user_settings.rb +++ b/app/models/user_settings.rb @@ -13,7 +13,6 @@ class UserSettings setting :noindex, default: -> { ::Setting.noindex } setting :noai, default: true setting :translatable_private, default: false - setting :link_preview, default: true setting :bio_markdown, default: false setting :discoverable_local, default: false setting :hide_statuses_count, default: false diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 335abe9e92..b9a1eec72d 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -121,12 +121,8 @@ class StatusPolicy < ApplicationPolicy (@domain_block.detect_invalid_subscription && status.public_unlisted_visibility? && status.account.user&.setting_reject_public_unlisted_subscription) || (@domain_block.detect_invalid_subscription && status.public_visibility? && status.account.user&.setting_reject_unlisted_subscription) else - (@domain_block.reject_send_not_public_searchability && status.compute_searchability != 'public') || - (@domain_block.reject_send_public_unlisted && status.public_unlisted_visibility?) || - (@domain_block.reject_send_dissubscribable && status.account.dissubscribable) || - (@domain_block.detect_invalid_subscription && status.public_unlisted_visibility? && status.account.user&.setting_reject_public_unlisted_subscription) || + (@domain_block.detect_invalid_subscription && status.public_unlisted_visibility? && status.account.user&.setting_reject_public_unlisted_subscription) || (@domain_block.detect_invalid_subscription && status.public_visibility? && status.account.user&.setting_reject_unlisted_subscription) || - (@domain_block.reject_send_media && status.with_media?) || (@domain_block.reject_send_sensitive && ((status.with_media? && status.sensitive) || status.spoiler_text?)) end else diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb index 5d2b5435dd..8482ef54da 100644 --- a/app/presenters/account_relationships_presenter.rb +++ b/app/presenters/account_relationships_presenter.rb @@ -5,8 +5,9 @@ class AccountRelationshipsPresenter :muting, :requested, :requested_by, :domain_blocking, :endorsed, :account_note - def initialize(account_ids, current_account_id, **options) - @account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i } + def initialize(accounts, current_account_id, **options) + @accounts = accounts.to_a + @account_ids = @accounts.pluck(:id) @current_account_id = current_account_id @following = cached[:following].merge(Account.following_map(@uncached_account_ids, @current_account_id)) @@ -16,10 +17,11 @@ class AccountRelationshipsPresenter @muting = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id)) @requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id)) @requested_by = cached[:requested_by].merge(Account.requested_by_map(@uncached_account_ids, @current_account_id)) - @domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id)) @endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id)) @account_note = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id)) + @domain_blocking = domain_blocking_map + cache_uncached! @following.merge!(options[:following_map] || {}) @@ -36,6 +38,31 @@ class AccountRelationshipsPresenter private + def domain_blocking_map + target_domains = @accounts.pluck(:domain).compact.uniq + blocks_by_domain = {} + + # Fetch from cache + cache_keys = target_domains.map { |domain| domain_cache_key(domain) } + Rails.cache.read_multi(*cache_keys).each do |key, blocking| + blocks_by_domain[key.last] = blocking + end + + uncached_domains = target_domains - blocks_by_domain.keys + + # Read uncached values from database + AccountDomainBlock.where(account_id: @current_account_id, domain: uncached_domains).pluck(:domain).each do |domain| + blocks_by_domain[domain] = true + end + + # Write database reads to cache + to_cache = uncached_domains.to_h { |domain| [domain_cache_key(domain), blocks_by_domain[domain]] } + Rails.cache.write_multi(to_cache, expires_in: 1.day) + + # Return formatted value + @accounts.each_with_object({}) { |account, h| h[account.id] = blocks_by_domain[account.domain] } + end + def cached return @cached if defined?(@cached) @@ -47,28 +74,23 @@ class AccountRelationshipsPresenter muting: {}, requested: {}, requested_by: {}, - domain_blocking: {}, endorsed: {}, account_note: {}, } - @uncached_account_ids = [] + @uncached_account_ids = @account_ids.uniq - @account_ids.each do |account_id| - maps_for_account = Rails.cache.read("relationship:#{@current_account_id}:#{account_id}") - - if maps_for_account.is_a?(Hash) - @cached.deep_merge!(maps_for_account) - else - @uncached_account_ids << account_id - end + cache_ids = @account_ids.map { |account_id| relationship_cache_key(account_id) } + Rails.cache.read_multi(*cache_ids).each do |key, maps_for_account| + @cached.deep_merge!(maps_for_account) + @uncached_account_ids.delete(key.last) end @cached end def cache_uncached! - @uncached_account_ids.each do |account_id| + to_cache = @uncached_account_ids.to_h do |account_id| maps_for_account = { following: { account_id => following[account_id] }, followed_by: { account_id => followed_by[account_id] }, @@ -77,12 +99,21 @@ class AccountRelationshipsPresenter muting: { account_id => muting[account_id] }, requested: { account_id => requested[account_id] }, requested_by: { account_id => requested_by[account_id] }, - domain_blocking: { account_id => domain_blocking[account_id] }, endorsed: { account_id => endorsed[account_id] }, account_note: { account_id => account_note[account_id] }, } - Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day) + [relationship_cache_key(account_id), maps_for_account] end + + Rails.cache.write_multi(to_cache, expires_in: 1.day) + end + + def domain_cache_key(domain) + ['exclude_domains', @current_account_id, domain] + end + + def relationship_cache_key(account_id) + ['relationship', @current_account_id, account_id] end end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 8f67f7e754..c79fb5b60e 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -100,8 +100,8 @@ class InitialStateSerializer < ActiveModel::Serializer ActiveRecord::Associations::Preloader.new( records: [object.current_account, object.admin, object.owner, object.disabled_account, object.moved_to_account].compact, - associations: [:account_stat, :user, { moved_to_account: [:account_stat, :user] }] - ) + associations: [:account_stat, { user: :role, moved_to_account: [:account_stat, { user: :role }] }] + ).call store[object.current_account.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.current_account, serializer: REST::AccountSerializer) if object.current_account store[object.admin.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.admin, serializer: REST::AccountSerializer) if object.admin diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb index 48f3aa7a6a..b6533815a5 100644 --- a/app/serializers/manifest_serializer.rb +++ b/app/serializers/manifest_serializer.rb @@ -16,11 +16,18 @@ class ManifestSerializer < ActiveModel::Serializer 512 ).freeze - attributes :name, :short_name, + attributes :id, :name, :short_name, :icons, :theme_color, :background_color, :display, :start_url, :scope, :share_target, :shortcuts + def id + # This is set to `/home` because that was the old value of `start_url` and + # thus the fallback ID computed by Chrome: + # https://developer.chrome.com/blog/pwa-manifest-id/ + '/home' + end + def name object.title end @@ -53,7 +60,7 @@ class ManifestSerializer < ActiveModel::Serializer end def start_url - '/home' + '/' end def scope diff --git a/app/serializers/nodeinfo/serializer.rb b/app/serializers/nodeinfo/serializer.rb index 3555f0bd8d..b316753c72 100644 --- a/app/serializers/nodeinfo/serializer.rb +++ b/app/serializers/nodeinfo/serializer.rb @@ -11,7 +11,7 @@ class NodeInfo::Serializer < ActiveModel::Serializer end def software - { name: 'mastodon', version: Mastodon::Version.to_s } + { name: 'kmyblue', version: Mastodon::Version.to_s } end def services @@ -41,6 +41,10 @@ class NodeInfo::Serializer < ActiveModel::Serializer def metadata { features: fedibird_capabilities, + upstream: { + name: 'Mastodon', + version: Mastodon::Version.to_s_of_mastodon, + }, } end diff --git a/app/serializers/rest/admin/domain_block_serializer.rb b/app/serializers/rest/admin/domain_block_serializer.rb index e7cdc40ad1..d7ec7230c6 100644 --- a/app/serializers/rest/admin/domain_block_serializer.rb +++ b/app/serializers/rest/admin/domain_block_serializer.rb @@ -1,14 +1,18 @@ # 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_favourite, :reject_reply, :reject_reports, - :reject_send_not_public_searchability, :reject_reply_exclude_followers, - :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, + :reject_reply_exclude_followers, + :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate def id object.id.to_s end + + def digest + object.domain_digest + end end diff --git a/app/serializers/rest/featured_tag_serializer.rb b/app/serializers/rest/featured_tag_serializer.rb index c4b35ab03a..c1ff4602aa 100644 --- a/app/serializers/rest/featured_tag_serializer.rb +++ b/app/serializers/rest/featured_tag_serializer.rb @@ -10,7 +10,9 @@ class REST::FeaturedTagSerializer < ActiveModel::Serializer end def url - short_account_tag_url(object.account, object.tag) + # The path is hardcoded because we have to deal with both local and + # remote users, which are different routes + account_with_domain_url(object.account, "tagged/#{object.tag.to_param}") end def name diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb index da35ab3b49..684e578b32 100644 --- a/app/services/account_search_service.rb +++ b/app/services/account_search_service.rb @@ -242,7 +242,7 @@ class AccountSearchService < BaseService records = query_builder.build.limit(limit_for_non_exact_results).offset(offset).objects.compact - ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat) + ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call records rescue Faraday::ConnectionFailed, Parslet::ParseFailed diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb index e8a31dade9..89c3a1b6c0 100644 --- a/app/services/activitypub/fetch_featured_collection_service.rb +++ b/app/services/activitypub/fetch_featured_collection_service.rb @@ -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 @@ -37,6 +37,8 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService end def process_items(items) + return if items.nil? + process_note_items(items) if @options[:note] process_hashtag_items(items) if @options[:hashtag] end diff --git a/app/services/activitypub/fetch_references_service.rb b/app/services/activitypub/fetch_references_service.rb index 682ec7eb16..92d9c1da3f 100644 --- a/app/services/activitypub/fetch_references_service.rb +++ b/app/services/activitypub/fetch_references_service.rb @@ -6,7 +6,7 @@ class ActivityPub::FetchReferencesService < BaseService def call(status, collection_or_uri) @account = status.account - collection_items(collection_or_uri)&.map { |item| value_or_id(item) } + collection_items(collection_or_uri)&.take(8)&.map { |item| value_or_id(item) } end private @@ -20,9 +20,9 @@ class ActivityPub::FetchReferencesService < BaseService case collection['type'] when 'Collection', 'CollectionPage' - collection['items'] + as_array(collection['items']) when 'OrderedCollection', 'OrderedCollectionPage' - collection['orderedItems'] + as_array(collection['orderedItems']) end end @@ -31,6 +31,19 @@ class ActivityPub::FetchReferencesService < BaseService return if unsupported_uri_scheme?(collection_or_uri) return if ActivityPub::TagManager.instance.local_uri?(collection_or_uri) - fetch_resource_without_id_validation(collection_or_uri, nil, true) + # NOTE: For backward compatibility reasons, Mastodon signs outgoing + # queries incorrectly by default. + # + # While this is relevant for all URLs with query strings, this is + # the only code path where this happens in practice. + # + # Therefore, retry with correct signatures if this fails. + begin + fetch_resource_without_id_validation(collection_or_uri, nil, true) + rescue Mastodon::UnexpectedResponseError => e + raise unless e.response && e.response.code == 401 && Addressable::URI.parse(collection_or_uri).query.present? + + fetch_resource_without_id_validation(collection_or_uri, nil, true, request_options: { with_query_string: true }) + end end end diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 567dd8a14a..7b083d889b 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -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) diff --git a/app/services/activitypub/fetch_remote_actor_service.rb b/app/services/activitypub/fetch_remote_actor_service.rb index 8df8c75876..2c372c2ec3 100644 --- a/app/services/activitypub/fetch_remote_actor_service.rb +++ b/app/services/activitypub/fetch_remote_actor_service.rb @@ -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}" @@ -49,7 +49,7 @@ class ActivityPub::FetchRemoteActorService < BaseService confirmed_username, confirmed_domain = split_acct(webfinger.subject) if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? - raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri + raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.self_link_href != @uri return end @@ -58,8 +58,7 @@ class ActivityPub::FetchRemoteActorService < BaseService @username, @domain = split_acct(webfinger.subject) raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{@uri} (stopped at #{@username}@#{@domain})" unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero? - - raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri + raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.self_link_href != @uri rescue Webfinger::RedirectError => e raise Error, e.message rescue Webfinger::Error => e diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb index 8eb97c1e66..e96b5ad3bb 100644 --- a/app/services/activitypub/fetch_remote_key_service.rb +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -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) diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index a491b32b26..5a3eeeaf4e 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -8,14 +8,14 @@ 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) return if domain_not_allowed?(uri) @request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}" @json = 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 return unless supported_context? @@ -65,7 +65,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 diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb index b5c7759ec5..46cab6caf9 100644 --- a/app/services/activitypub/fetch_replies_service.rb +++ b/app/services/activitypub/fetch_replies_service.rb @@ -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 @@ -37,7 +37,20 @@ class ActivityPub::FetchRepliesService < BaseService return unless @allow_synchronous_requests return if non_matching_uri_hosts?(@account.uri, collection_or_uri) - fetch_resource_without_id_validation(collection_or_uri, nil, true) + # NOTE: For backward compatibility reasons, Mastodon signs outgoing + # queries incorrectly by default. + # + # While this is relevant for all URLs with query strings, this is + # the only code path where this happens in practice. + # + # Therefore, retry with correct signatures if this fails. + begin + fetch_resource_without_id_validation(collection_or_uri, nil, true) + rescue Mastodon::UnexpectedResponseError => e + raise unless e.response && e.response.code == 401 && Addressable::URI.parse(collection_or_uri).query.present? + + fetch_resource_without_id_validation(collection_or_uri, nil, true, request_options: { omit_query_string: false }) + end end def filtered_replies diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 473b2cabd3..bf276aa33c 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -46,7 +46,6 @@ class ActivityPub::ProcessAccountService < BaseService end create_account - fetch_instance_info end update_account @@ -66,6 +65,8 @@ class ActivityPub::ProcessAccountService < BaseService check_links! if @account.fields.any?(&:requires_verification?) end + fetch_instance_info + @account rescue Oj::ParseError nil @@ -131,8 +132,8 @@ class ActivityPub::ProcessAccountService < BaseService end def valid_account? - display_name = @json['name'] - note = @json['summary'] + display_name = @json['name'] || '' + note = @json['summary'] || '' !Admin::NgWord.reject?(display_name) && !Admin::NgWord.reject?(note) end @@ -211,7 +212,7 @@ class ActivityPub::ProcessAccountService < BaseService end def fetch_instance_info - ActivityPub::FetchInstanceInfoWorker.perform_async(@account.domain) unless InstanceInfo.exists?(domain: @account.domain) + ActivityPub::FetchInstanceInfoWorker.perform_async(@account.domain) unless Rails.cache.exist?("fetch_instance_info:#{@account.domain}", expires_in: 1.day) end def actor_type @@ -226,10 +227,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 @@ -261,7 +267,7 @@ class ActivityPub::ProcessAccountService < BaseService end def searchability_from_audience - if audience_searchable_by.nil? + if audience_searchable_by.blank? bio = searchability_from_bio return bio unless bio.nil? @@ -382,7 +388,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 diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb index 4f049a5ae9..6cdca7fc34 100644 --- a/app/services/activitypub/process_collection_service.rb +++ b/app/services/activitypub/process_collection_service.rb @@ -29,6 +29,10 @@ class ActivityPub::ProcessCollectionService < BaseService @json.delete('signature') unless safe_for_forwarding?(original_json, @json) end + # any namespaces for general-original activity type + @json['type'] = 'EmojiReact' if original_json['type'] == 'EmojiReact' + @json['type'] = 'EmojiReaction' if original_json['type'] == 'EmojiReaction' + case @json['type'] when 'Collection', 'CollectionPage' process_items @json['items'] diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 92d5788151..9ab1ff902f 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -180,9 +180,9 @@ class ActivityPub::ProcessStatusUpdateService < BaseService as_array(@json['tag']).each do |tag| if equals_or_includes?(tag['type'], 'Hashtag') - @raw_tags << tag['name'] + @raw_tags << tag['name'] if tag['name'].present? elsif equals_or_includes?(tag['type'], 'Mention') - @raw_mentions << tag['href'] + @raw_mentions << tag['href'] if tag['href'].present? elsif equals_or_includes?(tag['type'], 'Emoji') @raw_emojis << tag end diff --git a/app/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb index 9bd6034a57..a9aab653ca 100644 --- a/app/services/activitypub/synchronize_followers_service.rb +++ b/app/services/activitypub/synchronize_followers_service.rb @@ -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 diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 5de64a8a39..6240b7da64 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -33,7 +33,7 @@ class BackupService < BaseService file.write(statuses.map do |status| item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status, use_bearcap: false), ActivityPub::ActivitySerializer) - item.delete('@context') + item.delete(:@context) unless item[:type] == 'Announce' || item[:object][:attachment].blank? item[:object][:attachment].each do |attachment| diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 672d40f425..f5c5cb7109 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -11,7 +11,7 @@ class BatchedRemoveStatusService < BaseService ActiveRecord::Associations::Preloader.new( records: statuses, associations: options[:skip_side_effects] ? :reblogs : [:account, :tags, reblogs: :account] - ) + ).call statuses_and_reblogs = statuses.flat_map { |status| [status] + status.reblogs } @@ -23,7 +23,7 @@ class BatchedRemoveStatusService < BaseService ActiveRecord::Associations::Preloader.new( records: statuses_with_account_conversations, associations: [mentions: :account] - ) + ).call statuses_with_account_conversations.each(&:unlink_from_conversations!) diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index a2e2653813..979788fcda 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -9,6 +9,7 @@ class FanOutOnWriteService < BaseService # @param [Hash] options # @option options [Boolean] update # @option options [Array] silenced_account_ids + # @option options [Boolean] skip_notifications def call(status, options = {}) @status = status @account = status.account @@ -45,8 +46,11 @@ class FanOutOnWriteService < BaseService def fan_out_to_local_recipients! deliver_to_self! - notify_mentioned_accounts! - notify_about_update! if update? + + unless @options[:skip_notifications] + notify_mentioned_accounts! + notify_about_update! if update? + end case @status.visibility.to_sym when :public, :unlisted, :public_unlisted, :login, :private diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 8323c6cdfe..1930627d6c 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -16,11 +16,14 @@ class FetchLinkCardService < BaseService ) }iox + # URL size limit to safely store in PosgreSQL's unique indexes + BYTESIZE_LIMIT = 2692 + def call(status) @status = status @original_url = parse_urls - return if @original_url.nil? || @status.preview_cards.any? || !@status.account.link_preview? + return if @original_url.nil? || @status.preview_cards.any? @url = @original_url.to_s @@ -102,7 +105,7 @@ class FetchLinkCardService < BaseService def bad_url?(uri) # Avoid local instance URLs and invalid URLs - uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme) + uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme) || uri.to_s.bytesize > BYTESIZE_LIMIT end def mention_link?(anchor) diff --git a/app/services/fetch_oembed_service.rb b/app/services/fetch_oembed_service.rb index 1ae592238e..dc84b16b68 100644 --- a/app/services/fetch_oembed_service.rb +++ b/app/services/fetch_oembed_service.rb @@ -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 diff --git a/app/services/fetch_resource_service.rb b/app/services/fetch_resource_service.rb index a3406e5a57..84c36f6a10 100644 --- a/app/services/fetch_resource_service.rb +++ b/app/services/fetch_resource_service.rb @@ -44,11 +44,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) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index fc2868a02f..d7d3d3a320 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -71,7 +71,7 @@ class FollowService < BaseService if @target_account.local? LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request') elsif @target_account.activitypub? - ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url) + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url, { 'bypass_availability' => true }) end follow_request diff --git a/app/services/keys/query_service.rb b/app/services/keys/query_service.rb index 14c9d9205b..33e13293f3 100644 --- a/app/services/keys/query_service.rb +++ b/app/services/keys/query_service.rb @@ -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 diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index eaa5ce233b..57305aaa47 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -73,21 +73,23 @@ 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 def from_staff? - @notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user_role&.overrides?(@recipient.user_role) + sender = @notification.from_account + sender.local? && sender.user.present? && sender.user_role&.overrides?(@recipient.user_role) && sender.user_role&.highlighted? && sender.user_role&.can?(*UserRole::Flags::CATEGORIES[:moderation].map(&:to_sym)) end def optional_non_following_and_direct? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index f48555245e..0df0ab0e18 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -240,7 +240,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 @@ -251,7 +251,7 @@ class PostStatusService < BaseService end def scheduled_in_the_past? - @scheduled_at.present? && @scheduled_at <= Time.now.utc + MIN_SCHEDULE_OFFSET + @scheduled_at.present? && @scheduled_at <= Time.now.utc end def bump_potential_friendship! @@ -298,7 +298,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 diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 42db4d199a..92fba4256e 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -44,11 +44,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) diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index 6204fefd6f..842c0040a2 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -104,8 +104,6 @@ class ResolveAccountService < BaseService end def fetch_account! - return unless activitypub_ready? - with_redis_lock("resolve:#{@username}@#{@domain}") do @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors]) end @@ -120,12 +118,8 @@ class ResolveAccountService < BaseService @options[:skip_cache] || @account.nil? || @account.possibly_stale? end - def activitypub_ready? - ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self', 'type')) - end - def actor_url - @actor_url ||= @webfinger.link('self', 'href') + @actor_url ||= @webfinger.self_link_href end def gone_from_origin? diff --git a/app/services/un_emoji_react_service.rb b/app/services/un_emoji_react_service.rb index b473d43459..90bc740f5c 100644 --- a/app/services/un_emoji_react_service.rb +++ b/app/services/un_emoji_react_service.rb @@ -39,8 +39,8 @@ class UnEmojiReactService < BaseService end def write_stream(emoji_reaction) - emoji_group = @status.emoji_reactions_grouped_by_name - .find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) } + emoji_group = @status.emoji_reactions_grouped_by_name(@account) + .find { |reaction_group| reaction_group['name'] == emoji_reaction.name } if emoji_group emoji_group['status_id'] = @status.id.to_s else @@ -48,6 +48,7 @@ class UnEmojiReactService < BaseService emoji_group = { 'name' => emoji_reaction.name, 'count' => 0, 'account_ids' => [], 'status_id' => @status.id.to_s } emoji_group['domain'] = emoji_reaction.custom_emoji.domain if emoji_reaction.custom_emoji end + DeliveryEmojiReactionWorker.perform_async(render_emoji_reaction(emoji_group), @status.id, emoji_reaction.account_id) end diff --git a/app/services/verify_link_service.rb b/app/services/verify_link_service.rb index 707aeb4e08..b317fc31a8 100644 --- a/app/services/verify_link_service.rb +++ b/app/services/verify_link_service.rb @@ -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 diff --git a/app/validators/email_address_validator.rb b/app/validators/email_address_validator.rb new file mode 100644 index 0000000000..ed0bb11652 --- /dev/null +++ b/app/validators/email_address_validator.rb @@ -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 diff --git a/app/views/admin/custom_emojis/new.html.haml b/app/views/admin/custom_emojis/new.html.haml index 95996dec86..a03676b001 100644 --- a/app/views/admin/custom_emojis/new.html.haml +++ b/app/views/admin/custom_emojis/new.html.haml @@ -7,7 +7,7 @@ .fields-group = f.input :shortcode, wrapper: :with_label, label: t('admin.custom_emojis.shortcode'), hint: t('admin.custom_emojis.shortcode_hint') .fields-group - = f.input :image, wrapper: :with_label, input_html: { accept: CustomEmoji::IMAGE_MIME_TYPES.join(' ') }, hint: t('admin.custom_emojis.image_hint', size: number_to_human_size(CustomEmoji::LIMIT)) + = f.input :image, wrapper: :with_label, input_html: { accept: CustomEmoji::IMAGE_MIME_TYPES.join(',') }, hint: t('admin.custom_emojis.image_hint', size: number_to_human_size(CustomEmoji::LIMIT)) .actions = f.button :button, t('admin.custom_emojis.upload'), type: :submit diff --git a/app/views/admin/domain_blocks/edit.html.haml b/app/views/admin/domain_blocks/edit.html.haml index 8a06441508..a2148e6806 100644 --- a/app/views/admin/domain_blocks/edit.html.haml +++ b/app/views/admin/domain_blocks/edit.html.haml @@ -23,18 +23,6 @@ .fields-group = f.input :reject_reply_exclude_followers, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_reply_exclude_followers'), hint: I18n.t('admin.domain_blocks.reject_reply_exclude_followers_hint') - .fields-group - = f.input :reject_send_not_public_searchability, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_not_public_searchability'), hint: I18n.t('admin.domain_blocks.reject_send_not_public_searchability_hint') - - .fields-group - = f.input :reject_send_dissubscribable, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_dissubscribable'), hint: I18n.t('admin.domain_blocks.reject_send_dissubscribable_hint') - - .fields-group - = f.input :reject_send_public_unlisted, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_public_unlisted'), hint: I18n.t('admin.domain_blocks.reject_send_public_unlisted_hint') - - .fields-group - = f.input :reject_send_media, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_media'), hint: I18n.t('admin.domain_blocks.reject_send_media_hint') - .fields-group = f.input :reject_send_sensitive, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_sensitive'), hint: I18n.t('admin.domain_blocks.reject_send_sensitive_hint') @@ -65,8 +53,5 @@ .fields-group = f.input :hidden, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.hidden'), hint: I18n.t('admin.domain_blocks.hidden_hint') - .fields-group - = f.input :hidden_anonymous, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.hidden_anonymous'), hint: I18n.t('admin.domain_blocks.hidden_anonymous_hint') - .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml index 606a784e12..eda02e3207 100644 --- a/app/views/admin/domain_blocks/new.html.haml +++ b/app/views/admin/domain_blocks/new.html.haml @@ -23,18 +23,6 @@ .fields-group = f.input :reject_reply_exclude_followers, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_reply_exclude_followers'), hint: I18n.t('admin.domain_blocks.reject_reply_exclude_followers_hint') - .fields-group - = f.input :reject_send_not_public_searchability, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_not_public_searchability'), hint: I18n.t('admin.domain_blocks.reject_send_not_public_searchability_hint') - - .fields-group - = f.input :reject_send_dissubscribable, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_dissubscribable'), hint: I18n.t('admin.domain_blocks.reject_send_dissubscribable_hint') - - .fields-group - = f.input :reject_send_public_unlisted, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_public_unlisted'), hint: I18n.t('admin.domain_blocks.reject_send_public_unlisted_hint') - - .fields-group - = f.input :reject_send_media, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_media'), hint: I18n.t('admin.domain_blocks.reject_send_media_hint') - .fields-group = f.input :reject_send_sensitive, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_send_sensitive'), hint: I18n.t('admin.domain_blocks.reject_send_sensitive_hint') @@ -65,8 +53,5 @@ .fields-group = f.input :hidden, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.hidden'), hint: I18n.t('admin.domain_blocks.hidden_hint') - .fields-group - = f.input :hidden_anonymous, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.hidden_anonymous'), hint: I18n.t('admin.domain_blocks.hidden_anonymous_hint') - .actions = f.button :button, t('.create'), type: :submit diff --git a/app/views/admin/export_domain_blocks/_domain_block.html.haml b/app/views/admin/export_domain_blocks/_domain_block.html.haml index 6b2f0227f5..4789c4801b 100644 --- a/app/views/admin/export_domain_blocks/_domain_block.html.haml +++ b/app/views/admin/export_domain_blocks/_domain_block.html.haml @@ -13,10 +13,6 @@ = f.hidden_field :reject_favourite = f.hidden_field :reject_reply = f.hidden_field :reject_reply_exclude_followers - = f.hidden_field :reject_send_not_public_searchability - = f.hidden_field :reject_send_public_unlisted - = f.hidden_field :reject_send_dissubscribable - = f.hidden_field :reject_send_media = f.hidden_field :reject_send_sensitive = f.hidden_field :reject_hashtag = f.hidden_field :reject_straight_follow diff --git a/app/views/admin/settings/registrations/show.html.haml b/app/views/admin/settings/registrations/show.html.haml index 3674c6c2d3..eea3e5c238 100644 --- a/app/views/admin/settings/registrations/show.html.haml +++ b/app/views/admin/settings/registrations/show.html.haml @@ -10,9 +10,11 @@ %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: ->(mode) { I18n.t("admin.settings.registrations_mode.modes.#{mode}") } + = f.input :registrations_mode, collection: %w(open approved none), wrapper: :with_label, include_blank: false, label_method: ->(mode) { I18n.t("admin.settings.registrations_mode.modes.#{mode}") }, warning_hint: I18n.t('admin.settings.registrations_mode.warning_hint') .fields-row__column.fields-row__column-6.fields-group = f.input :require_invite_text, as: :boolean, wrapper: :with_label, disabled: !approved_registrations? diff --git a/app/views/admin_mailer/auto_close_registrations.text.erb b/app/views/admin_mailer/auto_close_registrations.text.erb new file mode 100644 index 0000000000..c0f8486929 --- /dev/null +++ b/app/views/admin_mailer/auto_close_registrations.text.erb @@ -0,0 +1,3 @@ +<%= raw t('admin_mailer.auto_close_registrations.body', instance: @instance) %> + +<%= raw t('application_mailer.view')%> <%= admin_settings_registrations_url %> diff --git a/app/views/settings/privacy_extra/show.html.haml b/app/views/settings/privacy_extra/show.html.haml index 285ba3abd3..1ac006ee83 100644 --- a/app/views/settings/privacy_extra/show.html.haml +++ b/app/views/settings/privacy_extra/show.html.haml @@ -18,9 +18,6 @@ .fields-group = ff.input :translatable_private, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_translatable_private') - .fields-group - = ff.input :link_preview, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_link_preview'), hint: I18n.t('simple_form.hints.defaults.setting_link_preview') - .fields-group = f.input :dissubscribable, as: :boolean, wrapper: :with_label, kmyblue: true, hint: t('simple_form.hints.defaults.dissubscribable') diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb index 7c1c14766b..376c237a98 100644 --- a/app/workers/activitypub/delivery_worker.rb +++ b/app/workers/activitypub/delivery_worker.rb @@ -23,9 +23,10 @@ class ActivityPub::DeliveryWorker HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze def perform(json, source_account_id, inbox_url, options = {}) - return unless DeliveryFailureTracker.available?(inbox_url) - @options = options.with_indifferent_access + + return unless @options[:bypass_availability] || DeliveryFailureTracker.available?(inbox_url) + @json = json @source_account = Account.find(source_account_id) @inbox_url = inbox_url diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index 34b6f6e32f..57ee1fbc0a 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -7,13 +7,23 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker @status = Status.find(status_id) @account = @status.account - distribute! + if @status.limited_visibility? + distribute_limited! + else + distribute! + end rescue ActiveRecord::RecordNotFound true end protected + def distribute_limited! + ActivityPub::DeliveryWorker.push_bulk(inboxes_for_limited, limit: 1_000) do |inbox_url| + [payload, @account.id, inbox_url, options] + end + end + def inboxes @inboxes ||= status_reach_finder.inboxes end @@ -22,6 +32,10 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker @inboxes_for_misskey ||= status_reach_finder.inboxes_for_misskey end + def inboxes_for_limited + @inboxes_for_limited ||= status_reach_finder.inboxes_for_limited + end + def status_reach_finder @status_reach_finder ||= StatusReachFinder.new(@status) end diff --git a/app/workers/activitypub/fetch_instance_info_worker.rb b/app/workers/activitypub/fetch_instance_info_worker.rb index 57cbd97d10..bc9a1a4815 100644 --- a/app/workers/activitypub/fetch_instance_info_worker.rb +++ b/app/workers/activitypub/fetch_instance_info_worker.rb @@ -8,28 +8,32 @@ class ActivityPub::FetchInstanceInfoWorker sidekiq_options queue: 'push', retry: 2 - class Error < StandardError; end - class RequestError < Error; end - class DeadError < Error; end - SUPPORTED_NOTEINFO_RELS = ['http://nodeinfo.diaspora.software/ns/schema/2.0', 'http://nodeinfo.diaspora.software/ns/schema/2.1'].freeze def perform(domain) @instance = Instance.find_by(domain: domain) return if !@instance || @instance.unavailable_domain.present? - with_redis_lock("instance_info:#{domain}") do - link = nodeinfo_link - return if link.nil? - - update_info!(link) + Rails.cache.fetch("fetch_instance_info:#{@instance.domain}", expires_in: 1.day, race_condition_ttl: 1.hour) do + fetch! end - rescue ActivityPub::FetchInstanceInfoWorker::DeadError + true end private + def fetch! + link = nodeinfo_link + return if link.nil? + + update_info!(link) + + true + rescue Mastodon::UnexpectedResponseError + true + end + def nodeinfo_link nodeinfo = fetch_json("https://#{@instance.domain}/.well-known/nodeinfo") return nil if nodeinfo.nil? || !nodeinfo.key?('links') @@ -63,15 +67,9 @@ class ActivityPub::FetchInstanceInfoWorker def fetch_json(url) build_request(url).perform do |response| - if [200, 203].include?(response.code) - raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) + raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) - body_to_json(response.body_with_limit) - elsif [400, 401, 403, 404, 410].include?(response.code) - raise ActivityPub::FetchInstanceInfoWorker::DeadError, "Request for #{@instance.domain} returned HTTP #{response.code}" - else - raise ActivityPub::FetchInstanceInfoWorker::RequestError, "Request for #{@instance.domain} returned HTTP #{response.code}" - end + body_to_json(response.body_with_limit) end end diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb index b3d8aa2646..c63af1e43a 100644 --- a/app/workers/link_crawl_worker.rb +++ b/app/workers/link_crawl_worker.rb @@ -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 diff --git a/app/workers/scheduler/auto_close_registrations_scheduler.rb b/app/workers/scheduler/auto_close_registrations_scheduler.rb new file mode 100644 index 0000000000..6874502915 --- /dev/null +++ b/app/workers/scheduler/auto_close_registrations_scheduler.rb @@ -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.with(recipient: user.account).auto_close_registrations.deliver_later + end + end +end diff --git a/app/workers/scheduler/sidekiq_health_scheduler.rb b/app/workers/scheduler/sidekiq_health_scheduler.rb deleted file mode 100644 index 646976d831..0000000000 --- a/app/workers/scheduler/sidekiq_health_scheduler.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class Scheduler::SidekiqHealthScheduler - include Sidekiq::Worker - - sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 15.seconds.to_i - - def perform - url = ENV.fetch('SIDEKIQ_HEALTH_FETCH_URL', nil) - Request.new(:head, url).perform if url.present? - end -end diff --git a/app/workers/scheduler/update_instance_info_scheduler.rb b/app/workers/scheduler/update_instance_info_scheduler.rb deleted file mode 100644 index f5b2852859..0000000000 --- a/app/workers/scheduler/update_instance_info_scheduler.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class Scheduler::UpdateInstanceInfoScheduler - include Sidekiq::Worker - - sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.day.to_i - - def perform - Instance.select(:domain).reorder(nil).find_in_batches do |instances| - ActivityPub::FetchInstanceInfoWorker.push_bulk(instances) do |instance| - [instance.domain] - end - end - end -end diff --git a/app/workers/scheduler/vacuum_scheduler.rb b/app/workers/scheduler/vacuum_scheduler.rb index 1c9a2aabe3..c22d6f5f80 100644 --- a/app/workers/scheduler/vacuum_scheduler.rb +++ b/app/workers/scheduler/vacuum_scheduler.rb @@ -22,7 +22,6 @@ class Scheduler::VacuumScheduler preview_cards_vacuum, backups_vacuum, access_tokens_vacuum, - applications_vacuum, feeds_vacuum, imports_vacuum, ] @@ -56,10 +55,6 @@ class Scheduler::VacuumScheduler Vacuum::ImportsVacuum.new end - def applications_vacuum - Vacuum::ApplicationsVacuum.new - end - def content_retention_policy ContentRetentionPolicy.current end diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index 3206c45f63..d4cefb3fdc 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -7,13 +7,18 @@ class ThreadResolveWorker sidekiq_options queue: 'pull', retry: 3 def perform(child_status_id, parent_url, options = {}) - child_status = Status.find(child_status_id) - parent_status = FetchRemoteStatusService.new.call(parent_url, **options.deep_symbolize_keys) + child_status = Status.find(child_status_id) + return if child_status.in_reply_to_id.present? + + parent_status = ActivityPub::TagManager.instance.uri_to_resource(parent_url, Status) + parent_status ||= FetchRemoteStatusService.new.call(parent_url, **options.deep_symbolize_keys) return if parent_status.nil? child_status.thread = parent_status child_status.save! + + DistributionWorker.perform_async(child_status_id, { 'skip_notifications' => true }) if child_status.within_realtime_window? rescue ActiveRecord::RecordNotFound true end diff --git a/config/application.rb b/config/application.rb index 2a62c37e8b..c2a63c8697 100644 --- a/config/application.rb +++ b/config/application.rb @@ -48,6 +48,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' diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 6ce84a6e42..85a328b448 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -5,7 +5,11 @@ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy def host_to_url(str) - "http#{Rails.configuration.x.use_https ? 's' : ''}://#{str.split('/').first}" if str.present? + return if str.blank? + + uri = Addressable::URI.parse("http#{Rails.configuration.x.use_https ? 's' : ''}://#{str}") + uri.path += '/' unless uri.path.blank? || uri.path.end_with?('/') + uri.to_s end base_host = Rails.configuration.x.web_domain diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index ffce438008..d4cfbbc18f 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -21,9 +21,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). diff --git a/config/initializers/ffmpeg.rb b/config/initializers/ffmpeg.rb index 30ea617fcd..87f85eeec7 100644 --- a/config/initializers/ffmpeg.rb +++ b/config/initializers/ffmpeg.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -if ENV['FFMPEG_BINARY'].present? - FFMPEG.ffmpeg_binary = ENV['FFMPEG_BINARY'] +Rails.application.configure do + config.x.ffmpeg_binary = ENV['FFMPEG_BINARY'] || 'ffmpeg' + config.x.ffprobe_binary = ENV['FFPROBE_BINARY'] || 'ffprobe' end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index d0af0fe940..8125b335f9 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -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 @@ -105,6 +105,10 @@ class Rack::Attack req.authenticated_user_id if (req.post? && req.path.match?(API_DELETE_REBLOG_REGEX)) || (req.delete? && req.path.match?(API_DELETE_STATUS_REGEX)) end + throttle('throttle_oauth_application_registrations/ip', limit: 5, period: 10.minutes) do |req| + req.throttleable_remote_ip if req.post? && req.path == '/api/v1/apps' + end + throttle('throttle_sign_up_attempts/ip', limit: 25, period: 5.minutes) do |req| req.throttleable_remote_ip if req.post? && req.path_matches?('/auth') end @@ -137,6 +141,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') || req.path_matches?('/auth/password')) + end + self.throttled_responder = lambda do |request| now = Time.now.utc match_data = request.env['rack.attack.match_data'] diff --git a/config/initializers/statsd.rb b/config/initializers/statsd.rb index a655c10716..f1628a9d12 100644 --- a/config/initializers/statsd.rb +++ b/config/initializers/statsd.rb @@ -3,13 +3,17 @@ if ENV['STATSD_ADDR'].present? host, port = ENV['STATSD_ADDR'].split(':') - statsd = Statsd.new(host, port) - statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') } + begin + statsd = Statsd.new(host, port) + statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') } - NSA.inform_statsd(statsd) do |informant| - informant.collect(:action_controller, :web) - informant.collect(:active_record, :db) - informant.collect(:active_support_cache, :cache) - informant.collect(:sidekiq, :sidekiq) if ENV['STATSD_SIDEKIQ'] == 'true' + NSA.inform_statsd(statsd) do |informant| + informant.collect(:action_controller, :web) + informant.collect(:active_record, :db) + informant.collect(:active_support_cache, :cache) + informant.collect(:sidekiq, :sidekiq) if ENV['STATSD_SIDEKIQ'] == 'true' + end + rescue + Rails.logger.warn("statsd address #{ENV['STATSD_ADDR']} not reachable, proceeding without statsd") end end diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml index eef8214817..b7c060016c 100644 --- a/config/locales/devise.en.yml +++ b/config/locales/devise.en.yml @@ -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 login again to continue. unauthenticated: You need to login or sign up before continuing. diff --git a/config/locales/en.yml b/config/locales/en.yml index ecb59c9463..d2d8e8d15d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -438,14 +438,6 @@ en: reject_reply_exclude_followers_hint: Reject replies exclude followers in the future reject_reports: Reject reports reject_reports_hint: Ignore all reports coming from this domain. Irrelevant for suspensions - reject_send_dissubscribable: 購読拒否アカウントの投稿を配送しない - reject_send_dissubscribable_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です - reject_send_media: 画像付き投稿を配送しない - reject_send_media_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です - reject_send_not_public_searchability: 検索許可が「誰でも」でない投稿を配送しない - reject_send_not_public_searchability_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です - reject_send_public_unlisted: ローカル公開投稿を配送しない - reject_send_public_unlisted_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です reject_send_sensitive: センシティブな投稿を配送しない reject_send_sensitive_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です reject_send_unlisted_dissubscribable: 購読拒否アカウントの未収載投稿を配送しない @@ -822,6 +814,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: @@ -829,6 +822,7 @@ en: approved: Approval required for sign up none: Nobody can sign up open: Anyone can sign up + warning_hint: We recommend using “Approval required for sign up” unless you are confident your moderation team can handle spam and malicious registrations in a timely fashion. security: authorized_fetch: Require authentication from federated servers authorized_fetch_hint: Requiring authentication from federated servers enables stricter enforcement of both user-level and server-level blocks. However, this comes at the cost of a performance penalty, reduces the reach of your replies, and may introduce compatibility issues with some federated services. In addition, this will not prevent dedicated actors from fetching your public posts and accounts. @@ -921,6 +915,9 @@ en: message_html: You haven't defined any server rules. sidekiq_process_check: message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration + software_version_check: + action: See available updates + message_html: A Mastodon update is available. software_version_critical_check: action: See available updates message_html: A critical Mastodon update is available, please update as quickly as possible. @@ -1031,6 +1028,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 @@ -1843,6 +1843,8 @@ en: too_many_hashtags: Too many hashtags visibilities: direct: Direct + login: Login only + login_long: Only show to logined users private: Followers-only private_long: Only show to followers public: Public @@ -1993,6 +1995,7 @@ en: go_to_sso_account_settings: Go to your identity provider's account settings 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: diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 85cb345e21..0893fc6135 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -400,8 +400,6 @@ ja: export: エクスポート hidden: 非公開にする hidden_hint: 公開することで当サーバーの安全が脅かされる場合、このドメインブロックを非公開にすることができます。 - hidden_anonymous: 未ログインユーザーに非公開にする - hidden_anonymous_hint: 公開することで当サーバーの安全が脅かされる場合、非ログインユーザーに限りこのドメインブロックを非公開にすることができます。 import: インポート new: create: ブロックを作成 @@ -434,14 +432,6 @@ ja: reject_reply_exclude_followers_hint: 今後のリプライを拒否します。停止とは無関係です reject_reports: 通報を拒否 reject_reports_hint: このドメインからの通報をすべて無視します。停止とは無関係です - reject_send_dissubscribable: 購読拒否アカウントの投稿を配送しない - reject_send_dissubscribable_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です - reject_send_media: 画像付き投稿を配送しない - reject_send_media_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です - reject_send_not_public_searchability: 検索許可が「誰でも」でない投稿を配送しない - reject_send_not_public_searchability_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です - reject_send_public_unlisted: ローカル公開投稿を配送しない - reject_send_public_unlisted_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です reject_send_sensitive: センシティブな投稿を配送しない reject_send_sensitive_hint: 相手サーバーからのフェッチは防げません。停止とは無関係です reject_send_unlisted_dissubscribable: 購読拒否アカウントの未収載投稿を配送しない @@ -520,10 +510,6 @@ ja: reject_reply: リプライを拒否 reject_reply_exclude_followers: フォロー相手以外からのリプライを拒否 reject_reports: 通報を拒否 - reject_send_dissubscribable: 購読拒否投稿配送なし - reject_send_media: メディア付き投稿配送なし - reject_send_not_public_searchability: 検索許可全て投稿配送なし - reject_send_public_unlisted: ローカル公開投稿配送なし reject_send_sensitive: センシティブ投稿配送なし reject_send_unlisted_dissubscribable: 購読拒否未収載投稿配送なし reject_straight_follow: フォローを制限 @@ -1821,6 +1807,8 @@ ja: too_many_hashtags: ハッシュタグが多すぎます visibilities: direct: ダイレクト + login: ログインユーザー限定 + login_long: ログインしたユーザーのみ表示されます private: フォロワー限定 private_long: フォロワーにのみ表示されます public: 公開 diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index aa17a42816..43be308d62 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -263,7 +263,6 @@ en: setting_hide_network: Hide your social graph setting_hide_recent_emojis: Hide recent emojis setting_hide_statuses_count: Hide statuses count - setting_link_preview: Generate post link preview card setting_lock_follow_from_bot: Request approval about bot follow setting_noai: Set noai meta tags setting_public_post_to_unlisted: Convert public post to public unlisted if not using Web app diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index e255c43cd4..bbf27c263c 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -78,7 +78,6 @@ ja: setting_emoji_reaction_streaming_notify_impl2: 当該サーバーの独自機能に対応したアプリを利用時に、スタンプ機能を利用できます。動作確認していないため(そもそもそのようなアプリ自体を確認できていないため)正しく動かない場合があります setting_enable_emoji_reaction: この機能を無効にしても、他の人はあなたの投稿にスタンプをつけられます setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします - setting_link_preview: プレビュー生成を停止することは、センシティブなサイトへのリンクを頻繁に投稿する人にも有効かもしれません setting_noai: AI学習への利用を禁止するメタタグをプロフィールページに追加します。ただし実効性があるとは限りません setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります setting_reject_unlisted_subscription: Misskeyやそのフォーク(Calckeyなど)は、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください @@ -277,7 +276,6 @@ ja: setting_hide_network: 繋がりを隠す setting_hide_recent_emojis: 絵文字ピッカーで最近使用した絵文字を隠す(リアクションデッキのみを表示する) setting_hide_statuses_count: 投稿数を隠す - setting_link_preview: リンクのプレビューを生成する setting_lock_follow_from_bot: botからのフォローを承認制にする setting_stay_privacy: 投稿時に公開範囲を保存する setting_noai: 自分のコンテンツのAI学習利用に対して不快感を表明する diff --git a/config/routes.rb b/config/routes.rb index 3db26c7f8c..82417bb7b0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,20 @@ # 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' +class RedirectWithVary < ActionDispatch::Routing::PathRedirect + def serve(...) + super.tap do |_, headers, _| + headers['Vary'] = 'Origin, Accept' + end + end +end + +def redirect_with_vary(path) + RedirectWithVary.new(301, path) +end + Rails.application.routes.draw do # Paths of routes on the web app that to not require to be indexed or # have alternative format representations requiring separate controllers @@ -97,10 +109,13 @@ Rails.application.routes.draw do confirmations: 'auth/confirmations', } - get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? } - get '/users/:username/following', to: redirect('/@%{username}/following'), constraints: lambda { |req| req.format.nil? || req.format.html? } - get '/users/:username/followers', to: redirect('/@%{username}/followers'), constraints: lambda { |req| req.format.nil? || req.format.html? } - get '/users/:username/statuses/:id', to: redirect('/@%{username}/%{id}'), constraints: lambda { |req| req.format.nil? || req.format.html? } + # rubocop:disable Style/FormatStringToken - those do not go through the usual formatting functions and are not safe to correct + get '/users/:username', to: redirect_with_vary('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? } + get '/users/:username/following', to: redirect_with_vary('/@%{username}/following'), constraints: lambda { |req| req.format.nil? || req.format.html? } + get '/users/:username/followers', to: redirect_with_vary('/@%{username}/followers'), constraints: lambda { |req| req.format.nil? || req.format.html? } + get '/users/:username/statuses/:id', to: redirect_with_vary('/@%{username}/%{id}'), constraints: lambda { |req| req.format.nil? || req.format.html? } + # rubocop:enable Style/FormatStringToken + get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" } resources :accounts, path: 'users', only: [:show], param: :username do @@ -142,7 +157,7 @@ Rails.application.routes.draw do get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status end - get '/@:username_with_domain/(*any)', to: 'home#index', constraints: { username_with_domain: %r{([^/])+?} }, format: false + get '/@:username_with_domain/(*any)', to: 'home#index', constraints: { username_with_domain: %r{([^/])+?} }, as: :account_with_domain, format: false get '/settings', to: redirect('/settings/profile') draw(:settings) diff --git a/config/settings.yml b/config/settings.yml index 69b3ed1ee3..aceb025029 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -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: '' timeline_preview: true diff --git a/config/sidekiq.yml b/config/sidekiq.yml index f954c00aaa..8c9481c050 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -58,15 +58,11 @@ interval: 1 minute class: Scheduler::SuspendedUserCleanupScheduler queue: scheduler - sidekiq_health_scheduler: - interval: 30 seconds - class: Scheduler::SidekiqHealthScheduler - queue: scheduler - update_instance_info_scheduler: - cron: '0 0 * * *' - class: Scheduler::UpdateInstanceInfoScheduler - queue: scheduler software_update_check_scheduler: interval: 30 minutes class: Scheduler::SoftwareUpdateCheckScheduler queue: scheduler + auto_close_registrations_scheduler: + interval: 1 hour + class: Scheduler::AutoCloseRegistrationsScheduler + queue: scheduler diff --git a/db/migrate/20231212225737_improve_index_for_public_timeline_speed.rb b/db/migrate/20231212225737_improve_index_for_public_timeline_speed.rb new file mode 100644 index 0000000000..bcfe83de03 --- /dev/null +++ b/db/migrate/20231212225737_improve_index_for_public_timeline_speed.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ImproveIndexForPublicTimelineSpeed < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def up + add_index :statuses, [:id, :account_id], name: :index_statuses_local_20231213, algorithm: :concurrently, order: { id: :desc }, where: '(local OR (uri IS NULL)) AND deleted_at IS NULL AND visibility IN (0, 10, 11) AND reblog_of_id IS NULL AND ((NOT reply) OR (in_reply_to_account_id = account_id))' + add_index :statuses, [:id, :account_id], name: :index_statuses_public_20231213, algorithm: :concurrently, order: { id: :desc }, where: 'deleted_at IS NULL AND visibility IN (0, 10, 11) AND reblog_of_id IS NULL AND ((NOT reply) OR (in_reply_to_account_id = account_id))' + remove_index :statuses, name: :index_statuses_local_20190824 + remove_index :statuses, name: :index_statuses_public_20200119 + end + + def down + add_index :statuses, [:id, :account_id], name: :index_statuses_local_20190824, algorithm: :concurrently, order: { id: :desc }, where: '(local OR (uri IS NULL)) AND deleted_at IS NULL AND visibility = 0 AND reblog_of_id IS NULL AND ((NOT reply) OR (in_reply_to_account_id = account_id))' + add_index :statuses, [:id, :account_id], name: :index_statuses_public_20200119, algorithm: :concurrently, order: { id: :desc }, where: 'deleted_at IS NULL AND visibility = 0 AND reblog_of_id IS NULL AND ((NOT reply) OR (in_reply_to_account_id = account_id))' + remove_index :statuses, name: :index_statuses_local_20231213 + remove_index :statuses, name: :index_statuses_public_20231213 + end +end diff --git a/db/migrate/20240109035435_remove_hidden_anonymous_from_domain_blocks.rb b/db/migrate/20240109035435_remove_hidden_anonymous_from_domain_blocks.rb new file mode 100644 index 0000000000..d1272cd79f --- /dev/null +++ b/db/migrate/20240109035435_remove_hidden_anonymous_from_domain_blocks.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class RemoveHiddenAnonymousFromDomainBlocks < ActiveRecord::Migration[7.0] + class DomainBlock < ApplicationRecord; end + + def up + safety_assured do + DomainBlock.where(hidden_anonymous: true, hidden: false).update_all(hidden: true) + remove_column :domain_blocks, :hidden_anonymous + end + end + + def down + safety_assured do + add_column :domain_blocks, :hidden_anonymous, :boolean, null: false, default: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 24dc5450e1..8542eb9450 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do +ActiveRecord::Schema[7.0].define(version: 2024_01_09_035435) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -570,7 +570,6 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do t.boolean "reject_straight_follow", default: false, null: false t.boolean "reject_new_follow", default: false, null: false t.boolean "hidden", default: false, null: false - t.boolean "hidden_anonymous", default: false, null: false t.boolean "detect_invalid_subscription", default: false, null: false t.boolean "reject_reply_exclude_followers", default: false, null: false t.index ["domain"], name: "index_domain_blocks_on_domain", unique: true @@ -1193,8 +1192,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do t.index ["account_id", "reblog_of_id", "deleted_at", "searchability"], name: "index_statuses_for_get_following_accounts_to_search", where: "((deleted_at IS NULL) AND (reblog_of_id IS NULL) AND (searchability = ANY (ARRAY[0, 10, 1])))" t.index ["account_id"], name: "index_statuses_on_account_id" t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)" - t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" - t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" + t.index ["id", "account_id"], name: "index_statuses_local_20231213", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = ANY (ARRAY[0, 10, 11])) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" + t.index ["id", "account_id"], name: "index_statuses_public_20231213", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = ANY (ARRAY[0, 10, 11])) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id", where: "(in_reply_to_account_id IS NOT NULL)" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", where: "(in_reply_to_id IS NOT NULL)" t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id" diff --git a/db/seeds/04_admin.rb b/db/seeds/04_admin.rb index c9b0369c9f..887b4a2213 100644 --- a/db/seeds/04_admin.rb +++ b/db/seeds/04_admin.rb @@ -7,5 +7,7 @@ if Rails.env.development? admin = Account.where(username: 'admin').first_or_initialize(username: 'admin') admin.save(validate: false) - User.where(email: "admin@#{domain}").first_or_initialize(email: "admin@#{domain}", password: 'mastodonadmin', password_confirmation: 'mastodonadmin', confirmed_at: Time.now.utc, role: UserRole.find_by(name: 'Owner'), account: admin, agreement: true, approved: true).save! + user = User.where(email: "admin@#{domain}").first_or_initialize(email: "admin@#{domain}", password: 'mastodonadmin', password_confirmation: 'mastodonadmin', confirmed_at: Time.now.utc, role: UserRole.find_by(name: 'Owner'), account: admin, agreement: true, approved: true) + user.save! + user.approve! end diff --git a/docker-compose.yml b/docker-compose.yml index 1a180b0890..2645c9eebe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: web: build: . - image: ghcr.io/mastodon/mastodon:v4.2.1 + image: ghcr.io/mastodon/mastodon:v4.2.13 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.2.1 + image: ghcr.io/mastodon/mastodon:v4.2.13 restart: always env_file: .env.production command: node ./streaming @@ -95,7 +95,7 @@ services: sidekiq: build: . - image: ghcr.io/mastodon/mastodon:v4.2.1 + image: ghcr.io/mastodon/mastodon:v4.2.13 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/install/5.0/setup4.sh b/install/5.0/setup4.sh index 60014adb11..4c946d9a41 100644 --- a/install/5.0/setup4.sh +++ b/install/5.0/setup4.sh @@ -6,8 +6,8 @@ Install Ruby EOF git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build -RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 3.2.2 -rbenv global 3.2.2 +RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 3.2.3 +rbenv global 3.2.3 cat << EOF diff --git a/lib/action_dispatch/remote_ip_extensions.rb b/lib/action_dispatch/remote_ip_extensions.rb new file mode 100644 index 0000000000..e5c48bf3c5 --- /dev/null +++ b/lib/action_dispatch/remote_ip_extensions.rb @@ -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 diff --git a/lib/mastodon/cli/feeds.rb b/lib/mastodon/cli/feeds.rb index 3467dd427c..4f2de4ebb4 100644 --- a/lib/mastodon/cli/feeds.rb +++ b/lib/mastodon/cli/feeds.rb @@ -48,10 +48,35 @@ module Mastodon::CLI say('OK', :green) end + desc 'remove_legacy', 'Remove old list and antenna feeds from Redis' + def remove_legacy + current_id = 1 + List.reorder(:id).select(:id).find_in_batches do |lists| + current_id = remove_legacy_feeds(:list, lists, current_id) + end + + current_id = 1 + Antenna.reorder(:id).select(:id).find_in_batches do |antennas| + current_id = remove_legacy_feeds(:antenna, antennas, current_id) + end + + say('OK', :green) + end + private def active_user_accounts Account.joins(:user).merge(User.active) end + + def remove_legacy_feeds(type, items, current_id) + exist_ids = items.pluck(:id) + last_id = exist_ids.max + + ids = Range.new(current_id, last_id).to_a - exist_ids + FeedManager.instance.clean_feeds!(type, ids) + + last_id + 1 + end end end diff --git a/lib/mastodon/cli/maintenance.rb b/lib/mastodon/cli/maintenance.rb index e73bcbf86a..c2a6802e1d 100644 --- a/lib/mastodon/cli/maintenance.rb +++ b/lib/mastodon/cli/maintenance.rb @@ -224,7 +224,7 @@ module Mastodon::CLI users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse ref_user = users.shift say "Multiple users registered with e-mail address #{ref_user.email}.", :yellow - say "e-mail will be disabled for the following accounts: #{user.map(&:account).map(&:acct).join(', ')}", :yellow + say "e-mail will be disabled for the following accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow say 'Please reach out to them and set another address with `tootctl account modify` or delete them.', :yellow users.each_with_index do |user, index| diff --git a/lib/mastodon/cli/media.rb b/lib/mastodon/cli/media.rb index c906161770..6113264619 100644 --- a/lib/mastodon/cli/media.rb +++ b/lib/mastodon/cli/media.rb @@ -13,6 +13,7 @@ module Mastodon::CLI option :remove_headers, type: :boolean, default: false option :include_follows, type: :boolean, default: false option :concurrency, type: :numeric, default: 5, aliases: [:c] + option :verbose, type: :boolean, default: false, aliases: [:v] option :dry_run, type: :boolean, default: false desc 'remove', 'Remove remote media files, headers or avatars' long_desc <<-DESC @@ -134,7 +135,7 @@ module Mastodon::CLI 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) @@ -180,7 +181,7 @@ module Mastodon::CLI 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 @@ -311,7 +312,7 @@ module Mastodon::CLI 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) @@ -361,7 +362,7 @@ module Mastodon::CLI next unless VALID_PATH_SEGMENTS_SIZE.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) diff --git a/lib/mastodon/sidekiq_middleware.rb b/lib/mastodon/sidekiq_middleware.rb index 3a747afb63..ac63d4d38c 100644 --- a/lib/mastodon/sidekiq_middleware.rb +++ b/lib/mastodon/sidekiq_middleware.rb @@ -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 diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 5840c6363e..d960334a9b 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -9,7 +9,7 @@ module Mastodon end def kmyblue_minor - 9 + 26 end def kmyblue_flag @@ -25,7 +25,7 @@ module Mastodon end def patch - 1 + 13 end def default_prerelease @@ -46,8 +46,19 @@ module Mastodon components.join end + def to_s_of_mastodon + components = [to_a.join('.')] + components << "-#{prerelease}" if prerelease.present? + components << "+#{build_metadata_of_mastodon}" if build_metadata_of_mastodon.present? + components.join + end + def build_metadata - ['kmyblue', to_s_of_kmyblue, ENV.fetch('MASTODON_VERSION_METADATA', nil)].compact.join('.') + ['kmyblue', to_s_of_kmyblue, build_metadata_of_mastodon].compact.join('.') + end + + def build_metadata_of_mastodon + ENV.fetch('MASTODON_VERSION_METADATA', nil) end def to_a diff --git a/lib/paperclip/image_extractor.rb b/lib/paperclip/image_extractor.rb index 17fe4326fd..8a565d0469 100644 --- a/lib/paperclip/image_extractor.rb +++ b/lib/paperclip/image_extractor.rb @@ -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) diff --git a/lib/paperclip/response_with_limit_adapter.rb b/lib/paperclip/response_with_limit_adapter.rb index deb89717a4..ff7a938abb 100644 --- a/lib/paperclip/response_with_limit_adapter.rb +++ b/lib/paperclip/response_with_limit_adapter.rb @@ -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 diff --git a/lib/paperclip/transcoder.rb b/lib/paperclip/transcoder.rb index d2d946d3ad..3efffa355a 100644 --- a/lib/paperclip/transcoder.rb +++ b/lib/paperclip/transcoder.rb @@ -61,7 +61,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}" diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb index d8a841a047..b10500c609 100644 --- a/lib/sanitize_ext/sanitize_config.rb +++ b/lib/sanitize_ext/sanitize_config.rb @@ -88,7 +88,7 @@ class Sanitize end MASTODON_STRICT ||= freeze_config( - elements: %w(p br span a del pre blockquote code b strong u i em ul ol li), + elements: %w(p br span a del s pre blockquote code b strong u i em ul ol li), attributes: { 'a' => %w(href rel class translate), diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index e8a64b8fb2..f51a2459c5 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -17,7 +17,7 @@ namespace :db do task :pre_migration_check do version = ActiveRecord::Base.connection.select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i - abort 'This version of Mastodon requires PostgreSQL 9.5 or newer. Please update PostgreSQL before updating Mastodon' if version < 90_500 + abort 'This version of Mastodon requires PostgreSQL 10.0 or newer. Please update PostgreSQL before updating Mastodon' if version < 100_000 end Rake::Task['db:migrate'].enhance(['db:pre_migration_check']) diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index dfa2c5a8e1..aa612fe486 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -515,6 +515,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 diff --git a/lib/tasks/sidekiq_unique_jobs.rake b/lib/tasks/sidekiq_unique_jobs.rake new file mode 100644 index 0000000000..bedc8fe4c6 --- /dev/null +++ b/lib/tasks/sidekiq_unique_jobs.rake @@ -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 diff --git a/lib/tasks/statistics.rake b/lib/tasks/statistics.rake index dde7890f6b..82840f4fdc 100644 --- a/lib/tasks/statistics.rake +++ b/lib/tasks/statistics.rake @@ -9,11 +9,13 @@ namespace :mastodon do [ ['App Libraries', 'app/lib'], %w(Presenters app/presenters), + %w(Policies app/policies), + %w(Serializers app/serializers), %w(Services app/services), %w(Validators app/validators), %w(Workers app/workers), ].each do |name, dir| - STATS_DIRECTORIES << [name, Rails.root.join(dir)] + STATS_DIRECTORIES << [name, dir] end end end diff --git a/package.json b/package.json index 5de05c53b6..aeb1651207 100644 --- a/package.json +++ b/package.json @@ -228,5 +228,6 @@ "*.{haml}": "bundle exec haml-lint", "*.{js,jsx,ts,tsx}": "eslint --fix", "*.{css,scss}": "stylelint --fix" - } + }, + "packageManager": "yarn@1.22.22" } diff --git a/spec/config/initializers/rack_attack_spec.rb b/spec/config/initializers/rack_attack_spec.rb index 7cd4ac76bb..cf8ba4fe35 100644 --- a/spec/config/initializers/rack_attack_spec.rb +++ b/spec/config/initializers/rack_attack_spec.rb @@ -7,7 +7,7 @@ describe Rack::Attack, type: :request do Rails.application end - shared_examples 'throttled endpoint' do + shared_context 'with throttled endpoint base' do before do # Rack::Attack periods are not rolling, so avoid flaky tests by setting the time in a way # to avoid crossing period boundaries. @@ -18,6 +18,10 @@ describe Rack::Attack, type: :request do travel_to Time.zone.at((Time.now.to_i / period.seconds).to_i * period.seconds) end + end + + shared_examples 'throttled endpoint' do + include_examples 'with throttled endpoint base' context 'when the number of requests is lower than the limit' do it 'does not change the request status' do @@ -43,6 +47,28 @@ describe Rack::Attack, type: :request do end end + shared_examples 'does not throttle endpoint' do + include_examples 'with throttled endpoint base' + + context 'when the number of requests is lower than the limit' do + it 'does not change the request status' do + limit.times do + request.call + expect(response).to_not have_http_status(429) + end + end + end + + context 'when the number of requests is higher than the limit' do + it 'returns http too many requests after limit and returns to normal status after period' do + (limit * 2).times do |_i| + request.call + expect(response).to_not have_http_status(429) + end + end + end + end + let(:remote_ip) { '1.2.3.5' } describe 'throttle excessive sign-up requests by IP address' do @@ -103,4 +129,64 @@ describe Rack::Attack, type: :request do it_behaves_like 'throttled endpoint' end end + + describe 'throttle excessive oauth application registration requests by IP address' do + let(:throttle) { 'throttle_oauth_application_registrations/ip' } + let(:limit) { 5 } + let(:period) { 10.minutes } + let(:path) { '/api/v1/apps' } + let(:params) do + { + client_name: 'Throttle Test', + redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', + scopes: 'read', + } + end + + let(:request) { -> { post path, params: params, headers: { 'REMOTE_ADDR' => remote_ip } } } + + it_behaves_like 'throttled endpoint' + 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 + + describe 'throttle excessive emoji reaction 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(:status) { Fabricate(:status) } + let(:emoji) { Fabricate(:custom_emoji) } + let(:path) { "/api/v1/statuses/#{status.id}/emoji_reactions/#{emoji.shortcode}" } + + before do + sign_in user, scope: :user + + get '/' + end + + it_behaves_like 'does not throttle endpoint' + end end diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb index 9befdf978f..5c8fc0a639 100644 --- a/spec/controllers/admin/statuses_controller_spec.rb +++ b/spec/controllers/admin/statuses_controller_spec.rb @@ -43,6 +43,11 @@ describe Admin::StatusesController do describe 'GET #show' do before do + status.media_attachments << Fabricate(:media_attachment, type: :image, account: status.account) + status.save! + status.snapshot!(at_time: status.created_at, rate_limit: false) + status.update!(text: 'Hello, this is an edited post') + status.snapshot!(rate_limit: false) get :show, params: { account_id: account.id, id: status.id } end diff --git a/spec/controllers/api/v1/accounts/featured_tags_controller_spec.rb b/spec/controllers/api/v1/accounts/featured_tags_controller_spec.rb deleted file mode 100644 index 53ac1e2a7a..0000000000 --- a/spec/controllers/api/v1/accounts/featured_tags_controller_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Api::V1::Accounts::FeaturedTagsController 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) } - - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - describe 'GET #index' do - it 'returns http success' do - get :index, params: { account_id: account.id, limit: 2 } - - expect(response).to have_http_status(200) - end - end -end diff --git a/spec/controllers/api/v1/featured_tags/suggestions_controller_spec.rb b/spec/controllers/api/v1/featured_tags/suggestions_controller_spec.rb index 54c63dcc6f..8cb928ea2c 100644 --- a/spec/controllers/api/v1/featured_tags/suggestions_controller_spec.rb +++ b/spec/controllers/api/v1/featured_tags/suggestions_controller_spec.rb @@ -7,17 +7,39 @@ describe Api::V1::FeaturedTags::SuggestionsController do let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') } - let(:account) { Fabricate(:account) } + let(:account) { Fabricate(:account, user: user) } before do allow(controller).to receive(:doorkeeper_token) { token } end describe 'GET #index' do - it 'returns http success' 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(response) + .to have_http_status(200) + expect(body_as_json) + .to contain_exactly( + include(name: used_tag.name) + ) end end end diff --git a/spec/controllers/api/v1/scheduled_statuses_controller_spec.rb b/spec/controllers/api/v1/scheduled_statuses_controller_spec.rb index 256c4b272a..cc3b65f37d 100644 --- a/spec/controllers/api/v1/scheduled_statuses_controller_spec.rb +++ b/spec/controllers/api/v1/scheduled_statuses_controller_spec.rb @@ -13,6 +13,17 @@ describe Api::V1::ScheduledStatusesController do allow(controller).to receive(:doorkeeper_token) { token } end + context 'with an application token' do + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: 'read:statuses') } + + it 'returns http unprocessable entity' do + get :index + + expect(response) + .to have_http_status(422) + end + end + describe 'GET #index' do it 'returns http success' do get :index diff --git a/spec/controllers/api/v1/statuses/translations_controller_spec.rb b/spec/controllers/api/v1/statuses/translations_controller_spec.rb index 6257494ae1..da152843b2 100644 --- a/spec/controllers/api/v1/statuses/translations_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/translations_controller_spec.rb @@ -9,6 +9,26 @@ describe Api::V1::Statuses::TranslationsController do let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) } + context 'with an application token' do + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: 'read:statuses', application: app) } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'POST /api/v1/statuses/:status_id/translate' do + let(:status) { Fabricate(:status, account: user.account, text: 'Hola', language: 'es') } + + before do + post :create, params: { status_id: status.id } + end + + it 'returns http unprocessable entity' do + expect(response).to have_http_status(422) + end + end + end + context 'with an oauth token' do before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb index c2bdba9ace..591307fe22 100644 --- a/spec/controllers/api/v1/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/statuses_controller_spec.rb @@ -182,6 +182,46 @@ RSpec.describe Api::V1::StatusesController do expect(response.headers['X-RateLimit-Remaining']).to eq '0' end end + + context 'with missing thread' do + subject { post :create, params: params } + + let(:params) { { status: 'Hello world', in_reply_to_id: 0 } } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when scheduling a status' do + subject { post :create, params: params } + + let(:params) { { status: 'Hello world', scheduled_at: 10.minutes.from_now } } + let(:account) { user.account } + + it 'returns HTTP 200' do + subject + + expect(response).to have_http_status(200) + end + + it 'creates a scheduled status' do + expect { subject }.to change { account.scheduled_statuses.count }.from(0).to(1) + end + + context 'when the scheduling time is less than 5 minutes' do + let(:params) { { status: 'Hello world', scheduled_at: 4.minutes.from_now } } + + it 'does not create a scheduled status', :aggregate_failures do + subject + + expect(response).to have_http_status(422) + expect(account.scheduled_statuses).to be_empty + end + end + end end describe 'DELETE #destroy' do diff --git a/spec/controllers/api/v1/streaming_controller_spec.rb b/spec/controllers/api/v1/streaming_controller_spec.rb index 7014ed9b2b..825bb11978 100644 --- a/spec/controllers/api/v1/streaming_controller_spec.rb +++ b/spec/controllers/api/v1/streaming_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/timelines/tag_controller_spec.rb b/spec/controllers/api/v1/timelines/tag_controller_spec.rb index 1c60798fcf..89622a41a2 100644 --- a/spec/controllers/api/v1/timelines/tag_controller_spec.rb +++ b/spec/controllers/api/v1/timelines/tag_controller_spec.rb @@ -6,7 +6,8 @@ describe Api::V1::Timelines::TagController do render_views let(:user) { Fabricate(:user) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') } + let(:scopes) { 'read:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } before do allow(controller).to receive(:doorkeeper_token) { token } @@ -48,13 +49,23 @@ describe Api::V1::Timelines::TagController do Form::AdminSettings.new(timeline_preview: false).save end - context 'when the user is not authenticated' do + context 'without an access token' do let(:token) { nil } - it 'returns http unauthorized' do + it 'returns http unprocessable entity' do subject - expect(response).to have_http_status(401) + expect(response).to have_http_status(422) + end + end + + context 'with an application access token, not bound to a user' do + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: scopes) } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) end end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index e9d4796035..5cd45e9919 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -221,39 +221,4 @@ describe ApplicationController do include_examples 'respond_with_error', 422 end - - describe 'cache_collection' do - subject do - Class.new(ApplicationController) do - public :cache_collection - end - end - - shared_examples 'receives :with_includes' do |fabricator, klass| - it 'uses raw if it is not an ActiveRecord::Relation' do - record = Fabricate(fabricator) - expect(subject.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(subject.new.cache_collection(relation, klass)).to eq [record] - end - end - - it 'returns raw unless class responds to :with_includes' do - raw = Object.new - expect(subject.new.cache_collection(raw, Object)).to eq raw - end - - context 'with a Status' do - include_examples 'cacheable', :status, Status - end - end end diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb index c727a76333..95055ade4c 100644 --- a/spec/controllers/auth/sessions_controller_spec.rb +++ b/spec/controllers/auth/sessions_controller_spec.rb @@ -263,6 +263,26 @@ RSpec.describe Auth::SessionsController do end end + context 'when repeatedly using an invalid TOTP code before using a valid code' do + before do + stub_const('Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR', 2) + end + + it 'does not log the user in' do + # Travel to the beginning of an hour to avoid crossing rate-limit buckets + travel_to '2023-12-20T10:00:00Z' + + Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR.times do + post :create, params: { user: { otp_attempt: '1234' } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s } + expect(controller.current_user).to be_nil + end + + post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s } + expect(controller.current_user).to be_nil + expect(flash[:alert]).to match I18n.t('users.rate_limited') + end + end + context 'when using a valid OTP' do 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 } diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb deleted file mode 100644 index 650cd21eaf..0000000000 --- a/spec/controllers/concerns/signature_verification_spec.rb +++ /dev/null @@ -1,305 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe SignatureVerification do - let(:wrapped_actor_class) do - Class.new do - attr_reader :wrapped_account - - def initialize(wrapped_account) - @wrapped_account = wrapped_account - end - - delegate :uri, :keypair, to: :wrapped_account - end - end - - controller(ApplicationController) 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) { wrapped_actor_class.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 unparsable 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 diff --git a/spec/controllers/oauth/authorized_applications_controller_spec.rb b/spec/controllers/oauth/authorized_applications_controller_spec.rb index b54610604c..3fd9f9499f 100644 --- a/spec/controllers/oauth/authorized_applications_controller_spec.rb +++ b/spec/controllers/oauth/authorized_applications_controller_spec.rb @@ -50,9 +50,11 @@ describe Oauth::AuthorizedApplicationsController do let!(:application) { Fabricate(:application) } let!(:access_token) { Fabricate(:accessible_access_token, application: application, resource_owner_id: user.id) } let!(:web_push_subscription) { Fabricate(:web_push_subscription, user: user, access_token: access_token) } + let(:redis_pipeline_stub) { instance_double(Redis::Namespace, publish: nil) } before do sign_in user, scope: :user + allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub) post :destroy, params: { id: application.id } end @@ -63,5 +65,13 @@ describe Oauth::AuthorizedApplicationsController do it 'removes subscriptions for the application\'s access tokens' do expect(Web::PushSubscription.where(user: user).count).to eq 0 end + + it 'removes the web_push_subscription' do + expect { web_push_subscription.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'sends a session kill payload to the streaming server' do + expect(redis_pipeline_stub).to have_received(:publish).with("timeline:access_token:#{access_token.id}", '{"event":"kill"}') + end end end diff --git a/spec/controllers/settings/applications_controller_spec.rb b/spec/controllers/settings/applications_controller_spec.rb index 169304b3ed..88974e551d 100644 --- a/spec/controllers/settings/applications_controller_spec.rb +++ b/spec/controllers/settings/applications_controller_spec.rb @@ -166,7 +166,11 @@ describe Settings::ApplicationsController do end describe 'destroy' do + let(:redis_pipeline_stub) { instance_double(Redis::Namespace, publish: nil) } + let!(:access_token) { Fabricate(:accessible_access_token, application: app) } + before do + allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub) post :destroy, params: { id: app.id } end @@ -177,6 +181,10 @@ describe Settings::ApplicationsController do it 'removes the app' do expect(Doorkeeper::Application.find_by(id: app.id)).to be_nil end + + it 'sends a session kill payload to the streaming server' do + expect(redis_pipeline_stub).to have_received(:publish).with("timeline:access_token:#{access_token.id}", '{"event":"kill"}') + end end describe 'regenerate' do diff --git a/spec/controllers/settings/preferences/appearance_controller_spec.rb b/spec/controllers/settings/preferences/appearance_controller_spec.rb index 9a98a41886..083bf49544 100644 --- a/spec/controllers/settings/preferences/appearance_controller_spec.rb +++ b/spec/controllers/settings/preferences/appearance_controller_spec.rb @@ -31,11 +31,5 @@ describe Settings::Preferences::AppearanceController do expect(response).to redirect_to(settings_preferences_appearance_path) end - - it 'renders show on failure' do - put :update, params: { user: { locale: 'fake option' } } - - expect(response).to render_template('preferences/appearance/show') - end end end diff --git a/spec/fabricators/featured_tag_fabricator.rb b/spec/fabricators/featured_tag_fabricator.rb index 0803dc43a7..6003099dbd 100644 --- a/spec/fabricators/featured_tag_fabricator.rb +++ b/spec/fabricators/featured_tag_fabricator.rb @@ -2,6 +2,6 @@ Fabricator(:featured_tag) do account { Fabricate.build(:account) } - tag { Fabricate.build(:tag) } + tag { nil } name { sequence(:name) { |i| "Tag#{i}" } } end diff --git a/spec/fixtures/requests/activitypub-webfinger.txt b/spec/fixtures/requests/activitypub-webfinger.txt index 465066d84e..733b1693dc 100644 --- a/spec/fixtures/requests/activitypub-webfinger.txt +++ b/spec/fixtures/requests/activitypub-webfinger.txt @@ -4,4 +4,4 @@ Content-Type: application/jrd+json; charset=utf-8 X-Content-Type-Options: nosniff Date: Sun, 17 Sep 2017 06:22:50 GMT -{"subject":"acct:foo@ap.example.com","aliases":["https://ap.example.com/@foo","https://ap.example.com/users/foo"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://ap.example.com/@foo"},{"rel":"http://schemas.google.com/g/2010#updates-from","type":"application/atom+xml","href":"https://ap.example.com/users/foo.atom"},{"rel":"self","type":"application/activity+json","href":"https://ap.example.com/users/foo"},{"rel":"salmon","href":"https://ap.example.com/api/salmon/1"},{"rel":"magic-public-key","href":"data:application/magic-public-key,RSA.u3L4vnpNLzVH31MeWI394F0wKeJFsLDAsNXGeOu0QF2x-h1zLWZw_agqD2R3JPU9_kaDJGPIV2Sn5zLyUA9S6swCCMOtn7BBR9g9sucgXJmUFB0tACH2QSgHywMAybGfmSb3LsEMNKsGJ9VsvYoh8lDET6X4Pyw-ZJU0_OLo_41q9w-OrGtlsTm_PuPIeXnxa6BLqnDaxC-4IcjG_FiPahNCTINl_1F_TgSSDZ4Taf4U9XFEIFw8wmgploELozzIzKq-t8nhQYkgAkt64euWpva3qL5KD1mTIZQEP-LZvh3s2WHrLi3fhbdRuwQ2c0KkJA2oSTFPDpqqbPGZ3QvuHQ==.AQAB"},{"rel":"http://ostatus.org/schema/1.0/subscribe","template":"https://ap.example.com/authorize_follow?acct={uri}"}]} \ No newline at end of file +{"subject":"acct:foo@ap.example.com","aliases":["https://ap.example.com/@foo","https://ap.example.com/users/foo"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://ap.example.com/@foo"},{"rel":"http://schemas.google.com/g/2010#updates-from","type":"application/atom+xml","href":"https://ap.example.com/users/foo.atom"},{"rel":"self","type":"application/html","href":"https://ap.example.com/users/foo.html"},{"rel":"self","type":"application/activity+json","href":"https://ap.example.com/users/foo"},{"rel":"self","type":"application/json","href":"https://ap.example.com/users/foo.json"},{"rel":"salmon","href":"https://ap.example.com/api/salmon/1"},{"rel":"magic-public-key","href":"data:application/magic-public-key,RSA.u3L4vnpNLzVH31MeWI394F0wKeJFsLDAsNXGeOu0QF2x-h1zLWZw_agqD2R3JPU9_kaDJGPIV2Sn5zLyUA9S6swCCMOtn7BBR9g9sucgXJmUFB0tACH2QSgHywMAybGfmSb3LsEMNKsGJ9VsvYoh8lDET6X4Pyw-ZJU0_OLo_41q9w-OrGtlsTm_PuPIeXnxa6BLqnDaxC-4IcjG_FiPahNCTINl_1F_TgSSDZ4Taf4U9XFEIFw8wmgploELozzIzKq-t8nhQYkgAkt64euWpva3qL5KD1mTIZQEP-LZvh3s2WHrLi3fhbdRuwQ2c0KkJA2oSTFPDpqqbPGZ3QvuHQ==.AQAB"},{"rel":"http://ostatus.org/schema/1.0/subscribe","template":"https://ap.example.com/authorize_follow?acct={uri}"}]} \ No newline at end of file diff --git a/spec/fixtures/requests/low_confidence_latin1.txt b/spec/fixtures/requests/low_confidence_latin1.txt new file mode 100644 index 0000000000..39c3e23d64 --- /dev/null +++ b/spec/fixtures/requests/low_confidence_latin1.txt @@ -0,0 +1,17 @@ +HTTP/1.1 200 OK +server: nginx +date: Thu, 13 Jun 2024 14:33:13 GMT +content-type: text/html; charset=ISO-8859-1 +content-length: 158 +accept-ranges: bytes + + + + + + Tofu l'orange + + +

Tofu l'orange

+ + diff --git a/spec/fixtures/requests/webfinger.txt b/spec/fixtures/requests/webfinger.txt index f337ecae6f..fce821bddb 100644 --- a/spec/fixtures/requests/webfinger.txt +++ b/spec/fixtures/requests/webfinger.txt @@ -8,4 +8,4 @@ Access-Control-Allow-Origin: * Vary: Accept-Encoding,Cookie Strict-Transport-Security: max-age=31536000; includeSubdomains; -{"subject":"acct:gargron@quitter.no","aliases":["https:\/\/quitter.no\/user\/7477","https:\/\/quitter.no\/gargron","https:\/\/quitter.no\/index.php\/user\/7477","https:\/\/quitter.no\/index.php\/gargron"],"links":[{"rel":"http:\/\/webfinger.net\/rel\/profile-page","type":"text\/html","href":"https:\/\/quitter.no\/gargron"},{"rel":"http:\/\/gmpg.org\/xfn\/11","type":"text\/html","href":"https:\/\/quitter.no\/gargron"},{"rel":"describedby","type":"application\/rdf+xml","href":"https:\/\/quitter.no\/gargron\/foaf"},{"rel":"http:\/\/apinamespace.org\/atom","type":"application\/atomsvc+xml","href":"https:\/\/quitter.no\/api\/statusnet\/app\/service\/gargron.xml"},{"rel":"http:\/\/apinamespace.org\/twitter","href":"https:\/\/quitter.no\/api\/"},{"rel":"http:\/\/specs.openid.net\/auth\/2.0\/provider","href":"https:\/\/quitter.no\/gargron"},{"rel":"http:\/\/schemas.google.com\/g\/2010#updates-from","type":"application\/atom+xml","href":"https:\/\/quitter.no\/api\/statuses\/user_timeline\/7477.atom"},{"rel":"magic-public-key","href":"data:application\/magic-public-key,RSA.1ZBkHTavLvxH3FzlKv4O6WtlILKRFfNami3_Rcu8EuogtXSYiS-bB6hElZfUCSHbC4uLemOA34PEhz__CDMozax1iI_t8dzjDnh1x0iFSup7pSfW9iXk_WU3Dm74yWWW2jildY41vWgrEstuQ1dJ8vVFfSJ9T_tO4c-T9y8vDI8=.AQAB"},{"rel":"salmon","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/salmon-protocol.org\/ns\/salmon-replies","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/salmon-protocol.org\/ns\/salmon-mention","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/ostatus.org\/schema\/1.0\/subscribe","template":"https:\/\/quitter.no\/main\/ostatussub?profile={uri}"}]} +{"subject":"acct:gargron@quitter.no","aliases":["https:\/\/quitter.no\/user\/7477","https:\/\/quitter.no\/gargron","https:\/\/quitter.no\/index.php\/user\/7477","https:\/\/quitter.no\/index.php\/gargron"],"links":[{"rel":"http:\/\/webfinger.net\/rel\/profile-page","type":"text\/html","href":"https:\/\/quitter.no\/gargron"},{"rel":"http:\/\/gmpg.org\/xfn\/11","type":"text\/html","href":"https:\/\/quitter.no\/gargron"},{"rel":"describedby","type":"application\/rdf+xml","href":"https:\/\/quitter.no\/gargron\/foaf"},{"rel":"self","type":"application/activity+json","href":"https://ap.example.com/users/foo"},{"rel":"http:\/\/apinamespace.org\/atom","type":"application\/atomsvc+xml","href":"https:\/\/quitter.no\/api\/statusnet\/app\/service\/gargron.xml"},{"rel":"http:\/\/apinamespace.org\/twitter","href":"https:\/\/quitter.no\/api\/"},{"rel":"http:\/\/specs.openid.net\/auth\/2.0\/provider","href":"https:\/\/quitter.no\/gargron"},{"rel":"http:\/\/schemas.google.com\/g\/2010#updates-from","type":"application\/atom+xml","href":"https:\/\/quitter.no\/api\/statuses\/user_timeline\/7477.atom"},{"rel":"magic-public-key","href":"data:application\/magic-public-key,RSA.1ZBkHTavLvxH3FzlKv4O6WtlILKRFfNami3_Rcu8EuogtXSYiS-bB6hElZfUCSHbC4uLemOA34PEhz__CDMozax1iI_t8dzjDnh1x0iFSup7pSfW9iXk_WU3Dm74yWWW2jildY41vWgrEstuQ1dJ8vVFfSJ9T_tO4c-T9y8vDI8=.AQAB"},{"rel":"salmon","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/salmon-protocol.org\/ns\/salmon-replies","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/salmon-protocol.org\/ns\/salmon-mention","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/ostatus.org\/schema\/1.0\/subscribe","template":"https:\/\/quitter.no\/main\/ostatussub?profile={uri}"}]} diff --git a/spec/helpers/jsonld_helper_spec.rb b/spec/helpers/jsonld_helper_spec.rb index 1f1a7fd891..cecf8d7459 100644 --- a/spec/helpers/jsonld_helper_spec.rb +++ b/spec/helpers/jsonld_helper_spec.rb @@ -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 be_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 be_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 be_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 diff --git a/spec/lib/account_statuses_filter_spec.rb b/spec/lib/account_statuses_filter_spec.rb index 1f9868f2fb..7e2095588b 100644 --- a/spec/lib/account_statuses_filter_spec.rb +++ b/spec/lib/account_statuses_filter_spec.rb @@ -282,5 +282,38 @@ RSpec.describe AccountStatusesFilter do it_behaves_like 'filter params' end + + context 'when accessed by remote user' do + let(:current_account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:sensitive_status_with_cw) { Fabricate(:status, sensitive: true, spoiler_text: 'CW', account: account) } + let(:sensitive_status_with_media) do + Fabricate(:status, sensitive: true, spoiler_text: 'CW', account: account).tap do |status| + Fabricate(:media_attachment, account: account, status: status) + end + end + + before do + Fabricate(:domain_block, domain: 'example.com', severity: :noop, reject_send_sensitive: true) + end + + it 'returns everything' do + expect(subject.results.pluck(:visibility).uniq).to match_array %w(login unlisted public_unlisted public) + end + + it 'returns replies' do + expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty + end + + it 'returns reblogs' do + expect(subject.results.pluck(:reblog_of_id)).to_not be_empty + end + + it 'does not send sensitive posts' do + expect(subject.results.pluck(:id)).to_not include sensitive_status_with_cw.id + expect(subject.results.pluck(:id)).to_not include sensitive_status_with_media.id + end + + it_behaves_like 'filter params' + end end end diff --git a/spec/lib/activitypub/activity/announce_spec.rb b/spec/lib/activitypub/activity/announce_spec.rb index 5e3f679af1..48647e1d9b 100644 --- a/spec/lib/activitypub/activity/announce_spec.rb +++ b/spec/lib/activitypub/activity/announce_spec.rb @@ -35,7 +35,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 @@ -136,7 +136,7 @@ RSpec.describe ActivityPub::Activity::Announce do let(:object_json) { 'https://example.com/actor/hello-world' } 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 'when the relay is enabled' do diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index bb41e3065a..4dad74c4bc 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -24,6 +24,109 @@ RSpec.describe ActivityPub::Activity::Create do stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' }) end + describe 'processing posts received out of order' do + let(:follower) { Fabricate(:account, username: 'bob') } + + 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', + published: 1.hour.ago.utc.iso8601, + updated: 1.hour.ago.utc.iso8601, + tag: { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(follower), + }, + } + end + + let(:reply_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), 'reply'].join('/'), + type: 'Note', + inReplyTo: object_json[:id], + to: [ + 'https://www.w3.org/ns/activitystreams#Public', + ActivityPub::TagManager.instance.uri_for(follower), + ], + content: '@bob lorem ipsum', + published: Time.now.utc.iso8601, + updated: Time.now.utc.iso8601, + tag: { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(follower), + }, + } + end + + def activity_for_object(json) + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: [json[:id], 'activity'].join('/'), + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: json, + }.with_indifferent_access + end + + before do + follower.follow!(sender) + end + + around do |example| + Sidekiq::Testing.fake! do + example.run + Sidekiq::Worker.clear_all + end + end + + it 'correctly processes posts and inserts them in timelines', :aggregate_failures do + # Simulate a temporary failure preventing from fetching the parent post + stub_request(:get, object_json[:id]).to_return(status: 500) + + # When receiving the reply… + described_class.new(activity_for_object(reply_json), sender, delivery: true).perform + + # NOTE: Refering explicitly to the workers is a bit awkward + DistributionWorker.drain + FeedInsertWorker.drain + + # …it creates a status with an unknown parent + reply = Status.find_by(uri: reply_json[:id]) + expect(reply.reply?).to be true + expect(reply.in_reply_to_id).to be_nil + + # …and creates a notification + expect(LocalNotificationWorker.jobs.size).to eq 1 + + # …but does not insert it into timelines + expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_nil + + # When receiving the parent… + described_class.new(activity_for_object(object_json), sender, delivery: true).perform + + Sidekiq::Worker.drain_all + + # …it creates a status and insert it into timelines + parent = Status.find_by(uri: object_json[:id]) + expect(parent.reply?).to be false + expect(parent.in_reply_to_id).to be_nil + expect(reply.reload.in_reply_to_id).to eq parent.id + + # Check that the both statuses have been inserted into the home feed + expect(redis.zscore(FeedManager.instance.key(:home, follower.id), parent.id)).to be_within(0.1).of(parent.id.to_f) + expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_within(0.1).of(reply.id.to_f) + + # Creates two notifications + expect(Notification.count).to eq 2 + end + end + describe '#perform' do context 'when fetching' do subject { described_class.new(json, sender) } @@ -36,6 +139,46 @@ RSpec.describe ActivityPub::Activity::Create do subject.perform unless custom_before end + context 'when object publication date is below ISO8601 range' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + published: '-0977-11-03T08:31:22Z', + } + end + + it 'creates status with a valid creation date', :aggregate_failures do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + + expect(status.created_at).to be_within(30).of(Time.now.utc) + end + end + + context 'when object publication date is above ISO8601 range' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + published: '10000-11-03T08:31:22Z', + } + end + + it 'creates status with a valid creation date', :aggregate_failures do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + + expect(status.created_at).to be_within(30).of(Time.now.utc) + end + end + context 'when object has been edited' do let(:object_json) do { @@ -47,18 +190,16 @@ RSpec.describe ActivityPub::Activity::Create do } end - it 'creates status' do + it 'creates status with appropriate creation and edition dates', :aggregate_failures do status = sender.statuses.first expect(status).to_not be_nil expect(status.text).to eq 'Lorem ipsum' - end - it 'marks status as edited' do - status = sender.statuses.first + expect(status.created_at).to eq '2022-01-22T15:00:00Z'.to_datetime - expect(status).to_not be_nil expect(status.edited?).to be true + expect(status.edited_at).to eq '2022-01-22T16:00:00Z'.to_datetime end end @@ -423,7 +564,7 @@ RSpec.describe ActivityPub::Activity::Create do end context 'with direct' do - let(:searchable_by) { '' } + let(:searchable_by) { 'https://example.com/actor' } it 'create status' do status = sender.statuses.first @@ -433,6 +574,17 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'with empty array' do + let(:searchable_by) { '' } + + it 'create status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.searchability).to be_nil + end + end + context 'with direct when not specify' do let(:searchable_by) { nil } diff --git a/spec/lib/activitypub/activity/like_spec.rb b/spec/lib/activitypub/activity/like_spec.rb index 804d241052..cd908ba47e 100644 --- a/spec/lib/activitypub/activity/like_spec.rb +++ b/spec/lib/activitypub/activity/like_spec.rb @@ -60,8 +60,8 @@ RSpec.describe ActivityPub::Activity::Like do before do stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png')) stub_request(:get, 'http://foo.bar/emoji2.png').to_return(body: attachment_fixture('emojo.png')) - stub_request(:get, 'https://example.com/aaa').to_return(status: 200, body: Oj.dump(original_emoji)) - stub_request(:get, 'https://example.com/invalid').to_return(status: 200, body: Oj.dump(original_invalid_emoji)) + stub_request(:get, 'https://example.com/aaa').to_return(status: 200, body: Oj.dump(original_emoji), headers: { 'Content-Type': 'application/activity+json' }) + stub_request(:get, 'https://example.com/invalid').to_return(status: 200, body: Oj.dump(original_invalid_emoji), headers: { 'Content-Type': 'application/activity+json' }) end let(:json) do diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb index 87e96d2d1b..6c84c5836a 100644 --- a/spec/lib/activitypub/activity/update_spec.rb +++ b/spec/lib/activitypub/activity/update_spec.rb @@ -55,6 +55,7 @@ RSpec.describe ActivityPub::Activity::Update do stub_request(:get, actor_json[:following]).to_return(status: 404) stub_request(:get, actor_json[:featured]).to_return(status: 404) stub_request(:get, actor_json[:featuredTags]).to_return(status: 404) + stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(status: 404) subject.perform end diff --git a/spec/lib/activitypub/adapter_spec.rb b/spec/lib/activitypub/adapter_spec.rb index f9f8b8dce0..bd63ebb9c8 100644 --- a/spec/lib/activitypub/adapter_spec.rb +++ b/spec/lib/activitypub/adapter_spec.rb @@ -59,7 +59,7 @@ RSpec.describe ActivityPub::Adapter do let(:serializer_class) { TestWithBasicContextSerializer } it 'renders a basic @context' do - expect(subject).to include({ '@context' => 'https://www.w3.org/ns/activitystreams' }) + expect(subject).to include({ '@context': 'https://www.w3.org/ns/activitystreams' }) end end @@ -67,7 +67,7 @@ RSpec.describe ActivityPub::Adapter do let(:serializer_class) { TestWithNamedContextSerializer } it 'renders a @context with both items' do - expect(subject).to include({ '@context' => ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] }) + expect(subject).to include({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] }) end end @@ -75,7 +75,7 @@ RSpec.describe ActivityPub::Adapter do let(:serializer_class) { TestWithNestedNamedContextSerializer } it 'renders a @context with both items' do - expect(subject).to include({ '@context' => ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] }) + expect(subject).to include({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] }) end end @@ -83,7 +83,7 @@ RSpec.describe ActivityPub::Adapter do let(:serializer_class) { TestWithContextExtensionSerializer } it 'renders a @context with the extension' do - expect(subject).to include({ '@context' => ['https://www.w3.org/ns/activitystreams', { 'sensitive' => 'as:sensitive' }] }) + expect(subject).to include({ '@context': ['https://www.w3.org/ns/activitystreams', { 'sensitive' => 'as:sensitive' }] }) end end @@ -91,7 +91,7 @@ RSpec.describe ActivityPub::Adapter do let(:serializer_class) { TestWithNestedContextExtensionSerializer } it 'renders a @context with both extensions' do - expect(subject).to include({ '@context' => ['https://www.w3.org/ns/activitystreams', { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', 'sensitive' => 'as:sensitive' }] }) + expect(subject).to include({ '@context': ['https://www.w3.org/ns/activitystreams', { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', 'sensitive' => 'as:sensitive' }] }) end end end diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb index 6a6ad1a706..8a867790a0 100644 --- a/spec/lib/activitypub/linked_data_signature_spec.rb +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -38,6 +38,40 @@ RSpec.describe ActivityPub::LinkedDataSignature do end end + context 'when local account record is missing a public key' do + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + + let(:service_stub) { instance_double(ActivityPub::FetchRemoteKeyService) } + + before do + # Ensure signature is computed with the old key + signature + + # Unset key + old_key = sender.public_key + sender.update!(private_key: '', public_key: '') + + allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub) + + allow(service_stub).to receive(:call).with('http://example.com/alice') do + sender.update!(public_key: old_key) + sender + end + end + + 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').once + end + end + context 'when signature is missing' do let(:signature) { nil } @@ -65,16 +99,11 @@ RSpec.describe ActivityPub::LinkedDataSignature do describe '#sign!' do subject { described_class.new(raw_json).sign!(sender) } - it 'returns a hash' do + it 'returns a hash with a signature, the expected context, and the signature can be verified', :aggregate_failures do expect(subject).to be_a Hash - end - - it 'contains signature' do expect(subject['signature']).to be_a Hash expect(subject['signature']['signatureValue']).to be_present - end - - it 'can be verified again' do + expect(Array(subject['@context'])).to include('https://w3id.org/security/v1') expect(described_class.new(subject).verify_actor!).to eq sender end end diff --git a/spec/lib/activitypub/parser/status_parser_spec.rb b/spec/lib/activitypub/parser/status_parser_spec.rb new file mode 100644 index 0000000000..5d9f008db1 --- /dev/null +++ b/spec/lib/activitypub/parser/status_parser_spec.rb @@ -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 diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index 2bff125a6a..55e9b4bb51 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -112,6 +112,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 diff --git a/spec/lib/admin/system_check/software_version_check_spec.rb b/spec/lib/admin/system_check/software_version_check_spec.rb index de4335fc51..cc4c80e7a4 100644 --- a/spec/lib/admin/system_check/software_version_check_spec.rb +++ b/spec/lib/admin/system_check/software_version_check_spec.rb @@ -51,8 +51,8 @@ describe Admin::SystemCheck::SoftwareVersionCheck do Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: false) end - it 'returns true' do - expect(check.pass?).to be true + it 'returns false' do + expect(check.pass?).to be false end end diff --git a/spec/lib/link_details_extractor_spec.rb b/spec/lib/link_details_extractor_spec.rb index 599bc4e6de..8c485cef2a 100644 --- a/spec/lib/link_details_extractor_spec.rb +++ b/spec/lib/link_details_extractor_spec.rb @@ -82,6 +82,10 @@ RSpec.describe LinkDetailsExtractor do 'name' => 'Pet News', 'url' => 'https://example.com', }, + 'inLanguage' => { + name: 'English', + alternateName: 'en', + }, }.to_json end @@ -115,6 +119,12 @@ RSpec.describe LinkDetailsExtractor do expect(subject.provider_name).to eq 'Pet News' end end + + describe '#language' do + it 'returns the language from structured data' do + expect(subject.language).to eq 'en' + end + end end context 'when is wrapped in CDATA tags' do diff --git a/spec/lib/status_reach_finder_spec.rb b/spec/lib/status_reach_finder_spec.rb index 57946d3a70..ee1a9668b9 100644 --- a/spec/lib/status_reach_finder_spec.rb +++ b/spec/lib/status_reach_finder_spec.rb @@ -176,45 +176,15 @@ describe StatusReachFinder do let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, uri: 'https://example.com/', inbox_url: 'https://example.com/inbox') } let(:tom) { Fabricate(:account, username: 'tom', domain: 'tom.com', protocol: :activitypub, uri: 'https://tom.com/', inbox_url: 'https://tom.com/inbox') } - context 'when reject_send_not_public_searchability' do - let(:properties) { { reject_send_not_public_searchability: true } } - let(:searchability) { :private } + context 'when reject_send_sensitive' do + let(:properties) { { reject_send_sensitive: true } } + let(:spoiler_text) { 'CW' } it 'does not include the inbox of blocked domain' do expect(subject.inboxes).to_not include 'https://example.com/inbox' expect(subject.inboxes).to include 'https://tom.com/inbox' end end - - context 'when reject_send_public_unlisted' do - let(:properties) { { reject_send_public_unlisted: true } } - let(:visibility) { :public_unlisted } - - it 'does not include the inbox of blocked domain' do - expect(subject.inboxes).to_not include 'https://example.com/inbox' - expect(subject.inboxes).to include 'https://tom.com/inbox' - end - - context 'when reject_send_dissubscribable' do - let(:properties) { { reject_send_dissubscribable: true } } - let(:dissubscribable) { true } - - it 'does not include the inbox of blocked domain' do - expect(subject.inboxes).to_not include 'https://example.com/inbox' - expect(subject.inboxes).to include 'https://tom.com/inbox' - end - end - - context 'when reject_send_sensitive' do - let(:properties) { { reject_send_sensitive: true } } - let(:spoiler_text) { 'CW' } - - it 'does not include the inbox of blocked domain' do - expect(subject.inboxes).to_not include 'https://example.com/inbox' - expect(subject.inboxes).to include 'https://tom.com/inbox' - end - end - end end end end diff --git a/spec/lib/vacuum/applications_vacuum_spec.rb b/spec/lib/vacuum/applications_vacuum_spec.rb deleted file mode 100644 index 57a222aafc..0000000000 --- a/spec/lib/vacuum/applications_vacuum_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Vacuum::ApplicationsVacuum do - subject { described_class.new } - - describe '#perform' do - let!(:app_with_token) { Fabricate(:application, created_at: 1.month.ago) } - let!(:app_with_grant) { Fabricate(:application, created_at: 1.month.ago) } - let!(:app_with_signup) { Fabricate(:application, created_at: 1.month.ago) } - let!(:app_with_owner) { Fabricate(:application, created_at: 1.month.ago, owner: Fabricate(:user)) } - let!(:unused_app) { Fabricate(:application, created_at: 1.month.ago) } - let!(:recent_app) { Fabricate(:application, created_at: 1.hour.ago) } - - let!(:active_access_token) { Fabricate(:access_token, application: app_with_token) } - let!(:active_access_grant) { Fabricate(:access_grant, application: app_with_grant) } - let!(:user) { Fabricate(:user, created_by_application: app_with_signup) } - - before do - subject.perform - end - - it 'does not delete applications with valid access tokens' do - expect { app_with_token.reload }.to_not raise_error - end - - it 'does not delete applications with valid access grants' do - expect { app_with_grant.reload }.to_not raise_error - end - - it 'does not delete applications that were used to create users' do - expect { app_with_signup.reload }.to_not raise_error - end - - it 'does not delete owned applications' do - expect { app_with_owner.reload }.to_not raise_error - end - - it 'does not delete applications registered less than a day ago' do - expect { recent_app.reload }.to_not raise_error - end - - it 'deletes unused applications' do - expect { unused_app.reload }.to raise_error ActiveRecord::RecordNotFound - end - end -end diff --git a/spec/lib/vacuum/feeds_vacuum_spec.rb b/spec/lib/vacuum/feeds_vacuum_spec.rb index ede1e3c360..fa5381c8ae 100644 --- a/spec/lib/vacuum/feeds_vacuum_spec.rb +++ b/spec/lib/vacuum/feeds_vacuum_spec.rb @@ -8,12 +8,16 @@ RSpec.describe Vacuum::FeedsVacuum do describe '#perform' do let!(:active_user) { Fabricate(:user, current_sign_in_at: 2.days.ago) } let!(:inactive_user) { Fabricate(:user, current_sign_in_at: 22.days.ago) } + let!(:list) { Fabricate(:list, account: inactive_user.account) } + let!(:antenna) { Fabricate(:antenna, account: inactive_user.account) } before do redis.zadd(feed_key_for(inactive_user), 1, 1) redis.zadd(feed_key_for(active_user), 1, 1) redis.zadd(feed_key_for(inactive_user, 'reblogs'), 2, 2) redis.sadd(feed_key_for(inactive_user, 'reblogs:2'), 3) + redis.zadd(list_key_for(list), 1, 1) + redis.zadd(antenna_key_for(antenna), 1, 1) subject.perform end @@ -23,10 +27,20 @@ RSpec.describe Vacuum::FeedsVacuum do expect(redis.zcard(feed_key_for(active_user))).to eq 1 expect(redis.exists?(feed_key_for(inactive_user, 'reblogs'))).to be false expect(redis.exists?(feed_key_for(inactive_user, 'reblogs:2'))).to be false + expect(redis.zcard(list_key_for(list))).to eq 0 + expect(redis.zcard(antenna_key_for(antenna))).to eq 0 end end def feed_key_for(user, subtype = nil) FeedManager.instance.key(:home, user.account_id, subtype) end + + def list_key_for(list) + FeedManager.instance.key(:list, list.id) + end + + def antenna_key_for(antenna) + FeedManager.instance.key(:antenna, antenna.id) + end end diff --git a/spec/lib/webfinger_spec.rb b/spec/lib/webfinger_spec.rb new file mode 100644 index 0000000000..5015deac7f --- /dev/null +++ b/spec/lib/webfinger_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Webfinger do + describe 'self link' do + context 'when self link is specified with type application/activity+json' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } + + it 'correctly parses the response' do + 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' }) + + response = described_class.new('acct:alice@example.com').perform + + expect(response.self_link_href).to eq 'https://example.com/alice' + end + end + + context 'when self link is specified with type application/ld+json' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }] } } + + it 'correctly parses the response' do + 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' }) + + response = described_class.new('acct:alice@example.com').perform + + expect(response.self_link_href).to eq 'https://example.com/alice' + end + end + + context 'when self link is specified with incorrect type' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/json"' }] } } + + it 'raises an error' do + 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' }) + + expect { described_class.new('acct:alice@example.com').perform }.to raise_error(Webfinger::Error) + end + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 5cdc31ecd9..4346b506b4 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -904,6 +904,14 @@ RSpec.describe Account do it 'does not match URL query string' do expect(subject.match('https://example.com/?x=@alice')).to be_nil end + + it 'matches usernames immediately following the letter ß' do + expect(subject.match('Hello toß @alice from me')[1]).to eq 'alice' + end + + it 'matches usernames containing uppercase characters' do + expect(subject.match('Hello to @aLice@Example.com from me')[1]).to eq 'aLice@Example.com' + end end describe 'validations' do diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb index 0b8825135d..7519b5b094 100644 --- a/spec/models/concerns/account_interactions_spec.rb +++ b/spec/models/concerns/account_interactions_spec.rb @@ -252,6 +252,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) } @@ -347,6 +365,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) } diff --git a/spec/models/home_feed_spec.rb b/spec/models/home_feed_spec.rb index bd649d8269..06bb63b1a4 100644 --- a/spec/models/home_feed_spec.rb +++ b/spec/models/home_feed_spec.rb @@ -27,7 +27,6 @@ RSpec.describe HomeFeed 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 diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb index 2fca1e1c14..22c8dbf224 100644 --- a/spec/models/identity_spec.rb +++ b/spec/models/identity_spec.rb @@ -3,16 +3,16 @@ require 'rails_helper' RSpec.describe Identity 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 described_class + expect(described_class.find_for_omniauth(auth)).to be_instance_of described_class end end end diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 0093dcd8de..830f2f6085 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -133,5 +133,18 @@ describe Report do report = Fabricate.build(:report, account: remote_account, comment: Faker::Lorem.characters(number: 1001)) expect(report.valid?).to be true end + + it 'is invalid if it references invalid rules' do + report = Fabricate.build(:report, category: :violation, rule_ids: [-1]) + expect(report.valid?).to be false + expect(report).to model_have_error_on_field(:rule_ids) + end + + it 'is invalid if it references rules but category is not "violation"' do + rule = Fabricate(:rule) + report = Fabricate.build(:report, category: :spam, rule_ids: rule.id) + expect(report.valid?).to be false + expect(report).to model_have_error_on_field(:rule_ids) + end end end diff --git a/spec/models/setting_spec.rb b/spec/models/setting_spec.rb index 5ed5c5d766..97e548e099 100644 --- a/spec/models/setting_spec.rb +++ b/spec/models/setting_spec.rb @@ -142,22 +142,12 @@ RSpec.describe Setting do context 'when records includes nothing' do let(:records) { [] } - context 'when 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_a described_class - expect(setting).to have_attributes(var: key) - expect(setting).to have_attributes(value: 'default_value') - end - end - - context 'when 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 diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 4d6e5c380b..529de32695 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -32,44 +32,56 @@ RSpec.describe Tag do expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)#Lawsuit')).to be_nil end + it 'does not match URLs with hashtag-like anchors after a numeral' do + expect(subject.match('https://gcc.gnu.org/bugzilla/show_bug.cgi?id=111895#c4')).to be_nil + end + + it 'does not match URLs with hashtag-like anchors after a non-ascii character' do + expect(subject.match('https://example.org/testé#foo')).to be_nil + end + + it 'does not match URLs with hashtag-like anchors after an empty query parameter' do + expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)?foo=#Lawsuit')).to be_nil + end + it 'matches #aesthetic' do - expect(subject.match('this is #aesthetic').to_s).to eq ' #aesthetic' + expect(subject.match('this is #aesthetic').to_s).to eq '#aesthetic' end it 'matches digits at the start' do - expect(subject.match('hello #3d').to_s).to eq ' #3d' + expect(subject.match('hello #3d').to_s).to eq '#3d' end it 'matches digits in the middle' do - expect(subject.match('hello #l33ts35k').to_s).to eq ' #l33ts35k' + expect(subject.match('hello #l33ts35k').to_s).to eq '#l33ts35k' end it 'matches digits at the end' do - expect(subject.match('hello #world2016').to_s).to eq ' #world2016' + expect(subject.match('hello #world2016').to_s).to eq '#world2016' end it 'matches underscores at the beginning' do - expect(subject.match('hello #_test').to_s).to eq ' #_test' + expect(subject.match('hello #_test').to_s).to eq '#_test' end it 'matches underscores at the end' do - expect(subject.match('hello #test_').to_s).to eq ' #test_' + expect(subject.match('hello #test_').to_s).to eq '#test_' end it 'matches underscores in the middle' do - expect(subject.match('hello #one_two_three').to_s).to eq ' #one_two_three' + expect(subject.match('hello #one_two_three').to_s).to eq '#one_two_three' end it 'matches middle dots' do - expect(subject.match('hello #one·two·three').to_s).to eq ' #one·two·three' + expect(subject.match('hello #one·two·three').to_s).to eq '#one·two·three' end it 'matches ・unicode in ぼっち・ざ・ろっく correctly' do - expect(subject.match('testing #ぼっち・ざ・ろっく').to_s).to eq ' #ぼっち・ざ・ろっく' + expect(subject.match('testing #ぼっち・ざ・ろっく').to_s).to eq '#ぼっち・ざ・ろっく' end it 'matches ZWNJ' do - expect(subject.match('just add #نرم‌افزار and').to_s).to eq ' #نرم‌افزار' + expect(subject.match('just add #نرم‌افزار and').to_s).to eq '#نرم‌افزار' end it 'does not match middle dots at the start' do @@ -77,12 +89,20 @@ RSpec.describe Tag do end it 'does not match middle dots at the end' do - expect(subject.match('hello #one·two·three·').to_s).to eq ' #one·two·three' + expect(subject.match('hello #one·two·three·').to_s).to eq '#one·two·three' end it 'does not match purely-numeric hashtags' do expect(subject.match('hello #0123456')).to be_nil end + + it 'matches hashtags immediately following the letter ß' do + expect(subject.match('Hello toß #ruby').to_s).to eq '#ruby' + end + + it 'matches hashtags containing uppercase characters' do + expect(subject.match('Hello #rubyOnRails').to_s).to eq '#rubyOnRails' + end end describe '#to_param' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index bb61c02a63..f06150f02c 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -27,12 +27,6 @@ RSpec.describe User do expect(user).to model_have_error_on_field(:account) end - it 'is invalid without a valid locale' do - user = Fabricate.build(:user, locale: 'toto') - user.valid? - expect(user).to model_have_error_on_field(:locale) - end - it 'is invalid without a valid email' do user = Fabricate.build(:user, email: 'john@') user.valid? @@ -45,6 +39,24 @@ RSpec.describe User 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 invalid locale' do + user = Fabricate.build(:user, locale: 'toto') + expect(user.valid?).to be true + expect(user.locale).to be_nil + end + + it 'cleans out invalid timezone' do + user = Fabricate.build(:user, time_zone: 'toto') + expect(user.valid?).to be true + expect(user.time_zone).to be_nil + end + it 'cleans out empty string from languages' do user = Fabricate.build(:user, chosen_languages: ['']) user.valid? @@ -432,7 +444,10 @@ RSpec.describe User 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 @@ -448,6 +463,10 @@ RSpec.describe User 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 diff --git a/spec/presenters/account_relationships_presenter_spec.rb b/spec/presenters/account_relationships_presenter_spec.rb index 5c2ba54e00..282cae4f06 100644 --- a/spec/presenters/account_relationships_presenter_spec.rb +++ b/spec/presenters/account_relationships_presenter_spec.rb @@ -5,30 +5,57 @@ require 'rails_helper' RSpec.describe AccountRelationshipsPresenter do describe '.initialize' do before do - allow(Account).to receive(:following_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:followed_by_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:blocking_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:muting_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:requested_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:requested_by_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:domain_blocking_map).with(account_ids, current_account_id).and_return(default_map) + allow(Account).to receive(:following_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:followed_by_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:blocking_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:muting_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:requested_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:requested_by_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) end - let(:presenter) { described_class.new(account_ids, current_account_id, **options) } + let(:presenter) { described_class.new(accounts, current_account_id, **options) } let(:current_account_id) { Fabricate(:account).id } - let(:account_ids) { [Fabricate(:account).id] } - let(:default_map) { { 1 => true } } + let(:accounts) { [Fabricate(:account)] } + let(:default_map) { { accounts[0].id => true } } context 'when options are not set' do let(:options) { {} } it 'sets default maps' do - expect(presenter.following).to eq default_map - expect(presenter.followed_by).to eq default_map - expect(presenter.blocking).to eq default_map - expect(presenter.muting).to eq default_map - expect(presenter.requested).to eq default_map - expect(presenter.domain_blocking).to eq default_map + expect(presenter).to have_attributes( + following: default_map, + followed_by: default_map, + blocking: default_map, + muting: default_map, + requested: default_map, + domain_blocking: { accounts[0].id => nil } + ) + end + end + + context 'with a warm cache' do + let(:options) { {} } + + before do + described_class.new(accounts, current_account_id, **options) + + allow(Account).to receive(:following_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:followed_by_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:blocking_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:muting_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:requested_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:requested_by_map).with([], current_account_id).and_return({}) + end + + it 'sets returns expected values' do + expect(presenter).to have_attributes( + following: default_map, + followed_by: default_map, + blocking: default_map, + muting: default_map, + requested: default_map, + domain_blocking: { accounts[0].id => nil } + ) end end @@ -84,7 +111,7 @@ RSpec.describe AccountRelationshipsPresenter do let(:options) { { domain_blocking_map: { 7 => true } } } it 'sets @domain_blocking merged with default_map and options[:domain_blocking_map]' do - expect(presenter.domain_blocking).to eq default_map.merge(options[:domain_blocking_map]) + expect(presenter.domain_blocking).to eq({ accounts[0].id => nil }.merge(options[:domain_blocking_map])) end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 7b8dccb6a0..17067c58f5 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -152,6 +152,13 @@ RSpec.configure do |config| self.use_transactional_tests = false DatabaseCleaner.cleaning do + # NOTE: we switched registrations mode to closed by default, but the specs + # very heavily rely on having it enabled by default, as it relies on users + # being approved by default except in select cases where explicitly testing + # other registration modes + # Also needs to be set per-example here because of the database cleaner. + Setting.registrations_mode = 'open' + example.run end diff --git a/spec/requests/api/v1/accounts/featured_tags_spec.rb b/spec/requests/api/v1/accounts/featured_tags_spec.rb new file mode 100644 index 0000000000..bae7d448b6 --- /dev/null +++ b/spec/requests/api/v1/accounts/featured_tags_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'account featured tags API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + + describe 'GET /api/v1/accounts/:id/featured_tags' do + subject do + get "/api/v1/accounts/#{account.id}/featured_tags", headers: headers + end + + before do + account.featured_tags.create!(name: 'foo') + account.featured_tags.create!(name: 'bar') + end + + it 'returns the expected tags', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to contain_exactly(a_hash_including({ + name: 'bar', + url: "https://cb6e6126.ngrok.io/@#{account.username}/tagged/bar", + }), a_hash_including({ + name: 'foo', + url: "https://cb6e6126.ngrok.io/@#{account.username}/tagged/foo", + })) + end + + context 'when the account is remote' do + it 'returns the expected tags', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to contain_exactly(a_hash_including({ + name: 'bar', + url: "https://cb6e6126.ngrok.io/@#{account.pretty_acct}/tagged/bar", + }), a_hash_including({ + name: 'foo', + url: "https://cb6e6126.ngrok.io/@#{account.pretty_acct}/tagged/foo", + })) + end + end + end +end diff --git a/spec/requests/api/v1/admin/domain_blocks_spec.rb b/spec/requests/api/v1/admin/domain_blocks_spec.rb index 1432e52623..900fc84c93 100644 --- a/spec/requests/api/v1/admin/domain_blocks_spec.rb +++ b/spec/requests/api/v1/admin/domain_blocks_spec.rb @@ -49,6 +49,7 @@ RSpec.describe 'Domain Blocks' do { id: domain_block.id.to_s, domain: domain_block.domain, + digest: domain_block.domain_digest, created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), severity: domain_block.severity.to_s, reject_media: domain_block.reject_media, @@ -65,6 +66,7 @@ RSpec.describe 'Domain Blocks' do { id: domain_block.id.to_s, domain: domain_block.domain, + digest: domain_block.domain_digest, created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), severity: domain_block.severity.to_s, reject_media: domain_block.reject_media, @@ -78,10 +80,6 @@ RSpec.describe 'Domain Blocks' do reject_new_follow: domain_block.reject_new_follow, reject_reply: domain_block.reject_reply, reject_reply_exclude_followers: domain_block.reject_reply_exclude_followers, - reject_send_dissubscribable: domain_block.reject_send_dissubscribable, - reject_send_media: domain_block.reject_send_media, - reject_send_not_public_searchability: domain_block.reject_send_not_public_searchability, - reject_send_public_unlisted: domain_block.reject_send_public_unlisted, reject_send_sensitive: domain_block.reject_send_sensitive, reject_straight_follow: domain_block.reject_straight_follow, } @@ -113,32 +111,6 @@ RSpec.describe 'Domain Blocks' do let!(:domain_block) { Fabricate(:domain_block) } - let(:expected_response) do - { - id: domain_block.id.to_s, - domain: domain_block.domain, - created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), - severity: domain_block.severity.to_s, - reject_media: domain_block.reject_media, - reject_reports: domain_block.reject_reports, - private_comment: domain_block.private_comment, - public_comment: domain_block.public_comment, - obfuscate: domain_block.obfuscate, - reject_favourite: domain_block.reject_favourite, - reject_hashtag: domain_block.reject_hashtag, - detect_invalid_subscription: domain_block.detect_invalid_subscription, - reject_new_follow: domain_block.reject_new_follow, - reject_reply: domain_block.reject_reply, - reject_reply_exclude_followers: domain_block.reject_reply_exclude_followers, - reject_send_dissubscribable: domain_block.reject_send_dissubscribable, - reject_send_media: domain_block.reject_send_media, - reject_send_not_public_searchability: domain_block.reject_send_not_public_searchability, - reject_send_public_unlisted: domain_block.reject_send_public_unlisted, - reject_send_sensitive: domain_block.reject_send_sensitive, - reject_straight_follow: domain_block.reject_straight_follow, - } - 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' @@ -149,10 +121,31 @@ RSpec.describe 'Domain Blocks' do expect(response).to have_http_status(200) end - it 'returns the expected domain block content' do + it 'returns the expected domain block content' do # rubocop:disable RSpec/ExampleLength subject - expect(body_as_json).to eq(expected_response) + expect(body_as_json).to eq( + { + id: domain_block.id.to_s, + domain: domain_block.domain, + digest: domain_block.domain_digest, + created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + severity: domain_block.severity.to_s, + reject_media: domain_block.reject_media, + reject_reports: domain_block.reject_reports, + private_comment: domain_block.private_comment, + public_comment: domain_block.public_comment, + obfuscate: domain_block.obfuscate, + reject_favourite: domain_block.reject_favourite, + reject_hashtag: domain_block.reject_hashtag, + detect_invalid_subscription: domain_block.detect_invalid_subscription, + reject_new_follow: domain_block.reject_new_follow, + reject_reply: domain_block.reject_reply, + reject_reply_exclude_followers: domain_block.reject_reply_exclude_followers, + reject_send_sensitive: domain_block.reject_send_sensitive, + reject_straight_follow: domain_block.reject_straight_follow, + } + ) end context 'when the requested domain block does not exist' do @@ -175,14 +168,10 @@ RSpec.describe 'Domain Blocks' do 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 with the expected domain name and severity', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns expected domain name and severity' do - subject body = body_as_json @@ -200,7 +189,44 @@ RSpec.describe 'Domain Blocks' do expect(DomainBlock.find_by(domain: 'foo.bar.com')).to be_present end - context 'when a stricter domain block already exists' do + context 'when a looser domain block already exists on a higher level domain' do + let(:params) { { domain: 'foo.bar.com', severity: :suspend } } + + before do + Fabricate(:domain_block, domain: 'bar.com', severity: :silence) + end + + 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 before do Fabricate(:domain_block, domain: 'bar.com', severity: :suspend) end @@ -254,6 +280,7 @@ RSpec.describe 'Domain Blocks' do { id: domain_block.id.to_s, domain: domain_block.domain, + digest: domain_block.domain_digest, severity: 'suspend', } ) diff --git a/spec/requests/api/v1/antennas_spec.rb b/spec/requests/api/v1/antennas_spec.rb new file mode 100644 index 0000000000..1597bbd81f --- /dev/null +++ b/spec/requests/api/v1/antennas_spec.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Antennas' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:lists write:lists' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/antennas' do + subject do + get '/api/v1/antennas', headers: headers + end + + let!(:antennas) do + [ + Fabricate(:antenna, account: user.account, title: 'first antenna'), + Fabricate(:antenna, account: user.account, title: 'second antenna', with_media_only: true), + Fabricate(:antenna, account: user.account, title: 'third antenna', stl: true), + Fabricate(:antenna, account: user.account, title: 'fourth antenna', ignore_reblog: true), + ] + end + + let(:expected_response) do + antennas.map do |antenna| + { + id: antenna.id.to_s, + title: antenna.title, + with_media_only: antenna.with_media_only, + ignore_reblog: antenna.ignore_reblog, + stl: antenna.stl, + ltl: antenna.ltl, + insert_feeds: antenna.insert_feeds, + list: nil, + accounts_count: 0, + domains_count: 0, + tags_count: 0, + keywords_count: 0, + } + end + end + + before do + Fabricate(:antenna) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:lists' + + it 'returns the expected antennas', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match_array(expected_response) + end + end + + describe 'GET /api/v1/antennas/:id' do + subject do + get "/api/v1/antennas/#{antenna.id}", headers: headers + end + + let(:antenna) { Fabricate(:antenna, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'write write:lists' + + it 'returns the requested antenna correctly', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to eq({ + id: antenna.id.to_s, + title: antenna.title, + with_media_only: antenna.with_media_only, + ignore_reblog: antenna.ignore_reblog, + stl: antenna.stl, + ltl: antenna.ltl, + insert_feeds: antenna.insert_feeds, + list: nil, + accounts_count: 0, + domains_count: 0, + tags_count: 0, + keywords_count: 0, + }) + end + + context 'when the antenna belongs to a different user' do + let(:antenna) { Fabricate(:antenna) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when the antenna does not exist' do + it 'returns http not found' do + get '/api/v1/antennas/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/antennas' do + subject do + post '/api/v1/antennas', headers: headers, params: params + end + + let(:params) { { title: 'my antenna', ltl: 'true' } } + + it_behaves_like 'forbidden for wrong scope', 'read read:lists' + + it 'returns the new antenna', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match(a_hash_including(title: 'my antenna', ltl: true)) + expect(Antenna.where(account: user.account).count).to eq(1) + end + + context 'when a title is not given' do + let(:params) { { title: '' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'PUT /api/v1/antennas/:id' do + subject do + put "/api/v1/antennas/#{antenna.id}", headers: headers, params: params + end + + let(:antenna) { Fabricate(:antenna, account: user.account, title: 'my antenna') } + let(:params) { { title: 'antenna', ignore_reblog: 'true', insert_feeds: 'true' } } + + it_behaves_like 'forbidden for wrong scope', 'read read:lists' + + it 'returns the updated antenna and updates values', :aggregate_failures do + expect { subject } + .to change_antenna_title + .and change_antenna_ignore_reblog + .and change_antenna_insert_feeds + + expect(response).to have_http_status(200) + antenna.reload + + expect(body_as_json).to eq({ + id: antenna.id.to_s, + title: antenna.title, + with_media_only: antenna.with_media_only, + ignore_reblog: antenna.ignore_reblog, + stl: antenna.stl, + ltl: antenna.ltl, + insert_feeds: antenna.insert_feeds, + list: nil, + accounts_count: 0, + domains_count: 0, + tags_count: 0, + keywords_count: 0, + }) + end + + def change_antenna_title + change { antenna.reload.title }.from('my antenna').to('antenna') + end + + def change_antenna_ignore_reblog + change { antenna.reload.ignore_reblog }.from(false).to(true) + end + + def change_antenna_insert_feeds + change { antenna.reload.insert_feeds }.from(false).to(true) + end + + context 'when the antenna does not exist' do + it 'returns http not found' do + put '/api/v1/antennas/-1', headers: headers, params: params + + expect(response).to have_http_status(404) + end + end + + context 'when the antenna belongs to another user' do + let(:antenna) { Fabricate(:antenna) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /api/v1/antennas/:id' do + subject do + delete "/api/v1/antennas/#{antenna.id}", headers: headers + end + + let(:antenna) { Fabricate(:antenna, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'read read:lists' + + it 'deletes the antenna', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(Antenna.where(id: antenna.id)).to_not exist + end + + context 'when the antenna does not exist' do + it 'returns http not found' do + delete '/api/v1/antennas/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'when the antenna belongs to another user' do + let(:antenna) { Fabricate(:antenna) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/timelines/public_spec.rb b/spec/requests/api/v1/timelines/public_spec.rb index c436262407..03bde3da87 100644 --- a/spec/requests/api/v1/timelines/public_spec.rb +++ b/spec/requests/api/v1/timelines/public_spec.rb @@ -32,6 +32,8 @@ describe 'Public' do context 'when the instance allows public preview' do let(:expected_statuses) { [local_status, remote_status, media_status] } + it_behaves_like 'forbidden for wrong scope', 'profile' + context 'with an authorized user' do it_behaves_like 'a successful request to the public timeline' end @@ -96,13 +98,9 @@ describe 'Public' do Form::AdminSettings.new(timeline_preview: false).save end - context 'with an authenticated user' do - let(:expected_statuses) { [local_status, remote_status, media_status] } + it_behaves_like 'forbidden for wrong scope', 'profile' - it_behaves_like 'a successful request to the public timeline' - end - - context 'with an unauthenticated user' do + context 'without an authentication token' do let(:headers) { {} } it 'returns http unprocessable entity' do @@ -111,6 +109,22 @@ describe 'Public' do expect(response).to have_http_status(422) end end + + context 'with an application access token, not bound to a user' do + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: scopes) } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'with an authenticated user' do + let(:expected_statuses) { [local_status, remote_status, media_status] } + + it_behaves_like 'a successful request to the public timeline' + end end end end diff --git a/spec/requests/cache_spec.rb b/spec/requests/cache_spec.rb index c391c8b3da..d40895fc3b 100644 --- a/spec/requests/cache_spec.rb +++ b/spec/requests/cache_spec.rb @@ -124,7 +124,7 @@ describe 'Caching behavior' do expect(response.cookies).to be_empty end - it 'sets public cache control' do + it 'sets public cache control', :aggregate_failures do # expect(response.cache_control[:max_age]&.to_i).to be_positive expect(response.cache_control[:public]).to be_truthy expect(response.cache_control[:private]).to be_falsy @@ -141,11 +141,8 @@ describe 'Caching behavior' do end shared_examples 'non-cacheable error' do - it 'does not return HTTP success' do + it 'does not return HTTP success and does not have cache headers', :aggregate_failures do expect(response).to_not have_http_status(200) - end - - it 'does not have cache headers' do expect(response.cache_control[:public]).to be_falsy end end @@ -182,6 +179,15 @@ describe 'Caching behavior' do end context 'when anonymously accessed' do + describe '/users/alice' do + it 'redirects with proper cache header', :aggregate_failures do + get '/users/alice' + + expect(response).to redirect_to('/@alice') + expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('accept') + end + end + TestEndpoints::ALWAYS_CACHED.each do |endpoint| describe endpoint do before { get endpoint } diff --git a/spec/requests/disabled_oauth_endpoints_spec.rb b/spec/requests/disabled_oauth_endpoints_spec.rb new file mode 100644 index 0000000000..7c2c09f380 --- /dev/null +++ b/spec/requests/disabled_oauth_endpoints_spec.rb @@ -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 diff --git a/spec/requests/omniauth_callbacks_spec.rb b/spec/requests/omniauth_callbacks_spec.rb index 27aa5ec506..6381bf0667 100644 --- a/spec/requests/omniauth_callbacks_spec.rb +++ b/spec/requests/omniauth_callbacks_spec.rb @@ -39,16 +39,35 @@ describe 'OmniAuth callbacks' do Fabricate(:user, email: 'user@host.example') 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) + 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 - expect(Identity.find_by(user: User.last).uid).to eq('123') - expect(response).to redirect_to(root_path) + 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 @@ -96,7 +115,7 @@ describe 'OmniAuth callbacks' do context 'when a user cannot be built' do before do - allow(User).to receive(:find_for_oauth).and_return(User.new) + allow(User).to receive(:find_for_omniauth).and_return(User.new) end it 'redirects to the new user signup page' do diff --git a/spec/requests/signature_verification_spec.rb b/spec/requests/signature_verification_spec.rb new file mode 100644 index 0000000000..401828c4a3 --- /dev/null +++ b/spec/requests/signature_verification_spec.rb @@ -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 diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index 0425e2e66b..9a82d65298 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -5,9 +5,9 @@ require 'rails_helper' describe ActivityPub::NoteSerializer do subject { JSON.parse(@serialization.to_json) } - let!(:account) { Fabricate(:account) } + let!(:account) { Fabricate(:account, searchability: :public) } let!(:other) { Fabricate(:account) } - let!(:parent) { Fabricate(:status, account: account, visibility: :public) } + let!(:parent) { Fabricate(:status, account: account, visibility: :public, searchability: searchability, language: 'zh-TW') } let!(:reply_by_account_first) { Fabricate(:status, account: account, thread: parent, visibility: :public) } let!(:reply_by_account_next) { Fabricate(:status, account: account, thread: parent, visibility: :public) } let!(:reply_by_other_first) { Fabricate(:status, account: other, thread: parent, visibility: :public) } @@ -16,6 +16,7 @@ describe ActivityPub::NoteSerializer do let!(:referred) { nil } let!(:referred2) { nil } let(:convert_to_quote) { false } + let(:searchability) { :public } before(:each) do parent.references << referred if referred.present? @@ -24,8 +25,15 @@ describe ActivityPub::NoteSerializer do @serialization = ActiveModelSerializers::SerializableResource.new(parent, serializer: described_class, adapter: ActivityPub::Adapter) end - it 'has a Note type' do - expect(subject['type']).to eql('Note') + it 'has the expected shape' do + expect(subject).to include({ + '@context' => include('https://www.w3.org/ns/activitystreams'), + 'type' => 'Note', + 'attributedTo' => ActivityPub::TagManager.instance.uri_for(account), + 'contentMap' => include({ + 'zh-TW' => a_kind_of(String), + }), + }) end it 'has a replies collection' do @@ -48,6 +56,14 @@ describe ActivityPub::NoteSerializer do expect(subject['replies']['first']['items']).to_not include(reply_by_account_visibility_direct.uri) end + context 'when direct searchability' do + let(:searchability) { :direct } + + it 'send as direct searchability' do + expect(subject['searchableBy']).to include "https://cb6e6126.ngrok.io/users/#{account.username}" + end + end + context 'when has quote but no_convert setting' do let(:referred) { Fabricate(:status) } diff --git a/spec/services/activitypub/fetch_featured_collection_service_spec.rb b/spec/services/activitypub/fetch_featured_collection_service_spec.rb index 5975c81a10..237fc7123e 100644 --- a/spec/services/activitypub/fetch_featured_collection_service_spec.rb +++ b/spec/services/activitypub/fetch_featured_collection_service_spec.rb @@ -31,7 +31,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do } end - let(:status_json_pinned_unknown_unreachable) do + let(:status_json_pinned_unknown_reachable) do { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Note', @@ -42,12 +42,22 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do } end + let(:featured_with_null) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/account/collections/featured', + totalItems: 0, + type: 'OrderedCollection', + } + end + let(:items) do [ 'https://example.com/account/pinned/known', # known status_json_pinned_unknown_inlined, # unknown inlined 'https://example.com/account/pinned/unknown-unreachable', # unknown unreachable 'https://example.com/account/pinned/unknown-reachable', # unknown reachable + 'https://example.com/account/collections/featured', # featured with null ] end @@ -62,10 +72,11 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do shared_examples 'sets pinned posts' do before do - stub_request(:get, 'https://example.com/account/pinned/known').to_return(status: 200, body: Oj.dump(status_json_pinned_known)) - stub_request(:get, 'https://example.com/account/pinned/unknown-inlined').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_inlined)) + stub_request(:get, 'https://example.com/account/pinned/known').to_return(status: 200, body: Oj.dump(status_json_pinned_known), headers: { 'Content-Type': 'application/activity+json' }) + stub_request(:get, 'https://example.com/account/pinned/unknown-inlined').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_inlined), headers: { 'Content-Type': 'application/activity+json' }) stub_request(:get, 'https://example.com/account/pinned/unknown-unreachable').to_return(status: 404) - stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_unreachable)) + stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable), headers: { 'Content-Type': 'application/activity+json' }) + stub_request(:get, 'https://example.com/account/collections/featured').to_return(status: 200, body: Oj.dump(featured_with_null), headers: { 'Content-Type': 'application/activity+json' }) subject.call(actor, note: true, hashtag: false) end @@ -82,7 +93,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' @@ -99,10 +110,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/unknown-reachable' } + + before do + stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable), headers: { 'Content-Type': 'application/activity+json' }) + subject.call(actor, note: true, hashtag: false) + end + + it 'sets expected posts as pinned posts' do + expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly( + 'https://example.com/account/pinned/unknown-reachable' + ) + end + end end context 'when the endpoint is a paginated Collection' do @@ -120,10 +146,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/unknown-reachable' } + + before do + stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable), headers: { 'Content-Type': 'application/activity+json' }) + subject.call(actor, note: true, hashtag: false) + end + + it 'sets expected posts as pinned posts' do + expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly( + 'https://example.com/account/pinned/unknown-reachable' + ) + end + end end end end diff --git a/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb b/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb index 071e4d92d5..638278a10e 100644 --- a/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb +++ b/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb @@ -38,7 +38,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' @@ -46,7 +46,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') @@ -67,7 +67,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' @@ -88,7 +88,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' diff --git a/spec/services/activitypub/fetch_references_service_spec.rb b/spec/services/activitypub/fetch_references_service_spec.rb new file mode 100644 index 0000000000..f90f82a296 --- /dev/null +++ b/spec/services/activitypub/fetch_references_service_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::FetchReferencesService, type: :service do + subject { described_class.new.call(status, payload) } + + let(:actor) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/account') } + let(:status) { Fabricate(:status, account: actor) } + let(:collection_uri) { 'http://example.com/references/1' } + + let(:items) do + [ + 'http://example.com/self-references-1', + 'http://example.com/self-references-2', + 'http://example.com/self-references-3', + 'http://other.com/other-references-1', + 'http://other.com/other-references-2', + 'http://other.com/other-references-3', + 'http://example.com/self-references-4', + 'http://example.com/self-references-5', + 'http://example.com/self-references-6', + 'http://example.com/self-references-7', + 'http://example.com/self-references-8', + ] + end + + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: collection_uri, + items: items, + }.with_indifferent_access + end + + describe '#call' do + context 'when the payload is a Collection with inlined replies' do + context 'when there is a single reference, with the array compacted away' do + let(:items) { 'http://example.com/self-references-1' } + + it 'a item is returned' do + expect(subject).to eq ['http://example.com/self-references-1'] + end + end + + context 'when passing the collection itself' do + it 'first 8 items are returned' do + expect(subject).to eq items.take(8) + end + end + + context 'when passing the URL to the collection' do + subject { described_class.new.call(status, collection_uri) } + + before do + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) + end + + it 'first 8 items are returned' do + expect(subject).to eq items.take(8) + end + end + end + + context 'when the payload is an OrderedCollection with inlined references' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollection', + id: collection_uri, + orderedItems: items, + }.with_indifferent_access + end + + context 'when passing the collection itself' do + it 'first 8 items are returned' do + expect(subject).to eq items.take(8) + end + end + + context 'when passing the URL to the collection' do + subject { described_class.new.call(status, collection_uri) } + + before do + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) + end + + it 'first 8 items are returned' do + expect(subject).to eq items.take(8) + end + end + end + + context 'when the payload is a paginated Collection with inlined references' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: collection_uri, + first: { + type: 'CollectionPage', + partOf: collection_uri, + items: items, + }, + }.with_indifferent_access + end + + context 'when passing the collection itself' do + it 'first 8 items are returned' do + expect(subject).to eq items.take(8) + end + end + + context 'when passing the URL to the collection' do + subject { described_class.new.call(status, collection_uri) } + + before do + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) + end + + it 'first 8 items are returned' do + expect(subject).to eq items.take(8) + end + end + end + end +end diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb index 0abd1daa2c..6081f3e9fb 100644 --- a/spec/services/activitypub/fetch_remote_account_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -18,7 +18,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 @@ -39,12 +39,12 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do end context 'when the account does not have a inbox' do - let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } 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' }) stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') end @@ -65,10 +65,10 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do end context 'when URI and WebFinger share the same host' do - let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } before do - stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') end @@ -92,10 +92,10 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do end context 'when WebFinger presents different domain than URI' do - let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } 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' }) stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}') @@ -125,10 +125,10 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do end context 'when WebFinger returns a different URI' do - let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob', type: 'application/activity+json' }] } } before do - stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') end @@ -149,10 +149,10 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do end context 'when WebFinger returns a different URI after a redirection' do - let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } + let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob', type: 'application/activity+json' }] } } 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' }) stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}') diff --git a/spec/services/activitypub/fetch_remote_actor_service_spec.rb b/spec/services/activitypub/fetch_remote_actor_service_spec.rb index 582bdb0adc..8ba96d19d5 100644 --- a/spec/services/activitypub/fetch_remote_actor_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_actor_service_spec.rb @@ -18,7 +18,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, 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 @@ -39,12 +39,12 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do end context 'when the account does not have a inbox' do - let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } 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' }) stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') end @@ -65,10 +65,10 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do end context 'when URI and WebFinger share the same host' do - let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } before do - stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') end @@ -92,10 +92,10 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do end context 'when WebFinger presents different domain than URI' do - let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } 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' }) stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}') @@ -125,10 +125,10 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do end context 'when WebFinger returns a different URI' do - let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob', type: 'application/activity+json' }] } } before do - stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') end @@ -149,10 +149,10 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do end context 'when WebFinger returns a different URI after a redirection' do - let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } + let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob', type: 'application/activity+json' }] } } 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' }) stub_request(:get, 'https://iscool.af/.well-known/nodeinfo').to_return(body: '{}') diff --git a/spec/services/activitypub/fetch_remote_key_service_spec.rb b/spec/services/activitypub/fetch_remote_key_service_spec.rb index a27b392bf5..28cf344684 100644 --- a/spec/services/activitypub/fetch_remote_key_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_key_service_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do subject { described_class.new } - let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } let(:public_key_pem) do <<~TEXT @@ -50,17 +50,17 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do end before do - stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' }) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') end describe '#call' do - let(:account) { subject.call(public_key_id, id: false) } + let(:account) { subject.call(public_key_id) } context 'when the key is a sub-object from the actor' do before do - stub_request(:get, public_key_id).to_return(body: Oj.dump(actor)) + stub_request(:get, public_key_id).to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' }) end it 'returns the expected account' do @@ -72,7 +72,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do let(:public_key_id) { 'https://example.com/alice-public-key.json' } before do - stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] }))) + stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] })), headers: { 'Content-Type': 'application/activity+json' }) end it 'returns the expected account' do @@ -85,7 +85,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do let(:actor_public_key) { 'https://example.com/alice-public-key.json' } before do - stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] }))) + stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] })), headers: { 'Content-Type': 'application/activity+json' }) end it 'returns the nil' do diff --git a/spec/services/activitypub/fetch_replies_service_spec.rb b/spec/services/activitypub/fetch_replies_service_spec.rb index bf8e296764..73c2b4506f 100644 --- a/spec/services/activitypub/fetch_replies_service_spec.rb +++ b/spec/services/activitypub/fetch_replies_service_spec.rb @@ -34,6 +34,18 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do describe '#call' do context 'when the payload is a Collection with inlined replies' do + context 'when there is a single reply, with the array compacted away' do + let(:items) { 'http://example.com/self-reply-1' } + + it 'queues the expected worker' do + allow(FetchReplyWorker).to receive(:push_bulk) + + subject.call(status, payload) + + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1']) + end + end + context 'when passing the collection itself' do it 'spawns workers for up to 5 replies on the same server' do expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) @@ -43,7 +55,7 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do context 'when passing the URL to the collection' do before do - stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) end it 'spawns workers for up to 5 replies on the same server' do @@ -72,7 +84,7 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do context 'when passing the URL to the collection' do before do - stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) end it 'spawns workers for up to 5 replies on the same server' do @@ -105,7 +117,7 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do context 'when passing the URL to the collection' do before do - stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) end it 'spawns workers for up to 5 replies on the same server' do diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb index 7810793559..05067b2341 100644 --- a/spec/services/activitypub/process_account_service_spec.rb +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -5,6 +5,10 @@ require 'rails_helper' RSpec.describe ActivityPub::ProcessAccountService, type: :service do subject { described_class.new } + before do + stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(status: 404) + end + context 'with searchability' do subject { described_class.new.call('alice', 'example.com', payload) } @@ -45,7 +49,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end context 'when direct' do - let(:searchable_by) { '' } + let(:searchable_by) { 'https://foo.test' } it 'searchability is direct' do expect(subject.searchability).to eq 'direct' @@ -68,6 +72,14 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end end + context 'when empty array' do + let(:searchable_by) { '' } + + it 'searchability is direct' do + expect(subject.searchability).to eq 'direct' + end + end + context 'when default value' do let(:searchable_by) { nil } @@ -155,7 +167,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end end - context 'with property values' do + context 'with property values, an avatar, and a profile header' do let(:payload) do { id: 'https://foo.test', @@ -166,23 +178,78 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do { type: 'PropertyValue', name: 'Occupation', value: 'Unit test' }, { type: 'PropertyValue', name: 'non-string', value: %w(foo bar) }, ], + image: { + type: 'Image', + mediaType: 'image/png', + url: 'https://foo.test/image.png', + }, + icon: { + type: 'Image', + url: [ + { + mediaType: 'image/png', + href: 'https://foo.test/icon.png', + }, + ], + }, }.with_indifferent_access end before do stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(body: '{}') + stub_request(:get, 'https://foo.test/image.png').to_return(request_fixture('avatar.txt')) + stub_request(:get, 'https://foo.test/icon.png').to_return(request_fixture('avatar.txt')) end - it 'parses out of attachment' do + it 'parses property values, avatar and profile header as expected' do account = subject.call('alice', 'example.com', payload) - expect(account.fields).to be_a Array - expect(account.fields.size).to eq 2 - expect(account.fields[0]).to be_a Account::Field - expect(account.fields[0].name).to eq 'Pronouns' - expect(account.fields[0].value).to eq 'They/them' - expect(account.fields[1]).to be_a Account::Field - expect(account.fields[1].name).to eq 'Occupation' - expect(account.fields[1].value).to eq 'Unit test' + + expect(account.fields) + .to be_an(Array) + .and have_attributes(size: 2) + expect(account.fields.first) + .to be_an(Account::Field) + .and have_attributes( + name: eq('Pronouns'), + value: eq('They/them') + ) + expect(account.fields.last) + .to be_an(Account::Field) + .and have_attributes( + name: eq('Occupation'), + value: eq('Unit test') + ) + expect(account).to have_attributes( + avatar_remote_url: 'https://foo.test/icon.png', + header_remote_url: 'https://foo.test/image.png' + ) + end + end + + context 'when account is using note contains ng words' do + subject { described_class.new.call(account.username, account.domain, payload) } + + let!(:account) { Fabricate(:account, username: 'alice', domain: 'example.com') } + + let(:payload) do + { + id: 'https://foo.test', + type: 'Actor', + inbox: 'https://foo.test/inbox', + name: 'Ohagi', + }.with_indifferent_access + end + + it 'creates account when ng word is not set' do + Setting.ng_words = ['Amazon'] + subject + expect(account.reload.display_name).to eq 'Ohagi' + end + + it 'does not create account when ng word is set' do + Setting.ng_words = ['Ohagi'] + subject + expect(account.reload.display_name).to_not eq 'Ohagi' end end @@ -343,7 +410,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do }.with_indifferent_access webfinger = { subject: "acct:user#{i}@foo.test", - links: [{ rel: 'self', href: "https://foo.test/users/#{i}" }], + links: [{ rel: 'self', href: "https://foo.test/users/#{i}", type: 'application/activity+json' }], }.with_indifferent_access stub_request(:get, "https://foo.test/users/#{i}").to_return(status: 200, body: actor_json.to_json, headers: { 'Content-Type': 'application/activity+json' }) stub_request(:get, "https://foo.test/users/#{i}/featured").to_return(status: 200, body: featured_json.to_json, headers: { 'Content-Type': 'application/activity+json' }) diff --git a/spec/services/activitypub/synchronize_followers_service_spec.rb b/spec/services/activitypub/synchronize_followers_service_spec.rb index c9a513e24b..f62376ab95 100644 --- a/spec/services/activitypub/synchronize_followers_service_spec.rb +++ b/spec/services/activitypub/synchronize_followers_service_spec.rb @@ -60,7 +60,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do describe '#call' do context 'when the endpoint is a Collection of actor URIs' do before do - stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) end it_behaves_like 'synchronizes followers' @@ -77,7 +77,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do end before do - stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) end it_behaves_like 'synchronizes followers' @@ -98,7 +98,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do end before do - stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) end it_behaves_like 'synchronizes followers' diff --git a/spec/services/backup_service_spec.rb b/spec/services/backup_service_spec.rb index 4695d128b5..9b5069a1d7 100644 --- a/spec/services/backup_service_spec.rb +++ b/spec/services/backup_service_spec.rb @@ -56,9 +56,12 @@ RSpec.describe BackupService, type: :service do end def expect_outbox_export - json = export_json(:outbox) + body = export_json_raw(:outbox) + json = Oj.load(body) aggregate_failures do + expect(body.scan('@context').count).to eq 1 + expect(body.scan('orderedItems').count).to eq 1 expect(json['@context']).to_not be_nil expect(json['type']).to eq 'OrderedCollection' expect(json['totalItems']).to eq 3 @@ -87,8 +90,12 @@ RSpec.describe BackupService, type: :service do end end + def export_json_raw(type) + read_zip_file(backup, "#{type}.json") + end + def export_json(type) - Oj.load(read_zip_file(backup, "#{type}.json")) + Oj.load(export_json_raw(type)) end def include_create_item(status) diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb index bb3ff5277c..94fbb9cd1b 100644 --- a/spec/services/fetch_link_card_service_spec.rb +++ b/spec/services/fetch_link_card_service_spec.rb @@ -27,6 +27,7 @@ RSpec.describe FetchLinkCardService, type: :service do stub_request(:get, 'http://example.com/sjis_with_wrong_charset').to_return(request_fixture('sjis_with_wrong_charset.txt')) stub_request(:get, 'http://example.com/koi8-r').to_return(request_fixture('koi8-r.txt')) stub_request(:get, 'http://example.com/windows-1251').to_return(request_fixture('windows-1251.txt')) + stub_request(:get, 'http://example.com/low_confidence_latin1').to_return(request_fixture('low_confidence_latin1.txt')) Rails.cache.write('oembed_endpoint:example.com', oembed_cache) if oembed_cache @@ -149,6 +150,14 @@ RSpec.describe FetchLinkCardService, type: :service do end end + context 'with a URL of a page in ISO-8859-1 encoding, that charlock_holmes cannot detect' do + let(:status) { Fabricate(:status, text: 'Check out http://example.com/low_confidence_latin1') } + + it 'decodes the HTML' do + expect(status.preview_card.title).to eq("Tofu á l'orange") + end + end + context 'with a Japanese path URL' do let(:status) { Fabricate(:status, text: 'テストhttp://example.com/日本語') } @@ -185,6 +194,19 @@ RSpec.describe FetchLinkCardService, type: :service do end end + context 'with an URL too long for PostgreSQL unique indexes' do + let(:url) { "http://example.com/#{'a' * 2674}" } + let(:status) { Fabricate(:status, text: url) } + + it 'does not fetch the URL' do + expect(a_request(:get, url)).to_not have_been_made + end + + it 'does not create a preview card' do + expect(status.preview_card).to be_nil + end + end + context 'with a URL of a page with oEmbed support' do let(:html) { 'Hello world' } let(:status) { Fabricate(:status, text: 'http://example.com/html') } diff --git a/spec/services/fetch_resource_service_spec.rb b/spec/services/fetch_resource_service_spec.rb index 0f1068471f..78037a06ce 100644 --- a/spec/services/fetch_resource_service_spec.rb +++ b/spec/services/fetch_resource_service_spec.rb @@ -57,7 +57,7 @@ RSpec.describe FetchResourceService, type: :service do let(:json) do { - id: 1, + id: 'http://example.com/foo', '@context': ActivityPub::TagManager::CONTEXT, type: 'Note', }.to_json @@ -83,27 +83,27 @@ RSpec.describe FetchResourceService, type: :service do let(:content_type) { 'application/activity+json; charset=utf-8' } let(:body) { json } - it { is_expected.to eq [1, { prefetched_body: body, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] } end context 'when content type is ld+json with profile' do let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' } let(:body) { json } - it { is_expected.to eq [1, { prefetched_body: body, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] } end context 'when link header is present' do let(:headers) { { 'Link' => '; rel="alternate"; type="application/activity+json"' } } - it { is_expected.to eq [1, { prefetched_body: json, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] } end context 'when content type is text/html' do let(:content_type) { 'text/html' } let(:body) { '' } - it { is_expected.to eq [1, { prefetched_body: json, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] } end end end diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index 8fcb586580..568fb1cdfd 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -18,6 +18,17 @@ RSpec.describe NotifyService, type: :service do expect { subject }.to_not change(Notification, :count) end + context 'when the sender is a local moderator' do + let(:sender) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:type) { :mention } + let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender)) } + + it 'does notify when the sender is blocked' do + recipient.block!(sender) + expect { subject }.to change(Notification, :count).by(1) + end + end + it 'does not notify when sender is muted with hide_notifications' do recipient.mute!(sender, notifications: true) expect { subject }.to_not change(Notification, :count) @@ -76,10 +87,10 @@ RSpec.describe NotifyService, type: :service do end context 'when the message chain is initiated by recipient, but without a mention to the sender, even if the sender sends multiple messages in a row' do - let(:reply_to) { Fabricate(:status, account: recipient) } - let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) } - let(:dummy_reply) { Fabricate(:status, account: sender, visibility: :direct, thread: reply_to) } - let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: dummy_reply)) } + let(:public_status) { Fabricate(:status, account: recipient) } + let(:intermediate_reply) { Fabricate(:status, account: sender, thread: public_status, visibility: :direct) } + let!(:intermediate_mention) { Fabricate(:mention, account: sender, status: intermediate_reply) } + let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: intermediate_reply)) } it 'does not notify' do expect { subject }.to_not change(Notification, :count) diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index a2cf6fbfb0..555106a0f8 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -54,6 +54,23 @@ RSpec.describe PostStatusService, type: :service do it 'does not change statuses count' do expect { subject.call(account, text: 'Hi future!', scheduled_at: future, thread: previous_status) }.to_not(change { [account.statuses_count, previous_status.replies_count] }) end + + it 'returns existing status when used twice with idempotency key' do + account = Fabricate(:account) + status1 = subject.call(account, text: 'test', idempotency: 'meepmeep', scheduled_at: future) + status2 = subject.call(account, text: 'test', idempotency: 'meepmeep', scheduled_at: future) + expect(status2.id).to eq status1.id + end + + context 'when scheduled_at is less than min offset' do + let(:invalid_scheduled_time) { 4.minutes.from_now } + + it 'raises invalid record error' do + expect do + subject.call(account, text: 'Hi future!', scheduled_at: invalid_scheduled_time) + end.to raise_error(ActiveRecord::RecordInvalid) + end + end end it 'creates response to the original status of boost' do diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb index 7b85e37ed8..357b315af0 100644 --- a/spec/services/reblog_service_spec.rb +++ b/spec/services/reblog_service_spec.rb @@ -86,9 +86,5 @@ RSpec.describe ReblogService, type: :service do it 'distributes to followers' do expect(ActivityPub::DistributionWorker).to have_received(:perform_async) end - - it 'sends an announce activity to the author' do - expect(a_request(:post, bob.inbox_url)).to have_been_made.once - end end end diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index c19b4fac15..7e81f3f6a4 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -110,4 +110,22 @@ RSpec.describe RemoveStatusService, type: :service do )).to have_been_made.once end end + + context 'when removed status is a reblog of a non-follower' do + let!(:original_status) { Fabricate(:status, account: bill, text: 'Hello ThisIsASecret', visibility: :public) } + let!(:status) { ReblogService.new.call(alice, original_status) } + + it 'sends Undo activity to followers' do + subject.call(status) + expect(a_request(:post, bill.inbox_url).with( + body: hash_including({ + 'type' => 'Undo', + 'object' => hash_including({ + 'type' => 'Announce', + 'object' => ActivityPub::TagManager.instance.uri_for(original_status), + }), + }) + )).to have_been_made.once + end + end end diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb index 7991aa6ef1..38d35a3a18 100644 --- a/spec/services/resolve_url_service_spec.rb +++ b/spec/services/resolve_url_service_spec.rb @@ -139,6 +139,7 @@ describe ResolveURLService, type: :service do stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url }) body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' }) + stub_request(:get, uri).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' }) end it 'returns status by url' do diff --git a/spec/services/verify_link_service_spec.rb b/spec/services/verify_link_service_spec.rb index 415788cb58..d06344f9cc 100644 --- a/spec/services/verify_link_service_spec.rb +++ b/spec/services/verify_link_service_spec.rb @@ -76,6 +76,20 @@ RSpec.describe VerifyLinkService, type: :service do end context 'when a document is truncated but the link back is valid' do + let(:html) do + " + + + + " + end + + it 'marks the field as verified' do + expect(field.verified?).to be true + end + end + + context 'when a link tag might be truncated' do let(:html) do " @@ -89,19 +103,6 @@ RSpec.describe VerifyLinkService, type: :service do end end - context 'when a link back might be truncated' do - let(:html) do - " - - -