diff --git a/.codeclimate.yml b/.codeclimate.yml index 9c9b4517a..dc8ca9a6f 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -30,7 +30,7 @@ plugins: channel: eslint-7 rubocop: enabled: true - channel: rubocop-0-88 + channel: rubocop-0-92 sass-lint: enabled: true exclude_patterns: diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index f49964bd9..8ae4bb882 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,7 +1,7 @@ --- name: Bug Report about: If something isn't working as expected - +labels: bug --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 3890729e2..ff92c0316 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,6 @@ --- name: Feature Request about: I have a suggestion - --- diff --git a/.ruby-version b/.ruby-version index 338a5b5d8..37c2961c2 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.6 +2.7.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 18790e860..fe2c1b8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,185 @@ Changelog All notable changes to this project will be documented in this file. +## Unreleased +### Added + +- **Add hotkeys for audio/video control in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/15158), [Gargron](https://github.com/tootsuite/mastodon/pull/15198)) + - `Space` and `k` to toggle playback + - `m` to toggle mute + - `f` to toggle fullscreen + - `j` and `l` to go back and forward by 10 seconds + - `.` and `,` to go back and forward by a frame (video only) +- Add expand/compress button on media modal in web UI ([mashirozx](https://github.com/tootsuite/mastodon/pull/15068), [mashirozx](https://github.com/tootsuite/mastodon/pull/15088), [mashirozx](https://github.com/tootsuite/mastodon/pull/15094)) +- Add border around 🕺 emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14769)) +- Add border around 🐞 emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14712)) +- Add home link to the getting started column when home isn't mounted ([ThibG](https://github.com/tootsuite/mastodon/pull/14707)) +- Add option to disable swiping motions across the web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13885)) +- **Add pop-out player for audio/video in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/14870), [Gargron](https://github.com/tootsuite/mastodon/pull/15157), [Gargron](https://github.com/tootsuite/mastodon/pull/14915)) + - Continue watching/listening when you scroll away + - Action bar to interact with/open toot from the pop-out player +- Add unread notification markers in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14818), [ThibG](https://github.com/tootsuite/mastodon/pull/14960), [ThibG](https://github.com/tootsuite/mastodon/pull/14954), [noellabo](https://github.com/tootsuite/mastodon/pull/14897), [noellabo](https://github.com/tootsuite/mastodon/pull/14907)) +- Add paragraph about browser add-ons when encountering errors in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14801)) +- Add import and export for bookmarks ([ThibG](https://github.com/tootsuite/mastodon/pull/14956)) +- Add cache buster feature for media files ([Gargron](https://github.com/tootsuite/mastodon/pull/15155)) + - If you have a proxy cache in front of object storage, deleted files will persist until the cache expires + - If enabled, cache buster will make a special request to the proxy to signal a cache reset +- Add duration option to the mute function ([aquarla](https://github.com/tootsuite/mastodon/pull/13831)) +- Add replies policy option to the list function ([ThibG](https://github.com/tootsuite/mastodon/pull/9205)) +- Add `og:published_time` OpenGraph tags on toots ([nornagon](https://github.com/tootsuite/mastodon/pull/14865)) +- **Add option to be notified when a followed user posts** ([Gargron](https://github.com/tootsuite/mastodon/pull/13546), [ThibG](https://github.com/tootsuite/mastodon/pull/14896), [Gargron](https://github.com/tootsuite/mastodon/pull/14822)) + - If you don't want to miss a toot, click the bell button! +- Add client-side validation in password change forms ([ThibG](https://github.com/tootsuite/mastodon/pull/14564)) +- Add client-side validation in the registration form ([ThibG](https://github.com/tootsuite/mastodon/pull/14560), [ThibG](https://github.com/tootsuite/mastodon/pull/14599)) +- Add support for Gemini URLs ([joshleeb](https://github.com/tootsuite/mastodon/pull/15013)) +- Add WebAuthn as an alternative 2FA method ([santiagorodriguez96](https://github.com/tootsuite/mastodon/pull/14466), [jiikko](https://github.com/tootsuite/mastodon/pull/14806)) +- Add icon for mutual relationships in relationship manager ([noellabo](https://github.com/tootsuite/mastodon/pull/15149)) +- Add follow selected followers button in relationship manager ([noellabo](https://github.com/tootsuite/mastodon/pull/15148)) +- **Add subresource integrity for JS and CSS assets** ([Gargron](https://github.com/tootsuite/mastodon/pull/15096)) + - If you use a CDN for static assets (JavaScript, CSS, and so on), you have to trust that the CDN does not modify the assets maliciously + - Subresource integrity compares server-generated asset digests with what's actually served from the CDN and prevents such attacks +- Add `ku`, `sa`, `sc`, `zgh` to available locales ([ykzts](https://github.com/tootsuite/mastodon/pull/15138)) +- Add ability to force an account to mark media as sensitive ([noellabo](https://github.com/tootsuite/mastodon/pull/14361)) +- **Add ability to block access or limit sign-ups from chosen IPs** ([Gargron](https://github.com/tootsuite/mastodon/pull/14963)) + - Add rules for IPs or CIDR ranges that automatically expire after a configurable amount of time + - Choose the severity of the rule, either blocking all access or merely limiting sign-ups +- **Add support for reversible suspensions through ActivityPub** ([Gargron](https://github.com/tootsuite/mastodon/pull/14989)) + - Servers can signal that one of their accounts has been suspended + - During suspension, the account can only delete its own content + - A reversal of the suspension can be signalled the same way + - A local suspension always overrides a remote one +- Add ActivityPub follower synchronization mechanism ([ThibG](https://github.com/tootsuite/mastodon/pull/14510), [ThibG](https://github.com/tootsuite/mastodon/pull/15026)) +- Add outbox attribute to instance actor ([ThibG](https://github.com/tootsuite/mastodon/pull/14721)) +- Add featured hashtags as an ActivityPub collection ([Gargron](https://github.com/tootsuite/mastodon/pull/11595)) +- Add support for dereferencing objects through bearcaps ([Gargron](https://github.com/tootsuite/mastodon/pull/14683), [noellabo](https://github.com/tootsuite/mastodon/pull/14981)) +- Add `S3_READ_TIMEOUT` environment variable ([tateisu](https://github.com/tootsuite/mastodon/pull/14952)) +- Add `ALLOWED_PRIVATE_ADDRESSES` environment variable ([ThibG](https://github.com/tootsuite/mastodon/pull/14722)) +- Add `--fix-permissions` option to `tootctl media remove-orphans` ([Gargron](https://github.com/tootsuite/mastodon/pull/14383), [uist1idrju3i](https://github.com/tootsuite/mastodon/pull/14715)) +- Add `tootctl accounts merge` ([Gargron](https://github.com/tootsuite/mastodon/pull/15201)) + - Has someone changed their domain or subdomain thereby creating two accounts where there should be one? + - This command will fix it on your end +- Add `tootctl maintenance fix-duplicates` ([ThibG](https://github.com/tootsuite/mastodon/pull/14860), [Gargron](https://github.com/tootsuite/mastodon/pull/15223)) + - Index corruption in the database? + - This command is for you +- **Add support for managing multiple stream subscriptions in a single connection** ([Gargron](https://github.com/tootsuite/mastodon/pull/14524), [Gargron](https://github.com/tootsuite/mastodon/pull/14566), [mfmfuyu](https://github.com/tootsuite/mastodon/pull/14859), [zunda](https://github.com/tootsuite/mastodon/pull/14608)) + - Previously, getting live updates for multiple timelines required opening a HTTP or WebSocket connection for each + - More connections means more resource consumption on both ends, not to mention the (ever so slight) delay when establishing a new connection + - Now, with just a single WebSocket connection you can subscribe and unsubscribe to and from multiple streams +- Add support for limiting results by both `min_id` and `max_id` at the same time in REST API ([tateisu](https://github.com/tootsuite/mastodon/pull/14776)) +- Add `GET /api/v1/accounts/:id/featured_tags` to REST API ([noellabo](https://github.com/tootsuite/mastodon/pull/11817)) +- Add optional `tootctl remove media` cronjob in Helm chart ([dunn](https://github.com/tootsuite/mastodon/pull/14396)) + +### Changed + +- **Change media modals look in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/15217), [Gargron](https://github.com/tootsuite/mastodon/pull/15221)) + - Background of the overlay matches the color of the image + - Action bar to interact with or open the toot from the modal +- Change order of announcements in admin UI to be newest-first ([ThibG](https://github.com/tootsuite/mastodon/pull/15091)) +- **Change account suspensions to be reversible by default** ([Gargron](https://github.com/tootsuite/mastodon/pull/14726), [ThibG](https://github.com/tootsuite/mastodon/pull/15152), [ThibG](https://github.com/tootsuite/mastodon/pull/15106), [ThibG](https://github.com/tootsuite/mastodon/pull/15100), [ThibG](https://github.com/tootsuite/mastodon/pull/15099), [noellabo](https://github.com/tootsuite/mastodon/pull/14855)) + - Suspensions no longer equal deletions + - A suspended account can be unsuspended with minimal consequences for 30 days + - Immediate deletion of data is still available as an explicit option + - Suspended accounts can request an archive of their data through the UI +- Change REST API to return empty data for suspended accounts (14765) +- Change web UI to show empty profile for suspended accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/14766)) +- Change featured hashtag suggestions to be recently used instead of most used ([abcang](https://github.com/tootsuite/mastodon/pull/14760)) +- Change direct toots to appear in the home feed again ([Gargron](https://github.com/tootsuite/mastodon/pull/14711), [ThibG](https://github.com/tootsuite/mastodon/pull/15182), [noellabo](https://github.com/tootsuite/mastodon/pull/14727)) + - Return to treating all toots the same instead of trying to retrofit direct visibility into an instant messaging model +- Change email address validation to return more specific errors ([ThibG](https://github.com/tootsuite/mastodon/pull/14565)) +- Change HTTP signature requirements to include `Digest` header on `POST` requests ([ThibG](https://github.com/tootsuite/mastodon/pull/15069)) +- Change click area of video/audio player buttons to be bigger in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15049)) +- Change order of filters by alphabetic by "keyword or phrase" ([ariasuni](https://github.com/tootsuite/mastodon/pull/15050)) +- Change suspension of remote accounts to also undo outgoing follows ([ThibG](https://github.com/tootsuite/mastodon/pull/15188)) +- Change string "Home" to "Home and lists" in the filter creation screen ([ariasuni](https://github.com/tootsuite/mastodon/pull/15139)) +- Change string "Boost to original audience" to "Boost with original visibility" in web UI ([3n-k1](https://github.com/tootsuite/mastodon/pull/14598)) +- Change string "Show more" to "Show newer" and "Show older" on public pages ([ariasuni](https://github.com/tootsuite/mastodon/pull/15052)) +- Change order of announcements to be reverse chronological in web UI ([dariusk](https://github.com/tootsuite/mastodon/pull/15065), [dariusk](https://github.com/tootsuite/mastodon/pull/15070)) +- Change visibility icon next to timestamp to be clickable in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15053), [mayaeh](https://github.com/tootsuite/mastodon/pull/15055)) + +### Removed + +- Remove fade-in animation from modals in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15199)) +- Remove auto-redirect to direct messages in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15142)) +- Remove obsolete IndexedDB operations from web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14730)) +- Remove dependency on unused and unmaintained http_parser.rb gem ([ThibG](https://github.com/tootsuite/mastodon/pull/14574)) + +### Fixed + +- **Fix deletes not reaching every server that interacted with toot** ([Gargron](https://github.com/tootsuite/mastodon/pull/15200)) + - Previously, delete of a toot would be primarily sent to the followers of its author, people mentioned in the toot, and people who reblogged the toot + - Now, additionally, it is ensured that it is sent to people who replied to it, favourited it, and to the person it replies to even if that person is not mentioned +- Fix resolving an account through its non-canonical form (i.e. alternate domain) ([ThibG](https://github.com/tootsuite/mastodon/pull/15187)) +- Fix sending redundant ActivityPub events when processing remote account deletion ([ThibG](https://github.com/tootsuite/mastodon/pull/15104)) +- Fix Move handler not being triggered when failing to fetch target account ([ThibG](https://github.com/tootsuite/mastodon/pull/15107)) +- Fix downloading remote media files when server returns empty filename ([ThibG](https://github.com/tootsuite/mastodon/pull/14867)) +- Fix account processing failing because of large collections ([ThibG](https://github.com/tootsuite/mastodon/pull/15027)) +- Fix not being able to unfavorite toots one has lost access to ([ThibG](https://github.com/tootsuite/mastodon/pull/15192)) +- Fix not being able to unbookmark toots one has lost access to ([ThibG](https://github.com/tootsuite/mastodon/pull/14604)) +- Fix possible casing inconsistencies in hashtag search ([ThibG](https://github.com/tootsuite/mastodon/pull/14906)) +- Fix updating account counters when association is not yet created ([Gargron](https://github.com/tootsuite/mastodon/pull/15108)) +- Fix cookies not having a SameSite attribute ([Gargron](https://github.com/tootsuite/mastodon/pull/15098)) +- Fix poll ending notifications being created for each vote ([ThibG](https://github.com/tootsuite/mastodon/pull/15071)) +- Fix multiple boosts of a same toot erroneously appearing in TL ([ThibG](https://github.com/tootsuite/mastodon/pull/14759)) +- Fix asset builds not picking up `CDN_HOST` change ([ThibG](https://github.com/tootsuite/mastodon/pull/14381)) +- Fix desktop notifications permission prompt in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14985), [Gargron](https://github.com/tootsuite/mastodon/pull/15141), [ThibG](https://github.com/tootsuite/mastodon/pull/13543)) + - Some time ago, browsers added a requirement that desktop notification prompts could only be displayed in response to a user-generated event (such as a click) + - This means that for some time, users who haven't already given the permission before were not getting a prompt and as such were not receiving desktop notifications +- Fix "Mark media as sensitive" string not supporting pluralizations in other languages in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15051)) +- Fix glitched image uploads when canvas read access is blocked in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15180)) +- Fix some account gallery items having empty labels in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15073)) +- Fix alt-key hotkeys activating while typing in a text field in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14942)) +- Fix wrong seek bar width on media player in web UI ([mfmfuyu](https://github.com/tootsuite/mastodon/pull/15060)) +- Fix logging out on mobile in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14901)) +- Fix wrong click area for GIFVs in media modal in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/14615)) +- Fix unreadable placeholder text color in high contrast theme in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14803)) +- Fix scrolling issues when closing some dropdown menus in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14606)) +- Fix notification filter bar incorrectly filtering gaps in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14808)) +- Fix disabled boost icon being replaced by private boost icon on hover in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14456)) +- Fix hashtag detection in compose form being different to server-side in web UI ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/14484), [ThibG](https://github.com/tootsuite/mastodon/pull/14513)) +- Fix home last read marker mishandling gaps in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14809)) +- Fix inefficiency when fetching hashtag timeline ([noellabo](https://github.com/tootsuite/mastodon/pull/14861), [akihikodaki](https://github.com/tootsuite/mastodon/pull/14662)) +- Fix inefficiency when fetching bookmarks ([akihikodaki](https://github.com/tootsuite/mastodon/pull/14674)) +- Fix inefficiency when fetching favourites ([akihikodaki](https://github.com/tootsuite/mastodon/pull/14673)) +- Fix inefficiency when fetching media-only account timeline ([akihikodaki](https://github.com/tootsuite/mastodon/pull/14675)) +- Fix redundant query when processing batch actions on custom emojis ([niwatori24](https://github.com/tootsuite/mastodon/pull/14534)) +- Fix PostgreSQL secret name for cronjob in Helm chart ([metal3d](https://github.com/tootsuite/mastodon/pull/15072)) +- Fix Procfile not being compatible with herokuish ([acuteaura](https://github.com/tootsuite/mastodon/pull/12685)) +- Fix installation of tini being split into multiple steps in Dockerfile ([ryncsn](https://github.com/tootsuite/mastodon/pull/14686)) + +### Security + +- Fix streaming API allowing connections to persist after access token invalidation ([Gargron](https://github.com/tootsuite/mastodon/pull/15111)) +- Fix 2FA/sign-in token sessions being valid after password change ([Gargron](https://github.com/tootsuite/mastodon/pull/14802)) + +## [3.2.1] - 2020-10-19 +### Added + +- Add support for latest HTTP Signatures spec draft ([ThibG](https://github.com/tootsuite/mastodon/pull/14556)) +- Add support for inlined objects in ActivityPub `to`/`cc` ([ThibG](https://github.com/tootsuite/mastodon/pull/14514)) + +### Changed + +- Change actors to not be served at all without authentication in limited federation mode ([ThibG](https://github.com/tootsuite/mastodon/pull/14800)) + - Previously, a bare version of an actor was served when not authenticated, i.e. username and public key + - Because all actor fetch requests are signed using a separate system actor, that is no longer required + +### Fixed + +- Fix `tootctl media` commands not recognizing very large IDs ([ThibG](https://github.com/tootsuite/mastodon/pull/14536)) +- Fix crash when failing to load emoji picker in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14525)) +- Fix contrast requirements in thumbnail color extraction ([ThibG](https://github.com/tootsuite/mastodon/pull/14464)) +- Fix audio/video player not using `CDN_HOST` on public pages ([ThibG](https://github.com/tootsuite/mastodon/pull/14486)) +- Fix private boost icon not being used on public pages ([OmmyZhang](https://github.com/tootsuite/mastodon/pull/14471)) +- Fix audio player on Safari in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14485), [ThibG](https://github.com/tootsuite/mastodon/pull/14465)) +- Fix dereferencing remote statuses not using the correct account for signature when receiving a targeted inbox delivery ([ThibG](https://github.com/tootsuite/mastodon/pull/14656)) +- Fix nil error in `tootctl media remove` ([noellabo](https://github.com/tootsuite/mastodon/pull/14657)) +- Fix videos with near-60 fps being rejected ([Gargron](https://github.com/tootsuite/mastodon/pull/14684)) +- Fix reported statuses not being included in warning e-mail ([Gargron](https://github.com/tootsuite/mastodon/pull/14778)) +- Fix `Reject` activities of `Follow` objects not correctly destroying a follow relationship ([ThibG](https://github.com/tootsuite/mastodon/pull/14479)) +- Fix inefficiencies in fan-out-on-write service ([Gargron](https://github.com/tootsuite/mastodon/pull/14682), [noellabo](https://github.com/tootsuite/mastodon/pull/14709)) +- Fix timeout errors when trying to webfinger some IPv6 configurations ([Gargron](https://github.com/tootsuite/mastodon/pull/14919)) +- Fix files served as `application/octet-stream` being rejected without attempting mime type detection ([ThibG](https://github.com/tootsuite/mastodon/pull/14452)) + ## [3.2.0] - 2020-07-27 ### Added diff --git a/Dockerfile b/Dockerfile index c52f89fdc..95d45bab4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM ubuntu:20.04 as build-dep SHELL ["bash", "-c"] # Install Node v12 (LTS) -ENV NODE_VER="12.16.3" +ENV NODE_VER="12.20.0" RUN ARCH= && \ dpkgArch="$(dpkg --print-architecture)" && \ case "${dpkgArch##*-}" in \ @@ -40,7 +40,7 @@ RUN apt update && \ cd .. && rm -rf jemalloc-$JE_VER $JE_VER.tar.gz # Install Ruby -ENV RUBY_VER="2.6.6" +ENV RUBY_VER="2.7.2" ENV CPPFLAGS="-I/opt/jemalloc/include" ENV LDFLAGS="-L/opt/jemalloc/lib/" RUN apt update && \ diff --git a/Gemfile b/Gemfile index 138566780..63becb7cc 100644 --- a/Gemfile +++ b/Gemfile @@ -11,16 +11,13 @@ gem 'sprockets', '~> 3.7.2' gem 'thor', '~> 1.0' gem 'rack', '~> 2.2.3' -gem 'thwait', '~> 0.2.0' -gem 'e2mmap', '~> 0.1.0' - gem 'hamlit-rails', '~> 0.2' gem 'pg', '~> 1.2' gem 'makara', '~> 0.4' gem 'pghero', '~> 2.7' gem 'dotenv-rails', '~> 2.7' -gem 'aws-sdk-s3', '~> 1.83', require: false +gem 'aws-sdk-s3', '~> 1.85', require: false gem 'fog-core', '<= 2.1.0' gem 'fog-openstack', '~> 0.3', require: false gem 'paperclip', '~> 6.0' @@ -30,7 +27,7 @@ gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.7' -gem 'bootsnap', '~> 1.4', require: false +gem 'bootsnap', '~> 1.5', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'iso-639' @@ -44,9 +41,10 @@ group :pam_authentication, optional: true do end gem 'net-ldap', '~> 0.16' -gem 'omniauth-cas', '~> 1.1' +gem 'omniauth-cas', '~> 2.0' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' +gem 'omniauth-rails_csrf_protection', '~> 0.1' gem 'color_diff', '~> 0.1' gem 'discard', '~> 1.2' @@ -71,7 +69,7 @@ gem 'nsa', '~> 0.2' gem 'oj', '~> 3.10' gem 'ox', '~> 2.13' gem 'parslet' -gem 'parallel', '~> 1.19' +gem 'parallel', '~> 1.20' gem 'posix-spawn' gem 'pundit', '~> 2.1' gem 'premailer-rails' @@ -125,21 +123,21 @@ group :test do gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.1' gem 'simplecov', '~> 0.19', require: false - gem 'webmock', '~> 3.9' - gem 'parallel_tests', '~> 3.3' + gem 'webmock', '~> 3.10' + gem 'parallel_tests', '~> 3.4' gem 'rspec_junit_formatter', '~> 0.4' end group :development do - gem 'active_record_query_trace', '~> 1.7' + gem 'active_record_query_trace', '~> 1.8' gem 'annotate', '~> 3.1' - gem 'better_errors', '~> 2.8' + gem 'better_errors', '~> 2.9' gem 'binding_of_caller', '~> 0.7' gem 'bullet', '~> 6.1' gem 'letter_opener', '~> 1.7' gem 'letter_opener_web', '~> 1.4' gem 'memory_profiler' - gem 'rubocop', '~> 0.93', require: false + gem 'rubocop', '~> 1.3', require: false gem 'rubocop-rails', '~> 2.8', require: false gem 'brakeman', '~> 4.10', require: false gem 'bundler-audit', '~> 0.7', require: false @@ -159,3 +157,6 @@ end gem 'concurrent-ruby', require: false gem 'connection_pool', require: false + +gem 'xorcist', '~> 1.1' +gem 'pluck_each', '~> 0.1.3' diff --git a/Gemfile.lock b/Gemfile.lock index aea592da1..9594c0e2b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -44,7 +44,7 @@ GEM activemodel (>= 4.1, < 6.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - active_record_query_trace (1.7) + active_record_query_trace (1.8) activejob (5.2.4.4) activesupport (= 5.2.4.4) globalid (>= 0.3.6) @@ -79,8 +79,8 @@ GEM cocaine (~> 0.5.3) awrence (1.1.1) aws-eventstream (1.1.0) - aws-partitions (1.380.0) - aws-sdk-core (3.109.1) + aws-partitions (1.397.0) + aws-sdk-core (3.109.3) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) @@ -88,14 +88,14 @@ GEM aws-sdk-kms (1.39.0) aws-sdk-core (~> 3, >= 3.109.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.83.0) + aws-sdk-s3 (1.85.0) aws-sdk-core (~> 3, >= 3.109.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) aws-sigv4 (1.2.2) aws-eventstream (~> 1, >= 1.0.2) bcrypt (3.1.16) - better_errors (2.8.3) + better_errors (2.9.1) coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) @@ -104,7 +104,7 @@ GEM debug_inspector (>= 0.0.1) blurhash (0.1.4) ffi (~> 1.10.0) - bootsnap (1.4.8) + bootsnap (1.5.1) msgpack (~> 1.0) brakeman (4.10.0) browser (4.2.0) @@ -289,7 +289,7 @@ GEM jmespath (1.4.0) json (2.3.1) json-canonicalization (0.2.0) - json-ld (3.1.4) + json-ld (3.1.5) htmlentities (~> 4.3) json-canonicalization (~> 0.2) link_header (~> 0.0, >= 0.0.8) @@ -367,15 +367,18 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.10.14) + oj (3.10.16) omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) - omniauth-cas (1.1.1) + omniauth-cas (2.0.0) addressable (~> 2.3) nokogiri (~> 1.5) omniauth (~> 1.2) - omniauth-saml (1.10.2) + omniauth-rails_csrf_protection (0.1.2) + actionpack (>= 4.2) + omniauth (>= 1.3.1) + omniauth-saml (1.10.3) omniauth (~> 1.3, >= 1.3.2) ruby-saml (~> 1.9) openssl (2.2.0) @@ -391,8 +394,8 @@ GEM paperclip-av-transcoder (0.6.4) av (~> 0.9.0) paperclip (>= 2.5.2) - parallel (1.19.2) - parallel_tests (3.3.0) + parallel (1.20.1) + parallel_tests (3.4.0) parallel parser (2.7.2.0) ast (~> 2.4.1) @@ -403,6 +406,9 @@ GEM pghero (2.7.2) activerecord (>= 5) pkg-config (1.4.4) + pluck_each (0.1.3) + activerecord (> 3.2.0) + activesupport (> 3.0.0) posix-spawn (0.3.15) premailer (1.14.2) addressable @@ -421,7 +427,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.0.2) + puma (5.0.4) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) @@ -470,12 +476,12 @@ GEM thor (>= 0.19.0, < 2.0) rainbow (3.0.0) rake (13.0.1) - rdf (3.1.6) + rdf (3.1.7) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.4.0) rdf (~> 3.1) - redis (4.2.2) + redis (4.2.5) redis-actionpack (5.2.0) actionpack (>= 5, < 7) redis-rack (>= 2.1.0, < 3) @@ -529,16 +535,16 @@ GEM rspec-support (3.9.3) rspec_junit_formatter (0.4.1) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (0.93.0) + rubocop (1.3.1) parallel (~> 1.10) parser (>= 2.7.1.5) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8) rexml - rubocop-ast (>= 0.6.0) + rubocop-ast (>= 1.1.1) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (0.7.1) + rubocop-ast (1.1.1) parser (>= 2.7.1.5) rubocop-rails (2.8.1) activesupport (>= 4.2.0) @@ -570,7 +576,7 @@ GEM sidekiq (>= 3) thwait tilt (>= 1.4.0) - sidekiq-unique-jobs (6.0.24) + sidekiq-unique-jobs (6.0.25) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 4.0, < 7.0) thor (>= 0.20, < 2.0) @@ -579,7 +585,7 @@ GEM simple_form (5.0.3) actionpack (>= 5.0) activemodel (>= 5.0) - simplecov (0.19.0) + simplecov (0.19.1) docile (~> 1.1) simplecov-html (~> 0.11) simplecov-html (0.12.3) @@ -593,12 +599,12 @@ GEM sshkit (1.21.0) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) - stackprof (0.2.15) + stackprof (0.2.16) statsd-ruby (1.4.0) stoplight (2.2.1) streamio-ffmpeg (3.0.2) multi_json (~> 1.8) - strong_migrations (0.7.1) + strong_migrations (0.7.2) activerecord (>= 5) temple (0.8.2) terminal-table (1.8.0) @@ -627,7 +633,7 @@ GEM unf (~> 0.1.0) tzinfo (1.2.7) thread_safe (~> 0.1) - tzinfo-data (1.2020.2) + tzinfo-data (1.2020.4) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext @@ -646,7 +652,7 @@ GEM safety_net_attestation (~> 0.4.0) securecompare (~> 1.0) tpm-key_attestation (~> 0.9.0) - webmock (3.9.1) + webmock (3.10.0) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -662,6 +668,7 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) wisper (2.0.1) + xorcist (1.1.2) xpath (3.2.0) nokogiri (~> 1.8) @@ -670,14 +677,14 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10) - active_record_query_trace (~> 1.7) + active_record_query_trace (~> 1.8) addressable (~> 2.7) annotate (~> 3.1) - aws-sdk-s3 (~> 1.83) - better_errors (~> 2.8) + aws-sdk-s3 (~> 1.85) + better_errors (~> 2.9) binding_of_caller (~> 0.7) blurhash (~> 0.1) - bootsnap (~> 1.4) + bootsnap (~> 1.5) brakeman (~> 4.10) browser bullet (~> 6.1) @@ -700,7 +707,6 @@ DEPENDENCIES discard (~> 1.2) doorkeeper (~> 5.4) dotenv-rails (~> 2.7) - e2mmap (~> 0.1.0) ed25519 (~> 1.2) fabrication (~> 2.21) faker (~> 2.14) @@ -737,17 +743,19 @@ DEPENDENCIES nsa (~> 0.2) oj (~> 3.10) omniauth (~> 1.9) - omniauth-cas (~> 1.1) + omniauth-cas (~> 2.0) + omniauth-rails_csrf_protection (~> 0.1) omniauth-saml (~> 1.10) ox (~> 2.13) paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) - parallel (~> 1.19) - parallel_tests (~> 3.3) + parallel (~> 1.20) + parallel_tests (~> 3.4) parslet pg (~> 1.2) pghero (~> 2.7) pkg-config (~> 1.4) + pluck_each (~> 0.1.3) posix-spawn premailer-rails private_address_check (~> 0.5) @@ -770,7 +778,7 @@ DEPENDENCIES rspec-rails (~> 4.0) rspec-sidekiq (~> 3.1) rspec_junit_formatter (~> 0.4) - rubocop (~> 0.93) + rubocop (~> 1.3) rubocop-rails (~> 2.8) ruby-progressbar (~> 1.10) sanitize (~> 5.2) @@ -788,11 +796,17 @@ DEPENDENCIES streamio-ffmpeg (~> 3.0) strong_migrations (~> 0.7) thor (~> 1.0) - thwait (~> 0.2.0) tty-prompt (~> 0.22) twitter-text (~> 1.14) tzinfo-data (~> 1.2020) webauthn (~> 3.0.0.alpha1) - webmock (~> 3.9) + webmock (~> 3.10) webpacker (~> 5.2) webpush + xorcist (~> 1.1) + +RUBY VERSION + ruby 2.7.2p137 + +BUNDLED WITH + 2.1.4 diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 6d711afd0..ccb5ef8e8 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -102,6 +102,10 @@ class AccountsController < ApplicationController params[:username] end + def skip_temporary_suspension_response? + request.format == :json + end + def rss_url if tag_requested? short_account_tag_url(@account, params[:tag], format: 'rss') diff --git a/app/controllers/activitypub/base_controller.rb b/app/controllers/activitypub/base_controller.rb index 0c2591e97..4cbc3ab8f 100644 --- a/app/controllers/activitypub/base_controller.rb +++ b/app/controllers/activitypub/base_controller.rb @@ -8,4 +8,8 @@ class ActivityPub::BaseController < Api::BaseController def set_cache_headers response.headers['Vary'] = 'Signature' if authorized_fetch_mode? end + + def skip_temporary_suspension_response? + false + end end diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb new file mode 100644 index 000000000..525031105 --- /dev/null +++ b/app/controllers/activitypub/followers_synchronizations_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController + include SignatureVerification + include AccountOwnedConcern + + before_action :require_signature! + before_action :set_items + before_action :set_cache_headers + + def show + expires_in 0, public: false + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + + private + + def uri_prefix + signed_request_account.uri[/http(s?):\/\/[^\/]+\//] + end + + def set_items + @items = @account.followers.where(Account.arel_table[:uri].matches(uri_prefix + '%', false, true)).pluck(:uri) + end + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_followers_synchronization_url(@account), + type: :ordered, + items: @items + ) + end +end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 0a561e7f0..d3044f180 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -11,6 +11,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController def create upgrade_account + process_collection_synchronization process_payload head 202 end @@ -32,6 +33,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController params[:account_username].present? end + def skip_temporary_suspension_response? + true + end + def body return @body if defined?(@body) @@ -52,6 +57,19 @@ class ActivityPub::InboxesController < ActivityPub::BaseController DeliveryFailureTracker.reset!(signed_request_account.inbox_url) end + def process_collection_synchronization + raw_params = request.headers['Collection-Synchronization'] + return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true' + + # Re-using the syntax for signature parameters + tree = SignatureParamsParser.new.parse(raw_params) + params = SignatureParamsTransformer.new.apply(tree) + + ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params) + rescue Parslet::ParseFailed + Rails.logger.warn 'Error parsing Collection-Synchronization header' + end + def process_payload ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id) end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 43bf4e657..fde6c861f 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -31,7 +31,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController end def set_replies - @replies = only_other_accounts? ? Status.where.not(account_id: @account.id) : @account.statuses + @replies = only_other_accounts? ? Status.where.not(account_id: @account.id).joins(:account).merge(Account.without_suspended) : @account.statuses @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted]) @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index b9b75727d..1dd7430e0 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -53,6 +53,13 @@ module Admin redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct) end + def unsensitive + authorize @account, :unsensitive? + @account.unsensitize! + log_action :unsensitive, @account + redirect_to admin_account_path(@account.id) + end + def unsilence authorize @account, :unsilence? @account.unsilence! diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb index 494fd13d0..351b9a991 100644 --- a/app/controllers/admin/announcements_controller.rb +++ b/app/controllers/admin/announcements_controller.rb @@ -71,7 +71,7 @@ class Admin::AnnouncementsController < Admin::BaseController private def set_announcements - @announcements = AnnouncementFilter.new(filter_params).results.page(params[:page]) + @announcements = AnnouncementFilter.new(filter_params).results.reverse_chronological.page(params[:page]) end def set_announcement diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index e962c4e97..fe199e689 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -103,7 +103,7 @@ class Api::BaseController < ApplicationController elsif !current_user.functional? render json: { error: 'Your login is currently disabled' }, status: 403 else - set_user_activity + update_user_sign_in end end diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index 3af572f25..63cc521ed 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -22,6 +22,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController active pending disabled + sensitized silenced suspended username @@ -68,6 +69,13 @@ class Api::V1::Admin::AccountsController < Api::BaseController render json: @account, serializer: REST::Admin::AccountSerializer end + def unsensitive + authorize @account, :unsensitive? + @account.unsensitize! + log_action :unsensitive, @account + render json: @account, serializer: REST::Admin::AccountSerializer + end + def unsilence authorize @account, :unsilence? @account.unsilence! diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index 7afa822ed..2e21ce6a0 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:favourites' } before_action :require_user! - before_action :set_status + before_action :set_status, only: [:create] def create FavouriteService.new.call(current_account, @status) @@ -13,8 +13,19 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController end def destroy - UnfavouriteWorker.perform_async(current_account.id, @status.id) + fav = current_account.favourites.find_by(status_id: params[:status_id]) + + if fav + @status = fav.status + UnfavouriteWorker.perform_async(current_account.id, @status.id) + else + @status = Status.find(params[:status_id]) + authorize @status, :show? + end + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }) + rescue Mastodon::NotPermittedError + not_found end private diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index c1ea702ad..13d158c67 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -7,6 +7,7 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_no_authentication, only: [:create] skip_before_action :require_functional! + skip_before_action :update_user_sign_in include TwoFactorAuthenticationConcern include SignInTokenAuthenticationConcern @@ -24,6 +25,7 @@ class Auth::SessionsController < Devise::SessionsController def create super do |resource| + resource.update_sign_in!(request, new_sign_in: true) remember_me(resource) flash.delete(:notice) end @@ -57,7 +59,7 @@ class Auth::SessionsController < Devise::SessionsController def find_user if session[:attempt_user_id] - User.find(session[:attempt_user_id]) + User.find_by(id: session[:attempt_user_id]) else user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication @@ -90,6 +92,7 @@ class Auth::SessionsController < Devise::SessionsController def require_no_authentication super + # Delete flash message that isn't entirely useful and may be confusing in # most cases because /web doesn't display/clear flash messages. flash.delete(:alert) if flash[:alert] == I18n.t('devise.failure.already_authenticated') @@ -107,13 +110,30 @@ class Auth::SessionsController < Devise::SessionsController def home_paths(resource) paths = [about_path] + if single_user_mode? && resource.is_a?(User) paths << short_account_path(username: resource.account) end + paths end def continue_after? truthy_param?(:continue) end + + def restart_session + clear_attempt_from_session + redirect_to new_user_session_path, alert: I18n.t('devise.failure.timeout') + end + + def set_attempt_session(user) + session[:attempt_user_id] = user.id + session[:attempt_user_updated_at] = user.updated_at.to_s + end + + def clear_attempt_from_session + session.delete(:attempt_user_id) + session.delete(:attempt_user_updated_at) + end end diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb index 460f71f65..62e379846 100644 --- a/app/controllers/concerns/account_owned_concern.rb +++ b/app/controllers/concerns/account_owned_concern.rb @@ -29,6 +29,24 @@ module AccountOwnedConcern end def check_account_suspension - expires_in(3.minutes, public: true) && gone if @account.suspended? + if @account.suspended_permanently? + permanent_suspension_response + elsif @account.suspended? && !skip_temporary_suspension_response? + temporary_suspension_response + end + end + + def skip_temporary_suspension_response? + false + end + + def permanent_suspension_response + expires_in(3.minutes, public: true) + gone + end + + def temporary_suspension_response + expires_in(3.minutes, public: true) + forbidden end end diff --git a/app/controllers/concerns/sign_in_token_authentication_concern.rb b/app/controllers/concerns/sign_in_token_authentication_concern.rb index 91f813acc..3c95a4afd 100644 --- a/app/controllers/concerns/sign_in_token_authentication_concern.rb +++ b/app/controllers/concerns/sign_in_token_authentication_concern.rb @@ -18,7 +18,9 @@ module SignInTokenAuthenticationConcern def authenticate_with_sign_in_token user = self.resource = find_user - if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id] + if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s + restart_session + elsif user_params.key?(:sign_in_token_attempt) && session[:attempt_user_id] authenticate_with_sign_in_token_attempt(user) elsif user.present? && user.external_or_valid_password?(user_params[:password]) prompt_for_sign_in_token(user) @@ -27,7 +29,7 @@ module SignInTokenAuthenticationConcern def authenticate_with_sign_in_token_attempt(user) if valid_sign_in_token_attempt?(user) - session.delete(:attempt_user_id) + clear_attempt_from_session remember_me(user) sign_in(user) else @@ -42,10 +44,10 @@ module SignInTokenAuthenticationConcern UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later! end - set_locale do - session[:attempt_user_id] = user.id - @body_classes = 'lighter' - render :sign_in_token - end + set_attempt_session(user) + + @body_classes = 'lighter' + + set_locale { render :sign_in_token } end end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index f69c62ec2..fc3978fbb 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -76,6 +76,7 @@ module SignatureVerification raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window? verify_signature_strength! + verify_body_digest! account = account_from_key_id(signature_params['keyId']) @@ -126,10 +127,19 @@ module SignatureVerification def verify_signature_strength! raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)') raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest') - raise SignatureVerificationError, 'Mastodon requires the Host header to be signed' unless signed_headers.include?('host') + raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host') raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest') end + def verify_body_digest! + return unless signed_headers.include?('digest') + + digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] } + sha256 = digests.assoc('sha-256') + raise SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil? + raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" if body_digest != sha256[1] + end + def verify_signature(account, signature, compare_signed_string) if account.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), signature, compare_signed_string) @signed_request_account = account @@ -153,8 +163,6 @@ module SignatureVerification raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank? "(expires): #{signature_params['expires']}" - elsif signed_header == 'digest' - "digest: #{body_digest}" else "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" end @@ -187,7 +195,7 @@ module SignatureVerification end def body_digest - "SHA-256=#{Digest::SHA256.base64digest(request_body)}" + @body_digest ||= Digest::SHA256.base64digest(request_body) end def to_header_name(name) diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb index 8a2a86a02..4d4ccf49c 100644 --- a/app/controllers/concerns/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/two_factor_authentication_concern.rb @@ -37,9 +37,11 @@ module TwoFactorAuthenticationConcern def authenticate_with_two_factor user = self.resource = find_user - if user.webauthn_enabled? && user_params[:credential].present? && session[:attempt_user_id] + if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s + restart_session + elsif user.webauthn_enabled? && user_params.key?(:credential) && session[:attempt_user_id] authenticate_with_two_factor_via_webauthn(user) - elsif user_params[:otp_attempt].present? && session[:attempt_user_id] + elsif user_params.key?(:otp_attempt) && session[:attempt_user_id] authenticate_with_two_factor_via_otp(user) elsif user.present? && user.external_or_valid_password?(user_params[:password]) prompt_for_two_factor(user) @@ -50,7 +52,7 @@ module TwoFactorAuthenticationConcern webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential]) if valid_webauthn_credential?(user, webauthn_credential) - session.delete(:attempt_user_id) + clear_attempt_from_session remember_me(user) sign_in(user) render json: { redirect_path: root_path }, status: :ok @@ -61,7 +63,7 @@ module TwoFactorAuthenticationConcern def authenticate_with_two_factor_via_otp(user) if valid_otp_attempt?(user) - session.delete(:attempt_user_id) + clear_attempt_from_session remember_me(user) sign_in(user) else @@ -71,16 +73,18 @@ module TwoFactorAuthenticationConcern end def prompt_for_two_factor(user) - set_locale do - session[:attempt_user_id] = user.id - @body_classes = 'lighter' - @webauthn_enabled = user.webauthn_enabled? - @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? - 'webauthn' - else - 'totp' - end - render :two_factor + set_attempt_session(user) + + @body_classes = 'lighter' + @webauthn_enabled = user.webauthn_enabled? + @scheme_type = begin + if user.webauthn_enabled? && user_params[:otp_attempt].blank? + 'webauthn' + else + 'totp' + end end + + set_locale { render :two_factor } end end diff --git a/app/controllers/concerns/user_tracking_concern.rb b/app/controllers/concerns/user_tracking_concern.rb index be10705fc..efda37fae 100644 --- a/app/controllers/concerns/user_tracking_concern.rb +++ b/app/controllers/concerns/user_tracking_concern.rb @@ -6,14 +6,13 @@ module UserTrackingConcern UPDATE_SIGN_IN_HOURS = 24 included do - before_action :set_user_activity + before_action :update_user_sign_in end private - def set_user_activity - return unless user_needs_sign_in_update? - current_user.update_tracked_fields!(request) + def update_user_sign_in + current_user.update_sign_in!(request) if user_needs_sign_in_update? end def user_needs_sign_in_update? diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 63d9d9cd3..79a1ab02b 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -9,7 +9,7 @@ class FiltersController < ApplicationController before_action :set_body_classes def index - @filters = current_account.custom_filters + @filters = current_account.custom_filters.order(:phrase) end def new diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index ab0749963..ff4df2adf 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -52,6 +52,14 @@ class FollowerAccountsController < ApplicationController account_followers_url(@account, page: page) unless page.nil? end + def next_page_url + page_url(follows.next_page) if follows.respond_to?(:next_page) + end + + def prev_page_url + page_url(follows.prev_page) if follows.respond_to?(:prev_page) + end + def collection_presenter if page_requested? ActivityPub::CollectionPresenter.new( @@ -60,8 +68,8 @@ class FollowerAccountsController < ApplicationController size: @account.followers_count, items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, part_of: account_followers_url(@account), - next: page_url(follows.next_page), - prev: page_url(follows.prev_page) + next: next_page_url, + prev: prev_page_url ) else ActivityPub::CollectionPresenter.new( diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 918bdac0a..6bb95c454 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -52,6 +52,14 @@ class FollowingAccountsController < ApplicationController account_following_index_url(@account, page: page) unless page.nil? end + def next_page_url + page_url(follows.next_page) if follows.respond_to?(:next_page) + end + + def prev_page_url + page_url(follows.prev_page) if follows.respond_to?(:prev_page) + end + def collection_presenter if page_requested? ActivityPub::CollectionPresenter.new( @@ -60,8 +68,8 @@ class FollowingAccountsController < ApplicationController size: @account.following_count, items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }, part_of: account_following_index_url(@account), - next: page_url(follows.next_page), - prev: page_url(follows.prev_page) + next: next_page_url, + prev: prev_page_url ) else ActivityPub::CollectionPresenter.new( diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index 0835758f2..96cce55e9 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -5,6 +5,7 @@ class RelationshipsController < ApplicationController before_action :authenticate_user! before_action :set_accounts, only: :show + before_action :set_relationships, only: :show before_action :set_body_classes helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? @@ -28,6 +29,10 @@ class RelationshipsController < ApplicationController @accounts = RelationshipFilter.new(current_account, filter_params).results.page(params[:page]).per(40) end + def set_relationships + @relationships = AccountRelationshipsPresenter.new(@accounts.pluck(:id), current_user.account_id) + end + def form_account_batch_params params.require(:form_account_batch).permit(:action, account_ids: []) end @@ -49,7 +54,9 @@ class RelationshipsController < ApplicationController end def action_from_button - if params[:unfollow] + if params[:follow] + 'follow' + elsif params[:unfollow] 'unfollow' elsif params[:remove_from_followers] 'remove_from_followers' diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index f96c83b80..7b8f8d207 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -42,7 +42,7 @@ class Settings::DeletesController < Settings::BaseController end def destroy_account! - current_account.suspend! + current_account.suspend!(origin: :local) AccountDeletionWorker.perform_async(current_user.account_id) sign_out end diff --git a/app/controllers/settings/exports/bookmarks_controller.rb b/app/controllers/settings/exports/bookmarks_controller.rb new file mode 100644 index 000000000..c12e2f147 --- /dev/null +++ b/app/controllers/settings/exports/bookmarks_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Settings + module Exports + class BookmarksController < BaseController + include ExportControllerConcern + + def index + send_export_file + end + + private + + def export_data + @export.to_bookmarks_csv + end + end + end +end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 9de9db6ba..0227f722a 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -35,7 +35,7 @@ module WellKnown end def check_account_suspension - expires_in(3.minutes, public: true) && gone if @account.suspended? + expires_in(3.minutes, public: true) && gone if @account.suspended_permanently? end def bad_request diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5512a9543..bf5742d34 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -7,6 +7,13 @@ module ApplicationHelper follow ).freeze + RTL_LOCALES = %i( + ar + fa + he + ku + ).freeze + def active_nav_class(*paths) paths.any? { |path| current_page?(path) } ? 'active' : '' end @@ -44,7 +51,7 @@ module ApplicationHelper end def locale_direction - if [:ar, :fa, :he].include?(I18n.locale) + if RTL_LOCALES.include?(I18n.locale) 'rtl' else 'ltr' @@ -89,6 +96,16 @@ module ApplicationHelper end end + def interrelationships_icon(relationships, account_id) + if relationships.following[account_id] && relationships.followed_by[account_id] + fa_icon('exchange', title: I18n.t('relationships.mutual'), class: 'fa-fw active passive') + elsif relationships.following[account_id] + fa_icon(locale_direction == 'ltr' ? 'arrow-right' : 'arrow-left', title: I18n.t('relationships.following'), class: 'fa-fw active') + elsif relationships.followed_by[account_id] + fa_icon(locale_direction == 'ltr' ? 'arrow-left' : 'arrow-right', title: I18n.t('relationships.followers'), class: 'fa-fw passive') + end + end + def custom_emoji_tag(custom_emoji, animate = true) if animate image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:") diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 87718dc05..5b39497b6 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -40,6 +40,7 @@ module SettingsHelper kk: 'Қазақша', kn: 'ಕನ್ನಡ', ko: '한국어', + ku: 'سۆرانی', lt: 'Lietuvių', lv: 'Latviešu', mk: 'Македонски', @@ -56,6 +57,8 @@ module SettingsHelper pt: 'Português', ro: 'Română', ru: 'Русский', + sa: 'संस्कृतम्', + sc: 'Sardu', sk: 'Slovenčina', sl: 'Slovenščina', sq: 'Shqip', @@ -69,6 +72,7 @@ module SettingsHelper uk: 'Українська', ur: 'اُردُو', vi: 'Tiếng Việt', + zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)', 'zh-TW': '繁體中文(臺灣)', diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index a51597cf3..daed9048f 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -4,8 +4,12 @@ module StatusesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' - def link_to_more(url) - link_to t('statuses.show_more'), url, class: 'load-more load-gap' + def link_to_newer(url) + link_to t('statuses.show_newer'), url, class: 'load-more load-gap' + end + + def link_to_older(url) + link_to t('statuses.show_older'), url, class: 'load-more load-gap' end def nothing_here(extra_classes = '') @@ -117,6 +121,14 @@ module StatusesHelper end end + def sensitized?(status, account) + if !account.nil? && account.id == status.account_id + status.sensitive + else + status.account.sensitized? || status.sensitive + end + end + private def simplified_text(text) diff --git a/app/javascript/mastodon/actions/app.js b/app/javascript/mastodon/actions/app.js index 414968f7d..c817c8708 100644 --- a/app/javascript/mastodon/actions/app.js +++ b/app/javascript/mastodon/actions/app.js @@ -8,3 +8,10 @@ export const focusApp = () => ({ export const unfocusApp = () => ({ type: APP_UNFOCUS, }); + +export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE'; + +export const changeLayout = layout => ({ + type: APP_LAYOUT_CHANGE, + layout, +}); diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 030922520..891403969 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -152,9 +152,7 @@ export function submitCompose(routerHistory) { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, }).then(function (response) { - if (response.data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) { - routerHistory.push('/timelines/direct'); - } else if (routerHistory && routerHistory.location.pathname === '/statuses/new' && window.history.state) { + if (routerHistory && routerHistory.location.pathname === '/statuses/new' && window.history.state) { routerHistory.goBack(); } diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index c4fa66428..93e18fba9 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -37,8 +37,9 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; -export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; -export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; +export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; +export const NOTIFICATIONS_DISMISS_BROWSER_PERMISSION = 'NOTIFICATIONS_DISMISS_BROWSER_PERMISSION'; defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, @@ -256,7 +257,7 @@ export function setupBrowserNotifications() { if ('Notification' in window && 'permissions' in navigator) { navigator.permissions.query({ name: 'notifications' }).then((status) => { status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); - }); + }).catch(console.warn); } }; } @@ -283,3 +284,7 @@ export function setBrowserPermission (value) { value, }; } + +export const dismissBrowserPermission = () => ({ + type: NOTIFICATIONS_DISMISS_BROWSER_PERMISSION, +}); diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js index 55e3bfd5e..239824a4f 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import detectPassiveEvents from 'detect-passive-events'; +import { supportsPassiveEvents } from 'detect-passive-events'; import { scrollTop } from '../scroll'; export default class Column extends React.PureComponent { @@ -35,9 +35,9 @@ export default class Column extends React.PureComponent { componentDidMount () { if (this.props.bindToDocument) { - document.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); } else { - this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); } } diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 09e3c9df8..c6b4b1187 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -5,9 +5,9 @@ import IconButton from './icon_button'; import Overlay from 'react-overlays/lib/Overlay'; import Motion from '../features/ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; -import detectPassiveEvents from 'detect-passive-events'; +import { supportsPassiveEvents } from 'detect-passive-events'; -const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; let id = 0; class DropdownMenu extends React.PureComponent { diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index 6297b5e29..9bfc0e49f 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -1,19 +1,21 @@ import React from 'react'; import PropTypes from 'prop-types'; import 'wicg-inert'; +import { normal } from 'color-blend'; export default class ModalRoot extends React.PureComponent { static propTypes = { children: PropTypes.node, onClose: PropTypes.func.isRequired, + backgroundColor: PropTypes.shape({ + r: PropTypes.number, + g: PropTypes.number, + b: PropTypes.number, + }), }; - state = { - revealed: !!this.props.children, - }; - - activeElement = this.state.revealed ? document.activeElement : null; + activeElement = this.props.children ? document.activeElement : null; handleKeyUp = (e) => { if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) @@ -53,8 +55,6 @@ export default class ModalRoot extends React.PureComponent { this.activeElement = document.activeElement; this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); - } else if (!nextProps.children) { - this.setState({ revealed: false }); } } @@ -68,14 +68,7 @@ export default class ModalRoot extends React.PureComponent { Promise.resolve().then(() => { this.activeElement.focus({ preventScroll: true }); this.activeElement = null; - }).catch((error) => { - console.error(error); - }); - } - if (this.props.children) { - requestAnimationFrame(() => { - this.setState({ revealed: true }); - }); + }).catch(console.error); } } @@ -94,7 +87,6 @@ export default class ModalRoot extends React.PureComponent { render () { const { children, onClose } = this.props; - const { revealed } = this.state; const visible = !!children; if (!visible) { @@ -103,10 +95,16 @@ export default class ModalRoot extends React.PureComponent { ); } + let backgroundColor = null; + + if (this.props.backgroundColor) { + backgroundColor = normal({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.3 }); + } + return ( -
+
-
+
{children}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index c1e1cd172..27d96e588 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -97,7 +97,10 @@ class Status extends ImmutablePureComponent { cachedMediaWidth: PropTypes.number, scrollKey: PropTypes.string, deployPictureInPicture: PropTypes.func, - usingPiP: PropTypes.bool, + pictureInPicture: PropTypes.shape({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), }; // Avoid checking props that are functions (and whose equality will always @@ -108,7 +111,7 @@ class Status extends ImmutablePureComponent { 'muted', 'hidden', 'unread', - 'usingPiP', + 'pictureInPicture', ]; state = { @@ -190,22 +193,24 @@ class Status extends ImmutablePureComponent { } handleOpenVideo = (media, options) => { - this.props.onOpenVideo(media, options); + this.props.onOpenVideo(this._properStatus().get('id'), media, options); + } + + handleOpenMedia = (media, index) => { + this.props.onOpenMedia(this._properStatus().get('id'), media, index); } handleHotkeyOpenMedia = e => { const { onOpenMedia, onOpenVideo } = this.props; - const status = this._properStatus(); + const statusId = this._properStatus().get('id'); e.preventDefault(); if (status.get('media_attachments').size > 0) { - if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { - // TODO: toggle play/paused? - } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - onOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 }); + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(statusId, status.getIn(['media_attachments', 0]), { startTime: 0 }); } else { - onOpenMedia(status.get('media_attachments'), 0); + onOpenMedia(statusId, status.get('media_attachments'), 0); } } } @@ -277,7 +282,7 @@ class Status extends ImmutablePureComponent { let media = null; let statusAvatar, prepend, rebloggedByText; - const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP } = this.props; + const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props; let { status, account, ...other } = this.props; @@ -348,7 +353,7 @@ class Status extends ImmutablePureComponent { status = status.get('reblog'); } - if (usingPiP) { + if (pictureInPicture.inUse) { media = ; } else if (status.get('media_attachments').size > 0) { if (this.props.muted) { @@ -375,7 +380,7 @@ class Status extends ImmutablePureComponent { width={this.props.cachedMediaWidth} height={110} cacheWidth={this.props.cacheMediaWidth} - deployPictureInPicture={this.handleDeployPictureInPicture} + deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined} /> )} @@ -388,6 +393,7 @@ class Status extends ImmutablePureComponent { {Component => ( @@ -412,7 +418,7 @@ class Status extends ImmutablePureComponent { media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} - onOpenMedia={this.props.onOpenMedia} + onOpenMedia={this.handleOpenMedia} cacheWidth={this.props.cacheMediaWidth} defaultWidth={this.props.cachedMediaWidth} visible={this.state.showMedia} @@ -460,8 +466,10 @@ class Status extends ImmutablePureComponent {
- - + + + +
diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index ba55ecbc7..afed6868e 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -30,6 +30,7 @@ export default class MediaContainer extends PureComponent { media: null, index: null, time: null, + backgroundColor: null, }; handleOpenMedia = (media, index) => { @@ -52,7 +53,16 @@ export default class MediaContainer extends PureComponent { document.body.classList.remove('with-modals--active'); document.documentElement.style.marginRight = 0; - this.setState({ media: null, index: null, time: null }); + this.setState({ + media: null, + index: null, + time: null, + backgroundColor: null, + }); + } + + setBackgroundColor = color => { + this.setState({ backgroundColor: color }); } render () { @@ -85,13 +95,14 @@ export default class MediaContainer extends PureComponent { ); })} - + {this.state.media && ( )} diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 7bfd66d3e..ef520b96a 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -57,7 +57,11 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props), - usingPiP: state.get('picture_in_picture').statusId === props.id, + + pictureInPicture: { + inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id, + available: state.getIn(['meta', 'layout']) !== 'mobile', + }, }); return mapStateToProps; @@ -148,12 +152,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(mentionCompose(account, router)); }, - onOpenMedia (media, index) { - dispatch(openModal('MEDIA', { media, index })); + onOpenMedia (statusId, media, index) { + dispatch(openModal('MEDIA', { statusId, media, index })); }, - onOpenVideo (media, options) { - dispatch(openModal('VIDEO', { media, options })); + onOpenVideo (statusId, media, options) { + dispatch(openModal('VIDEO', { statusId, media, options })); }, onBlock (status) { diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index c9a7af7f7..ba7ec46a3 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -122,7 +122,7 @@ export default class MediaItem extends ImmutablePureComponent {
{content} - {label} + {label && {label}}
); } diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index e5caec0bc..597ca8af6 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -105,15 +105,18 @@ class AccountGallery extends ImmutablePureComponent { } handleOpenMedia = attachment => { + const { dispatch } = this.props; + const statusId = attachment.getIn(['status', 'id']); + if (attachment.get('type') === 'video') { - this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } })); + dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } })); } else if (attachment.get('type') === 'audio') { - this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } })); + dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } })); } else { const media = attachment.getIn(['status', 'media_attachments']); const index = media.findIndex(x => x.get('id') === attachment.get('id')); - this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') })); + dispatch(openModal('MEDIA', { media, index, statusId })); } } diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js index 6954d2a4c..c47f55dd1 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -246,7 +246,7 @@ class Audio extends React.PureComponent { handleTimeUpdate = () => { this.setState({ currentTime: this.audio.currentTime, - duration: Math.floor(this.audio.duration), + duration: this.audio.duration, }); } @@ -386,13 +386,59 @@ class Audio extends React.PureComponent { return this.props.foregroundColor || '#ffffff'; } + seekBy (time) { + const currentTime = this.audio.currentTime + time; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.audio.currentTime = currentTime; + }); + } + } + + handleAudioKeyDown = e => { + // On the audio element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + } + } + + handleKeyDown = e => { + switch(e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + this.toggleMute(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(10); + break; + } + } + render () { const { src, intl, alt, editable, autoPlay } = this.props; const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; const progress = Math.min((currentTime / duration) * 100, 100); return ( -
+
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 2bee5666b..35358d238 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -79,6 +79,18 @@ class ComposeForm extends ImmutablePureComponent { } } + getFulltextForCharacterCounting = () => { + return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join(''); + } + + canSubmit = () => { + const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; + const fulltext = this.getFulltextForCharacterCounting(); + const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0; + + return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia)); + } + handleSubmit = () => { if (this.props.text !== this.autosuggestTextarea.textarea.value) { // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) @@ -86,11 +98,7 @@ class ComposeForm extends ImmutablePureComponent { this.props.onChange(this.autosuggestTextarea.textarea.value); } - // Submit disabled: - const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; - const fulltext = [this.props.spoilerText, countableText(this.props.text)].join(''); - - if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { + if (!this.canSubmit()) { return; } @@ -180,10 +188,8 @@ class ComposeForm extends ImmutablePureComponent { } render () { - const { intl, onPaste, showSearch, anyMedia } = this.props; + const { intl, onPaste, showSearch } = this.props; const disabled = this.props.isSubmitting; - const text = [this.props.spoilerText, countableText(this.props.text)].join(''); - const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0 && !anyMedia); let publishText = ''; if (this.props.privacy === 'private' || this.props.privacy === 'direct') { @@ -245,11 +251,11 @@ class ComposeForm extends ImmutablePureComponent {
-
+
-
+
); diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index bac136e5e..dc4f48060 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -5,7 +5,7 @@ import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components' import Overlay from 'react-overlays/lib/Overlay'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import detectPassiveEvents from 'detect-passive-events'; +import { supportsPassiveEvents } from 'detect-passive-events'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; import { assetHost } from 'mastodon/utils/config'; @@ -29,7 +29,7 @@ const messages = defineMessages({ let EmojiPicker, Emoji; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; -const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; class ModifierPickerMenu extends React.PureComponent { diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index 5223025fb..309f46290 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -5,7 +5,7 @@ import IconButton from '../../../components/icon_button'; import Overlay from 'react-overlays/lib/Overlay'; import Motion from '../../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; -import detectPassiveEvents from 'detect-passive-events'; +import { supportsPassiveEvents } from 'detect-passive-events'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -21,7 +21,7 @@ const messages = defineMessages({ change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, }); -const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; class PrivacyDropdownMenu extends React.PureComponent { diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js index 7073f76c2..1bcce5731 100644 --- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js @@ -6,13 +6,20 @@ import { changeComposeSensitivity } from 'mastodon/actions/compose'; import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; const messages = defineMessages({ - marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, - unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' }, + marked: { + id: 'compose_form.sensitive.marked', + defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}', + }, + unmarked: { + id: 'compose_form.sensitive.unmarked', + defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}', + }, }); const mapStateToProps = state => ({ active: state.getIn(['compose', 'sensitive']), disabled: state.getIn(['compose', 'spoiler']), + mediaCount: state.getIn(['compose', 'media_attachments']).size, }); const mapDispatchToProps = dispatch => ({ @@ -28,16 +35,17 @@ class SensitiveButton extends React.PureComponent { static propTypes = { active: PropTypes.bool, disabled: PropTypes.bool, + mediaCount: PropTypes.number, onClick: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; render () { - const { active, disabled, onClick, intl } = this.props; + const { active, disabled, mediaCount, onClick, intl } = this.props; return (
-
); diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js index 4853c3935..5bc3abac6 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.js +++ b/app/javascript/mastodon/features/getting_started/components/announcements.js @@ -396,7 +396,7 @@ class Announcements extends ImmutablePureComponent { _markAnnouncementAsRead () { const { dismissAnnouncement, announcements } = this.props; const { index } = this.state; - const announcement = announcements.get(index); + const announcement = announcements.get(announcements.size - 1 - index); if (!announcement.get('read')) dismissAnnouncement(announcement.get('id')); } @@ -437,7 +437,7 @@ class Announcements extends ImmutablePureComponent { selected={index === idx} disabled={disableSwiping} /> - ))} + )).reverse()} {announcements.size > 1 && ( diff --git a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js index 766c9bb5b..6daf75814 100644 --- a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js +++ b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js @@ -1,25 +1,42 @@ import React from 'react'; import Icon from 'mastodon/components/icon'; import Button from 'mastodon/components/button'; -import { requestBrowserPermission } from 'mastodon/actions/notifications'; +import IconButton from 'mastodon/components/icon_button'; +import { requestBrowserPermission, dismissBrowserPermission } from 'mastodon/actions/notifications'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -export default @connect(() => {}) +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @connect() +@injectIntl class NotificationsPermissionBanner extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, }; handleClick = () => { this.props.dispatch(requestBrowserPermission()); } + handleClose = () => { + this.props.dispatch(dismissBrowserPermission()); + } + render () { + const { intl } = this.props; + return (
+
+ +
+

}} />

diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js index 086cda954..1b1ec6d54 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js @@ -22,6 +22,7 @@ const messages = defineMessages({ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, }); const makeMapStateToProps = () => { @@ -49,11 +50,19 @@ class Footer extends ImmutablePureComponent { intl: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, askReplyConfirmation: PropTypes.bool, + withOpenButton: PropTypes.bool, + onClose: PropTypes.func, }; _performReply = () => { - const { dispatch, status } = this.props; - dispatch(replyCompose(status, this.context.router.history)); + const { dispatch, status, onClose } = this.props; + const { router } = this.context; + + if (onClose) { + onClose(); + } + + dispatch(replyCompose(status, router.history)); }; handleReplyClick = () => { @@ -97,8 +106,20 @@ class Footer extends ImmutablePureComponent { } }; + handleOpenClick = e => { + const { router } = this.context; + + if (e.button !== 0 || !router) { + return; + } + + const { status } = this.props; + + router.history.push(`/statuses/${status.get('id')}`); + } + render () { - const { status, intl } = this.props; + const { status, intl, withOpenButton } = this.props; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; @@ -130,6 +151,7 @@ class Footer extends ImmutablePureComponent { + {withOpenButton && }
); } diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.js b/app/javascript/mastodon/features/picture_in_picture/components/header.js index 4cb6de1a4..7dd199b75 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/header.js +++ b/app/javascript/mastodon/features/picture_in_picture/components/header.js @@ -7,12 +7,18 @@ import IconButton from 'mastodon/components/icon_button'; import { Link } from 'react-router-dom'; import Avatar from 'mastodon/components/avatar'; import DisplayName from 'mastodon/components/display_name'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); const mapStateToProps = (state, { accountId }) => ({ account: state.getIn(['accounts', accountId]), }); export default @connect(mapStateToProps) +@injectIntl class Header extends ImmutablePureComponent { static propTypes = { @@ -20,10 +26,11 @@ class Header extends ImmutablePureComponent { statusId: PropTypes.string.isRequired, account: ImmutablePropTypes.map.isRequired, onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, }; render () { - const { account, statusId, onClose } = this.props; + const { account, statusId, onClose, intl } = this.props; return (
@@ -32,7 +39,7 @@ class Header extends ImmutablePureComponent { - +
); } diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index c2b883f7f..cd29b5489 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -142,6 +142,7 @@ class DetailedStatus extends ImmutablePureComponent { media = (