diff --git a/.dockerignore b/.dockerignore index 9d990ab9ce6..fe87f6e6006 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ .gitattributes .gitignore .github +.vscode public/system public/assets public/packs @@ -20,6 +21,7 @@ postgres14 redis elasticsearch chart +storybook-static .yarn/ !.yarn/patches !.yarn/plugins diff --git a/.env.production.sample b/.env.production.sample index 12e3d32be73..52a2838cbf5 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -91,9 +91,6 @@ SESSION_RETENTION_PERIOD=31556952 # Fetch All Replies Behavior # -------------------------- -# When a user expands a post (DetailedStatus view), fetch all of its replies -# (default: false) -FETCH_REPLIES_ENABLED=false # Period to wait between fetching replies (in minutes) FETCH_REPLIES_COOLDOWN_MINUTES=15 diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml index bb4b71dd9f7..2261275a432 100644 --- a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml @@ -1,6 +1,6 @@ name: Bug Report (Web Interface) description: There is a problem using Mastodon's web interface. -labels: ['status/to triage', 'area/web interface'] +labels: ['area/web interface'] type: Bug body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml index c6d7e8e16b6..99ec9cf146f 100644 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -1,7 +1,6 @@ name: Bug Report (server / API) description: | There is a problem with the HTTP server, REST API, ActivityPub interaction, etc. -labels: ['status/to triage'] type: 'Bug' body: - type: markdown diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1850a45bbcd..c1a1c99eb70 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -6,6 +6,7 @@ ':labels(dependencies)', ':prConcurrentLimitNone', // Remove limit for open PRs at any time. ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. + ':enableVulnerabilityAlertsWithLabel(security)', ], rebaseWhen: 'conflicted', minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it @@ -23,7 +24,6 @@ matchManagers: ['npm'], matchPackageNames: [ 'tesseract.js', // Requires code changes - 'react-hotkeys', // Requires code changes // react-router: Requires manual upgrade 'history', @@ -94,6 +94,19 @@ matchUpdateTypes: ['patch', 'minor'], groupName: 'eslint (non-major)', }, + { + // Group all Storybook-related packages in the same PR + matchManagers: ['npm'], + matchPackageNames: [ + 'chromatic', + 'storybook', + '@storybook/*', + 'msw', + 'msw-storybook-addon', + ], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'storybook (non-major)', + }, { // Group actions/*-artifact in the same PR matchManagers: ['github-actions'], @@ -142,6 +155,12 @@ matchUpdateTypes: ['patch', 'minor'], groupName: 'opentelemetry-ruby (non-major)', }, + { + // Group Playwright Ruby & JS deps in the same PR, as they need to be in sync + matchManagers: ['bundler', 'npm'], + matchPackageNames: ['playwright-ruby-client', 'playwright'], + groupName: 'Playwright', + }, // Add labels depending on package manager { matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] }, { matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] }, diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml index d3cb4e5e0ae..72729b544b3 100644 --- a/.github/workflows/build-security.yml +++ b/.github/workflows/build-security.yml @@ -9,7 +9,6 @@ permissions: jobs: compute-suffix: runs-on: ubuntu-latest - if: github.repository == 'mastodon/mastodon' steps: - id: version_vars env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8690e9ed6d1..c864e12d2d8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,8 +25,8 @@ jobs: strategy: fail-fast: false matrix: - language: ['javascript', 'ruby'] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + language: ['actions', 'javascript', 'ruby'] + # CodeQL supports [ 'actions', 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index 6d9a0586298..8e18a9d0a0f 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -50,7 +50,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@v7.0.6 + uses: peter-evans/create-pull-request@v7.0.8 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml index 4f4d917d15a..d0d79d91996 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -14,6 +14,7 @@ on: - config/locales/devise.en.yml - config/locales/doorkeeper.en.yml - .github/workflows/crowdin-upload.yml + workflow_dispatch: jobs: upload-translations: diff --git a/.nvmrc b/.nvmrc index 4a203c23d83..403f75d0382 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.17 +22.20 diff --git a/.rubocop/metrics.yml b/.rubocop/metrics.yml index 89532af42ab..bb15e6ff313 100644 --- a/.rubocop/metrics.yml +++ b/.rubocop/metrics.yml @@ -1,17 +1,21 @@ --- Metrics/AbcSize: - Exclude: - - lib/mastodon/cli/*.rb + Enabled: false Metrics/BlockLength: Enabled: false +Metrics/BlockNesting: + Enabled: false + Metrics/ClassLength: Enabled: false +Metrics/CollectionLiteralLength: + Enabled: false + Metrics/CyclomaticComplexity: - Exclude: - - lib/mastodon/cli/*.rb + Enabled: false Metrics/MethodLength: Enabled: false @@ -20,4 +24,7 @@ Metrics/ModuleLength: Enabled: false Metrics/ParameterLists: - CountKeywordArgs: false + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4ec92f34121..0cc9c8d8fc1 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,32 +1,11 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.77.0. +# using RuboCop version 1.80.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -Lint/NonLocalExitFromIterator: - Exclude: - - 'app/helpers/json_ld_helper.rb' - -# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. -Metrics/AbcSize: - Max: 82 - -# Configuration parameters: CountBlocks, CountModifierForms, Max. -Metrics/BlockNesting: - Exclude: - - 'lib/tasks/mastodon.rake' - -# Configuration parameters: AllowedMethods, AllowedPatterns. -Metrics/CyclomaticComplexity: - Max: 25 - -# Configuration parameters: AllowedMethods, AllowedPatterns. -Metrics/PerceivedComplexity: - Max: 27 - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowedVars, DefaultToNil. Style/FetchEnvVar: diff --git a/.ruby-version b/.ruby-version index f9892605c75..2aa51319921 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.4 +3.4.7 diff --git a/.storybook/main.ts b/.storybook/main.ts index 72321cbf3f1..bb69f0c6649 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { @@ -26,6 +28,12 @@ const config: StorybookConfig = { 'oops.png', ].map((path) => ({ from: `../public/${path}`, to: `/${path}` })), ], + viteFinal(config) { + // For an unknown reason, Storybook does not use the root + // from the Vite config so we need to set it manually. + config.root = resolve(__dirname, '../app/javascript'); + return config; + }, }; export default config; diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html new file mode 100644 index 00000000000..1870d95b8fe --- /dev/null +++ b/.storybook/preview-body.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index f25d0547e83..d66f0fb11a8 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -12,13 +12,14 @@ import { initialize, mswLoader } from 'msw-storybook-addon'; import { action } from 'storybook/actions'; import type { LocaleData } from '@/mastodon/locales'; -import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers'; +import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; // If you want to run the dark theme during development, // you can change the below to `/application.scss` import '../app/javascript/styles/mastodon-light.scss'; +import './styles.css'; const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { query: { as: 'json' }, @@ -49,12 +50,23 @@ const preview: Preview = { locale: 'en', }, decorators: [ - (Story, { parameters }) => { + (Story, { parameters, globals, args }) => { + // Get the locale from the global toolbar + // and merge it with any parameters or args state. + const { locale } = globals as { locale: string }; const { state = {} } = parameters; - let reducer = rootReducer; - if (typeof state === 'object' && state) { - reducer = reducerWithInitialState(state as Record); - } + const { state: argsState = {} } = args; + + const reducer = reducerWithInitialState( + { + meta: { + locale, + }, + }, + state as Record, + argsState as Record, + ); + const store = configureStore({ reducer, middleware(getDefaultMiddleware) { diff --git a/.storybook/static/mockServiceWorker.js b/.storybook/static/mockServiceWorker.js index de7bc0f292d..15623f1090b 100644 --- a/.storybook/static/mockServiceWorker.js +++ b/.storybook/static/mockServiceWorker.js @@ -7,8 +7,8 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.10.2' -const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af' +const PACKAGE_VERSION = '2.11.3' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() @@ -71,11 +71,6 @@ addEventListener('message', async function (event) { break } - case 'MOCK_DEACTIVATE': { - activeClientIds.delete(clientId) - break - } - case 'CLIENT_CLOSED': { activeClientIds.delete(clientId) @@ -94,6 +89,8 @@ addEventListener('message', async function (event) { }) addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + // Bypass navigation requests. if (event.request.mode === 'navigate') { return @@ -110,23 +107,29 @@ addEventListener('fetch', function (event) { // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). + // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { return } const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId)) + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) }) /** * @param {FetchEvent} event * @param {string} requestId + * @param {number} requestInterceptedAt */ -async function handleRequest(event, requestId) { +async function handleRequest(event, requestId, requestInterceptedAt) { const client = await resolveMainClient(event) const requestCloneForEvents = event.request.clone() - const response = await getResponse(event, client, requestId) + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise @@ -204,7 +207,7 @@ async function resolveMainClient(event) { * @param {string} requestId * @returns {Promise} */ -async function getResponse(event, client, requestId) { +async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). const requestClone = event.request.clone() @@ -255,6 +258,7 @@ async function getResponse(event, client, requestId) { type: 'REQUEST', payload: { id: requestId, + interceptedAt: requestInterceptedAt, ...serializedRequest, }, }, diff --git a/.storybook/styles.css b/.storybook/styles.css new file mode 100644 index 00000000000..ac29890895e --- /dev/null +++ b/.storybook/styles.css @@ -0,0 +1,8 @@ +a { + color: inherit; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/CHANGELOG.md b/CHANGELOG.md index efdd3adf120..249596dc055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,129 @@ All notable changes to this project will be documented in this file. -## [4.4.0] - UNRELEASED +## [4.4.6] - 2025-10-13 + +### Security + +- Update dependencies `rack` and `uri` +- Fix streaming server connection not being closed on user suspension (by @ThisIsMissEm, [GHSA-r2fh-jr9c-9pxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-r2fh-jr9c-9pxh)) +- Fix password change through admin CLI not invalidating existing sessions and access tokens (by @ThisIsMissEm, [GHSA-f3q3-rmf7-9655](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q3-rmf7-9655)) +- Fix streaming server allowing access to public timelines even without the `read` or `read:statuses` OAuth scopes (by @ThisIsMissEm, [GHSA-7gwh-mw97-qjgp](https://github.com/mastodon/mastodon/security/advisories/GHSA-7gwh-mw97-qjgp)) + +### Added + +- Add support for processing quotes of deleted posts signaled through a `Tombstone` (#36381 by @ClearlyClaire) + +### Fixed + +- Fix quote post state sometimes not being updated through streaming server (#36408 by @ClearlyClaire) +- Fix inconsistent “pending tags” count on admin dashboard (#36404 by @mjankowski) +- Fix JSON payload being potentially mutated when processing interaction policies (#36392 by @ClearlyClaire) +- Fix quotes not being displayed in email notifications (#36379 by @diondiondion) +- Fix redirect to external object when URL is missing or malformed (#36347 by @ClearlyClaire) +- Fix quotes not being displayed in the featured carousel (#36335 by @diondiondion) + +## [4.4.5] - 2025-09-23 + +### Security + +- Update dependencies + +### Added + +- Add support for `has:quote` in search (#36217 by @ClearlyClaire) + +### Changed + +- Change quoted posts from silenced accounts to use a click-through rather than being hidden (#36166 and #36167 by @ClearlyClaire) + +### Fixed + +- Fix processing of out-of-order `Update` as implicit updates (#36190 by @ClearlyClaire) +- Fix getting `Create` and `Update` out of order (#36176 by @ClearlyClaire) +- Fix quotes with Content Warnings but no text being shown without Content Warnings (#36150 by @ClearlyClaire) + +## [4.4.4] - 2025-09-16 + +### Security + +- Update dependencies + +### Fixed + +- Fix missing memoization in `Web::PushNotificationWorker` (#36085 by @ClearlyClaire) +- Fix unresponsive areas around GIFV modals in some cases (#36059 by @ClearlyClaire) +- Fix missing `beforeUnload` confirmation when a poll is being authored (#36030 by @ClearlyClaire) +- Fix processing of remote edited statuses with new media and no text (#35970 by @unfokus) +- Fix polls not being displayed in moderation interface (#35644 and #35933 by @ThisIsMissEm) +- Fix WebUI handling of deleted quoted posts (#35909 and #35918 by @ClearlyClaire and @diondiondion) +- Fix “Edit” and “Delete & Redraft” on a poll not inserting empty option (#35892 by @ClearlyClaire) +- Fix loading of some compatibility CSS on some configurations (#35876 by @shleeable) +- Fix HttpLog not being enabled with `RAILS_LOG_LEVEL=debug` (#35833 by @mjankowski) +- Fix self-destruct scheduler behavior on some Redis setups (#35823 by @ClearlyClaire) +- Fix `tootctl admin create` not bypassing reserved username checks (#35779 by @ClearlyClaire) +- Fix interaction policy changes in implicit updates not being saved (#35751 by @ClearlyClaire) +- Fix quote revocation not being streamed (#35710 by @ClearlyClaire) +- Fix export of large user archives by enabling Zip64 (#35850 by @ClearlyClaire) + +### Changed + +- Change labels for quote policy settings (#35893 by @ClearlyClaire) +- Change standalone “Share” page to redirect to web interface after posting (#35763 by @ChaosExAnima) + +## [4.4.3] - 2025-08-05 + +### Security + +- Update dependencies +- Fix incorrect rate-limit handling [GHSA-84ch-6436-c7mg](https://github.com/mastodon/mastodon/security/advisories/GHSA-84ch-6436-c7mg) + +### Fixed + +- Fix race condition caused by ActiveRecord query cache in `Create` critical path (#35662 by @ClearlyClaire) +- Fix race condition caused by quote post processing (#35657 by @ClearlyClaire) +- Fix WebUI crashing for accounts with `null` URL (#35651 by @ClearlyClaire) +- Fix friends-of-friends recommendations suggesting already-requested accounts (#35604 by @ClearlyClaire) +- Fix synchronous recursive fetching of deeply-nested quoted posts (#35600 by @ClearlyClaire) +- Fix “Expand this post” link including user `@undefined` (#35478 by @ClearlyClaire) + +### Changed + +- Change `StatusReachFinder` to consider quotes as well as reblogs (#35601 by @ClearlyClaire) +- Add restrictions on which quote posts can trend (#35507 by @ClearlyClaire) +- Change quote verification to not bypass authorization flow for mentions (#35528 by @ClearlyClaire) + +## [4.4.2] - 2025-07-23 + +### Security + +- Update dependencies + +### Fixed + +- Fix menu not clickable in Firefox (#35390 and #35414 by @diondiondion) +- Add `lang` attribute to current composer language in alt text modal (#35412 by @diondiondion) +- Fix quote posts styling on notifications page (#35411 by @diondiondion) +- Improve a11y of custom select menus in notifications settings (#35403 by @diondiondion) +- Fix selected item in poll select menus is unreadable in Firefox (#35402 by @diondiondion) +- Update age limit wording (#35387 by @diondiondion) +- Fix support for quote verification in implicit status updates (#35384 by @ClearlyClaire) +- Improve `Dropdown` component accessibility (#35373 by @diondiondion) +- Fix processing some incoming quotes failing because of missing JSON-LD context (#35354 and #35380 by @ClearlyClaire) +- Make bio hashtags open the local page instead of the remote instance (#35349 by @ChaosExAnima) +- Fix styling of external log-in button (#35320 by @ClearlyClaire) + +## [4.4.1] - 2025-07-09 + +### Fixed + +- Fix nearly every sub-directory being crawled as part of Vite build (#35323 by @ClearlyClaire) +- Fix assets not building when Redis is unavailable (#35321 by @oneiros) +- Fix replying from media modal or pop-in-player tagging user `@undefined` (#35317 by @ClearlyClaire) +- Fix support for special characters in various environment variables (#35314 by @mjankowski and @ClearlyClaire) +- Fix some database migrations failing for indexes manually removed by admins (#35309 by @mjankowski) + +## [4.4.0] - 2025-07-08 ### Added @@ -38,7 +160,7 @@ All notable changes to this project will be documented in this file. Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path. - Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron) - Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm) -- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126 and #35127 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\ +- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\ Server administrators can now fill in Terms of Service and notify their users of upcoming changes. - Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\ This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\ @@ -51,7 +173,7 @@ All notable changes to this project will be documented in this file. - Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron) - Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron) - Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm) -- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033 and #35218 by @oneiros)\ +- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\ This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org). - Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\ This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users. @@ -64,7 +186,7 @@ All notable changes to this project will be documented in this file. - Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk) - Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\ Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action. -- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033 and #35109 by @oneiros)\ +- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\ For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests). - Add experimental Async Refreshes API (#34918 by @oneiros) - Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\ @@ -218,6 +340,7 @@ All notable changes to this project will be documented in this file. - Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire) - Fix OIDC account creation failing for long display names (#34639 by @defnull) - Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap) +- Fix inaccessible “Clear search” button (#35152 and #35281 by @diondiondion) - Fix search operators sometimes getting lost (#35190 by @ClearlyClaire) - Fix directory scroll position reset (#34560 by @przucidlo) - Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent) @@ -232,7 +355,7 @@ All notable changes to this project will be documented in this file. - Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire) - Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion) - Fix zoomed images being blurry in Safari (#35052 by @diondiondion) -- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096 and #35150 by @diondiondion) +- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096, #35150 and #35251 by @diondiondion) - Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion) - Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion) - Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion) @@ -530,7 +653,6 @@ The following changelog entries focus on changes visible to users, administrator You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\ Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\ This adds the following REST API endpoints: - - `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy - `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications - `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests @@ -542,7 +664,6 @@ The following changelog entries focus on changes visible to users, administrator - `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged In addition, accepting one or more notification requests generates a new streaming event: - - `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed - **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\ diff --git a/Dockerfile b/Dockerfile index 6cb4a2a1c0a..ad8150552a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.12 +# syntax=docker/dockerfile:1.18 # This file is designed for production server deployment, not local development work # For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/docs/DEVELOPMENT.md#docker @@ -13,15 +13,15 @@ ARG BASE_REGISTRY="docker.io" # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="3.4.4" +ARG RUBY_VERSION="3.4.7" # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # renovate: datasource=node-version depName=node ARG NODE_MAJOR_VERSION="22" -# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] -ARG DEBIAN_VERSION="bookworm" -# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim) +# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"] +ARG DEBIAN_VERSION="trixie" +# Node.js image to use for base image based on combined variables (ex: 20-trixie-slim) FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node -# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm) +# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-trixie) FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA @@ -96,9 +96,6 @@ RUN \ # Set /opt/mastodon as working directory WORKDIR /opt/mastodon -# Add backport repository for some specific packages where we need the latest version -RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list - # hadolint ignore=DL3008,DL3005 RUN \ # Mount Apt cache and lib directories from Docker buildx caches @@ -161,11 +158,11 @@ RUN \ libexif-dev \ libexpat1-dev \ libgirepository1.0-dev \ - libheif-dev/bookworm-backports \ + libheif-dev \ + libhwy-dev \ libimagequant-dev \ libjpeg62-turbo-dev \ liblcms2-dev \ - liborc-dev \ libspng-dev \ libtiff-dev \ libwebp-dev \ @@ -186,7 +183,7 @@ FROM build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.17.0 +ARG VIPS_VERSION=8.17.2 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download @@ -209,7 +206,7 @@ FROM build AS ffmpeg # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"] # renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg -ARG FFMPEG_VERSION=7.1 +ARG FFMPEG_VERSION=8.0 # ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"] ARG FFMPEG_URL=https://ffmpeg.org/releases @@ -327,28 +324,28 @@ RUN \ # Apt update install non-dev versions of necessary components apt-get install -y --no-install-recommends \ libexpat1 \ - libglib2.0-0 \ - libicu72 \ + libglib2.0-0t64 \ + libicu76 \ libidn12 \ libpq5 \ - libreadline8 \ - libssl3 \ + libreadline8t64 \ + libssl3t64 \ libyaml-0-2 \ # libvips components libcgif0 \ libexif12 \ - libheif1/bookworm-backports \ + libheif1 \ + libhwy1t64 \ libimagequant0 \ libjpeg62-turbo \ liblcms2-2 \ - liborc-0.4-0 \ libspng0 \ libtiff6 \ libwebp7 \ libwebpdemux2 \ libwebpmux3 \ # ffmpeg components - libdav1d6 \ + libdav1d7 \ libmp3lame0 \ libopencore-amrnb0 \ libopencore-amrwb0 \ @@ -358,9 +355,9 @@ RUN \ libvorbis0a \ libvorbisenc2 \ libvorbisfile3 \ - libvpx7 \ + libvpx9 \ libx264-164 \ - libx265-199 \ + libx265-215 \ ; # Copy Mastodon sources into final layer diff --git a/Gemfile b/Gemfile index ffd5371b06d..aa201d1e72b 100644 --- a/Gemfile +++ b/Gemfile @@ -4,12 +4,12 @@ source 'https://rubygems.org' ruby '>= 3.2.0', '< 3.5.0' gem 'propshaft' -gem 'puma', '~> 6.3' +gem 'puma', '~> 7.0' gem 'rails', '~> 8.0' gem 'thor', '~> 1.2' gem 'dotenv' -gem 'haml-rails', '~>2.0' +gem 'haml-rails', '~>3.0' gem 'pg', '~> 1.5' gem 'pghero' @@ -62,7 +62,7 @@ gem 'inline_svg' gem 'irb', '~> 1.8' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' -gem 'linzer', '~> 0.7.2' +gem 'linzer', '~> 0.7.7' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar' gem 'mutex_m' @@ -82,13 +82,13 @@ gem 'rqrcode', '~> 3.0' gem 'ruby-progressbar', '~> 1.13' gem 'sanitize', '~> 7.0' gem 'scenic', '~> 1.7' -gem 'sidekiq', '< 8' +gem 'sidekiq', '< 9' gem 'sidekiq-bulk', '~> 0.2.0' -gem 'sidekiq-scheduler', '~> 5.0' +gem 'sidekiq-scheduler', '~> 6.0' gem 'sidekiq-unique-jobs', '> 8' gem 'simple_form', '~> 5.2' gem 'simple-navigation', '~> 4.4' -gem 'stoplight', '~> 4.1' +gem 'stoplight' gem 'strong_migrations' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' @@ -102,23 +102,23 @@ gem 'rdf-normalize', '~> 0.5' gem 'prometheus_exporter', '~> 2.2', require: false -gem 'opentelemetry-api', '~> 1.5.0' +gem 'opentelemetry-api', '~> 1.7.0' group :opentelemetry do gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false - gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false - gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false - gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false - gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false - gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false - gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false - gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false - gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false - gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false - gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.36.0', require: false - gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-active_job', '~> 0.9.0', require: false + gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.25.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.29.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.25.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.25.0', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.31.0', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.28.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.38.0', require: false + gem 'opentelemetry-instrumentation-redis', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-sidekiq', '~> 0.27.0', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false end @@ -138,6 +138,7 @@ group :test do # Browser integration testing gem 'capybara', '~> 3.39' gem 'capybara-playwright-driver' + gem 'playwright-ruby-client', '1.55.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package # Used to reset the database between system tests gem 'database_cleaner-active_record' @@ -146,7 +147,7 @@ group :test do gem 'climate_control' # Validate schemas in specs - gem 'json-schema', '~> 5.0' + gem 'json-schema', '~> 6.0' # Test harness fo rack components gem 'rack-test', '~> 2.1' @@ -159,6 +160,9 @@ group :test do # Stub web requests for specs gem 'webmock', '~> 3.18' + + # Websocket driver for testing integration between rails/sidekiq and streaming + gem 'websocket-driver', '~> 0.8', require: false end group :development do @@ -223,7 +227,7 @@ gem 'connection_pool', require: false gem 'xorcist', '~> 1.1' gem 'net-http', '~> 0.6.0' -gem 'rubyzip', '~> 2.3' +gem 'rubyzip', '~> 3.0' gem 'hcaptcha', '~> 7.1' diff --git a/Gemfile.lock b/Gemfile.lock index b2eff8ad359..1d63e0d955c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,29 +10,29 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + actioncable (8.0.3) + actionpack (= 8.0.3) + activesupport (= 8.0.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actionmailbox (8.0.3) + actionpack (= 8.0.3) + activejob (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) mail (>= 2.8.0) - actionmailer (8.0.2) - actionpack (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activesupport (= 8.0.2) + actionmailer (8.0.3) + actionpack (= 8.0.3) + actionview (= 8.0.3) + activejob (= 8.0.3) + activesupport (= 8.0.3) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2) - actionview (= 8.0.2) - activesupport (= 8.0.2) + actionpack (8.0.3) + actionview (= 8.0.3) + activesupport (= 8.0.3) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -40,15 +40,15 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2) - actionpack (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actiontext (8.0.3) + actionpack (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2) - activesupport (= 8.0.2) + actionview (8.0.3) + activesupport (= 8.0.3) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -58,22 +58,22 @@ GEM activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (8.0.2) - activesupport (= 8.0.2) + activejob (8.0.3) + activesupport (= 8.0.3) globalid (>= 0.3.6) - activemodel (8.0.2) - activesupport (= 8.0.2) - activerecord (8.0.2) - activemodel (= 8.0.2) - activesupport (= 8.0.2) + activemodel (8.0.3) + activesupport (= 8.0.3) + activerecord (8.0.3) + activemodel (= 8.0.3) + activesupport (= 8.0.3) timeout (>= 0.4.0) - activestorage (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activesupport (= 8.0.2) + activestorage (8.0.3) + actionpack (= 8.0.3) + activejob (= 8.0.3) + activerecord (= 8.0.3) + activesupport (= 8.0.3) marcel (~> 1.0) - activesupport (8.0.2) + activesupport (8.0.3) base64 benchmark (>= 0.3) bigdecimal @@ -90,13 +90,13 @@ GEM public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) - annotaterb (4.16.0) + annotaterb (4.19.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) ast (2.4.3) attr_required (1.0.2) - aws-eventstream (1.3.2) - aws-partitions (1.1103.0) + aws-eventstream (1.4.0) + aws-partitions (1.1168.0) aws-sdk-core (3.215.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -109,9 +109,9 @@ GEM aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) - azure-blob (0.5.8) + azure-blob (0.5.9.1) rexml base64 (0.3.0) bcp47_spec (0.2.1) @@ -121,7 +121,7 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.2.2) + bigdecimal (3.2.3) bindata (2.5.1) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) @@ -144,13 +144,13 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - capybara-playwright-driver (0.5.6) + capybara-playwright-driver (0.5.7) addressable capybara playwright-ruby-client (>= 1.16.0) case_transform (0.2) activesupport - cbor (0.5.9.8) + cbor (0.5.10.1) cgi (0.4.2) charlock_holmes (0.7.9) chewy (7.6.0) @@ -164,7 +164,7 @@ GEM cocoon (1.2.15) color_diff (0.1) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.4) cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) @@ -175,9 +175,9 @@ GEM css_parser (1.21.1) addressable csv (3.3.5) - database_cleaner-active_record (2.2.1) + database_cleaner-active_record (2.2.2) activerecord (>= 5.a) - database_cleaner-core (~> 2.0.0) + database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) date (3.4.1) debug (1.11.0) @@ -207,7 +207,7 @@ GEM railties (>= 5) dotenv (3.1.8) drb (2.2.3) - dry-cli (1.2.0) + dry-cli (1.3.0) elasticsearch (7.17.11) elasticsearch-api (= 7.17.11) elasticsearch-transport (= 7.17.11) @@ -224,24 +224,24 @@ GEM mail (~> 2.7) email_validator (2.2.4) activemodel - erb (5.0.1) + erb (5.0.2) erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.4.0) tzinfo - excon (1.2.5) + excon (1.3.0) logger fabrication (3.0.0) faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.13.1) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.3.0) + faraday-follow_redirects (0.4.0) faraday (>= 1, < 3) faraday-httpclient (2.0.2) httpclient (>= 2.2) - faraday-net_http (3.4.0) + faraday-net_http (3.4.1) net-http (>= 0.5.0) fast_blank (1.0.1) fastimage (2.4.0) @@ -266,42 +266,43 @@ GEM fog-openstack (1.1.5) fog-core (~> 2.1) fog-json (>= 1.0) - formatador (1.1.0) + formatador (1.2.1) + reline forwardable (1.3.3) - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + fugit (1.12.0) + et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.2.1) + globalid (1.3.0) activesupport (>= 6.1) - google-protobuf (4.31.0) + google-protobuf (4.32.1) bigdecimal rake (>= 13) - googleapis-common-protos-types (1.20.0) - google-protobuf (>= 3.18, < 5.a) + googleapis-common-protos-types (1.21.0) + google-protobuf (~> 4.26) haml (6.3.0) temple (>= 0.8.2) thor tilt - haml-rails (2.1.0) + haml-rails (3.0.0) actionpack (>= 5.1) activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.64.0) + haml_lint (0.66.0) haml (>= 5.0) parallel (~> 1.10) rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.1.2) + hashdiff (1.2.1) hashie (5.0.0) hcaptcha (7.1.0) json highline (3.1.2) reline hiredis (0.6.3) - hiredis-client (0.24.0) - redis-client (= 0.24.0) + hiredis-client (0.26.1) + redis-client (= 0.26.1) hkdf (0.3.0) htmlentities (4.3.4) http (5.3.1) @@ -309,13 +310,13 @@ GEM http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.0.8) + http-cookie (1.1.0) domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) httpclient (2.9.0) mutex_m - httplog (1.7.0) + httplog (1.7.3) rack (>= 2.0) rainbow (>= 2.0.0) i18n (1.14.7) @@ -335,7 +336,7 @@ GEM inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.0) + io-console (0.8.1) irb (1.15.2) pp (>= 0.6.0) rdoc (>= 4.0.0) @@ -345,9 +346,9 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.12.2) + json (2.15.1) json-canonicalization (1.0.0) - json-jwt (1.16.7) + json-jwt (1.17.0) activesupport (>= 4.2) aes_key_wrap base64 @@ -362,14 +363,14 @@ GEM rack (>= 2.2, < 4) rdf (~> 3.3) rexml (~> 3.2) - json-ld-preloaded (3.3.1) + json-ld-preloaded (3.3.2) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (5.1.1) + json-schema (6.0.0) addressable (~> 2.8) bigdecimal (~> 3.1) jsonapi-renderer (0.2.2) - jwt (2.10.1) + jwt (2.10.2) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -403,7 +404,7 @@ GEM rexml link_header (0.0.8) lint_roller (1.1.0) - linzer (0.7.3) + linzer (0.7.7) cgi (~> 0.4.2) forwardable (~> 1.3, >= 1.3.3) logger (~> 1.7, >= 1.7.0) @@ -433,24 +434,26 @@ GEM marcel (1.0.4) mario-redis-lock (1.2.1) redis (>= 3.0.5) - matrix (0.4.2) + matrix (0.4.3) memory_profiler (1.1.0) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0514) + mime-types-data (3.2025.0924) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (5.25.5) msgpack (1.8.0) - multi_json (1.15.0) + multi_json (1.17.0) mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.8) + net-imap (0.5.12) date net-protocol - net-ldap (0.19.0) + net-ldap (0.20.0) + base64 + ostruct net-pop (0.1.2) net-protocol net-protocol (0.2.2) @@ -458,17 +461,18 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.8) + nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.11) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.3) + omniauth (2.1.4) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection - omniauth-cas (3.0.1) + omniauth-cas (3.0.2) addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) @@ -494,10 +498,10 @@ GEM tzinfo validate_url webfinger (~> 2.0) - openssl (3.3.0) + openssl (3.3.1) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.5.0) + opentelemetry-api (1.7.0) opentelemetry-common (0.22.0) opentelemetry-api (~> 1.0) opentelemetry-exporter-otlp (0.30.0) @@ -507,113 +511,88 @@ GEM opentelemetry-common (~> 0.20) opentelemetry-sdk (~> 1.2) opentelemetry-semantic_conventions - opentelemetry-helpers-sql (0.1.1) - opentelemetry-api (~> 1.0) + opentelemetry-helpers-sql (0.2.0) + opentelemetry-api (~> 1.7) opentelemetry-helpers-sql-obfuscation (0.3.0) opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.4.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-action_mailer (0.5.0) opentelemetry-instrumentation-active_support (~> 0.7) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-action_pack (0.12.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-action_pack (0.14.1) opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.9.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-action_view (0.10.0) opentelemetry-instrumentation-active_support (~> 0.7) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_job (0.8.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_model_serializers (0.22.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_job (0.9.2) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-active_model_serializers (0.23.0) opentelemetry-instrumentation-active_support (>= 0.7.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_record (0.9.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_storage (0.1.1) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_record (0.10.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-active_storage (0.2.0) opentelemetry-instrumentation-active_support (~> 0.7) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_support (0.8.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-base (0.23.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (0.9.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-base (0.24.0) + opentelemetry-api (~> 1.7) opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-concurrent_ruby (0.22.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-excon (0.23.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-faraday (0.27.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-http (0.25.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-http_client (0.23.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-net_http (0.23.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-pg (0.30.1) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-concurrent_ruby (0.23.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-excon (0.25.2) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-faraday (0.29.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-http (0.26.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-http_client (0.25.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-net_http (0.25.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-pg (0.31.1) opentelemetry-helpers-sql opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-rack (0.26.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-rails (0.36.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_mailer (~> 0.4.0) - opentelemetry-instrumentation-action_pack (~> 0.12.0) - opentelemetry-instrumentation-action_view (~> 0.9.0) - opentelemetry-instrumentation-active_job (~> 0.8.0) - opentelemetry-instrumentation-active_record (~> 0.9.0) - opentelemetry-instrumentation-active_storage (~> 0.1.0) - opentelemetry-instrumentation-active_support (~> 0.8.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) - opentelemetry-instrumentation-redis (0.26.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-sidekiq (0.26.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-rack (0.28.2) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-rails (0.38.0) + opentelemetry-instrumentation-action_mailer (~> 0.4) + opentelemetry-instrumentation-action_pack (~> 0.13) + opentelemetry-instrumentation-action_view (~> 0.9) + opentelemetry-instrumentation-active_job (~> 0.8) + opentelemetry-instrumentation-active_record (~> 0.9) + opentelemetry-instrumentation-active_storage (~> 0.1) + opentelemetry-instrumentation-active_support (~> 0.8) + opentelemetry-instrumentation-concurrent_ruby (~> 0.22) + opentelemetry-instrumentation-redis (0.27.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-sidekiq (0.27.1) + opentelemetry-instrumentation-base (~> 0.24) opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.8.0) + opentelemetry-sdk (1.9.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.11.0) + opentelemetry-semantic_conventions (1.36.0) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) - ostruct (0.6.1) + ostruct (0.6.3) ox (2.14.23) bigdecimal (>= 3.0) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.9.0) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.9) + pg (1.6.2) pghero (3.7.0) activerecord (>= 7.1) - playwright-ruby-client (1.52.0) + playwright-ruby-client (1.55.0) concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) - pp (0.6.2) + pp (0.6.3) prettyprint premailer (1.27.0) addressable @@ -624,25 +603,24 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.4.0) - prometheus_exporter (2.2.0) + prism (1.5.1) + prometheus_exporter (2.3.0) webrick - propshaft (1.1.0) + propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - railties (>= 7.0.0) psych (5.2.6) date stringio public_suffix (6.0.2) - puma (6.6.0) + puma (7.0.4) nio4r (~> 2.0) - pundit (2.5.0) + pundit (2.5.2) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.16) + rack (3.2.3) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (3.0.0) @@ -668,76 +646,81 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.2) - actioncable (= 8.0.2) - actionmailbox (= 8.0.2) - actionmailer (= 8.0.2) - actionpack (= 8.0.2) - actiontext (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activemodel (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + rails (8.0.3) + actioncable (= 8.0.3) + actionmailbox (= 8.0.3) + actionmailer (= 8.0.3) + actionpack (= 8.0.3) + actiontext (= 8.0.3) + actionview (= 8.0.3) + activejob (= 8.0.3) + activemodel (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) bundler (>= 1.15.0) - railties (= 8.0.2) - rails-dom-testing (2.2.0) + railties (= 8.0.3) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails-i18n (8.0.1) + rails-i18n (8.0.2) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) - railties (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + railties (8.0.3) + actionpack (= 8.0.3) + activesupport (= 8.0.3) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - rdf (3.3.2) + rdf (3.3.4) bcp47_spec (~> 0.2) bigdecimal (~> 3.1, >= 3.1.5) link_header (~> 0.0, >= 0.0.8) + logger (~> 1.5) + ostruct (~> 0.6) + readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.14.1) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort + readline (0.0.4) + reline redcarpet (3.6.1) redis (4.8.1) - redis-client (0.24.0) + redis-client (0.26.1) connection_pool - redlock (1.3.2) - redis (>= 3.0.0, < 6.0) - regexp_parser (2.10.0) - reline (0.6.1) + regexp_parser (2.11.3) + reline (0.6.2) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.4.1) + rexml (3.4.4) rotp (6.3.0) - rouge (4.5.2) + rouge (4.6.1) rpam2 (4.0.2) rqrcode (3.1.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.0) - rspec (3.13.0) + rspec (3.13.1) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.4) + rspec-core (3.13.5) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) @@ -747,7 +730,7 @@ GEM rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.1) + rspec-rails (8.0.2) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) @@ -755,13 +738,13 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-sidekiq (5.1.0) + rspec-sidekiq (5.2.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) - rspec-support (3.13.4) - rubocop (1.77.0) + rspec-support (3.13.6) + rubocop (1.81.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -769,10 +752,10 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.45.1, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.45.1) + rubocop-ast (1.47.1) parser (>= 3.3.7.2) prism (~> 1.4) rubocop-capybara (2.22.1) @@ -781,17 +764,17 @@ GEM rubocop-i18n (3.2.3) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-performance (1.25.0) + rubocop-performance (1.26.0) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.32.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails (2.33.4) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rspec (3.6.0) + rubocop-rspec (3.7.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-rspec_rails (2.31.0) @@ -801,38 +784,37 @@ GEM ruby-prof (1.7.2) base64 ruby-progressbar (1.13.0) - ruby-saml (1.18.0) + ruby-saml (1.18.1) nokogiri (>= 1.13.10) rexml - ruby-vips (2.2.4) + ruby-vips (2.2.5) ffi (~> 1.12) logger - rubyzip (2.4.1) + rubyzip (3.1.1) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) - safety_net_attestation (0.4.0) - jwt (~> 2.0) + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) sanitize (7.0.0) crass (~> 1.0.2) nokogiri (>= 1.16.8) - scenic (1.8.0) + scenic (1.9.0) activerecord (>= 4.0.0) railties (>= 4.0.0) securerandom (0.4.1) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) - sidekiq (7.3.9) - base64 - connection_pool (>= 2.3.0) - logger - rack (>= 2.2.4) - redis-client (>= 0.22.2) + sidekiq (8.0.8) + connection_pool (>= 2.5.0) + json (>= 2.9.0) + logger (>= 1.6.2) + rack (>= 3.1.0) + redis-client (>= 0.23.2) sidekiq-bulk (0.2.0) sidekiq - sidekiq-scheduler (5.0.6) + sidekiq-scheduler (6.0.1) rufus-scheduler (~> 3.2) - sidekiq (>= 6, < 8) - tilt (>= 1.4.0, < 3) + sidekiq (>= 7.3, < 9) sidekiq-unique-jobs (8.0.11) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 7.0.0, < 9.0.0) @@ -846,16 +828,16 @@ GEM docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.13.1) - simplecov-lcov (0.8.0) + simplecov-html (0.13.2) + simplecov-lcov (0.9.0) simplecov_json_formatter (0.1.4) stackprof (0.2.27) starry (0.2.0) base64 - stoplight (4.1.1) - redlock (~> 1.0) + stoplight (5.3.8) + zeitwerk stringio (3.1.7) - strong_migrations (2.4.0) + strong_migrations (2.5.1) activerecord (>= 7.1) swd (2.0.3) activesupport (>= 3) @@ -863,19 +845,20 @@ GEM faraday (~> 2.0) faraday-follow_redirects sysexits (1.2.0) - temple (0.10.3) + temple (0.10.4) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) - terrapin (1.1.0) + terrapin (1.1.1) climate_control test-prof (1.4.4) - thor (1.3.2) - tilt (2.6.0) + thor (1.4.0) + tilt (2.6.1) timeout (0.4.3) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) + tsort (0.2.0) tty-color (0.6.0) tty-cursor (0.7.1) tty-prompt (0.23.1) @@ -896,10 +879,10 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.9.1) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.3) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.0.4) useragent (0.16.11) validate_url (1.0.15) activemodel (>= 3.0.0) @@ -915,13 +898,13 @@ GEM zeitwerk (~> 2.2) warden (1.2.9) rack (>= 2.0.9) - webauthn (3.4.1) + webauthn (3.4.2) android_key_attestation (~> 0.3.0) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) openssl (>= 2.2) - safety_net_attestation (~> 0.4.0) + safety_net_attestation (~> 0.5.0) tpm-key_attestation (~> 0.14.0) webfinger (2.1.3) activesupport @@ -932,7 +915,7 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) webrick (1.9.1) - websocket-driver (0.7.7) + websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -985,7 +968,7 @@ DEPENDENCIES flatware-rspec fog-core (<= 2.6.0) fog-openstack (~> 1.0) - haml-rails (~> 2.0) + haml-rails (~> 3.0) haml_lint hcaptcha (~> 7.1) hiredis (~> 0.6) @@ -1002,13 +985,13 @@ DEPENDENCIES jd-paperclip-azure (~> 3.0) json-ld json-ld-preloaded (~> 3.2) - json-schema (~> 5.0) + json-schema (~> 6.0) kaminari (~> 1.2) kt-paperclip (~> 7.2) letter_opener (~> 1.8) letter_opener_web (~> 3.0) link_header (~> 0.0) - linzer (~> 0.7.2) + linzer (~> 0.7.7) lograge (~> 0.12) mail (~> 2.8) mario-redis-lock (~> 1.2) @@ -1024,31 +1007,32 @@ DEPENDENCIES omniauth-rails_csrf_protection (~> 1.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.8.0) - opentelemetry-api (~> 1.5.0) + opentelemetry-api (~> 1.7.0) opentelemetry-exporter-otlp (~> 0.30.0) - opentelemetry-instrumentation-active_job (~> 0.8.0) - opentelemetry-instrumentation-active_model_serializers (~> 0.22.0) - opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) - opentelemetry-instrumentation-excon (~> 0.23.0) - opentelemetry-instrumentation-faraday (~> 0.27.0) - opentelemetry-instrumentation-http (~> 0.25.0) - opentelemetry-instrumentation-http_client (~> 0.23.0) - opentelemetry-instrumentation-net_http (~> 0.23.0) - opentelemetry-instrumentation-pg (~> 0.30.0) - opentelemetry-instrumentation-rack (~> 0.26.0) - opentelemetry-instrumentation-rails (~> 0.36.0) - opentelemetry-instrumentation-redis (~> 0.26.0) - opentelemetry-instrumentation-sidekiq (~> 0.26.0) + opentelemetry-instrumentation-active_job (~> 0.9.0) + opentelemetry-instrumentation-active_model_serializers (~> 0.23.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.23.0) + opentelemetry-instrumentation-excon (~> 0.25.0) + opentelemetry-instrumentation-faraday (~> 0.29.0) + opentelemetry-instrumentation-http (~> 0.26.0) + opentelemetry-instrumentation-http_client (~> 0.25.0) + opentelemetry-instrumentation-net_http (~> 0.25.0) + opentelemetry-instrumentation-pg (~> 0.31.0) + opentelemetry-instrumentation-rack (~> 0.28.0) + opentelemetry-instrumentation-rails (~> 0.38.0) + opentelemetry-instrumentation-redis (~> 0.27.0) + opentelemetry-instrumentation-sidekiq (~> 0.27.0) opentelemetry-sdk (~> 1.4) ox (~> 2.14) parslet pg (~> 1.5) pghero + playwright-ruby-client (= 1.55.0) premailer-rails prometheus_exporter (~> 2.2) propshaft public_suffix (~> 6.0) - puma (~> 6.3) + puma (~> 7.0) pundit (~> 2.3) rack-attack (~> 6.6) rack-cors @@ -1072,20 +1056,20 @@ DEPENDENCIES ruby-prof ruby-progressbar (~> 1.13) ruby-vips (~> 2.2) - rubyzip (~> 2.3) + rubyzip (~> 3.0) sanitize (~> 7.0) scenic (~> 1.7) shoulda-matchers - sidekiq (< 8) + sidekiq (< 9) sidekiq-bulk (~> 0.2.0) - sidekiq-scheduler (~> 5.0) + sidekiq-scheduler (~> 6.0) sidekiq-unique-jobs (> 8) simple-navigation (~> 4.4) simple_form (~> 5.2) simplecov (~> 0.22) simplecov-lcov (~> 0.8) stackprof - stoplight (~> 4.1) + stoplight strong_migrations test-prof thor (~> 1.2) @@ -1096,10 +1080,11 @@ DEPENDENCIES webauthn (~> 3.0) webmock (~> 3.18) webpush! + websocket-driver (~> 0.8) xorcist (~> 1.1) RUBY VERSION ruby 3.4.1p0 BUNDLED WITH - 2.6.9 + 2.7.2 diff --git a/README.md b/README.md index 552940fbea7..5c0e596b727 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,14 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub] - [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing. - [Node.js](https://nodejs.org/) powers the streaming API. - [React.js](https://reactjs.org/) and [Redux](https://redux.js.org/) are used for the dynamic parts of the interface. -- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. -- [Chromatic](https://www.chromatic.com/) provides visual regression testing. +- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. (This project is tested with BrowserStack) +- [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic) ### Requirements - **Ruby** 3.2+ - **PostgreSQL** 13+ -- **Redis** 6.2+ +- **Redis** 7.0+ - **Node.js** 20+ This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation. diff --git a/SECURITY.md b/SECURITY.md index 26c06e67f83..19f431fac59 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through ## Supported Versions -| Version | Supported | -| ------- | --------- | -| 4.3.x | Yes | -| 4.2.x | Yes | -| < 4.2 | No | +| Version | Supported | +| ------- | ---------------- | +| 4.4.x | Yes | +| 4.3.x | Yes | +| 4.2.x | Until 2026-01-08 | +| < 4.2 | No | diff --git a/Vagrantfile b/Vagrantfile index ce456060cdd..0a343670240 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -54,6 +54,7 @@ sudo apt-get install \ pkg-config \ protobuf-compiler \ zlib1g-dev \ + libvips42t64 \ -y # Install rvm @@ -134,7 +135,7 @@ VAGRANTFILE_API_VERSION = "2" Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| - config.vm.box = "ubuntu/focal64" + config.vm.box = "bento/ubuntu-24.04" config.vm.provider :virtualbox do |vb| vb.name = "mastodon" diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c3131edce93..efd0c92cef2 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -71,6 +71,10 @@ class AccountsController < ApplicationController params[:username] end + def account_id_param + params[:id] + end + def skip_temporary_suspension_response? request.format == :json end diff --git a/app/controllers/activitypub/contexts_controller.rb b/app/controllers/activitypub/contexts_controller.rb new file mode 100644 index 00000000000..4daa75552e2 --- /dev/null +++ b/app/controllers/activitypub/contexts_controller.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class ActivityPub::ContextsController < ActivityPub::BaseController + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_conversation + before_action :set_items + + DESCENDANTS_LIMIT = 60 + + def show + expires_in 3.minutes, public: public_fetch_mode? + render_with_cache json: context_presenter, serializer: ActivityPub::ContextSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + def items + expires_in 3.minutes, public: public_fetch_mode? + render_with_cache json: items_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def account_required? + false + end + + def set_conversation + account_id, status_id = params[:id].split('-') + @conversation = Conversation.local.find_by(parent_account_id: account_id, parent_status_id: status_id) + end + + def set_items + @items = @conversation.statuses.distributable_visibility.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) + end + + def context_presenter + first_page = ActivityPub::CollectionPresenter.new( + id: items_context_url(@conversation, page_params), + type: :unordered, + part_of: items_context_url(@conversation), + next: next_page, + items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri } + ) + + ActivityPub::ContextPresenter.from_conversation(@conversation).tap do |presenter| + presenter.first = first_page + end + end + + def items_collection_presenter + page = ActivityPub::CollectionPresenter.new( + id: items_context_url(@conversation, page_params), + type: :unordered, + part_of: items_context_url(@conversation), + next: next_page, + items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri } + ) + + return page if page_requested? + + ActivityPub::CollectionPresenter.new( + id: items_context_url(@conversation), + type: :unordered, + first: page + ) + end + + def page_requested? + truthy_param?(:page) + end + + def next_page + return nil if @items.size < DESCENDANTS_LIMIT + + items_context_url(@conversation, page: true, min_id: @items.last.id) + end + + def page_params + params.permit(:page, :min_id) + end +end diff --git a/app/controllers/activitypub/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb index 4aa6a4a771f..e875517b021 100644 --- a/app/controllers/activitypub/likes_controller.rb +++ b/app/controllers/activitypub/likes_controller.rb @@ -28,7 +28,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController def likes_collection_presenter ActivityPub::CollectionPresenter.new( - id: account_status_likes_url(@account, @status), + id: ActivityPub::TagManager.instance.likes_uri_for(@status), type: :unordered, size: @status.favourites_count ) diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index a9476b806f5..928977768b9 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -73,6 +73,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController end def set_account - @account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative + return super if params[:account_username].present? || params[:account_id].present? + + @account = Account.representative end end diff --git a/app/controllers/activitypub/quote_authorizations_controller.rb b/app/controllers/activitypub/quote_authorizations_controller.rb new file mode 100644 index 00000000000..f2f5313e1ad --- /dev/null +++ b/app/controllers/activitypub/quote_authorizations_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_quote_authorization + + def show + expires_in 30.seconds, public: true if @quote.status.distributable? && public_fetch_mode? + render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_quote_authorization + @quote = Quote.accepted.where(quoted_account: @account).find(params[:id]) + return not_found unless @quote.status.present? && @quote.quoted_status.present? + + authorize @quote.status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 0a19275d38e..1959f50d676 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -37,7 +37,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController def replies_collection_presenter page = ActivityPub::CollectionPresenter.new( - id: account_status_replies_url(@account, @status, page_params), + id: ActivityPub::TagManager.instance.replies_uri_for(@status, page_params), type: :unordered, part_of: account_status_replies_url(@account, @status), next: next_page, @@ -47,7 +47,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController return page if page_requested? ActivityPub::CollectionPresenter.new( - id: account_status_replies_url(@account, @status), + id: ActivityPub::TagManager.instance.replies_uri_for(@status), type: :unordered, first: page ) @@ -66,8 +66,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController # Only consider remote accounts return nil if @replies.size < DESCENDANTS_LIMIT - account_status_replies_url( - @account, + ActivityPub::TagManager.instance.replies_uri_for( @status, page: true, min_id: @replies&.last&.id, @@ -77,8 +76,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController # For now, we're serving only self-replies, but next page might be other accounts next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT - account_status_replies_url( - @account, + ActivityPub::TagManager.instance.replies_uri_for( @status, page: true, min_id: next_only_other_accounts ? nil : @replies&.last&.id, diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb index 65b4a5b3831..2d1e389885a 100644 --- a/app/controllers/activitypub/shares_controller.rb +++ b/app/controllers/activitypub/shares_controller.rb @@ -28,7 +28,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController def shares_collection_presenter ActivityPub::CollectionPresenter.new( - id: account_status_shares_url(@account, @status), + id: ActivityPub::TagManager.instance.shares_uri_for(@status), type: :unordered, size: @status.reblogs_count ) diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index 91849811e36..3cfd1e17617 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -14,16 +14,20 @@ module Admin def create authorize @account, :show? - account_action = Admin::AccountAction.new(resource_params) - account_action.target_account = @account - account_action.current_account = current_account + @account_action = Admin::AccountAction.new(resource_params) + @account_action.target_account = @account + @account_action.current_account = current_account - account_action.save! - - if account_action.with_report? - redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id]) + if @account_action.save + if @account_action.with_report? + 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 else - redirect_to admin_account_path(@account.id) + @warning_presets = AccountWarningPreset.all + + render :new end end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 10391aa3e21..e1406930147 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -16,11 +16,14 @@ module Admin def batch authorize :account, :index? - @form = Form::AccountBatch.new(form_account_batch_params) - @form.current_account = current_account - @form.action = action_from_button - @form.select_all_matching = params[:select_all_matching] - @form.query = filtered_accounts + @form = Form::AccountBatch.new( + form_account_batch_params.merge( + action: action_from_button, + current_account:, + query: filtered_accounts, + select_all_matching: params[:select_all_matching] + ) + ) @form.save rescue ActionController::ParameterMissing flash[:alert] = I18n.t('admin.accounts.no_account_selected') diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb index 8b8e83fde77..61ca1661898 100644 --- a/app/controllers/admin/action_logs_controller.rb +++ b/app/controllers/admin/action_logs_controller.rb @@ -6,7 +6,7 @@ module Admin def index authorize :audit_log, :index? - @auditable_accounts = Account.auditable.select(:id, :username) + @auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc) end private diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb index 702550eecc1..5d1555796f4 100644 --- a/app/controllers/admin/confirmations_controller.rb +++ b/app/controllers/admin/confirmations_controller.rb @@ -19,15 +19,13 @@ module Admin log_action :resend, @user - flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success') - redirect_to admin_accounts_path + redirect_to admin_accounts_path, notice: t('admin.accounts.resend_confirmation.success') end private def redirect_confirmed_user - flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') - redirect_to admin_accounts_path + redirect_to admin_accounts_path, flash: { error: t('admin.accounts.resend_confirmation.already_confirmed') } end def user_confirmed? diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 5b0867dcfba..fe314daeca6 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -9,10 +9,16 @@ module Admin @pending_appeals_count = Appeal.pending.async_count @pending_reports_count = Report.unresolved.async_count - @pending_tags_count = Tag.pending_review.async_count + @pending_tags_count = pending_tags.async_count @pending_users_count = User.pending.async_count @system_checks = Admin::SystemCheck.perform(current_user) @time_period = (29.days.ago.to_date...Time.now.utc.to_date) end + + private + + def pending_tags + ::Trends::TagFilter.new(status: :pending_review).results + end end end diff --git a/app/controllers/admin/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb index 0c415536767..7c70603e231 100644 --- a/app/controllers/admin/disputes/appeals_controller.rb +++ b/app/controllers/admin/disputes/appeals_controller.rb @@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController end def reject - authorize @appeal, :approve? + authorize @appeal, :reject? log_action :reject, @appeal @appeal.reject!(current_account) UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index c3443b70776..5e1074b224a 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -36,7 +36,7 @@ module Admin end def edit - authorize :domain_block, :create? + authorize :domain_block, :update? end def create @@ -129,7 +129,7 @@ module Admin end def requires_confirmation? - @domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm] + @domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.suspend? && !params[:confirm] end end end diff --git a/app/controllers/admin/export_domain_allows_controller.rb b/app/controllers/admin/export_domain_allows_controller.rb index ca88c6525e0..d1a2ea5bbf2 100644 --- a/app/controllers/admin/export_domain_allows_controller.rb +++ b/app/controllers/admin/export_domain_allows_controller.rb @@ -49,8 +49,8 @@ module Admin def export_data CSV.generate(headers: export_headers, write_headers: true) do |content| - DomainAllow.allowed_domains.each do |instance| - content << [instance.domain] + DomainAllow.allowed_domains.each do |domain| + content << [domain] end end end diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb index 554f7906f83..fb7b6878bae 100644 --- a/app/controllers/admin/reports/actions_controller.rb +++ b/app/controllers/admin/reports/actions_controller.rb @@ -13,27 +13,9 @@ class Admin::Reports::ActionsController < Admin::BaseController case action_from_button when 'delete', 'mark_as_sensitive' - status_batch_action = Admin::StatusBatchAction.new( - type: action_from_button, - status_ids: @report.status_ids, - current_account: current_account, - report_id: @report.id, - send_email_notification: !@report.spam?, - text: params[:text] - ) - - status_batch_action.save! + Admin::StatusBatchAction.new(status_batch_action_params).save! when 'silence', 'suspend' - account_action = Admin::AccountAction.new( - type: action_from_button, - report_id: @report.id, - target_account: @report.target_account, - current_account: current_account, - send_email_notification: !@report.spam?, - text: params[:text] - ) - - account_action.save! + Admin::AccountAction.new(account_action_params).save! else return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button) end @@ -43,6 +25,26 @@ class Admin::Reports::ActionsController < Admin::BaseController private + def status_batch_action_params + shared_params + .merge(status_ids: @report.status_ids) + end + + def account_action_params + shared_params + .merge(target_account: @report.target_account) + end + + def shared_params + { + current_account: current_account, + report_id: @report.id, + send_email_notification: !@report.spam?, + text: params[:text], + type: action_from_button, + } + end + def set_report @report = Report.find(params[:report_id]) end diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index 2ae5ec82556..a08375e0a41 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -14,8 +14,7 @@ module Admin @admin_settings = Form::AdminSettings.new(settings_params) if @admin_settings.save - flash[:notice] = I18n.t('generic.changes_saved_msg') - redirect_to after_update_redirect_path + redirect_to after_update_redirect_path, notice: t('generic.changes_saved_msg') else render :show end diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index a7bfd647944..f2c28328f86 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -5,6 +5,7 @@ module Admin before_action :set_tag, except: [:index] PER_PAGE = 20 + PERIOD_DAYS = 6.days def index authorize :tag, :index? @@ -15,7 +16,7 @@ module Admin def show authorize @tag, :show? - @time_period = (6.days.ago.to_date...Time.now.utc.to_date) + @time_period = report_range end def update @@ -24,7 +25,7 @@ module Admin if @tag.update(tag_params.merge(reviewed_at: Time.now.utc)) redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg') else - @time_period = (6.days.ago.to_date...Time.now.utc.to_date) + @time_period = report_range render :show end @@ -36,6 +37,10 @@ module Admin @tag = Tag.find(params[:id]) end + def report_range + (PERIOD_DAYS.ago.to_date...Time.now.utc.to_date) + end + def tag_params params .expect(tag: [:name, :display_name, :trendable, :usable, :listable]) diff --git a/app/controllers/admin/username_blocks_controller.rb b/app/controllers/admin/username_blocks_controller.rb new file mode 100644 index 00000000000..22ac9408178 --- /dev/null +++ b/app/controllers/admin/username_blocks_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class Admin::UsernameBlocksController < Admin::BaseController + before_action :set_username_block, only: [:edit, :update] + + def index + authorize :username_block, :index? + @username_blocks = UsernameBlock.order(username: :asc).page(params[:page]) + @form = Form::UsernameBlockBatch.new + end + + def batch + authorize :username_block, :index? + + @form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected') + rescue Mastodon::NotPermittedError + flash[:alert] = I18n.t('admin.username_blocks.not_permitted') + ensure + redirect_to admin_username_blocks_path + end + + def new + authorize :username_block, :create? + @username_block = UsernameBlock.new(exact: true) + end + + def edit + authorize @username_block, :update? + end + + def create + authorize :username_block, :create? + + @username_block = UsernameBlock.new(resource_params) + + if @username_block.save + log_action :create, @username_block + redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg') + else + render :new + end + end + + def update + authorize @username_block, :update? + + if @username_block.update(resource_params) + log_action :update, @username_block + redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg') + else + render :new + end + end + + private + + def set_username_block + @username_block = UsernameBlock.find(params[:id]) + end + + def form_username_block_batch_params + params + .expect(form_username_block_batch: [username_block_ids: []]) + end + + def resource_params + params + .expect(username_block: [:username, :comparison, :allow_with_approval]) + end + + def action_from_button + 'delete' if params[:delete] + end +end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index b90036a5cd9..2c46afff609 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -48,6 +48,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy), default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive), default_language: source_params.fetch(:language, @account.user.setting_default_language), + default_quote_policy: source_params.fetch(:quote_policy, @account.user.setting_default_quote_policy), }, } end diff --git a/app/controllers/api/v1/admin/tags_controller.rb b/app/controllers/api/v1/admin/tags_controller.rb index 283383acb4a..dd272120e21 100644 --- a/app/controllers/api/v1/admin/tags_controller.rb +++ b/app/controllers/api/v1/admin/tags_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Admin::TagsController < Api::BaseController include Authorization + before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write' }, only: :update diff --git a/app/controllers/api/v1/invites_controller.rb b/app/controllers/api/v1/invites_controller.rb index ea17ba74038..7b24cec7918 100644 --- a/app/controllers/api/v1/invites_controller.rb +++ b/app/controllers/api/v1/invites_controller.rb @@ -7,6 +7,7 @@ class Api::V1::InvitesController < Api::BaseController skip_around_action :set_locale before_action :set_invite + before_action :check_valid_usage! before_action :check_enabled_registrations! # Override `current_user` to avoid reading session cookies @@ -22,9 +23,11 @@ class Api::V1::InvitesController < Api::BaseController @invite = Invite.find_by!(code: params[:invite_code]) end - def check_enabled_registrations! - return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use? + def check_valid_usage! + render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use? + end + def check_enabled_registrations! raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite) end end diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index f2c52f2846e..3b0cda7d931 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -16,16 +16,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController def create with_redis_lock("push_subscription:#{current_user.id}") do destroy_web_push_subscriptions! - - @push_subscription = Web::PushSubscription.create!( - endpoint: subscription_params[:endpoint], - key_p256dh: subscription_params[:keys][:p256dh], - key_auth: subscription_params[:keys][:auth], - standard: subscription_params[:standard] || false, - data: data_params, - user_id: current_user.id, - access_token_id: doorkeeper_token.id - ) + @push_subscription = Web::PushSubscription.create!(web_push_subscription_params) end render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer @@ -55,6 +46,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController not_found if @push_subscription.nil? end + def web_push_subscription_params + { + access_token_id: doorkeeper_token.id, + data: data_params, + endpoint: subscription_params[:endpoint], + key_auth: subscription_params[:keys][:auth], + key_p256dh: subscription_params[:keys][:p256dh], + standard: subscription_params[:standard] || false, + user_id: current_user.id, + } + end + def subscription_params params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]]) end diff --git a/app/controllers/api/v1/statuses/interaction_policies_controller.rb b/app/controllers/api/v1/statuses/interaction_policies_controller.rb new file mode 100644 index 00000000000..5cfb2d0e8fd --- /dev/null +++ b/app/controllers/api/v1/statuses/interaction_policies_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::BaseController + include Api::InteractionPoliciesConcern + + before_action -> { doorkeeper_authorize! :write, :'write:statuses' } + + def update + authorize @status, :update? + + @status.update!(quote_approval_policy: quote_approval_policy) + + broadcast_updates! if @status.quote_approval_policy_previously_changed? + + render json: @status, serializer: REST::StatusSerializer + end + + private + + def status_params + params.permit(:quote_approval_policy) + end + + def broadcast_updates! + DistributionWorker.perform_async(@status.id, { 'update' => true, 'skip_notifications' => true }) + ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 }) + end +end diff --git a/app/controllers/api/v1/statuses/quotes_controller.rb b/app/controllers/api/v1/statuses/quotes_controller.rb new file mode 100644 index 00000000000..be3a4edc83d --- /dev/null +++ b/app/controllers/api/v1/statuses/quotes_controller.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index + before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke + + before_action :set_statuses, only: :index + + before_action :set_quote, only: :revoke + after_action :insert_pagination_headers, only: :index + + def index + cache_if_unauthenticated! + render json: @statuses, each_serializer: REST::StatusSerializer + end + + def revoke + authorize @quote, :revoke? + + RevokeQuoteService.new.call(@quote) + + render json: @quote.status, serializer: REST::StatusSerializer + end + + private + + def set_quote + @quote = @status.quotes.find_by!(status_id: params[:id]) + end + + def set_statuses + scope = default_statuses + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? + @statuses = scope.merge(paginated_quotes).to_a + + # Store next page info before filtering + @records_continue = @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + @pagination_since_id = @statuses.first.quote.id unless @statuses.empty? + @pagination_max_id = @statuses.last.quote.id if @records_continue + + if current_account&.id != @status.account_id + domains = @statuses.filter_map(&:account_domain).uniq + account_ids = @statuses.map(&:account_id).uniq + relations = current_account&.relations_map(account_ids, domains) || {} + @statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? } + end + end + + def default_statuses + Status.includes(:quote).references(:quote) + end + + def paginated_quotes + @status.quotes.accepted.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def next_path + api_v1_status_quotes_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty? + end + + attr_reader :pagination_max_id, :pagination_since_id + + def records_continue? + @records_continue + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index d3b0e89e97b..6c4e7619b7b 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -2,6 +2,8 @@ class Api::V1::StatusesController < Api::BaseController include Authorization + include AsyncRefreshesConcern + include Api::InteractionPoliciesConcern before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy] @@ -9,6 +11,7 @@ class Api::V1::StatusesController < Api::BaseController before_action :set_statuses, only: [:index] before_action :set_status, only: [:show, :context] before_action :set_thread, only: [:create] + before_action :set_quoted_status, only: [:create] before_action :check_statuses_limit, only: [:index] override_rate_limit_headers :create, family: :statuses @@ -57,9 +60,21 @@ class Api::V1::StatusesController < Api::BaseController @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) statuses = [@status] + @context.ancestors + @context.descendants - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + refresh_key = "context:#{@status.id}:refresh" + async_refresh = AsyncRefresh.new(refresh_key) - ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies? + if async_refresh.running? + add_async_refresh_header(async_refresh) + elsif !current_account.nil? && @status.should_fetch_replies? + add_async_refresh_header(AsyncRefresh.create(refresh_key)) + + WorkerBatch.new.within do |batch| + batch.connect(refresh_key, threshold: 1.0) + ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) + end + end + + render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) end def create @@ -67,6 +82,8 @@ class Api::V1::StatusesController < Api::BaseController current_user.account, text: status_params[:status], thread: @thread, + quoted_status: @quoted_status, + quote_approval_policy: quote_approval_policy, media_ids: status_params[:media_ids], sensitive: status_params[:sensitive], spoiler_text: status_params[:spoiler_text], @@ -98,7 +115,8 @@ class Api::V1::StatusesController < Api::BaseController sensitive: status_params[:sensitive], language: status_params[:language], spoiler_text: status_params[:spoiler_text], - poll: status_params[:poll] + poll: status_params[:poll], + quote_approval_policy: quote_approval_policy ) render json: @status, serializer: REST::StatusSerializer @@ -138,6 +156,14 @@ class Api::V1::StatusesController < Api::BaseController render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 end + def set_quoted_status + @quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present? + authorize(@quoted_status, :quote?) if @quoted_status.present? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + # TODO: distinguish between non-existing and non-quotable posts + render json: { error: I18n.t('statuses.errors.quoted_status_not_found') }, status: 404 + end + def check_statuses_limit raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT end @@ -154,6 +180,8 @@ class Api::V1::StatusesController < Api::BaseController params.permit( :status, :in_reply_to_id, + :quoted_status_id, + :quote_approval_policy, :sensitive, :spoiler_text, :visibility, diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb index 1dba4a5bb21..e79eba79ee5 100644 --- a/app/controllers/api/v1/timelines/base_controller.rb +++ b/app/controllers/api/v1/timelines/base_controller.rb @@ -3,14 +3,8 @@ class Api::V1::Timelines::BaseController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - before_action :require_user!, if: :require_auth? - private - def require_auth? - !Setting.timeline_preview - end - def pagination_collection @statuses end diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index b8384a13687..a07faae7208 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -3,8 +3,8 @@ class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController include AsyncRefreshesConcern - before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show] - before_action :require_user!, only: [:show] + before_action -> { doorkeeper_authorize! :read, :'read:statuses' } + before_action :require_user! PERMITTED_PARAMS = %i(local limit).freeze diff --git a/app/controllers/api/v1/timelines/link_controller.rb b/app/controllers/api/v1/timelines/link_controller.rb index 37ed084f062..9e6ddd69243 100644 --- a/app/controllers/api/v1/timelines/link_controller.rb +++ b/app/controllers/api/v1/timelines/link_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController +class Api::V1::Timelines::LinkController < Api::V1::Timelines::TopicController before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :set_preview_card before_action :set_statuses diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 029e8fc2c13..670c3b02b6b 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' } + before_action :require_user!, if: :require_auth? PERMITTED_PARAMS = %i(local remote limit only_media).freeze @@ -13,6 +14,16 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController private + def require_auth? + if truthy_param?(:local) + Setting.local_live_feed_access != 'public' + elsif truthy_param?(:remote) + Setting.remote_live_feed_access != 'public' + else + Setting.local_live_feed_access != 'public' || Setting.remote_live_feed_access != 'public' + end + end + def load_statuses preloaded_public_statuses_page end diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 2b097aab0f8..dc3c6a72157 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController +class Api::V1::Timelines::TagController < Api::V1::Timelines::TopicController before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :load_tag @@ -14,10 +14,6 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController private - def require_auth? - !Setting.timeline_preview - end - def load_tag @tag = Tag.find_normalized(params[:id]) end diff --git a/app/controllers/api/v1/timelines/topic_controller.rb b/app/controllers/api/v1/timelines/topic_controller.rb new file mode 100644 index 00000000000..6faf54f7083 --- /dev/null +++ b/app/controllers/api/v1/timelines/topic_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::TopicController < Api::V1::Timelines::BaseController + before_action :require_user!, if: :require_auth? + + private + + def require_auth? + if truthy_param?(:local) + Setting.local_topic_feed_access != 'public' + elsif truthy_param?(:remote) + Setting.remote_topic_feed_access != 'public' + else + Setting.local_topic_feed_access != 'public' || Setting.remote_topic_feed_access != 'public' + end + end +end diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb index 3ca13cc4275..c00ddf92cc0 100644 --- a/app/controllers/api/v2/search_controller.rb +++ b/app/controllers/api/v2/search_controller.rb @@ -20,7 +20,7 @@ class Api::V2::SearchController < Api::BaseController @search = Search.new(search_results) render json: @search, serializer: REST::SearchSerializer rescue Mastodon::SyntaxError - unprocessable_entity + unprocessable_content rescue ActiveRecord::RecordNotFound not_found end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 2711071b4a5..ced68d39fc7 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController { policy: 'all', alerts: Notification::TYPES.index_with { alerts_enabled }, - } + }.deep_stringify_keys end def alerts_enabled diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 42abe990483..82d9e8380fc 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base rescue_from Mastodon::NotPermittedError, with: :forbidden rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::UnknownFormat, with: :not_acceptable - rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity + rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) @@ -123,7 +123,7 @@ class ApplicationController < ActionController::Base respond_with_error(410) end - def unprocessable_entity + def unprocessable_content respond_with_error(422) end diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb index 9d496220a3d..16de03fd72d 100644 --- a/app/controllers/auth/omniauth_callbacks_controller.rb +++ b/app/controllers/auth/omniauth_callbacks_controller.rb @@ -38,8 +38,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController private def record_login_activity - LoginActivity.create( - user: @user, + @user.login_activities.create( success: true, authentication_method: :omniauth, provider: @provider, diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index 7c1ff59671d..2680a1c5fdc 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -19,8 +19,7 @@ class Auth::PasswordsController < Devise::PasswordsController private def redirect_invalid_reset_token - flash[:error] = I18n.t('auth.invalid_reset_password_token') - redirect_to new_password_path(resource_name) + redirect_to new_password_path(resource_name), flash: { error: t('auth.invalid_reset_password_token') } end def reset_password_token_is_valid? diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 3b42dc48ba9..fc430544fbe 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -23,11 +23,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController super(&:build_invite_request) end - def edit # rubocop:disable Lint/UselessMethodDefinition + def edit super end - def create # rubocop:disable Lint/UselessMethodDefinition + def create super end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 2808066aaf9..182f242ae5b 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -12,6 +12,8 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_functional! skip_before_action :update_user_sign_in + around_action :preserve_stored_location, only: :destroy, if: :continue_after? + prepend_before_action :check_suspicious!, only: [:create] include Auth::TwoFactorAuthenticationConcern @@ -31,11 +33,9 @@ class Auth::SessionsController < Devise::SessionsController end def destroy - tmp_stored_location = stored_location_for(:user) super session.delete(:challenge_passed_at) flash.delete(:notice) - store_location_for(:user, tmp_stored_location) if continue_after? end def webauthn_options @@ -96,6 +96,12 @@ class Auth::SessionsController < Devise::SessionsController private + def preserve_stored_location + original_stored_location = stored_location_for(:user) + yield + store_location_for(:user, original_stored_location) + end + def check_suspicious! user = find_user @login_is_suspicious = suspicious_sign_in?(user) unless user.nil? @@ -151,12 +157,11 @@ class Auth::SessionsController < Devise::SessionsController sign_in(user) flash.delete(:notice) - LoginActivity.create( - user: user, - success: true, - authentication_method: security_measure, - ip: request.remote_ip, - user_agent: request.user_agent + user.login_activities.create( + request_details.merge( + authentication_method: security_measure, + success: true + ) ) UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious @@ -167,13 +172,12 @@ class Auth::SessionsController < Devise::SessionsController end def on_authentication_failure(user, security_measure, failure_reason) - LoginActivity.create( - user: user, - success: false, - authentication_method: security_measure, - failure_reason: failure_reason, - ip: request.remote_ip, - user_agent: request.user_agent + user.login_activities.create( + request_details.merge( + authentication_method: security_measure, + failure_reason: failure_reason, + success: false + ) ) # Only send a notification email every hour at most @@ -182,6 +186,13 @@ class Auth::SessionsController < Devise::SessionsController UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! end + def request_details + { + ip: request.remote_ip, + user_agent: request.user_agent, + } + end + def second_factor_attempts_key(user) "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}" end diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb index 2b132417f7c..7b3cd4d3ea6 100644 --- a/app/controllers/concerns/account_owned_concern.rb +++ b/app/controllers/concerns/account_owned_concern.rb @@ -18,7 +18,11 @@ module AccountOwnedConcern end def set_account - @account = Account.find_local!(username_param) + @account = username_param.present? ? Account.find_local!(username_param) : Account.local.find(account_id_param) + end + + def account_id_param + params[:account_id] end def username_param diff --git a/app/controllers/concerns/api/interaction_policies_concern.rb b/app/controllers/concerns/api/interaction_policies_concern.rb new file mode 100644 index 00000000000..f1e1480c0c0 --- /dev/null +++ b/app/controllers/concerns/api/interaction_policies_concern.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Api::InteractionPoliciesConcern + extend ActiveSupport::Concern + + def quote_approval_policy + case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy + when 'public' + Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16 + when 'followers' + Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16 + when 'nobody' + 0 + else + # TODO: raise more useful message + raise ActiveRecord::RecordInvalid + end + end +end diff --git a/app/controllers/concerns/async_refreshes_concern.rb b/app/controllers/concerns/async_refreshes_concern.rb index 29122e16b5e..2d0e9ff4ff4 100644 --- a/app/controllers/concerns/async_refreshes_concern.rb +++ b/app/controllers/concerns/async_refreshes_concern.rb @@ -6,6 +6,9 @@ module AsyncRefreshesConcern def add_async_refresh_header(async_refresh, retry_seconds: 3) return unless async_refresh.running? - response.headers['Mastodon-Async-Refresh'] = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}" + value = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}" + value += ", result_count=#{async_refresh.result_count}" unless async_refresh.result_count.nil? + + response.headers['Mastodon-Async-Refresh'] = value end end diff --git a/app/controllers/concerns/auth/captcha_concern.rb b/app/controllers/concerns/auth/captcha_concern.rb index c01da212499..a6232db943b 100644 --- a/app/controllers/concerns/auth/captcha_concern.rb +++ b/app/controllers/concerns/auth/captcha_concern.rb @@ -5,6 +5,18 @@ module Auth::CaptchaConcern include Hcaptcha::Adapters::ViewMethods + CAPTCHA_DIRECTIVES = %w( + connect_src + frame_src + script_src + style_src + ).freeze + + CAPTCHA_SOURCES = %w( + https://*.hcaptcha.com + https://hcaptcha.com + ).freeze + included do helper_method :render_captcha end @@ -42,20 +54,9 @@ module Auth::CaptchaConcern end def extend_csp_for_captcha! - policy = request.content_security_policy&.clone + return unless captcha_required? && request.content_security_policy.present? - return unless captcha_required? && policy.present? - - %w(script_src frame_src style_src connect_src).each do |directive| - values = policy.send(directive) - - values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:') - values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:') - - policy.send(directive, *values) - end - - request.content_security_policy = policy + request.content_security_policy = captcha_adjusted_policy end def render_captcha @@ -63,4 +64,24 @@ module Auth::CaptchaConcern hcaptcha_tags end + + private + + def captcha_adjusted_policy + request.content_security_policy.clone.tap do |policy| + populate_captcha_policy(policy) + end + end + + def populate_captcha_policy(policy) + CAPTCHA_DIRECTIVES.each do |directive| + values = policy.send(directive) + + CAPTCHA_SOURCES.each do |source| + values << source unless values.include?(source) || values.include?('https:') + end + + policy.send(directive, *values) + end + end end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 902feef6838..2bdd3558643 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -9,6 +9,8 @@ module SignatureVerification EXPIRATION_WINDOW_LIMIT = 12.hours CLOCK_SKEW_MARGIN = 1.hour + STOPLIGHT_COOL_OFF_TIME = 5.minutes.seconds + STOPLIGHT_THRESHOLD = 1 def require_account_signature! render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account @@ -64,6 +66,9 @@ module SignatureVerification return (@signed_request_actor = actor) if signed_request.verified?(actor) fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}" + rescue Mastodon::MalformedHeaderError => e + @signature_verification_failure_code = 400 + fail_with! e.message rescue Mastodon::SignatureVerificationError => e fail_with! e.message rescue *Mastodon::HTTP_CONNECTION_ERRORS => e @@ -104,10 +109,12 @@ module SignatureVerification end def stoplight_wrapper - Stoplight("source:#{request.remote_ip}") - .with_threshold(1) - .with_cool_off_time(5.minutes.seconds) - .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) } + Stoplight( + "source:#{request.remote_ip}", + cool_off_time: STOPLIGHT_COOL_OFF_TIME, + threshold: STOPLIGHT_THRESHOLD, + tracked_errors: [HTTP::Error, OpenSSL::SSL::SSLError] + ) end def actor_refresh_key!(actor) diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index f4c7b37088a..e9727b756a4 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -60,17 +60,17 @@ class FollowerAccountsController < ApplicationController def collection_presenter if page_requested? ActivityPub::CollectionPresenter.new( - id: account_followers_url(@account, page: params.fetch(:page, 1)), + id: page_url(params.fetch(:page, 1)), type: :ordered, size: @account.followers_count, items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) }, - part_of: account_followers_url(@account), + part_of: ActivityPub::TagManager.instance.followers_uri_for(@account), next: next_page_url, prev: prev_page_url ) else ActivityPub::CollectionPresenter.new( - id: account_followers_url(@account), + id: ActivityPub::TagManager.instance.followers_uri_for(@account), type: :ordered, size: @account.followers_count, first: page_url(1) diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 268fad96d09..803d6e342a9 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -49,7 +49,7 @@ class FollowingAccountsController < ApplicationController end def page_url(page) - account_following_index_url(@account, page: page) unless page.nil? + ActivityPub::TagManager.instance.following_uri_for(@account, page: page) unless page.nil? end def next_page_url @@ -63,17 +63,17 @@ class FollowingAccountsController < ApplicationController def collection_presenter if page_requested? ActivityPub::CollectionPresenter.new( - id: account_following_index_url(@account, page: params.fetch(:page, 1)), + id: page_url(params.fetch(:page, 1)), type: :ordered, size: @account.following_count, items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) }, - part_of: account_following_index_url(@account), + part_of: ActivityPub::TagManager.instance.following_uri_for(@account), next: next_page_url, prev: prev_page_url ) else ActivityPub::CollectionPresenter.new( - id: account_following_index_url(@account), + id: ActivityPub::TagManager.instance.following_uri_for(@account), type: :ordered, size: @account.following_count, first: page_url(1) diff --git a/app/controllers/settings/login_activities_controller.rb b/app/controllers/settings/login_activities_controller.rb index 50e2d70cb9a..ae32dbf5570 100644 --- a/app/controllers/settings/login_activities_controller.rb +++ b/app/controllers/settings/login_activities_controller.rb @@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController skip_before_action :require_functional! def index - @login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page]) + @login_activities = current_user.login_activities.order(id: :desc).page(params[:page]) end end diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb index d850e05e94f..08b01d6b108 100644 --- a/app/controllers/settings/migration/redirects_controller.rb +++ b/app/controllers/settings/migration/redirects_controller.rb @@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController end def destroy - if current_account.moved_to_account_id.present? + if current_account.moved? current_account.update!(moved_to_account: nil) ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) end diff --git a/app/controllers/settings/preferences/posting_defaults_controller.rb b/app/controllers/settings/preferences/posting_defaults_controller.rb new file mode 100644 index 00000000000..dcff94fc712 --- /dev/null +++ b/app/controllers/settings/preferences/posting_defaults_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Settings::Preferences::PostingDefaultsController < Settings::Preferences::BaseController + private + + def after_update_redirect_path + settings_preferences_posting_defaults_path + end + + def user_params + super.tap do |params| + params[:settings_attributes][:default_quote_policy] = 'nobody' if params[:settings_attributes][:default_privacy] == 'private' + end + end +end diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb index ee2fc5dc80f..fe59bdc4917 100644 --- a/app/controllers/settings/sessions_controller.rb +++ b/app/controllers/settings/sessions_controller.rb @@ -8,8 +8,7 @@ class Settings::SessionsController < Settings::BaseController def destroy @session.destroy! - flash[:notice] = I18n.t('sessions.revoke_success') - redirect_to edit_user_registration_path + redirect_to edit_user_registration_path, notice: t('sessions.revoke_success') end private diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb index 9714d54f954..83dedb411d4 100644 --- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb +++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb @@ -52,7 +52,7 @@ module Settings end else flash[:error] = I18n.t('webauthn_credentials.create.error') - status = :unprocessable_entity + status = :unprocessable_content end else flash[:error] = t('webauthn_credentials.create.error') @@ -86,13 +86,11 @@ module Settings private def redirect_invalid_otp - flash[:error] = t('webauthn_credentials.otp_required') - redirect_to settings_two_factor_authentication_methods_path + redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.otp_required') } end def redirect_invalid_webauthn - flash[:error] = t('webauthn_credentials.not_enabled') - redirect_to settings_two_factor_authentication_methods_path + redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.not_enabled') } end end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 341b0e64729..af6bebf36fd 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -11,6 +11,7 @@ class StatusesController < ApplicationController before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_status before_action :redirect_to_original, only: :show + before_action :verify_embed_allowed, only: :embed after_action :set_link_headers @@ -40,8 +41,6 @@ class StatusesController < ApplicationController end def embed - return not_found if @status.hidden? || @status.reblog? - expires_in 180, public: true response.headers.delete('X-Frame-Options') @@ -50,6 +49,10 @@ class StatusesController < ApplicationController private + def verify_embed_allowed + not_found if @status.hidden? || @status.reblog? + end + def set_link_headers response.headers['Link'] = LinkHeader.new( [[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]] diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 859f9246876..4a55a36ecd1 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -13,6 +13,8 @@ module Admin::ActionLogsHelper end when 'UserRole' link_to log.human_identifier, admin_roles_path(log.target_id) + when 'UsernameBlock' + link_to log.human_identifier, edit_admin_username_block_path(log.target_id) when 'Report' link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5a5ee055321..80cff698298 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -66,7 +66,7 @@ module ApplicationHelper def provider_sign_in_link(provider) label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize) - link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post + link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post end def locale_direction @@ -102,6 +102,16 @@ module ApplicationHelper policy(record).public_send(:"#{action}?") end + def conditional_link_to(condition, name, options = {}, html_options = {}, &block) + if condition && !current_page?(block_given? ? name : options) + link_to(name, options, html_options, &block) + elsif block_given? + content_tag(:span, options, html_options, &block) + else + content_tag(:span, name, html_options) + end + end + def material_symbol(icon, attributes = {}) safe_join( [ @@ -233,6 +243,10 @@ module ApplicationHelper tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options) end + def recent_tag_users(tag) + tag.statuses.public_visibility.joins(:account).merge(Account.without_suspended.without_silenced).includes(:account).limit(3).map(&:account) + end + def recent_tag_usage(tag) people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people @@ -246,6 +260,10 @@ module ApplicationHelper 'https://play.google.com/store/apps/details?id=org.joinmastodon.android' end + def within_authorization_flow? + session[:user_return_to].present? && Rails.application.routes.recognize_path(session[:user_return_to])[:controller] == 'oauth/authorizations' + end + private def storage_host_var diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 33d7267905b..885f578fd0d 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -26,6 +26,12 @@ module ContextHelper suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' }, + quotes: { + 'quote' => 'https://w3id.org/fep/044f#quote', + 'quoteUri' => 'http://fedibird.com/ns#quoteUri', + '_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote', + 'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' }, + }, interaction_policies: { 'gts' => 'https://gotosocial.org/ns#', 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, @@ -33,6 +39,12 @@ module ContextHelper 'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' }, 'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' }, }, + quote_authorizations: { + 'gts' => 'https://gotosocial.org/ns#', + 'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' }, + 'interactingObject' => { '@id' => 'gts:interactingObject' }, + 'interactionTarget' => { '@id' => 'gts:interactionTarget' }, + }, }.freeze def full_context diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb deleted file mode 100644 index 0800601f98b..00000000000 --- a/app/helpers/email_helper.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module EmailHelper - def self.included(base) - base.extend(self) - end - - def email_to_canonical_email(str) - username, domain = str.downcase.split('@', 2) - username, = username.delete('.').split('+', 2) - - "#{username}@#{domain}" - end - - def email_to_canonical_email_hash(str) - Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str)) - end -end diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index 1b364a000cf..68aad4f6771 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -27,7 +27,9 @@ module FormattingHelper module_function :extract_status_plain_text def status_content_format(status) - html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) + quoted_status = status.quote&.quoted_status if status.local? + + html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), quoted_status: quoted_status) end def rss_status_content_format(status) @@ -65,12 +67,12 @@ module FormattingHelper end def rss_content_preroll(status) - if status.spoiler_text? - safe_join [ - tag.p { spoiler_with_warning(status) }, - tag.hr, - ] - end + return unless status.spoiler_text? + + safe_join [ + tag.p { spoiler_with_warning(status) }, + tag.hr, + ] end def spoiler_with_warning(status) @@ -81,10 +83,10 @@ module FormattingHelper end def rss_content_postroll(status) - if status.preloadable_poll - tag.p do - poll_option_tags(status) - end + return unless status.preloadable_poll + + tag.p do + poll_option_tags(status) end end diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index c5b83326db3..59bc06031ee 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -21,7 +21,13 @@ module HomeHelper end end else - link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do + account_url = if account.suspended? + ActivityPub::TagManager.instance.url_for(account) + else + web_url("@#{account.pretty_acct}") + end + + link_to(path || account_url, class: 'account__display-name') do content_tag(:div, class: 'account__avatar-wrapper') do image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar', width: 46, height: 46) end + @@ -39,18 +45,8 @@ module HomeHelper end end - def obscured_counter(count) - if count <= 0 - '0' - elsif count == 1 - '1' - else - '1+' - end - end - - def custom_field_classes(field) - if field.verified? + def field_verified_class(verified) + if verified 'verified' else 'emojify' diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index 078aba456ae..675d8b87309 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -134,7 +134,7 @@ module JsonLdHelper patch_for_forwarding!(value, compacted_value) elsif value.is_a?(Array) compacted_value = [compacted_value] unless compacted_value.is_a?(Array) - return if value.size != compacted_value.size + return nil if value.size != compacted_value.size compacted[key] = value.zip(compacted_value).map do |v, vc| if v.is_a?(Hash) && vc.is_a?(Hash) diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 0a8ebcde549..ddb6b79c866 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -107,6 +107,7 @@ module LanguagesHelper mk: ['Macedonian', 'македонски јазик'].freeze, ml: ['Malayalam', 'മലയാളം'].freeze, mn: ['Mongolian', 'Монгол хэл'].freeze, + 'mn-Mong': ['Traditional Mongolian', 'ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ'].freeze, mr: ['Marathi', 'मराठी'].freeze, ms: ['Malay', 'Bahasa Melayu'].freeze, 'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze, diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 16b9d3fb531..68e9b130478 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -57,6 +57,20 @@ module StatusesHelper components.compact_blank.join("\n\n") end + # This logic should be kept in sync with https://github.com/mastodon/mastodon/blob/425311e1d95c8a64ddac6c724fca247b8b893a82/app/javascript/mastodon/features/status/components/card.jsx#L160 + def preview_card_aspect_ratio_classname(preview_card) + interactive = preview_card.type == 'video' + large_image = (preview_card.image.present? && preview_card.width > preview_card.height) || interactive + + if large_image && interactive + 'status-card__image--video' + elsif large_image + 'status-card__image--large' + else + 'status-card__image--normal' + end + end + def visibility_icon(status) VISIBLITY_ICONS[status.visibility.to_sym] end @@ -64,4 +78,16 @@ module StatusesHelper def prefers_autoplay? ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif end + + def render_seo_schema(status) + json = ActiveModelSerializers::SerializableResource.new( + status, + serializer: SEO::SocialMediaPostingSerializer, + adapter: SEO::Adapter + ).to_json + + # rubocop:disable Rails/OutputSafety + content_tag(:script, json_escape(json).html_safe, type: 'application/ld+json') + # rubocop:enable Rails/OutputSafety + end end diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index 0f24063385b..00b4a6d2b3f 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -24,24 +24,24 @@ module ThemeHelper end def custom_stylesheet - if active_custom_stylesheet.present? - stylesheet_link_tag( - custom_css_path(active_custom_stylesheet), - host: root_url, - media: :all, - skip_pipeline: true - ) - end + return if active_custom_stylesheet.blank? + + stylesheet_link_tag( + custom_css_path(active_custom_stylesheet), + host: root_url, + media: :all, + skip_pipeline: true + ) end private def active_custom_stylesheet - if cached_custom_css_digest.present? - [:custom, cached_custom_css_digest.to_s.first(8)] - .compact_blank - .join('-') - end + return if cached_custom_css_digest.blank? + + [:custom, cached_custom_css_digest.to_s.first(8)] + .compact_blank + .join('-') end def cached_custom_css_digest diff --git a/app/javascript/config/html-tags.json b/app/javascript/config/html-tags.json new file mode 100644 index 00000000000..c788113487c --- /dev/null +++ b/app/javascript/config/html-tags.json @@ -0,0 +1,61 @@ +{ + "global": { + "class": "className", + "id": true, + "title": true, + "dir": true, + "lang": true + }, + "tags": { + "p": {}, + "br": { + "children": false + }, + "span": { + "attributes": { + "translate": true + } + }, + "a": { + "attributes": { + "href": true, + "rel": true, + "translate": true, + "target": true + } + }, + "del": {}, + "s": {}, + "pre": {}, + "blockquote": {}, + "code": {}, + "b": {}, + "strong": {}, + "u": {}, + "i": {}, + "img": { + "children": false, + "attributes": { + "src": true, + "alt": true, + "title": true + } + }, + "em": {}, + "ul": {}, + "ol": { + "attributes": { + "start": true, + "reversed": true + } + }, + "li": { + "attributes": { + "value": true + } + }, + "ruby": {}, + "rt": {}, + "rp": {} + } +} diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx index a60778f0c04..af9309d342c 100644 --- a/app/javascript/entrypoints/admin.tsx +++ b/app/javascript/entrypoints/admin.tsx @@ -1,6 +1,7 @@ import { createRoot } from 'react-dom/client'; import Rails from '@rails/ujs'; +import { decode, ValidationError } from 'blurhash'; import ready from '../mastodon/ready'; @@ -362,6 +363,46 @@ ready(() => { document.querySelectorAll('[data-admin-component]').forEach((element) => { void mountReactComponent(element); }); + + document + .querySelectorAll('canvas[data-blurhash]') + .forEach((canvas) => { + const blurhash = canvas.dataset.blurhash; + if (blurhash) { + try { + // decode returns a Uint8ClampedArray not Uint8ClampedArray + const pixels = decode( + blurhash, + 32, + 32, + ) as Uint8ClampedArray; + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx?.putImageData(imageData, 0, 0); + } catch (err) { + if (err instanceof ValidationError) { + // ignore blurhash validation errors + return; + } + + throw err; + } + } + }); + + document + .querySelectorAll('.preview-card') + .forEach((previewCard) => { + const spoilerButton = previewCard.querySelector('.spoiler-button'); + if (!spoilerButton) { + return; + } + + spoilerButton.addEventListener('click', () => { + previewCard.classList.toggle('preview-card--image-visible'); + }); + }); }).catch((reason: unknown) => { throw reason; }); diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 0970fc585e6..fea3eb0d792 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -70,7 +70,7 @@ function loaded() { }; document.querySelectorAll('.emojify').forEach((content) => { - content.innerHTML = emojify(content.innerHTML); + content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system. }); document @@ -145,6 +145,10 @@ function loaded() { ); }); + updateDefaultQuotePrivacyFromPrivacy( + document.querySelector('#user_settings_attributes_default_privacy'), + ); + const reactComponents = document.querySelectorAll('[data-component]'); if (reactComponents.length > 0) { @@ -347,6 +351,31 @@ const setInputDisabled = ( } }; +const setInputHint = ( + input: HTMLInputElement | HTMLSelectElement, + hintPrefix: string, +) => { + const fieldWrapper = input.closest('.fields-group > .input'); + if (!fieldWrapper) return; + + const hint = fieldWrapper.dataset[`${hintPrefix}Hint`]; + const hintElement = + fieldWrapper.querySelector(':scope > .hint'); + + if (hint) { + if (hintElement) { + hintElement.textContent = hint; + } else { + const newHintElement = document.createElement('span'); + newHintElement.className = 'hint'; + newHintElement.textContent = hint; + fieldWrapper.appendChild(newHintElement); + } + } else { + hintElement?.remove(); + } +}; + Rails.delegate( document, '#account_statuses_cleanup_policy_enabled', @@ -364,6 +393,36 @@ Rails.delegate( }, ); +const updateDefaultQuotePrivacyFromPrivacy = ( + privacySelect: EventTarget | null, +) => { + if (!(privacySelect instanceof HTMLSelectElement) || !privacySelect.form) + return; + + const select = privacySelect.form.querySelector( + 'select#user_settings_attributes_default_quote_policy', + ); + if (!select) return; + + setInputHint(select, privacySelect.value); + + if (privacySelect.value === 'private') { + select.value = 'nobody'; + setInputDisabled(select, true); + } else { + setInputDisabled(select, false); + } +}; + +Rails.delegate( + document, + '#user_settings_attributes_default_privacy', + 'change', + ({ target }) => { + updateDefaultQuotePrivacyFromPrivacy(target); + }, +); + // Empty the honeypot fields in JS in case something like an extension // automatically filled them. Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { diff --git a/app/javascript/images/mailer-new/heading/README.md b/app/javascript/images/mailer-new/heading/README.md index ecd4b949e76..9fb6841f14b 100644 --- a/app/javascript/images/mailer-new/heading/README.md +++ b/app/javascript/images/mailer-new/heading/README.md @@ -1 +1,3 @@ Images in this folder are based on [Tabler.io icons](https://tabler.io/icons). + +Seems to be 1.5 width icons scaled to 64×64px and centered above a blue square with round corners (24px). diff --git a/app/javascript/images/mailer-new/heading/quote.png b/app/javascript/images/mailer-new/heading/quote.png new file mode 100644 index 00000000000..c2af73282f1 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/quote.png differ diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index d70834cec65..ccb69f0a3d3 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -84,6 +84,7 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, + uploadQuote: { id: 'upload_error.quote', defaultMessage: 'File upload not allowed with quotes.' }, open: { id: 'compose.published.open', defaultMessage: 'Open' }, published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, @@ -96,12 +97,17 @@ export const ensureComposeIsVisible = (getState) => { }; export function setComposeToStatus(status, text, spoiler_text) { - return{ - type: COMPOSE_SET_STATUS, - status, - text, - spoiler_text, - }; + return (dispatch, getState) => { + const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']); + + dispatch({ + type: COMPOSE_SET_STATUS, + status, + text, + spoiler_text, + maxOptions, + }); + } } export function changeCompose(text) { @@ -146,7 +152,7 @@ export function resetCompose() { }; } -export const focusCompose = (defaultText) => (dispatch, getState) => { +export const focusCompose = (defaultText = '') => (dispatch, getState) => { dispatch({ type: COMPOSE_FOCUS, defaultText, @@ -183,13 +189,15 @@ export function directCompose(account) { }; } -export function submitCompose() { +export function submitCompose(successCallback) { return function (dispatch, getState) { const status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); const statusId = getState().getIn(['compose', 'id'], null); + const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']); + const spoiler_text = getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : ''; - if ((!status || !status.length) && media.size === 0) { + if (!(status?.length || media.size !== 0 || (hasQuote && spoiler_text?.length))) { return; } @@ -215,19 +223,22 @@ export function submitCompose() { }); } + const visibility = getState().getIn(['compose', 'privacy']); api().request({ url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, method: statusId === null ? 'post' : 'put', data: { status, + spoiler_text, in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: media.map(item => item.get('id')), media_attributes, sensitive: getState().getIn(['compose', 'sensitive']), - spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', - visibility: getState().getIn(['compose', 'privacy']), + visibility: visibility, poll: getState().getIn(['compose', 'poll'], null), language: getState().getIn(['compose', 'language']), + quoted_status_id: getState().getIn(['compose', 'quoted_status_id']), + quote_approval_policy: visibility === 'private' || visibility === 'direct' ? 'nobody' : getState().getIn(['compose', 'quote_policy']), }, headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), @@ -239,6 +250,9 @@ export function submitCompose() { dispatch(insertIntoTagHistory(response.data.tags, status)); dispatch(submitComposeSuccess({ ...response.data })); + if (typeof successCallback === 'function') { + successCallback(response.data); + } // To make the app more responsive, immediately push the status // into the columns @@ -298,6 +312,11 @@ export function submitComposeFail(error) { export function uploadCompose(files) { return function (dispatch, getState) { + // Exit if there's a quote. + if (getState().compose.get('quoted_status_id')) { + dispatch(showAlert({ message: messages.uploadQuote })); + return; + } const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']); const media = getState().getIn(['compose', 'media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']); diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 97f0d68c514..0f9bf5cfb3c 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -1,9 +1,47 @@ +import { defineMessages } from 'react-intl'; + +import { createAction } from '@reduxjs/toolkit'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { apiUpdateMedia } from 'mastodon/api/compose'; +import { apiGetSearch } from 'mastodon/api/search'; import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; import type { MediaAttachment } from 'mastodon/models/media_attachment'; -import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; +import { + createDataLoadingThunk, + createAppThunk, +} from 'mastodon/store/typed_functions'; + +import type { ApiQuotePolicy } from '../api_types/quotes'; +import type { Status } from '../models/status'; + +import { showAlert } from './alerts'; +import { focusCompose } from './compose'; +import { importFetchedStatuses } from './importer'; +import { openModal } from './modal'; + +const messages = defineMessages({ + quoteErrorEdit: { + id: 'quote_error.edit', + defaultMessage: 'Quotes cannot be added when editing a post.', + }, + quoteErrorUpload: { + id: 'quote_error.upload', + defaultMessage: 'Quoting is not allowed with media attachments.', + }, + quoteErrorPoll: { + id: 'quote_error.poll', + defaultMessage: 'Quoting is not allowed with polls.', + }, + quoteErrorQuote: { + id: 'quote_error.quote', + defaultMessage: 'Only one quote at a time is allowed.', + }, + quoteErrorUnauthorized: { + id: 'quote_error.unauthorized', + defaultMessage: 'You are not authorized to quote this post.', + }, +}); type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { unattached?: boolean; @@ -68,3 +106,111 @@ export const changeUploadCompose = createDataLoadingThunk( useLoadingBar: false, }, ); + +export const quoteCompose = createAppThunk( + 'compose/quoteComposeStatus', + (status: Status, { dispatch }) => { + dispatch(focusCompose()); + return status; + }, +); + +export const quoteComposeByStatus = createAppThunk( + (status: Status, { dispatch, getState }) => { + const state = getState(); + const composeState = state.compose; + const mediaAttachments = composeState.get('media_attachments'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const wasQuietPostHintModalDismissed: boolean = + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + state.settings.getIn( + ['dismissed_banners', 'quote/quiet_post_hint'], + false, + ); + + if (composeState.get('id')) { + dispatch(showAlert({ message: messages.quoteErrorEdit })); + } else if (composeState.get('poll')) { + dispatch(showAlert({ message: messages.quoteErrorPoll })); + } else if ( + composeState.get('is_uploading') || + (mediaAttachments && + typeof mediaAttachments !== 'string' && + typeof mediaAttachments !== 'number' && + typeof mediaAttachments !== 'boolean' && + mediaAttachments.size !== 0) + ) { + dispatch(showAlert({ message: messages.quoteErrorUpload })); + } else if (composeState.get('quoted_status_id')) { + dispatch(showAlert({ message: messages.quoteErrorQuote })); + } else if ( + status.getIn(['quote_approval', 'current_user']) !== 'automatic' && + status.getIn(['quote_approval', 'current_user']) !== 'manual' + ) { + dispatch(showAlert({ message: messages.quoteErrorUnauthorized })); + } else if ( + status.get('visibility') === 'unlisted' && + !wasQuietPostHintModalDismissed + ) { + dispatch( + openModal({ + modalType: 'CONFIRM_QUIET_QUOTE', + modalProps: { status }, + }), + ); + } else { + dispatch(quoteCompose(status)); + } + }, +); + +export const quoteComposeById = createAppThunk( + (statusId: string, { dispatch, getState }) => { + const status = getState().statuses.get(statusId); + if (status) { + dispatch(quoteComposeByStatus(status)); + } + }, +); + +export const pasteLinkCompose = createDataLoadingThunk( + 'compose/pasteLink', + async ({ url }: { url: string }) => { + return await apiGetSearch({ + q: url, + type: 'statuses', + resolve: true, + limit: 2, + }); + }, + (data, { dispatch, getState }) => { + const composeState = getState().compose; + + if ( + composeState.get('quoted_status_id') || + composeState.get('is_submitting') || + composeState.get('poll') || + composeState.get('is_uploading') || + composeState.get('id') + ) + return; + + dispatch(importFetchedStatuses(data.statuses)); + + if ( + data.statuses.length === 1 && + data.statuses[0] && + ['automatic', 'manual'].includes( + data.statuses[0].quote_approval?.current_user ?? 'denied', + ) + ) { + dispatch(quoteComposeById(data.statuses[0].id)); + } + }, +); + +export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); + +export const setComposeQuotePolicy = createAction( + 'compose/setQuotePolicy', +); diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 330da74000b..7723379804c 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -21,6 +21,15 @@ export function normalizeFilterResult(result) { return normalResult; } +function stripQuoteFallback(text) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = text; + + wrapper.querySelector('.quote-inline')?.remove(); + + return wrapper.innerHTML; +} + export function normalizeStatus(status, normalOldStatus) { const normalStatus = { ...status }; @@ -72,7 +81,7 @@ export function normalizeStatus(status, normalOldStatus) { } else { // If the status has a CW but no contents, treat the CW as if it were the // status' contents, to avoid having a CW toggle with seemingly no effect. - if (normalStatus.spoiler_text && !normalStatus.content) { + if (normalStatus.spoiler_text && !normalStatus.content && !normalStatus.quote) { normalStatus.content = normalStatus.spoiler_text; normalStatus.spoiler_text = ''; } @@ -86,6 +95,11 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (normalStatus.quote) { + normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml); + } + if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) { normalStatus.url = null; } @@ -125,6 +139,11 @@ export function normalizeStatusTranslation(translation, status) { spoiler_text: translation.spoiler_text, }; + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (status.get('quote')) { + normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml); + } + return normalTranslation; } diff --git a/app/javascript/mastodon/actions/interactions_typed.ts b/app/javascript/mastodon/actions/interactions_typed.ts index f58faffa86d..36f9f85b9cc 100644 --- a/app/javascript/mastodon/actions/interactions_typed.ts +++ b/app/javascript/mastodon/actions/interactions_typed.ts @@ -1,8 +1,13 @@ -import { apiReblog, apiUnreblog } from 'mastodon/api/interactions'; +import { + apiReblog, + apiUnreblog, + apiRevokeQuote, + apiGetQuotes, +} from 'mastodon/api/interactions'; import type { StatusVisibility } from 'mastodon/models/status'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; -import { importFetchedStatus } from './importer'; +import { importFetchedStatus, importFetchedStatuses } from './importer'; export const reblog = createDataLoadingThunk( 'status/reblog', @@ -33,3 +38,35 @@ export const unreblog = createDataLoadingThunk( return discardLoadData; }, ); + +export const revokeQuote = createDataLoadingThunk( + 'status/revoke_quote', + ({ + statusId, + quotedStatusId, + }: { + statusId: string; + quotedStatusId: string; + }) => apiRevokeQuote(quotedStatusId, statusId), + (data, { dispatch, discardLoadData }) => { + dispatch(importFetchedStatus(data)); + + return discardLoadData; + }, +); + +export const fetchQuotes = createDataLoadingThunk( + 'status/fetch_quotes', + async ({ statusId, next }: { statusId: string; next?: string }) => { + const { links, statuses } = await apiGetQuotes(statusId, next); + + return { + links, + statuses, + replace: !next, + }; + }, + (payload, { dispatch }) => { + dispatch(importFetchedStatuses(payload.statuses)); + }, +); diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index 43863254818..2d03ef080f5 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -30,8 +30,21 @@ import { importFetchedAccounts, importFetchedStatuses } from './importer'; import { NOTIFICATIONS_FILTER_SET } from './notifications'; import { saveSettings } from './settings'; +function notificationTypeForFilter(type: NotificationType) { + if (type === 'quoted_update') return 'update'; + else return type; +} + +function notificationTypeForQuickFilter(type: NotificationType) { + if (type === 'quoted_update') return 'update'; + else if (type === 'quote') return 'mention'; + else return type; +} + function excludeAllTypesExcept(filter: string) { - return allNotificationTypes.filter((item) => item !== filter); + return allNotificationTypes.filter( + (item) => notificationTypeForQuickFilter(item) !== filter, + ); } function getExcludedTypes(state: RootState) { @@ -155,13 +168,17 @@ export const processNewNotificationForGroups = createAppAsyncThunk( const showInColumn = activeFilter === 'all' - ? notificationShows[notification.type] !== false - : activeFilter === notification.type; + ? notificationShows[notificationTypeForFilter(notification.type)] !== + false + : activeFilter === notificationTypeForQuickFilter(notification.type); if (!showInColumn) return; if ( - (notification.type === 'mention' || notification.type === 'update') && + (notification.type === 'mention' || + notification.type === 'quote' || + notification.type === 'update' || + notification.type === 'quoted_update') && notification.status?.filtered ) { const filters = notification.status.filtered.filter((result) => diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 2499b8da1d7..558390b9cff 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { let filtered = false; - if (['mention', 'status'].includes(notification.type) && notification.status.filtered) { + if (['mention', 'quote', 'status'].includes(notification.type) && notification.status.filtered) { const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications')); if (filters.some(result => result.filter.filter_action === 'hide')) { diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 42d0c1c0f11..7572efe95f5 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -1,9 +1,12 @@ +import { defineMessages } from 'react-intl'; + import { browserHistory } from 'mastodon/components/router'; import api from '../api'; +import { showAlert } from './alerts'; import { ensureComposeIsVisible, setComposeToStatus } from './compose'; -import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; +import { importFetchedStatus, importFetchedAccount } from './importer'; import { fetchContext } from './statuses_typed'; import { deleteFromTimelines } from './timelines'; @@ -40,6 +43,10 @@ export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS'; export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; +const messages = defineMessages({ + deleteSuccess: { id: 'status.delete.success', defaultMessage: 'Post deleted' }, +}); + export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, @@ -48,7 +55,18 @@ export function fetchStatusRequest(id, skipLoading) { }; } -export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) { +/** + * @param {string} id + * @param {Object} [options] + * @param {boolean} [options.forceFetch] + * @param {boolean} [options.alsoFetchContext] + * @param {string | null | undefined} [options.parentQuotePostId] + */ +export function fetchStatus(id, { + forceFetch = false, + alsoFetchContext = true, + parentQuotePostId, +} = {}) { return (dispatch, getState) => { const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; @@ -66,7 +84,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) { dispatch(importFetchedStatus(response.data)); dispatch(fetchStatusSuccess(skipLoading)); }).catch(error => { - dispatch(fetchStatusFail(id, error, skipLoading)); + dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId)); }); }; } @@ -78,21 +96,27 @@ export function fetchStatusSuccess(skipLoading) { }; } -export function fetchStatusFail(id, error, skipLoading) { +export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) { return { type: STATUS_FETCH_FAIL, id, error, + parentQuotePostId, skipLoading, skipAlert: true, }; } export function redraft(status, raw_text) { - return { - type: REDRAFT, - status, - raw_text, + return (dispatch, getState) => { + const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']); + + dispatch({ + type: REDRAFT, + status, + raw_text, + maxOptions, + }); }; } @@ -137,7 +161,7 @@ export function deleteStatus(id, withRedraft = false) { dispatch(deleteStatusRequest(id)); - api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => { + return api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => { dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); dispatch(importFetchedAccount(response.data.account)); @@ -145,9 +169,14 @@ export function deleteStatus(id, withRedraft = false) { if (withRedraft) { dispatch(redraft(status, response.data.text)); ensureComposeIsVisible(getState); + } else { + dispatch(showAlert({ message: messages.deleteSuccess })); } + + return response; }).catch(error => { dispatch(deleteStatusFail(id, error)); + throw error; }); }; } diff --git a/app/javascript/mastodon/actions/statuses_typed.ts b/app/javascript/mastodon/actions/statuses_typed.ts index b98abbe122e..be9bec71bb3 100644 --- a/app/javascript/mastodon/actions/statuses_typed.ts +++ b/app/javascript/mastodon/actions/statuses_typed.ts @@ -1,18 +1,44 @@ -import { apiGetContext } from 'mastodon/api/statuses'; +import { createAction } from '@reduxjs/toolkit'; + +import { apiGetContext, apiSetQuotePolicy } from 'mastodon/api/statuses'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; +import type { ApiQuotePolicy } from '../api_types/quotes'; + import { importFetchedStatuses } from './importer'; export const fetchContext = createDataLoadingThunk( 'status/context', - ({ statusId }: { statusId: string }) => apiGetContext(statusId), - (context, { dispatch }) => { + ({ statusId }: { statusId: string; prefetchOnly?: boolean }) => + apiGetContext(statusId), + ({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => { const statuses = context.ancestors.concat(context.descendants); dispatch(importFetchedStatuses(statuses)); return { context, + refresh, + prefetchOnly, }; }, ); + +export const completeContextRefresh = createAction<{ statusId: string }>( + 'status/context/complete', +); + +export const showPendingReplies = createAction<{ statusId: string }>( + 'status/context/showPendingReplies', +); + +export const clearPendingReplies = createAction<{ statusId: string }>( + 'status/context/clearPendingReplies', +); + +export const setStatusQuotePolicy = createDataLoadingThunk( + 'status/setQuotePolicy', + ({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => { + return apiSetQuotePolicy(statusId, policy); + }, +); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index dc9c20b5085..1820e00a537 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -20,6 +20,50 @@ export const getLinks = (response: AxiosResponse) => { return LinkHeader.parse(value); }; +export interface AsyncRefreshHeader { + id: string; + retry: number; +} + +const isAsyncRefreshHeader = (obj: object): obj is AsyncRefreshHeader => + 'id' in obj && 'retry' in obj; + +export const getAsyncRefreshHeader = ( + response: AxiosResponse, +): AsyncRefreshHeader | null => { + const value = response.headers['mastodon-async-refresh'] as + | string + | undefined; + + if (!value) { + return null; + } + + const asyncRefreshHeader: Record = {}; + + value.split(/,\s*/).forEach((pair) => { + const [key, val] = pair.split('=', 2); + + let typedValue: string | number; + + if (key && ['id', 'retry'].includes(key) && val) { + if (val.startsWith('"')) { + typedValue = val.slice(1, -1); + } else { + typedValue = parseInt(val); + } + + asyncRefreshHeader[key] = typedValue; + } + }); + + if (isAsyncRefreshHeader(asyncRefreshHeader)) { + return asyncRefreshHeader; + } + + return null; +}; + const csrfHeader: RawAxiosRequestHeaders = {}; const setCSRFHeader = () => { @@ -83,7 +127,7 @@ export default function api(withAuthorization = true) { return instance; } -type ApiUrl = `v${1 | 2}/${string}`; +type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`; type RequestParamsOrData = Record; export async function apiRequest( diff --git a/app/javascript/mastodon/api/async_refreshes.ts b/app/javascript/mastodon/api/async_refreshes.ts new file mode 100644 index 00000000000..953300a4a86 --- /dev/null +++ b/app/javascript/mastodon/api/async_refreshes.ts @@ -0,0 +1,5 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { ApiAsyncRefreshJSON } from 'mastodon/api_types/async_refreshes'; + +export const apiGetAsyncRefresh = (id: string) => + apiRequestGet(`v1_alpha/async_refreshes/${id}`); diff --git a/app/javascript/mastodon/api/interactions.ts b/app/javascript/mastodon/api/interactions.ts index 118b5f06d20..36aaeef1866 100644 --- a/app/javascript/mastodon/api/interactions.ts +++ b/app/javascript/mastodon/api/interactions.ts @@ -1,10 +1,28 @@ -import { apiRequestPost } from 'mastodon/api'; -import type { Status, StatusVisibility } from 'mastodon/models/status'; +import api, { apiRequestPost, getLinks } from 'mastodon/api'; +import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; +import type { StatusVisibility } from 'mastodon/models/status'; export const apiReblog = (statusId: string, visibility: StatusVisibility) => - apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, { + apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, { visibility, }); export const apiUnreblog = (statusId: string) => - apiRequestPost(`v1/statuses/${statusId}/unreblog`); + apiRequestPost(`v1/statuses/${statusId}/unreblog`); + +export const apiRevokeQuote = (quotedStatusId: string, statusId: string) => + apiRequestPost( + `v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`, + ); + +export const apiGetQuotes = async (statusId: string, url?: string) => { + const response = await api().request({ + method: 'GET', + url: url ?? `/api/v1/statuses/${statusId}/quotes`, + }); + + return { + statuses: response.data, + links: getLinks(response), + }; +}; diff --git a/app/javascript/mastodon/api/statuses.ts b/app/javascript/mastodon/api/statuses.ts index 921a7bfe636..123f2759d09 100644 --- a/app/javascript/mastodon/api/statuses.ts +++ b/app/javascript/mastodon/api/statuses.ts @@ -1,5 +1,31 @@ -import { apiRequestGet } from 'mastodon/api'; -import type { ApiContextJSON } from 'mastodon/api_types/statuses'; +import api, { apiRequestPut, getAsyncRefreshHeader } from 'mastodon/api'; +import type { + ApiContextJSON, + ApiStatusJSON, +} from 'mastodon/api_types/statuses'; -export const apiGetContext = (statusId: string) => - apiRequestGet(`v1/statuses/${statusId}/context`); +import type { ApiQuotePolicy } from '../api_types/quotes'; + +export const apiGetContext = async (statusId: string) => { + const response = await api().request({ + method: 'GET', + url: `/api/v1/statuses/${statusId}/context`, + }); + + return { + context: response.data, + refresh: getAsyncRefreshHeader(response), + }; +}; + +export const apiSetQuotePolicy = async ( + statusId: string, + policy: ApiQuotePolicy, +) => { + return apiRequestPut( + `v1/statuses/${statusId}/interaction_policy`, + { + quote_approval_policy: policy, + }, + ); +}; diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index b93054a1f6f..913a201fef4 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -37,7 +37,7 @@ export interface BaseApiAccountJSON { roles?: ApiAccountJSON[]; statuses_count: number; uri: string; - url: string; + url?: string; username: string; moved?: ApiAccountJSON; suspended?: boolean; diff --git a/app/javascript/mastodon/api_types/announcements.ts b/app/javascript/mastodon/api_types/announcements.ts new file mode 100644 index 00000000000..03e8922d8f1 --- /dev/null +++ b/app/javascript/mastodon/api_types/announcements.ts @@ -0,0 +1,28 @@ +// See app/serializers/rest/announcement_serializer.rb + +import type { ApiCustomEmojiJSON } from './custom_emoji'; +import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses'; + +export interface ApiAnnouncementJSON { + id: string; + content: string; + starts_at: null | string; + ends_at: null | string; + all_day: boolean; + published_at: string; + updated_at: null | string; + read: boolean; + mentions: ApiMentionJSON[]; + statuses: ApiStatusJSON[]; + tags: ApiTagJSON[]; + emojis: ApiCustomEmojiJSON[]; + reactions: ApiAnnouncementReactionJSON[]; +} + +export interface ApiAnnouncementReactionJSON { + name: string; + count: number; + me: boolean; + url?: string; + static_url?: string; +} diff --git a/app/javascript/mastodon/api_types/async_refreshes.ts b/app/javascript/mastodon/api_types/async_refreshes.ts new file mode 100644 index 00000000000..2d2fed24127 --- /dev/null +++ b/app/javascript/mastodon/api_types/async_refreshes.ts @@ -0,0 +1,7 @@ +export interface ApiAsyncRefreshJSON { + async_refresh: { + id: string; + status: 'running' | 'finished'; + result_count: number; + }; +} diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index 190d8c83966..533e9903682 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -7,12 +7,13 @@ import type { ApiReportJSON } from './reports'; import type { ApiStatusJSON } from './statuses'; // See app/model/notification.rb -export const allNotificationTypes = [ +export const allNotificationTypes: NotificationType[] = [ 'follow', 'follow_request', 'favourite', 'reblog', 'mention', + 'quote', 'poll', 'status', 'update', @@ -28,8 +29,10 @@ export type NotificationWithStatusType = | 'reblog' | 'status' | 'mention' + | 'quote' | 'poll' - | 'update'; + | 'update' + | 'quoted_update'; export type NotificationType = | NotificationWithStatusType diff --git a/app/javascript/mastodon/api_types/quotes.ts b/app/javascript/mastodon/api_types/quotes.ts new file mode 100644 index 00000000000..f42a3eb7289 --- /dev/null +++ b/app/javascript/mastodon/api_types/quotes.ts @@ -0,0 +1,39 @@ +import type { ApiStatusJSON } from './statuses'; + +export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized'; +export type ApiQuotePolicy = + | 'public' + | 'followers' + | 'following' + | 'nobody' + | 'unsupported_policy'; +export type ApiUserQuotePolicy = 'automatic' | 'manual' | 'denied' | 'unknown'; + +interface ApiQuoteEmptyJSON { + state: Exclude; + quoted_status: null; +} + +interface ApiNestedQuoteJSON { + state: 'accepted'; + quoted_status_id: string; +} + +interface ApiQuoteAcceptedJSON { + state: 'accepted'; + quoted_status: Omit & { + quote: ApiNestedQuoteJSON | ApiQuoteEmptyJSON; + }; +} + +export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON; + +export interface ApiQuotePolicyJSON { + automatic: ApiQuotePolicy[]; + manual: ApiQuotePolicy[]; + current_user: ApiUserQuotePolicy; +} + +export function isQuotePolicy(policy: string): policy is ApiQuotePolicy { + return ['public', 'followers', 'nobody'].includes(policy); +} diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index 09bd2349b39..00418a13d29 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -4,6 +4,7 @@ import type { ApiAccountJSON } from './accounts'; import type { ApiCustomEmojiJSON } from './custom_emoji'; import type { ApiMediaAttachmentJSON } from './media_attachments'; import type { ApiPollJSON } from './polls'; +import type { ApiQuoteJSON, ApiQuotePolicyJSON } from './quotes'; // See app/modals/status.rb export type StatusVisibility = @@ -95,6 +96,7 @@ export interface ApiStatusJSON { replies_count: number; reblogs_count: number; favorites_count: number; + quotes_count: number; edited_at?: string; favorited?: boolean; @@ -118,9 +120,23 @@ export interface ApiStatusJSON { card?: ApiPreviewCardJSON; poll?: ApiPollJSON; + quote?: ApiQuoteJSON; + quote_approval?: ApiQuotePolicyJSON; } export interface ApiContextJSON { ancestors: ApiStatusJSON[]; descendants: ApiStatusJSON[]; } + +export interface ApiStatusSourceJSON { + id: string; + text: string; + spoiler_text: string; +} + +export function isStatusVisibility( + visibility: string, +): visibility is StatusVisibility { + return ['public', 'unlisted', 'private', 'direct'].includes(visibility); +} diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap deleted file mode 100644 index 9d1b236fad0..00000000000 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders display name + account name 1`] = ` - - - Foo

", - } - } - /> -
- - - @ - bar@baz - -
-`; diff --git a/app/javascript/mastodon/components/__tests__/display_name-test.jsx b/app/javascript/mastodon/components/__tests__/display_name-test.jsx deleted file mode 100644 index 05a0f47170f..00000000000 --- a/app/javascript/mastodon/components/__tests__/display_name-test.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import { fromJS } from 'immutable'; - -import renderer from 'react-test-renderer'; - -import { DisplayName } from '../display_name'; - -describe('', () => { - it('renders display name + account name', () => { - const account = fromJS({ - username: 'bar', - acct: 'bar@baz', - display_name_html: '

Foo

', - }); - const component = renderer.create(); - const tree = component.toJSON(); - - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index 8397695a443..3aebedc9497 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -5,6 +5,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { blockAccount, @@ -331,9 +332,10 @@ export const Account: React.FC = ({ {account && withBio && (account.note.length > 0 ? ( -
) : (
diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index 301ffcbb247..e87ae654fdf 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -1,20 +1,91 @@ +import { useCallback } from 'react'; + +import classNames from 'classnames'; + import { useLinks } from 'mastodon/hooks/useLinks'; -export const AccountBio: React.FC<{ - note: string; - className: string; -}> = ({ note, className }) => { - const handleClick = useLinks(); +import { useAppSelector } from '../store'; +import { isModernEmojiEnabled } from '../utils/environment'; - if (note.length === 0 || note === '

') { +import { EmojiHTML } from './emoji/html'; +import { useElementHandledLink } from './status/handled_link'; + +interface AccountBioProps { + className: string; + accountId: string; + showDropdown?: boolean; +} + +export const AccountBio: React.FC = ({ + className, + accountId, + showDropdown = false, +}) => { + const handleClick = useLinks(showDropdown); + const handleNodeChange = useCallback( + (node: HTMLDivElement | null) => { + if ( + !showDropdown || + !node || + node.childNodes.length === 0 || + isModernEmojiEnabled() + ) { + return; + } + addDropdownToHashtags(node, accountId); + }, + [showDropdown, accountId], + ); + + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: showDropdown ? accountId : undefined, + }); + + const note = useAppSelector((state) => { + const account = state.accounts.get(accountId); + if (!account) { + return ''; + } + return account.note_emojified; + }); + const extraEmojis = useAppSelector((state) => { + const account = state.accounts.get(accountId); + return account?.emojis; + }); + + if (note.length === 0) { return null; } return ( -
); }; + +function addDropdownToHashtags(node: HTMLElement | null, accountId: string) { + if (!node) { + return; + } + for (const childNode of node.childNodes) { + if (!(childNode instanceof HTMLElement)) { + continue; + } + if ( + childNode instanceof HTMLAnchorElement && + (childNode.classList.contains('hashtag') || + childNode.innerText.startsWith('#')) && + !childNode.dataset.menuHashtag + ) { + childNode.dataset.menuHashtag = accountId; + } else if (childNode.childNodes.length > 0) { + addDropdownToHashtags(childNode, accountId); + } + } +} diff --git a/app/javascript/mastodon/components/account_fields.tsx b/app/javascript/mastodon/components/account_fields.tsx index 4ce55f7896a..dd17b89d865 100644 --- a/app/javascript/mastodon/components/account_fields.tsx +++ b/app/javascript/mastodon/components/account_fields.tsx @@ -1,42 +1,70 @@ +import { useIntl } from 'react-intl'; + import classNames from 'classnames'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import { Icon } from 'mastodon/components/icon'; -import { useLinks } from 'mastodon/hooks/useLinks'; import type { Account } from 'mastodon/models/account'; -export const AccountFields: React.FC<{ - fields: Account['fields']; - limit: number; -}> = ({ fields, limit = -1 }) => { - const handleClick = useLinks(); +import { CustomEmojiProvider } from './emoji/context'; +import { EmojiHTML } from './emoji/html'; +import { useElementHandledLink } from './status/handled_link'; + +export const AccountFields: React.FC> = ({ + fields, + emojis, +}) => { + const intl = useIntl(); + const htmlHandlers = useElementHandledLink(); if (fields.size === 0) { return null; } return ( -
- {fields.take(limit).map((pair, i) => ( -
-
+ {fields.map((pair, i) => ( +
+ -
- {pair.get('verified_at') && ( - - )} - + {pair.verified_at && ( + + + + )}{' '} +
))} -
+ ); }; + +const dateFormatOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}; diff --git a/app/javascript/mastodon/components/alert/alert.stories.tsx b/app/javascript/mastodon/components/alert/alert.stories.tsx new file mode 100644 index 00000000000..f12f06751d7 --- /dev/null +++ b/app/javascript/mastodon/components/alert/alert.stories.tsx @@ -0,0 +1,125 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn, expect } from 'storybook/test'; + +import { Alert } from '.'; + +const meta = { + title: 'Components/Alert', + component: Alert, + args: { + isActive: true, + isLoading: false, + animateFrom: 'side', + title: '', + message: '', + action: '', + onActionClick: fn(), + }, + argTypes: { + isActive: { + control: 'boolean', + type: 'boolean', + description: 'Animate to the active (displayed) state of the alert', + }, + isLoading: { + control: 'boolean', + type: 'boolean', + description: + 'Display a loading indicator in the alert, replacing the dismiss button if present', + }, + animateFrom: { + control: 'radio', + type: 'string', + options: ['side', 'below'], + description: + 'Direction that the alert animates in from when activated. `side` is dependent on reading direction, defaulting to left in ltr languages.', + }, + title: { + control: 'text', + type: 'string', + description: '(Optional) title of the alert', + }, + message: { + control: 'text', + type: 'string', + description: 'Main alert text', + }, + action: { + control: 'text', + type: 'string', + description: + 'Label of the alert action (requires `onActionClick` handler)', + }, + }, + tags: ['test'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = { + args: { + message: 'Post published.', + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WithAction: Story = { + args: { + ...Simple.args, + action: 'Open', + }, + render: Simple.render, + play: async ({ args, canvas, userEvent }) => { + const button = await canvas.findByRole('button', { name: 'Open' }); + await userEvent.click(button); + await expect(args.onActionClick).toHaveBeenCalled(); + }, +}; + +export const WithTitle: Story = { + args: { + title: 'Warning:', + message: 'This is an alert', + }, + render: Simple.render, +}; + +export const WithDismissButton: Story = { + args: { + message: 'More replies found', + action: 'Show', + onDismiss: fn(), + }, + render: Simple.render, +}; + +export const InSizedContainer: Story = { + args: WithDismissButton.args, + render: (args) => ( +
+ +
+ ), +}; + +export const WithLoadingIndicator: Story = { + args: { + ...WithDismissButton.args, + isLoading: true, + }, + render: InSizedContainer.render, +}; diff --git a/app/javascript/mastodon/components/alert/index.tsx b/app/javascript/mastodon/components/alert/index.tsx new file mode 100644 index 00000000000..72fee0a4a30 --- /dev/null +++ b/app/javascript/mastodon/components/alert/index.tsx @@ -0,0 +1,77 @@ +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; + +import { IconButton } from '../icon_button'; + +/** + * Snackbar/Toast-style notification component. + */ +export const Alert: React.FC<{ + title?: string; + message: string; + action?: string; + onActionClick?: () => void; + onDismiss?: () => void; + isActive?: boolean; + isLoading?: boolean; + animateFrom?: 'side' | 'below'; +}> = ({ + title, + message, + action, + onActionClick, + onDismiss, + isActive, + isLoading, + animateFrom = 'side', +}) => { + const intl = useIntl(); + + const hasAction = Boolean(action && onActionClick); + + return ( +
+ + {Boolean(title) && ( + {title} + )} + {message} + + + {hasAction && ( + + )} + + {isLoading && ( + + + + )} + + {onDismiss && !isLoading && ( + + )} +
+ ); +}; diff --git a/app/javascript/mastodon/components/alerts_controller.tsx b/app/javascript/mastodon/components/alerts_controller.tsx index 26749fa1037..aa97feeca58 100644 --- a/app/javascript/mastodon/components/alerts_controller.tsx +++ b/app/javascript/mastodon/components/alerts_controller.tsx @@ -3,16 +3,16 @@ import { useState, useEffect } from 'react'; import { useIntl } from 'react-intl'; import type { IntlShape } from 'react-intl'; -import classNames from 'classnames'; - import { dismissAlert } from 'mastodon/actions/alerts'; import type { - Alert, + Alert as AlertType, TranslatableString, TranslatableValues, } from 'mastodon/models/alert'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { Alert } from './alert'; + const formatIfNeeded = ( intl: IntlShape, message: TranslatableString, @@ -25,8 +25,8 @@ const formatIfNeeded = ( return message; }; -const Alert: React.FC<{ - alert: Alert; +const TimedAlert: React.FC<{ + alert: AlertType; dismissAfter: number; }> = ({ alert: { key, title, message, values, action, onClick }, @@ -62,29 +62,13 @@ const Alert: React.FC<{ }, [dispatch, setActive, key, dismissAfter]); return ( -
-
- {title && ( - - {formatIfNeeded(intl, title, values)} - - )} - - - {formatIfNeeded(intl, message, values)} - - - {action && ( - - )} -
-
+ ); }; @@ -98,7 +82,11 @@ export const AlertsController: React.FC = () => { return (
{alerts.map((alert, idx) => ( - + ))}
); diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx index 07369795aca..c7fb0cd81b1 100644 --- a/app/javascript/mastodon/components/alt_text_badge.tsx +++ b/app/javascript/mastodon/components/alt_text_badge.tsx @@ -13,9 +13,9 @@ import { useSelectableClick } from 'mastodon/hooks/useSelectableClick'; const offset = [0, 4] as OffsetValue; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; -export const AltTextBadge: React.FC<{ - description: string; -}> = ({ description }) => { +export const AltTextBadge: React.FC<{ description: string }> = ({ + description, +}) => { const accessibilityId = useId(); const anchorRef = useRef(null); const [open, setOpen] = useState(false); @@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{ {({ props }) => (
{ const [suggestionsHidden, setSuggestionsHidden] = useState(true); @@ -149,10 +150,7 @@ const AutosuggestTextarea = forwardRef(({ }, [suggestions, onSuggestionSelected, textareaRef]); const handlePaste = useCallback((e) => { - if (e.clipboardData && e.clipboardData.files.length === 1) { - onPaste(e.clipboardData.files); - e.preventDefault(); - } + onPaste(e); }, [onPaste]); // Show the suggestions again whenever they change and the textarea is focused @@ -192,7 +190,7 @@ const AutosuggestTextarea = forwardRef(({ }; return ( -
+