Merge branch 'main' into fix-10609-poll-options

This commit is contained in:
Zee Aslam 2025-10-14 23:56:42 -04:00 committed by GitHub
commit d85fb8aeb7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1044 changed files with 35404 additions and 11909 deletions

View File

@ -5,6 +5,7 @@
.gitattributes .gitattributes
.gitignore .gitignore
.github .github
.vscode
public/system public/system
public/assets public/assets
public/packs public/packs
@ -20,6 +21,7 @@ postgres14
redis redis
elasticsearch elasticsearch
chart chart
storybook-static
.yarn/ .yarn/
!.yarn/patches !.yarn/patches
!.yarn/plugins !.yarn/plugins

View File

@ -91,9 +91,6 @@ SESSION_RETENTION_PERIOD=31556952
# Fetch All Replies Behavior # 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) # Period to wait between fetching replies (in minutes)
FETCH_REPLIES_COOLDOWN_MINUTES=15 FETCH_REPLIES_COOLDOWN_MINUTES=15

View File

@ -1,6 +1,6 @@
name: Bug Report (Web Interface) name: Bug Report (Web Interface)
description: There is a problem using Mastodon's 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 type: Bug
body: body:
- type: markdown - type: markdown

View File

@ -1,7 +1,6 @@
name: Bug Report (server / API) name: Bug Report (server / API)
description: | description: |
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc. There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
labels: ['status/to triage']
type: 'Bug' type: 'Bug'
body: body:
- type: markdown - type: markdown

View File

@ -6,6 +6,7 @@
':labels(dependencies)', ':labels(dependencies)',
':prConcurrentLimitNone', // Remove limit for open PRs at any time. ':prConcurrentLimitNone', // Remove limit for open PRs at any time.
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
':enableVulnerabilityAlertsWithLabel(security)',
], ],
rebaseWhen: 'conflicted', rebaseWhen: 'conflicted',
minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it
@ -23,7 +24,6 @@
matchManagers: ['npm'], matchManagers: ['npm'],
matchPackageNames: [ matchPackageNames: [
'tesseract.js', // Requires code changes 'tesseract.js', // Requires code changes
'react-hotkeys', // Requires code changes
// react-router: Requires manual upgrade // react-router: Requires manual upgrade
'history', 'history',
@ -94,6 +94,19 @@
matchUpdateTypes: ['patch', 'minor'], matchUpdateTypes: ['patch', 'minor'],
groupName: 'eslint (non-major)', 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 // Group actions/*-artifact in the same PR
matchManagers: ['github-actions'], matchManagers: ['github-actions'],
@ -142,6 +155,12 @@
matchUpdateTypes: ['patch', 'minor'], matchUpdateTypes: ['patch', 'minor'],
groupName: 'opentelemetry-ruby (non-major)', 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 // Add labels depending on package manager
{ matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] }, { matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] },
{ matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] }, { matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] },

View File

@ -9,7 +9,6 @@ permissions:
jobs: jobs:
compute-suffix: compute-suffix:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'mastodon/mastodon'
steps: steps:
- id: version_vars - id: version_vars
env: env:

View File

@ -25,8 +25,8 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: ['javascript', 'ruby'] language: ['actions', 'javascript', 'ruby']
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', '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 # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps: steps:

View File

@ -50,7 +50,7 @@ jobs:
# Create or update the pull request # Create or update the pull request
- name: Create 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: with:
commit-message: 'New Crowdin translations' commit-message: 'New Crowdin translations'
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'

View File

@ -14,6 +14,7 @@ on:
- config/locales/devise.en.yml - config/locales/devise.en.yml
- config/locales/doorkeeper.en.yml - config/locales/doorkeeper.en.yml
- .github/workflows/crowdin-upload.yml - .github/workflows/crowdin-upload.yml
workflow_dispatch:
jobs: jobs:
upload-translations: upload-translations:

2
.nvmrc
View File

@ -1 +1 @@
22.17 22.20

View File

@ -1,17 +1,21 @@
--- ---
Metrics/AbcSize: Metrics/AbcSize:
Exclude: Enabled: false
- lib/mastodon/cli/*.rb
Metrics/BlockLength: Metrics/BlockLength:
Enabled: false Enabled: false
Metrics/BlockNesting:
Enabled: false
Metrics/ClassLength: Metrics/ClassLength:
Enabled: false Enabled: false
Metrics/CollectionLiteralLength:
Enabled: false
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Exclude: Enabled: false
- lib/mastodon/cli/*.rb
Metrics/MethodLength: Metrics/MethodLength:
Enabled: false Enabled: false
@ -20,4 +24,7 @@ Metrics/ModuleLength:
Enabled: false Enabled: false
Metrics/ParameterLists: Metrics/ParameterLists:
CountKeywordArgs: false Enabled: false
Metrics/PerceivedComplexity:
Enabled: false

View File

@ -1,32 +1,11 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` # `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 # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again. # 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). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowedVars, DefaultToNil. # Configuration parameters: AllowedVars, DefaultToNil.
Style/FetchEnvVar: Style/FetchEnvVar:

View File

@ -1 +1 @@
3.4.4 3.4.7

View File

@ -1,3 +1,5 @@
import { resolve } from 'node:path';
import type { StorybookConfig } from '@storybook/react-vite'; import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = { const config: StorybookConfig = {
@ -26,6 +28,12 @@ const config: StorybookConfig = {
'oops.png', 'oops.png',
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })), ].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; export default config;

View File

@ -0,0 +1,2 @@
<html class="no-reduce-motion">
</html>

View File

@ -12,13 +12,14 @@ import { initialize, mswLoader } from 'msw-storybook-addon';
import { action } from 'storybook/actions'; import { action } from 'storybook/actions';
import type { LocaleData } from '@/mastodon/locales'; import type { LocaleData } from '@/mastodon/locales';
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers'; import { reducerWithInitialState } from '@/mastodon/reducers';
import { defaultMiddleware } from '@/mastodon/store/store'; import { defaultMiddleware } from '@/mastodon/store/store';
import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
// If you want to run the dark theme during development, // If you want to run the dark theme during development,
// you can change the below to `/application.scss` // you can change the below to `/application.scss`
import '../app/javascript/styles/mastodon-light.scss'; import '../app/javascript/styles/mastodon-light.scss';
import './styles.css';
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
query: { as: 'json' }, query: { as: 'json' },
@ -49,12 +50,23 @@ const preview: Preview = {
locale: 'en', locale: 'en',
}, },
decorators: [ 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; const { state = {} } = parameters;
let reducer = rootReducer; const { state: argsState = {} } = args;
if (typeof state === 'object' && state) {
reducer = reducerWithInitialState(state as Record<string, unknown>); const reducer = reducerWithInitialState(
} {
meta: {
locale,
},
},
state as Record<string, unknown>,
argsState as Record<string, unknown>,
);
const store = configureStore({ const store = configureStore({
reducer, reducer,
middleware(getDefaultMiddleware) { middleware(getDefaultMiddleware) {

View File

@ -7,8 +7,8 @@
* - Please do NOT modify this file. * - Please do NOT modify this file.
*/ */
const PACKAGE_VERSION = '2.10.2' const PACKAGE_VERSION = '2.11.3'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set() const activeClientIds = new Set()
@ -71,11 +71,6 @@ addEventListener('message', async function (event) {
break break
} }
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': { case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId) activeClientIds.delete(clientId)
@ -94,6 +89,8 @@ addEventListener('message', async function (event) {
}) })
addEventListener('fetch', function (event) { addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests. // Bypass navigation requests.
if (event.request.mode === 'navigate') { if (event.request.mode === 'navigate') {
return return
@ -110,23 +107,29 @@ addEventListener('fetch', function (event) {
// Bypass all requests when there are no active clients. // Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests // 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) { if (activeClientIds.size === 0) {
return return
} }
const requestId = crypto.randomUUID() const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId)) event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
}) })
/** /**
* @param {FetchEvent} event * @param {FetchEvent} event
* @param {string} requestId * @param {string} requestId
* @param {number} requestInterceptedAt
*/ */
async function handleRequest(event, requestId) { async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event) const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone() 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. // Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise // Ensure MSW is active and ready to handle the message, otherwise
@ -204,7 +207,7 @@ async function resolveMainClient(event) {
* @param {string} requestId * @param {string} requestId
* @returns {Promise<Response>} * @returns {Promise<Response>}
*/ */
async function getResponse(event, client, requestId) { async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used // Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client). // (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone() const requestClone = event.request.clone()
@ -255,6 +258,7 @@ async function getResponse(event, client, requestId) {
type: 'REQUEST', type: 'REQUEST',
payload: { payload: {
id: requestId, id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest, ...serializedRequest,
}, },
}, },

8
.storybook/styles.css Normal file
View File

@ -0,0 +1,8 @@
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@ -2,7 +2,129 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.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 ### 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. 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 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 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. 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)\ - 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.\ 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 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 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 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). 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)\ - 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. 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 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)\ - 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. 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). 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 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)\ - 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 admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
- Fix OIDC account creation failing for long display names (#34639 by @defnull) - 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 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 search operators sometimes getting lost (#35190 by @ClearlyClaire)
- Fix directory scroll position reset (#34560 by @przucidlo) - Fix directory scroll position reset (#34560 by @przucidlo)
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent) - 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 extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion) - Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
- Fix zoomed images being blurry in Safari (#35052 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 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 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) - 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.\ 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.\ 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: This adds the following REST API endpoints:
- `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy - `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 - `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 - `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 - `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: 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 - `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)\ - **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\

View File

@ -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 # 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 # 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"] # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
# renovate: datasource=docker depName=docker.io/ruby # 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"] # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node # renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="22" ARG NODE_MAJOR_VERSION="22"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"]
ARG DEBIAN_VERSION="bookworm" ARG DEBIAN_VERSION="trixie"
# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim) # 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 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 FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
@ -96,9 +96,6 @@ RUN \
# Set /opt/mastodon as working directory # Set /opt/mastodon as working directory
WORKDIR /opt/mastodon 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 # hadolint ignore=DL3008,DL3005
RUN \ RUN \
# Mount Apt cache and lib directories from Docker buildx caches # Mount Apt cache and lib directories from Docker buildx caches
@ -161,11 +158,11 @@ RUN \
libexif-dev \ libexif-dev \
libexpat1-dev \ libexpat1-dev \
libgirepository1.0-dev \ libgirepository1.0-dev \
libheif-dev/bookworm-backports \ libheif-dev \
libhwy-dev \
libimagequant-dev \ libimagequant-dev \
libjpeg62-turbo-dev \ libjpeg62-turbo-dev \
liblcms2-dev \ liblcms2-dev \
liborc-dev \
libspng-dev \ libspng-dev \
libtiff-dev \ libtiff-dev \
libwebp-dev \ libwebp-dev \
@ -186,7 +183,7 @@ FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips # 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"] # 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 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"] # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg # 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"] # ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
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 update install non-dev versions of necessary components
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
libexpat1 \ libexpat1 \
libglib2.0-0 \ libglib2.0-0t64 \
libicu72 \ libicu76 \
libidn12 \ libidn12 \
libpq5 \ libpq5 \
libreadline8 \ libreadline8t64 \
libssl3 \ libssl3t64 \
libyaml-0-2 \ libyaml-0-2 \
# libvips components # libvips components
libcgif0 \ libcgif0 \
libexif12 \ libexif12 \
libheif1/bookworm-backports \ libheif1 \
libhwy1t64 \
libimagequant0 \ libimagequant0 \
libjpeg62-turbo \ libjpeg62-turbo \
liblcms2-2 \ liblcms2-2 \
liborc-0.4-0 \
libspng0 \ libspng0 \
libtiff6 \ libtiff6 \
libwebp7 \ libwebp7 \
libwebpdemux2 \ libwebpdemux2 \
libwebpmux3 \ libwebpmux3 \
# ffmpeg components # ffmpeg components
libdav1d6 \ libdav1d7 \
libmp3lame0 \ libmp3lame0 \
libopencore-amrnb0 \ libopencore-amrnb0 \
libopencore-amrwb0 \ libopencore-amrwb0 \
@ -358,9 +355,9 @@ RUN \
libvorbis0a \ libvorbis0a \
libvorbisenc2 \ libvorbisenc2 \
libvorbisfile3 \ libvorbisfile3 \
libvpx7 \ libvpx9 \
libx264-164 \ libx264-164 \
libx265-199 \ libx265-215 \
; ;
# Copy Mastodon sources into final layer # Copy Mastodon sources into final layer

48
Gemfile
View File

@ -4,12 +4,12 @@ source 'https://rubygems.org'
ruby '>= 3.2.0', '< 3.5.0' ruby '>= 3.2.0', '< 3.5.0'
gem 'propshaft' gem 'propshaft'
gem 'puma', '~> 6.3' gem 'puma', '~> 7.0'
gem 'rails', '~> 8.0' gem 'rails', '~> 8.0'
gem 'thor', '~> 1.2' gem 'thor', '~> 1.2'
gem 'dotenv' gem 'dotenv'
gem 'haml-rails', '~>2.0' gem 'haml-rails', '~>3.0'
gem 'pg', '~> 1.5' gem 'pg', '~> 1.5'
gem 'pghero' gem 'pghero'
@ -62,7 +62,7 @@ gem 'inline_svg'
gem 'irb', '~> 1.8' gem 'irb', '~> 1.8'
gem 'kaminari', '~> 1.2' gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' 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 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar' gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
gem 'mutex_m' gem 'mutex_m'
@ -82,13 +82,13 @@ gem 'rqrcode', '~> 3.0'
gem 'ruby-progressbar', '~> 1.13' gem 'ruby-progressbar', '~> 1.13'
gem 'sanitize', '~> 7.0' gem 'sanitize', '~> 7.0'
gem 'scenic', '~> 1.7' gem 'scenic', '~> 1.7'
gem 'sidekiq', '< 8' gem 'sidekiq', '< 9'
gem 'sidekiq-bulk', '~> 0.2.0' gem 'sidekiq-bulk', '~> 0.2.0'
gem 'sidekiq-scheduler', '~> 5.0' gem 'sidekiq-scheduler', '~> 6.0'
gem 'sidekiq-unique-jobs', '> 8' gem 'sidekiq-unique-jobs', '> 8'
gem 'simple_form', '~> 5.2' gem 'simple_form', '~> 5.2'
gem 'simple-navigation', '~> 4.4' gem 'simple-navigation', '~> 4.4'
gem 'stoplight', '~> 4.1' gem 'stoplight'
gem 'strong_migrations' gem 'strong_migrations'
gem 'tty-prompt', '~> 0.23', require: false gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0' gem 'twitter-text', '~> 3.1.0'
@ -102,23 +102,23 @@ gem 'rdf-normalize', '~> 0.5'
gem 'prometheus_exporter', '~> 2.2', require: false gem 'prometheus_exporter', '~> 2.2', require: false
gem 'opentelemetry-api', '~> 1.5.0' gem 'opentelemetry-api', '~> 1.7.0'
group :opentelemetry do group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false gem 'opentelemetry-instrumentation-active_job', '~> 0.9.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false gem 'opentelemetry-instrumentation-excon', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false gem 'opentelemetry-instrumentation-faraday', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false gem 'opentelemetry-instrumentation-http', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false gem 'opentelemetry-instrumentation-http_client', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false gem 'opentelemetry-instrumentation-net_http', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false gem 'opentelemetry-instrumentation-pg', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false gem 'opentelemetry-instrumentation-rack', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.36.0', require: false gem 'opentelemetry-instrumentation-rails', '~> 0.38.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false gem 'opentelemetry-instrumentation-sidekiq', '~> 0.27.0', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false
end end
@ -138,6 +138,7 @@ group :test do
# Browser integration testing # Browser integration testing
gem 'capybara', '~> 3.39' gem 'capybara', '~> 3.39'
gem 'capybara-playwright-driver' 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 # Used to reset the database between system tests
gem 'database_cleaner-active_record' gem 'database_cleaner-active_record'
@ -146,7 +147,7 @@ group :test do
gem 'climate_control' gem 'climate_control'
# Validate schemas in specs # Validate schemas in specs
gem 'json-schema', '~> 5.0' gem 'json-schema', '~> 6.0'
# Test harness fo rack components # Test harness fo rack components
gem 'rack-test', '~> 2.1' gem 'rack-test', '~> 2.1'
@ -159,6 +160,9 @@ group :test do
# Stub web requests for specs # Stub web requests for specs
gem 'webmock', '~> 3.18' gem 'webmock', '~> 3.18'
# Websocket driver for testing integration between rails/sidekiq and streaming
gem 'websocket-driver', '~> 0.8', require: false
end end
group :development do group :development do
@ -223,7 +227,7 @@ gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1' gem 'xorcist', '~> 1.1'
gem 'net-http', '~> 0.6.0' gem 'net-http', '~> 0.6.0'
gem 'rubyzip', '~> 2.3' gem 'rubyzip', '~> 3.0'
gem 'hcaptcha', '~> 7.1' gem 'hcaptcha', '~> 7.1'

View File

@ -10,29 +10,29 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (8.0.2) actioncable (8.0.3)
actionpack (= 8.0.2) actionpack (= 8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.0.2) actionmailbox (8.0.3)
actionpack (= 8.0.2) actionpack (= 8.0.3)
activejob (= 8.0.2) activejob (= 8.0.3)
activerecord (= 8.0.2) activerecord (= 8.0.3)
activestorage (= 8.0.2) activestorage (= 8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.0.2) actionmailer (8.0.3)
actionpack (= 8.0.2) actionpack (= 8.0.3)
actionview (= 8.0.2) actionview (= 8.0.3)
activejob (= 8.0.2) activejob (= 8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.0.2) actionpack (8.0.3)
actionview (= 8.0.2) actionview (= 8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@ -40,15 +40,15 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.0.2) actiontext (8.0.3)
actionpack (= 8.0.2) actionpack (= 8.0.3)
activerecord (= 8.0.2) activerecord (= 8.0.3)
activestorage (= 8.0.2) activestorage (= 8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.0.2) actionview (8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
@ -58,22 +58,22 @@ GEM
activemodel (>= 4.1) activemodel (>= 4.1)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (8.0.2) activejob (8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.0.2) activemodel (8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
activerecord (8.0.2) activerecord (8.0.3)
activemodel (= 8.0.2) activemodel (= 8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.0.2) activestorage (8.0.3)
actionpack (= 8.0.2) actionpack (= 8.0.3)
activejob (= 8.0.2) activejob (= 8.0.3)
activerecord (= 8.0.2) activerecord (= 8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.0.2) activesupport (8.0.3)
base64 base64
benchmark (>= 0.3) benchmark (>= 0.3)
bigdecimal bigdecimal
@ -90,13 +90,13 @@ GEM
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
android_key_attestation (0.3.0) android_key_attestation (0.3.0)
annotaterb (4.16.0) annotaterb (4.19.0)
activerecord (>= 6.0.0) activerecord (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
ast (2.4.3) ast (2.4.3)
attr_required (1.0.2) attr_required (1.0.2)
aws-eventstream (1.3.2) aws-eventstream (1.4.0)
aws-partitions (1.1103.0) aws-partitions (1.1168.0)
aws-sdk-core (3.215.1) aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
@ -109,9 +109,9 @@ GEM
aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.5.8) azure-blob (0.5.9.1)
rexml rexml
base64 (0.3.0) base64 (0.3.0)
bcp47_spec (0.2.1) bcp47_spec (0.2.1)
@ -121,7 +121,7 @@ GEM
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
rouge (>= 1.0.0) rouge (>= 1.0.0)
bigdecimal (3.2.2) bigdecimal (3.2.3)
bindata (2.5.1) bindata (2.5.1)
binding_of_caller (1.0.1) binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0) debug_inspector (>= 1.2.0)
@ -144,13 +144,13 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
capybara-playwright-driver (0.5.6) capybara-playwright-driver (0.5.7)
addressable addressable
capybara capybara
playwright-ruby-client (>= 1.16.0) playwright-ruby-client (>= 1.16.0)
case_transform (0.2) case_transform (0.2)
activesupport activesupport
cbor (0.5.9.8) cbor (0.5.10.1)
cgi (0.4.2) cgi (0.4.2)
charlock_holmes (0.7.9) charlock_holmes (0.7.9)
chewy (7.6.0) chewy (7.6.0)
@ -164,7 +164,7 @@ GEM
cocoon (1.2.15) cocoon (1.2.15)
color_diff (0.1) color_diff (0.1)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.3) connection_pool (2.5.4)
cose (1.3.1) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
@ -175,9 +175,9 @@ GEM
css_parser (1.21.1) css_parser (1.21.1)
addressable addressable
csv (3.3.5) csv (3.3.5)
database_cleaner-active_record (2.2.1) database_cleaner-active_record (2.2.2)
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.4.1) date (3.4.1)
debug (1.11.0) debug (1.11.0)
@ -207,7 +207,7 @@ GEM
railties (>= 5) railties (>= 5)
dotenv (3.1.8) dotenv (3.1.8)
drb (2.2.3) drb (2.2.3)
dry-cli (1.2.0) dry-cli (1.3.0)
elasticsearch (7.17.11) elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11) elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.11) elasticsearch-transport (= 7.17.11)
@ -224,24 +224,24 @@ GEM
mail (~> 2.7) mail (~> 2.7)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erb (5.0.1) erb (5.0.2)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.4.0)
tzinfo tzinfo
excon (1.2.5) excon (1.3.0)
logger logger
fabrication (3.0.0) fabrication (3.0.0)
faker (3.5.2) faker (3.5.2)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.13.1) faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
faraday-follow_redirects (0.3.0) faraday-follow_redirects (0.4.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-httpclient (2.0.2) faraday-httpclient (2.0.2)
httpclient (>= 2.2) httpclient (>= 2.2)
faraday-net_http (3.4.0) faraday-net_http (3.4.1)
net-http (>= 0.5.0) net-http (>= 0.5.0)
fast_blank (1.0.1) fast_blank (1.0.1)
fastimage (2.4.0) fastimage (2.4.0)
@ -266,42 +266,43 @@ GEM
fog-openstack (1.1.5) fog-openstack (1.1.5)
fog-core (~> 2.1) fog-core (~> 2.1)
fog-json (>= 1.0) fog-json (>= 1.0)
formatador (1.1.0) formatador (1.2.1)
reline
forwardable (1.3.3) forwardable (1.3.3)
fugit (1.11.1) fugit (1.12.0)
et-orbi (~> 1, >= 1.2.11) et-orbi (~> 1.4)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (4.31.0) google-protobuf (4.32.1)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
googleapis-common-protos-types (1.20.0) googleapis-common-protos-types (1.21.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (~> 4.26)
haml (6.3.0) haml (6.3.0)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
tilt tilt
haml-rails (2.1.0) haml-rails (3.0.0)
actionpack (>= 5.1) actionpack (>= 5.1)
activesupport (>= 5.1) activesupport (>= 5.1)
haml (>= 4.0.6) haml (>= 4.0.6)
railties (>= 5.1) railties (>= 5.1)
haml_lint (0.64.0) haml_lint (0.66.0)
haml (>= 5.0) haml (>= 5.0)
parallel (~> 1.10) parallel (~> 1.10)
rainbow rainbow
rubocop (>= 1.0) rubocop (>= 1.0)
sysexits (~> 1.1) sysexits (~> 1.1)
hashdiff (1.1.2) hashdiff (1.2.1)
hashie (5.0.0) hashie (5.0.0)
hcaptcha (7.1.0) hcaptcha (7.1.0)
json json
highline (3.1.2) highline (3.1.2)
reline reline
hiredis (0.6.3) hiredis (0.6.3)
hiredis-client (0.24.0) hiredis-client (0.26.1)
redis-client (= 0.24.0) redis-client (= 0.26.1)
hkdf (0.3.0) hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.3.4)
http (5.3.1) http (5.3.1)
@ -309,13 +310,13 @@ GEM
http-cookie (~> 1.0) http-cookie (~> 1.0)
http-form_data (~> 2.2) http-form_data (~> 2.2)
llhttp-ffi (~> 0.5.0) llhttp-ffi (~> 0.5.0)
http-cookie (1.0.8) http-cookie (1.1.0)
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.3.0) http-form_data (2.3.0)
http_accept_language (2.1.1) http_accept_language (2.1.1)
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
httplog (1.7.0) httplog (1.7.3)
rack (>= 2.0) rack (>= 2.0)
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.14.7) i18n (1.14.7)
@ -335,7 +336,7 @@ GEM
inline_svg (1.10.0) inline_svg (1.10.0)
activesupport (>= 3.0) activesupport (>= 3.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
io-console (0.8.0) io-console (0.8.1)
irb (1.15.2) irb (1.15.2)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
@ -345,9 +346,9 @@ GEM
azure-blob (~> 0.5.2) azure-blob (~> 0.5.2)
hashie (~> 5.0) hashie (~> 5.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.12.2) json (2.15.1)
json-canonicalization (1.0.0) json-canonicalization (1.0.0)
json-jwt (1.16.7) json-jwt (1.17.0)
activesupport (>= 4.2) activesupport (>= 4.2)
aes_key_wrap aes_key_wrap
base64 base64
@ -362,14 +363,14 @@ GEM
rack (>= 2.2, < 4) rack (>= 2.2, < 4)
rdf (~> 3.3) rdf (~> 3.3)
rexml (~> 3.2) rexml (~> 3.2)
json-ld-preloaded (3.3.1) json-ld-preloaded (3.3.2)
json-ld (~> 3.3) json-ld (~> 3.3)
rdf (~> 3.3) rdf (~> 3.3)
json-schema (5.1.1) json-schema (6.0.0)
addressable (~> 2.8) addressable (~> 2.8)
bigdecimal (~> 3.1) bigdecimal (~> 3.1)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
jwt (2.10.1) jwt (2.10.2)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@ -403,7 +404,7 @@ GEM
rexml rexml
link_header (0.0.8) link_header (0.0.8)
lint_roller (1.1.0) lint_roller (1.1.0)
linzer (0.7.3) linzer (0.7.7)
cgi (~> 0.4.2) cgi (~> 0.4.2)
forwardable (~> 1.3, >= 1.3.3) forwardable (~> 1.3, >= 1.3.3)
logger (~> 1.7, >= 1.7.0) logger (~> 1.7, >= 1.7.0)
@ -433,24 +434,26 @@ GEM
marcel (1.0.4) marcel (1.0.4)
mario-redis-lock (1.2.1) mario-redis-lock (1.2.1)
redis (>= 3.0.5) redis (>= 3.0.5)
matrix (0.4.2) matrix (0.4.3)
memory_profiler (1.1.0) memory_profiler (1.1.0)
mime-types (3.7.0) mime-types (3.7.0)
logger logger
mime-types-data (~> 3.2025, >= 3.2025.0507) 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_mime (1.1.5)
mini_portile2 (2.8.9) mini_portile2 (2.8.9)
minitest (5.25.5) minitest (5.25.5)
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.17.0)
mutex_m (0.3.0) mutex_m (0.3.0)
net-http (0.6.0) net-http (0.6.0)
uri uri
net-imap (0.5.8) net-imap (0.5.12)
date date
net-protocol net-protocol
net-ldap (0.19.0) net-ldap (0.20.0)
base64
ostruct
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.2.2)
@ -458,17 +461,18 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.18.8) nokogiri (1.18.10)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.11) oj (3.16.11)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.3) omniauth (2.1.4)
hashie (>= 3.4.6) hashie (>= 3.4.6)
logger
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-cas (3.0.1) omniauth-cas (3.0.2)
addressable (~> 2.8) addressable (~> 2.8)
nokogiri (~> 1.12) nokogiri (~> 1.12)
omniauth (~> 2.1) omniauth (~> 2.1)
@ -494,10 +498,10 @@ GEM
tzinfo tzinfo
validate_url validate_url
webfinger (~> 2.0) webfinger (~> 2.0)
openssl (3.3.0) openssl (3.3.1)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
opentelemetry-api (1.5.0) opentelemetry-api (1.7.0)
opentelemetry-common (0.22.0) opentelemetry-common (0.22.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.30.0) opentelemetry-exporter-otlp (0.30.0)
@ -507,113 +511,88 @@ GEM
opentelemetry-common (~> 0.20) opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.2) opentelemetry-sdk (~> 1.2)
opentelemetry-semantic_conventions opentelemetry-semantic_conventions
opentelemetry-helpers-sql (0.1.1) opentelemetry-helpers-sql (0.2.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.7)
opentelemetry-helpers-sql-obfuscation (0.3.0) opentelemetry-helpers-sql-obfuscation (0.3.0)
opentelemetry-common (~> 0.21) opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.4.0) opentelemetry-instrumentation-action_mailer (0.5.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-action_pack (0.14.1)
opentelemetry-instrumentation-action_pack (0.12.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rack (~> 0.21) opentelemetry-instrumentation-rack (~> 0.21)
opentelemetry-instrumentation-action_view (0.9.0) opentelemetry-instrumentation-action_view (0.10.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-active_job (0.9.2)
opentelemetry-instrumentation-active_job (0.8.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-api (~> 1.0) opentelemetry-instrumentation-active_model_serializers (0.23.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_model_serializers (0.22.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (>= 0.7.0) opentelemetry-instrumentation-active_support (>= 0.7.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-active_record (0.10.1)
opentelemetry-instrumentation-active_record (0.9.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-api (~> 1.0) opentelemetry-instrumentation-active_storage (0.2.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_storage (0.1.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-active_support (0.9.1)
opentelemetry-instrumentation-active_support (0.8.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (0.24.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-api (~> 1.7)
opentelemetry-instrumentation-base (0.23.0)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.21) opentelemetry-common (~> 0.21)
opentelemetry-registry (~> 0.1) opentelemetry-registry (~> 0.1)
opentelemetry-instrumentation-concurrent_ruby (0.22.0) opentelemetry-instrumentation-concurrent_ruby (0.23.1)
opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-excon (0.25.2)
opentelemetry-instrumentation-excon (0.23.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-api (~> 1.0) opentelemetry-instrumentation-faraday (0.29.1)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-faraday (0.27.0) opentelemetry-instrumentation-http (0.26.1)
opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-http_client (0.25.1)
opentelemetry-instrumentation-http (0.25.1) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-api (~> 1.0) opentelemetry-instrumentation-net_http (0.25.1)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-http_client (0.23.0) opentelemetry-instrumentation-pg (0.31.1)
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-helpers-sql opentelemetry-helpers-sql
opentelemetry-helpers-sql-obfuscation opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-rack (0.26.0) opentelemetry-instrumentation-rack (0.28.2)
opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-rails (0.38.0)
opentelemetry-instrumentation-rails (0.36.0) opentelemetry-instrumentation-action_mailer (~> 0.4)
opentelemetry-api (~> 1.0) opentelemetry-instrumentation-action_pack (~> 0.13)
opentelemetry-instrumentation-action_mailer (~> 0.4.0) opentelemetry-instrumentation-action_view (~> 0.9)
opentelemetry-instrumentation-action_pack (~> 0.12.0) opentelemetry-instrumentation-active_job (~> 0.8)
opentelemetry-instrumentation-action_view (~> 0.9.0) opentelemetry-instrumentation-active_record (~> 0.9)
opentelemetry-instrumentation-active_job (~> 0.8.0) opentelemetry-instrumentation-active_storage (~> 0.1)
opentelemetry-instrumentation-active_record (~> 0.9.0) opentelemetry-instrumentation-active_support (~> 0.8)
opentelemetry-instrumentation-active_storage (~> 0.1.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.22)
opentelemetry-instrumentation-active_support (~> 0.8.0) opentelemetry-instrumentation-redis (0.27.1)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) opentelemetry-instrumentation-sidekiq (0.27.1)
opentelemetry-instrumentation-redis (0.26.1) opentelemetry-instrumentation-base (~> 0.24)
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-registry (0.4.0) opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1) opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.8.0) opentelemetry-sdk (1.9.0)
opentelemetry-api (~> 1.1) opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20) opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2) opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.11.0) opentelemetry-semantic_conventions (1.36.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostruct (0.6.1) ostruct (0.6.3)
ox (2.14.23) ox (2.14.23)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.8.0) parser (3.3.9.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.5.9) pg (1.6.2)
pghero (3.7.0) pghero (3.7.0)
activerecord (>= 7.1) activerecord (>= 7.1)
playwright-ruby-client (1.52.0) playwright-ruby-client (1.55.0)
concurrent-ruby (>= 1.1.6) concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0) mime-types (>= 3.0)
pp (0.6.2) pp (0.6.3)
prettyprint prettyprint
premailer (1.27.0) premailer (1.27.0)
addressable addressable
@ -624,25 +603,24 @@ GEM
net-smtp net-smtp
premailer (~> 1.7, >= 1.7.9) premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.4.0) prism (1.5.1)
prometheus_exporter (2.2.0) prometheus_exporter (2.3.0)
webrick webrick
propshaft (1.1.0) propshaft (1.3.1)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
railties (>= 7.0.0)
psych (5.2.6) psych (5.2.6)
date date
stringio stringio
public_suffix (6.0.2) public_suffix (6.0.2)
puma (6.6.0) puma (7.0.4)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.5.0) pundit (2.5.2)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.16) rack (3.2.3)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-cors (3.0.0) rack-cors (3.0.0)
@ -668,76 +646,81 @@ GEM
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.2.1)
rack (>= 3) rack (>= 3)
rails (8.0.2) rails (8.0.3)
actioncable (= 8.0.2) actioncable (= 8.0.3)
actionmailbox (= 8.0.2) actionmailbox (= 8.0.3)
actionmailer (= 8.0.2) actionmailer (= 8.0.3)
actionpack (= 8.0.2) actionpack (= 8.0.3)
actiontext (= 8.0.2) actiontext (= 8.0.3)
actionview (= 8.0.2) actionview (= 8.0.3)
activejob (= 8.0.2) activejob (= 8.0.3)
activemodel (= 8.0.2) activemodel (= 8.0.3)
activerecord (= 8.0.2) activerecord (= 8.0.3)
activestorage (= 8.0.2) activestorage (= 8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 8.0.3)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) 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) 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) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 8.0.0, < 9)
railties (8.0.2) railties (8.0.3)
actionpack (= 8.0.2) actionpack (= 8.0.3)
activesupport (= 8.0.2) activesupport (= 8.0.3)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.0) rake (13.3.0)
rdf (3.3.2) rdf (3.3.4)
bcp47_spec (~> 0.2) bcp47_spec (~> 0.2)
bigdecimal (~> 3.1, >= 3.1.5) bigdecimal (~> 3.1, >= 3.1.5)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
logger (~> 1.5)
ostruct (~> 0.6)
readline (~> 0.0)
rdf-normalize (0.7.0) rdf-normalize (0.7.0)
rdf (~> 3.3) rdf (~> 3.3)
rdoc (6.14.1) rdoc (6.15.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort
readline (0.0.4)
reline
redcarpet (3.6.1) redcarpet (3.6.1)
redis (4.8.1) redis (4.8.1)
redis-client (0.24.0) redis-client (0.26.1)
connection_pool connection_pool
redlock (1.3.2) regexp_parser (2.11.3)
redis (>= 3.0.0, < 6.0) reline (0.6.2)
regexp_parser (2.10.0)
reline (0.6.1)
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.7.0) request_store (1.7.0)
rack (>= 1.4) rack (>= 1.4)
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
rexml (3.4.1) rexml (3.4.4)
rotp (6.3.0) rotp (6.3.0)
rouge (4.5.2) rouge (4.6.1)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (3.1.0) rqrcode (3.1.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.0.0) rqrcode_core (2.0.0)
rspec (3.13.0) rspec (3.13.1)
rspec-core (~> 3.13.0) rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0) rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0) rspec-mocks (~> 3.13.0)
rspec-core (3.13.4) rspec-core (3.13.5)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.5) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
@ -747,7 +730,7 @@ GEM
rspec-mocks (3.13.5) rspec-mocks (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (8.0.1) rspec-rails (8.0.2)
actionpack (>= 7.2) actionpack (>= 7.2)
activesupport (>= 7.2) activesupport (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
@ -755,13 +738,13 @@ GEM
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-sidekiq (5.1.0) rspec-sidekiq (5.2.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
rspec-expectations (~> 3.0) rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9) sidekiq (>= 5, < 9)
rspec-support (3.13.4) rspec-support (3.13.6)
rubocop (1.77.0) rubocop (1.81.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@ -769,10 +752,10 @@ GEM
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.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) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.45.1) rubocop-ast (1.47.1)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.4)
rubocop-capybara (2.22.1) rubocop-capybara (2.22.1)
@ -781,17 +764,17 @@ GEM
rubocop-i18n (3.2.3) rubocop-i18n (3.2.3)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.72.1) rubocop (>= 1.72.1)
rubocop-performance (1.25.0) rubocop-performance (1.26.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails (2.32.0) rubocop-rails (2.33.4)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.6.0) rubocop-rspec (3.7.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1) rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0) rubocop-rspec_rails (2.31.0)
@ -801,38 +784,37 @@ GEM
ruby-prof (1.7.2) ruby-prof (1.7.2)
base64 base64
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-saml (1.18.0) ruby-saml (1.18.1)
nokogiri (>= 1.13.10) nokogiri (>= 1.13.10)
rexml rexml
ruby-vips (2.2.4) ruby-vips (2.2.5)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (2.4.1) rubyzip (3.1.1)
rufus-scheduler (3.9.2) rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1) fugit (~> 1.1, >= 1.11.1)
safety_net_attestation (0.4.0) safety_net_attestation (0.5.0)
jwt (~> 2.0) jwt (>= 2.0, < 4.0)
sanitize (7.0.0) sanitize (7.0.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.16.8) nokogiri (>= 1.16.8)
scenic (1.8.0) scenic (1.9.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
securerandom (0.4.1) securerandom (0.4.1)
shoulda-matchers (6.5.0) shoulda-matchers (6.5.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
sidekiq (7.3.9) sidekiq (8.0.8)
base64 connection_pool (>= 2.5.0)
connection_pool (>= 2.3.0) json (>= 2.9.0)
logger logger (>= 1.6.2)
rack (>= 2.2.4) rack (>= 3.1.0)
redis-client (>= 0.22.2) redis-client (>= 0.23.2)
sidekiq-bulk (0.2.0) sidekiq-bulk (0.2.0)
sidekiq sidekiq
sidekiq-scheduler (5.0.6) sidekiq-scheduler (6.0.1)
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8) sidekiq (>= 7.3, < 9)
tilt (>= 1.4.0, < 3)
sidekiq-unique-jobs (8.0.11) sidekiq-unique-jobs (8.0.11)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 7.0.0, < 9.0.0) sidekiq (>= 7.0.0, < 9.0.0)
@ -846,16 +828,16 @@ GEM
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1) simplecov-html (0.13.2)
simplecov-lcov (0.8.0) simplecov-lcov (0.9.0)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
stackprof (0.2.27) stackprof (0.2.27)
starry (0.2.0) starry (0.2.0)
base64 base64
stoplight (4.1.1) stoplight (5.3.8)
redlock (~> 1.0) zeitwerk
stringio (3.1.7) stringio (3.1.7)
strong_migrations (2.4.0) strong_migrations (2.5.1)
activerecord (>= 7.1) activerecord (>= 7.1)
swd (2.0.3) swd (2.0.3)
activesupport (>= 3) activesupport (>= 3)
@ -863,19 +845,20 @@ GEM
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
sysexits (1.2.0) sysexits (1.2.0)
temple (0.10.3) temple (0.10.4)
terminal-table (4.0.0) terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4) unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.0) terrapin (1.1.1)
climate_control climate_control
test-prof (1.4.4) test-prof (1.4.4)
thor (1.3.2) thor (1.4.0)
tilt (2.6.0) tilt (2.6.1)
timeout (0.4.3) timeout (0.4.3)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
openssl (> 2.0) openssl (> 2.0)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
tsort (0.2.0)
tty-color (0.6.0) tty-color (0.6.0)
tty-cursor (0.7.1) tty-cursor (0.7.1)
tty-prompt (0.23.1) tty-prompt (0.23.1)
@ -896,10 +879,10 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.9.1) unf_ext (0.0.9.1)
unicode-display_width (3.1.4) unicode-display_width (3.2.0)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.1)
unicode-emoji (4.0.4) unicode-emoji (4.1.0)
uri (1.0.3) uri (1.0.4)
useragent (0.16.11) useragent (0.16.11)
validate_url (1.0.15) validate_url (1.0.15)
activemodel (>= 3.0.0) activemodel (>= 3.0.0)
@ -915,13 +898,13 @@ GEM
zeitwerk (~> 2.2) zeitwerk (~> 2.2)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
webauthn (3.4.1) webauthn (3.4.2)
android_key_attestation (~> 0.3.0) android_key_attestation (~> 0.3.0)
bindata (~> 2.4) bindata (~> 2.4)
cbor (~> 0.5.9) cbor (~> 0.5.9)
cose (~> 1.1) cose (~> 1.1)
openssl (>= 2.2) openssl (>= 2.2)
safety_net_attestation (~> 0.4.0) safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0) tpm-key_attestation (~> 0.14.0)
webfinger (2.1.3) webfinger (2.1.3)
activesupport activesupport
@ -932,7 +915,7 @@ GEM
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1) webrick (1.9.1)
websocket-driver (0.7.7) websocket-driver (0.8.0)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
@ -985,7 +968,7 @@ DEPENDENCIES
flatware-rspec flatware-rspec
fog-core (<= 2.6.0) fog-core (<= 2.6.0)
fog-openstack (~> 1.0) fog-openstack (~> 1.0)
haml-rails (~> 2.0) haml-rails (~> 3.0)
haml_lint haml_lint
hcaptcha (~> 7.1) hcaptcha (~> 7.1)
hiredis (~> 0.6) hiredis (~> 0.6)
@ -1002,13 +985,13 @@ DEPENDENCIES
jd-paperclip-azure (~> 3.0) jd-paperclip-azure (~> 3.0)
json-ld json-ld
json-ld-preloaded (~> 3.2) json-ld-preloaded (~> 3.2)
json-schema (~> 5.0) json-schema (~> 6.0)
kaminari (~> 1.2) kaminari (~> 1.2)
kt-paperclip (~> 7.2) kt-paperclip (~> 7.2)
letter_opener (~> 1.8) letter_opener (~> 1.8)
letter_opener_web (~> 3.0) letter_opener_web (~> 3.0)
link_header (~> 0.0) link_header (~> 0.0)
linzer (~> 0.7.2) linzer (~> 0.7.7)
lograge (~> 0.12) lograge (~> 0.12)
mail (~> 2.8) mail (~> 2.8)
mario-redis-lock (~> 1.2) mario-redis-lock (~> 1.2)
@ -1024,31 +1007,32 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0) omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0) omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.8.0) omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.5.0) opentelemetry-api (~> 1.7.0)
opentelemetry-exporter-otlp (~> 0.30.0) opentelemetry-exporter-otlp (~> 0.30.0)
opentelemetry-instrumentation-active_job (~> 0.8.0) opentelemetry-instrumentation-active_job (~> 0.9.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0) opentelemetry-instrumentation-active_model_serializers (~> 0.23.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.23.0)
opentelemetry-instrumentation-excon (~> 0.23.0) opentelemetry-instrumentation-excon (~> 0.25.0)
opentelemetry-instrumentation-faraday (~> 0.27.0) opentelemetry-instrumentation-faraday (~> 0.29.0)
opentelemetry-instrumentation-http (~> 0.25.0) opentelemetry-instrumentation-http (~> 0.26.0)
opentelemetry-instrumentation-http_client (~> 0.23.0) opentelemetry-instrumentation-http_client (~> 0.25.0)
opentelemetry-instrumentation-net_http (~> 0.23.0) opentelemetry-instrumentation-net_http (~> 0.25.0)
opentelemetry-instrumentation-pg (~> 0.30.0) opentelemetry-instrumentation-pg (~> 0.31.0)
opentelemetry-instrumentation-rack (~> 0.26.0) opentelemetry-instrumentation-rack (~> 0.28.0)
opentelemetry-instrumentation-rails (~> 0.36.0) opentelemetry-instrumentation-rails (~> 0.38.0)
opentelemetry-instrumentation-redis (~> 0.26.0) opentelemetry-instrumentation-redis (~> 0.27.0)
opentelemetry-instrumentation-sidekiq (~> 0.26.0) opentelemetry-instrumentation-sidekiq (~> 0.27.0)
opentelemetry-sdk (~> 1.4) opentelemetry-sdk (~> 1.4)
ox (~> 2.14) ox (~> 2.14)
parslet parslet
pg (~> 1.5) pg (~> 1.5)
pghero pghero
playwright-ruby-client (= 1.55.0)
premailer-rails premailer-rails
prometheus_exporter (~> 2.2) prometheus_exporter (~> 2.2)
propshaft propshaft
public_suffix (~> 6.0) public_suffix (~> 6.0)
puma (~> 6.3) puma (~> 7.0)
pundit (~> 2.3) pundit (~> 2.3)
rack-attack (~> 6.6) rack-attack (~> 6.6)
rack-cors rack-cors
@ -1072,20 +1056,20 @@ DEPENDENCIES
ruby-prof ruby-prof
ruby-progressbar (~> 1.13) ruby-progressbar (~> 1.13)
ruby-vips (~> 2.2) ruby-vips (~> 2.2)
rubyzip (~> 2.3) rubyzip (~> 3.0)
sanitize (~> 7.0) sanitize (~> 7.0)
scenic (~> 1.7) scenic (~> 1.7)
shoulda-matchers shoulda-matchers
sidekiq (< 8) sidekiq (< 9)
sidekiq-bulk (~> 0.2.0) sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 5.0) sidekiq-scheduler (~> 6.0)
sidekiq-unique-jobs (> 8) sidekiq-unique-jobs (> 8)
simple-navigation (~> 4.4) simple-navigation (~> 4.4)
simple_form (~> 5.2) simple_form (~> 5.2)
simplecov (~> 0.22) simplecov (~> 0.22)
simplecov-lcov (~> 0.8) simplecov-lcov (~> 0.8)
stackprof stackprof
stoplight (~> 4.1) stoplight
strong_migrations strong_migrations
test-prof test-prof
thor (~> 1.2) thor (~> 1.2)
@ -1096,10 +1080,11 @@ DEPENDENCIES
webauthn (~> 3.0) webauthn (~> 3.0)
webmock (~> 3.18) webmock (~> 3.18)
webpush! webpush!
websocket-driver (~> 0.8)
xorcist (~> 1.1) xorcist (~> 1.1)
RUBY VERSION RUBY VERSION
ruby 3.4.1p0 ruby 3.4.1p0
BUNDLED WITH BUNDLED WITH
2.6.9 2.7.2

View File

@ -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. - [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
- [Node.js](https://nodejs.org/) powers the streaming API. - [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. - [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. - [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. - [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic)
### Requirements ### Requirements
- **Ruby** 3.2+ - **Ruby** 3.2+
- **PostgreSQL** 13+ - **PostgreSQL** 13+
- **Redis** 6.2+ - **Redis** 7.0+
- **Node.js** 20+ - **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. 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.

View File

@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
## Supported Versions ## Supported Versions
| Version | Supported | | Version | Supported |
| ------- | --------- | | ------- | ---------------- |
| 4.3.x | Yes | | 4.4.x | Yes |
| 4.2.x | Yes | | 4.3.x | Yes |
| < 4.2 | No | | 4.2.x | Until 2026-01-08 |
| < 4.2 | No |

3
Vagrantfile vendored
View File

@ -54,6 +54,7 @@ sudo apt-get install \
pkg-config \ pkg-config \
protobuf-compiler \ protobuf-compiler \
zlib1g-dev \ zlib1g-dev \
libvips42t64 \
-y -y
# Install rvm # Install rvm
@ -134,7 +135,7 @@ VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 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| config.vm.provider :virtualbox do |vb|
vb.name = "mastodon" vb.name = "mastodon"

View File

@ -71,6 +71,10 @@ class AccountsController < ApplicationController
params[:username] params[:username]
end end
def account_id_param
params[:id]
end
def skip_temporary_suspension_response? def skip_temporary_suspension_response?
request.format == :json request.format == :json
end end

View File

@ -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

View File

@ -28,7 +28,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
def likes_collection_presenter def likes_collection_presenter
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: account_status_likes_url(@account, @status), id: ActivityPub::TagManager.instance.likes_uri_for(@status),
type: :unordered, type: :unordered,
size: @status.favourites_count size: @status.favourites_count
) )

View File

@ -73,6 +73,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
end end
def set_account 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
end end

View File

@ -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

View File

@ -37,7 +37,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
def replies_collection_presenter def replies_collection_presenter
page = ActivityPub::CollectionPresenter.new( 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, type: :unordered,
part_of: account_status_replies_url(@account, @status), part_of: account_status_replies_url(@account, @status),
next: next_page, next: next_page,
@ -47,7 +47,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
return page if page_requested? return page if page_requested?
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: account_status_replies_url(@account, @status), id: ActivityPub::TagManager.instance.replies_uri_for(@status),
type: :unordered, type: :unordered,
first: page first: page
) )
@ -66,8 +66,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
# Only consider remote accounts # Only consider remote accounts
return nil if @replies.size < DESCENDANTS_LIMIT return nil if @replies.size < DESCENDANTS_LIMIT
account_status_replies_url( ActivityPub::TagManager.instance.replies_uri_for(
@account,
@status, @status,
page: true, page: true,
min_id: @replies&.last&.id, 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 # 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 next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT
account_status_replies_url( ActivityPub::TagManager.instance.replies_uri_for(
@account,
@status, @status,
page: true, page: true,
min_id: next_only_other_accounts ? nil : @replies&.last&.id, min_id: next_only_other_accounts ? nil : @replies&.last&.id,

View File

@ -28,7 +28,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
def shares_collection_presenter def shares_collection_presenter
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: account_status_shares_url(@account, @status), id: ActivityPub::TagManager.instance.shares_uri_for(@status),
type: :unordered, type: :unordered,
size: @status.reblogs_count size: @status.reblogs_count
) )

View File

@ -14,16 +14,20 @@ module Admin
def create def create
authorize @account, :show? authorize @account, :show?
account_action = Admin::AccountAction.new(resource_params) @account_action = Admin::AccountAction.new(resource_params)
account_action.target_account = @account @account_action.target_account = @account
account_action.current_account = current_account @account_action.current_account = current_account
account_action.save! if @account_action.save
if @account_action.with_report?
if account_action.with_report? redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id]) else
redirect_to admin_account_path(@account.id)
end
else else
redirect_to admin_account_path(@account.id) @warning_presets = AccountWarningPreset.all
render :new
end end
end end

View File

@ -16,11 +16,14 @@ module Admin
def batch def batch
authorize :account, :index? authorize :account, :index?
@form = Form::AccountBatch.new(form_account_batch_params) @form = Form::AccountBatch.new(
@form.current_account = current_account form_account_batch_params.merge(
@form.action = action_from_button action: action_from_button,
@form.select_all_matching = params[:select_all_matching] current_account:,
@form.query = filtered_accounts query: filtered_accounts,
select_all_matching: params[:select_all_matching]
)
)
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected') flash[:alert] = I18n.t('admin.accounts.no_account_selected')

View File

@ -6,7 +6,7 @@ module Admin
def index def index
authorize :audit_log, :index? authorize :audit_log, :index?
@auditable_accounts = Account.auditable.select(:id, :username) @auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc)
end end
private private

View File

@ -19,15 +19,13 @@ module Admin
log_action :resend, @user log_action :resend, @user
flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success') redirect_to admin_accounts_path, notice: t('admin.accounts.resend_confirmation.success')
redirect_to admin_accounts_path
end end
private private
def redirect_confirmed_user def redirect_confirmed_user
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') redirect_to admin_accounts_path, flash: { error: t('admin.accounts.resend_confirmation.already_confirmed') }
redirect_to admin_accounts_path
end end
def user_confirmed? def user_confirmed?

View File

@ -9,10 +9,16 @@ module Admin
@pending_appeals_count = Appeal.pending.async_count @pending_appeals_count = Appeal.pending.async_count
@pending_reports_count = Report.unresolved.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 @pending_users_count = User.pending.async_count
@system_checks = Admin::SystemCheck.perform(current_user) @system_checks = Admin::SystemCheck.perform(current_user)
@time_period = (29.days.ago.to_date...Time.now.utc.to_date) @time_period = (29.days.ago.to_date...Time.now.utc.to_date)
end end
private
def pending_tags
::Trends::TagFilter.new(status: :pending_review).results
end
end end
end end

View File

@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
end end
def reject def reject
authorize @appeal, :approve? authorize @appeal, :reject?
log_action :reject, @appeal log_action :reject, @appeal
@appeal.reject!(current_account) @appeal.reject!(current_account)
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later

View File

@ -36,7 +36,7 @@ module Admin
end end
def edit def edit
authorize :domain_block, :create? authorize :domain_block, :update?
end end
def create def create
@ -129,7 +129,7 @@ module Admin
end end
def requires_confirmation? 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 end
end end

View File

@ -49,8 +49,8 @@ module Admin
def export_data def export_data
CSV.generate(headers: export_headers, write_headers: true) do |content| CSV.generate(headers: export_headers, write_headers: true) do |content|
DomainAllow.allowed_domains.each do |instance| DomainAllow.allowed_domains.each do |domain|
content << [instance.domain] content << [domain]
end end
end end
end end

View File

@ -13,27 +13,9 @@ class Admin::Reports::ActionsController < Admin::BaseController
case action_from_button case action_from_button
when 'delete', 'mark_as_sensitive' when 'delete', 'mark_as_sensitive'
status_batch_action = Admin::StatusBatchAction.new( Admin::StatusBatchAction.new(status_batch_action_params).save!
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!
when 'silence', 'suspend' when 'silence', 'suspend'
account_action = Admin::AccountAction.new( Admin::AccountAction.new(account_action_params).save!
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!
else else
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button) return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
end end
@ -43,6 +25,26 @@ class Admin::Reports::ActionsController < Admin::BaseController
private 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 def set_report
@report = Report.find(params[:report_id]) @report = Report.find(params[:report_id])
end end

View File

@ -14,8 +14,7 @@ module Admin
@admin_settings = Form::AdminSettings.new(settings_params) @admin_settings = Form::AdminSettings.new(settings_params)
if @admin_settings.save if @admin_settings.save
flash[:notice] = I18n.t('generic.changes_saved_msg') redirect_to after_update_redirect_path, notice: t('generic.changes_saved_msg')
redirect_to after_update_redirect_path
else else
render :show render :show
end end

View File

@ -5,6 +5,7 @@ module Admin
before_action :set_tag, except: [:index] before_action :set_tag, except: [:index]
PER_PAGE = 20 PER_PAGE = 20
PERIOD_DAYS = 6.days
def index def index
authorize :tag, :index? authorize :tag, :index?
@ -15,7 +16,7 @@ module Admin
def show def show
authorize @tag, :show? authorize @tag, :show?
@time_period = (6.days.ago.to_date...Time.now.utc.to_date) @time_period = report_range
end end
def update def update
@ -24,7 +25,7 @@ module Admin
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc)) 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') redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
else else
@time_period = (6.days.ago.to_date...Time.now.utc.to_date) @time_period = report_range
render :show render :show
end end
@ -36,6 +37,10 @@ module Admin
@tag = Tag.find(params[:id]) @tag = Tag.find(params[:id])
end end
def report_range
(PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
end
def tag_params def tag_params
params params
.expect(tag: [:name, :display_name, :trendable, :usable, :listable]) .expect(tag: [:name, :display_name, :trendable, :usable, :listable])

View File

@ -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

View File

@ -48,6 +48,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy), default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive), default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
default_language: source_params.fetch(:language, @account.user.setting_default_language), 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 end

View File

@ -2,6 +2,7 @@
class Api::V1::Admin::TagsController < Api::BaseController class Api::V1::Admin::TagsController < Api::BaseController
include Authorization include Authorization
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update before_action -> { authorize_if_got_token! :'admin:write' }, only: :update

View File

@ -7,6 +7,7 @@ class Api::V1::InvitesController < Api::BaseController
skip_around_action :set_locale skip_around_action :set_locale
before_action :set_invite before_action :set_invite
before_action :check_valid_usage!
before_action :check_enabled_registrations! before_action :check_enabled_registrations!
# Override `current_user` to avoid reading session cookies # 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]) @invite = Invite.find_by!(code: params[:invite_code])
end end
def check_enabled_registrations! def check_valid_usage!
return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use? 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) raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
end end
end end

View File

@ -16,16 +16,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def create def create
with_redis_lock("push_subscription:#{current_user.id}") do with_redis_lock("push_subscription:#{current_user.id}") do
destroy_web_push_subscriptions! destroy_web_push_subscriptions!
@push_subscription = Web::PushSubscription.create!(web_push_subscription_params)
@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
)
end end
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
@ -55,6 +46,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
not_found if @push_subscription.nil? not_found if @push_subscription.nil?
end 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 def subscription_params
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]]) params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
end end

View File

@ -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

View File

@ -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

View File

@ -2,6 +2,8 @@
class Api::V1::StatusesController < Api::BaseController class Api::V1::StatusesController < Api::BaseController
include Authorization include Authorization
include AsyncRefreshesConcern
include Api::InteractionPoliciesConcern
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [: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_statuses, only: [:index]
before_action :set_status, only: [:show, :context] before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create] before_action :set_thread, only: [:create]
before_action :set_quoted_status, only: [:create]
before_action :check_statuses_limit, only: [:index] before_action :check_statuses_limit, only: [:index]
override_rate_limit_headers :create, family: :statuses 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) @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.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 end
def create def create
@ -67,6 +82,8 @@ class Api::V1::StatusesController < Api::BaseController
current_user.account, current_user.account,
text: status_params[:status], text: status_params[:status],
thread: @thread, thread: @thread,
quoted_status: @quoted_status,
quote_approval_policy: quote_approval_policy,
media_ids: status_params[:media_ids], media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
@ -98,7 +115,8 @@ class Api::V1::StatusesController < Api::BaseController
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
language: status_params[:language], language: status_params[:language],
spoiler_text: status_params[:spoiler_text], 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 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 render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
end 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 def check_statuses_limit
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
end end
@ -154,6 +180,8 @@ class Api::V1::StatusesController < Api::BaseController
params.permit( params.permit(
:status, :status,
:in_reply_to_id, :in_reply_to_id,
:quoted_status_id,
:quote_approval_policy,
:sensitive, :sensitive,
:spoiler_text, :spoiler_text,
:visibility, :visibility,

View File

@ -3,14 +3,8 @@
class Api::V1::Timelines::BaseController < Api::BaseController class Api::V1::Timelines::BaseController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? } after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
before_action :require_user!, if: :require_auth?
private private
def require_auth?
!Setting.timeline_preview
end
def pagination_collection def pagination_collection
@statuses @statuses
end end

View File

@ -3,8 +3,8 @@
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
include AsyncRefreshesConcern include AsyncRefreshesConcern
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show] before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
before_action :require_user!, only: [:show] before_action :require_user!
PERMITTED_PARAMS = %i(local limit).freeze PERMITTED_PARAMS = %i(local limit).freeze

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # 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 -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_preview_card before_action :set_preview_card
before_action :set_statuses before_action :set_statuses

View File

@ -2,6 +2,7 @@
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' } 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 PERMITTED_PARAMS = %i(local remote limit only_media).freeze
@ -13,6 +14,16 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
private 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 def load_statuses
preloaded_public_statuses_page preloaded_public_statuses_page
end end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # 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 -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :load_tag before_action :load_tag
@ -14,10 +14,6 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
private private
def require_auth?
!Setting.timeline_preview
end
def load_tag def load_tag
@tag = Tag.find_normalized(params[:id]) @tag = Tag.find_normalized(params[:id])
end end

View File

@ -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

View File

@ -20,7 +20,7 @@ class Api::V2::SearchController < Api::BaseController
@search = Search.new(search_results) @search = Search.new(search_results)
render json: @search, serializer: REST::SearchSerializer render json: @search, serializer: REST::SearchSerializer
rescue Mastodon::SyntaxError rescue Mastodon::SyntaxError
unprocessable_entity unprocessable_content
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
not_found not_found
end end

View File

@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
{ {
policy: 'all', policy: 'all',
alerts: Notification::TYPES.index_with { alerts_enabled }, alerts: Notification::TYPES.index_with { alerts_enabled },
} }.deep_stringify_keys
end end
def alerts_enabled def alerts_enabled

View File

@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base
rescue_from Mastodon::NotPermittedError, with: :forbidden rescue_from Mastodon::NotPermittedError, with: :forbidden
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::UnknownFormat, with: :not_acceptable 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::RateLimitExceededError, with: :too_many_requests
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
@ -123,7 +123,7 @@ class ApplicationController < ActionController::Base
respond_with_error(410) respond_with_error(410)
end end
def unprocessable_entity def unprocessable_content
respond_with_error(422) respond_with_error(422)
end end

View File

@ -38,8 +38,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
private private
def record_login_activity def record_login_activity
LoginActivity.create( @user.login_activities.create(
user: @user,
success: true, success: true,
authentication_method: :omniauth, authentication_method: :omniauth,
provider: @provider, provider: @provider,

View File

@ -19,8 +19,7 @@ class Auth::PasswordsController < Devise::PasswordsController
private private
def redirect_invalid_reset_token def redirect_invalid_reset_token
flash[:error] = I18n.t('auth.invalid_reset_password_token') redirect_to new_password_path(resource_name), flash: { error: t('auth.invalid_reset_password_token') }
redirect_to new_password_path(resource_name)
end end
def reset_password_token_is_valid? def reset_password_token_is_valid?

View File

@ -23,11 +23,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
super(&:build_invite_request) super(&:build_invite_request)
end end
def edit # rubocop:disable Lint/UselessMethodDefinition def edit
super super
end end
def create # rubocop:disable Lint/UselessMethodDefinition def create
super super
end end

View File

@ -12,6 +12,8 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_functional! skip_before_action :require_functional!
skip_before_action :update_user_sign_in skip_before_action :update_user_sign_in
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
prepend_before_action :check_suspicious!, only: [:create] prepend_before_action :check_suspicious!, only: [:create]
include Auth::TwoFactorAuthenticationConcern include Auth::TwoFactorAuthenticationConcern
@ -31,11 +33,9 @@ class Auth::SessionsController < Devise::SessionsController
end end
def destroy def destroy
tmp_stored_location = stored_location_for(:user)
super super
session.delete(:challenge_passed_at) session.delete(:challenge_passed_at)
flash.delete(:notice) flash.delete(:notice)
store_location_for(:user, tmp_stored_location) if continue_after?
end end
def webauthn_options def webauthn_options
@ -96,6 +96,12 @@ class Auth::SessionsController < Devise::SessionsController
private private
def preserve_stored_location
original_stored_location = stored_location_for(:user)
yield
store_location_for(:user, original_stored_location)
end
def check_suspicious! def check_suspicious!
user = find_user user = find_user
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil? @login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
@ -151,12 +157,11 @@ class Auth::SessionsController < Devise::SessionsController
sign_in(user) sign_in(user)
flash.delete(:notice) flash.delete(:notice)
LoginActivity.create( user.login_activities.create(
user: user, request_details.merge(
success: true, authentication_method: security_measure,
authentication_method: security_measure, success: true
ip: request.remote_ip, )
user_agent: request.user_agent
) )
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious 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 end
def on_authentication_failure(user, security_measure, failure_reason) def on_authentication_failure(user, security_measure, failure_reason)
LoginActivity.create( user.login_activities.create(
user: user, request_details.merge(
success: false, authentication_method: security_measure,
authentication_method: security_measure, failure_reason: failure_reason,
failure_reason: failure_reason, success: false
ip: request.remote_ip, )
user_agent: request.user_agent
) )
# Only send a notification email every hour at most # 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! UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
end end
def request_details
{
ip: request.remote_ip,
user_agent: request.user_agent,
}
end
def second_factor_attempts_key(user) def second_factor_attempts_key(user)
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}" "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
end end

View File

@ -18,7 +18,11 @@ module AccountOwnedConcern
end end
def set_account 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 end
def username_param def username_param

View File

@ -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

View File

@ -6,6 +6,9 @@ module AsyncRefreshesConcern
def add_async_refresh_header(async_refresh, retry_seconds: 3) def add_async_refresh_header(async_refresh, retry_seconds: 3)
return unless async_refresh.running? 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
end end

View File

@ -5,6 +5,18 @@ module Auth::CaptchaConcern
include Hcaptcha::Adapters::ViewMethods 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 included do
helper_method :render_captcha helper_method :render_captcha
end end
@ -42,20 +54,9 @@ module Auth::CaptchaConcern
end end
def extend_csp_for_captcha! 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? request.content_security_policy = captcha_adjusted_policy
%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
end end
def render_captcha def render_captcha
@ -63,4 +64,24 @@ module Auth::CaptchaConcern
hcaptcha_tags hcaptcha_tags
end 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 end

View File

@ -9,6 +9,8 @@ module SignatureVerification
EXPIRATION_WINDOW_LIMIT = 12.hours EXPIRATION_WINDOW_LIMIT = 12.hours
CLOCK_SKEW_MARGIN = 1.hour CLOCK_SKEW_MARGIN = 1.hour
STOPLIGHT_COOL_OFF_TIME = 5.minutes.seconds
STOPLIGHT_THRESHOLD = 1
def require_account_signature! def require_account_signature!
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account 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) return (@signed_request_actor = actor) if signed_request.verified?(actor)
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}" 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 rescue Mastodon::SignatureVerificationError => e
fail_with! e.message fail_with! e.message
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
@ -104,10 +109,12 @@ module SignatureVerification
end end
def stoplight_wrapper def stoplight_wrapper
Stoplight("source:#{request.remote_ip}") Stoplight(
.with_threshold(1) "source:#{request.remote_ip}",
.with_cool_off_time(5.minutes.seconds) cool_off_time: STOPLIGHT_COOL_OFF_TIME,
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) } threshold: STOPLIGHT_THRESHOLD,
tracked_errors: [HTTP::Error, OpenSSL::SSL::SSLError]
)
end end
def actor_refresh_key!(actor) def actor_refresh_key!(actor)

View File

@ -60,17 +60,17 @@ class FollowerAccountsController < ApplicationController
def collection_presenter def collection_presenter
if page_requested? if page_requested?
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: account_followers_url(@account, page: params.fetch(:page, 1)), id: page_url(params.fetch(:page, 1)),
type: :ordered, type: :ordered,
size: @account.followers_count, size: @account.followers_count,
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) }, 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, next: next_page_url,
prev: prev_page_url prev: prev_page_url
) )
else else
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: account_followers_url(@account), id: ActivityPub::TagManager.instance.followers_uri_for(@account),
type: :ordered, type: :ordered,
size: @account.followers_count, size: @account.followers_count,
first: page_url(1) first: page_url(1)

View File

@ -49,7 +49,7 @@ class FollowingAccountsController < ApplicationController
end end
def page_url(page) 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 end
def next_page_url def next_page_url
@ -63,17 +63,17 @@ class FollowingAccountsController < ApplicationController
def collection_presenter def collection_presenter
if page_requested? if page_requested?
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: account_following_index_url(@account, page: params.fetch(:page, 1)), id: page_url(params.fetch(:page, 1)),
type: :ordered, type: :ordered,
size: @account.following_count, size: @account.following_count,
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) }, 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, next: next_page_url,
prev: prev_page_url prev: prev_page_url
) )
else else
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: account_following_index_url(@account), id: ActivityPub::TagManager.instance.following_uri_for(@account),
type: :ordered, type: :ordered,
size: @account.following_count, size: @account.following_count,
first: page_url(1) first: page_url(1)

View File

@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
skip_before_action :require_functional! skip_before_action :require_functional!
def index 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
end end

View File

@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
end end
def destroy def destroy
if current_account.moved_to_account_id.present? if current_account.moved?
current_account.update!(moved_to_account: nil) current_account.update!(moved_to_account: nil)
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
end end

View File

@ -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

View File

@ -8,8 +8,7 @@ class Settings::SessionsController < Settings::BaseController
def destroy def destroy
@session.destroy! @session.destroy!
flash[:notice] = I18n.t('sessions.revoke_success') redirect_to edit_user_registration_path, notice: t('sessions.revoke_success')
redirect_to edit_user_registration_path
end end
private private

View File

@ -52,7 +52,7 @@ module Settings
end end
else else
flash[:error] = I18n.t('webauthn_credentials.create.error') flash[:error] = I18n.t('webauthn_credentials.create.error')
status = :unprocessable_entity status = :unprocessable_content
end end
else else
flash[:error] = t('webauthn_credentials.create.error') flash[:error] = t('webauthn_credentials.create.error')
@ -86,13 +86,11 @@ module Settings
private private
def redirect_invalid_otp def redirect_invalid_otp
flash[:error] = t('webauthn_credentials.otp_required') redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.otp_required') }
redirect_to settings_two_factor_authentication_methods_path
end end
def redirect_invalid_webauthn def redirect_invalid_webauthn
flash[:error] = t('webauthn_credentials.not_enabled') redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.not_enabled') }
redirect_to settings_two_factor_authentication_methods_path
end end
end end
end end

View File

@ -11,6 +11,7 @@ class StatusesController < ApplicationController
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status before_action :set_status
before_action :redirect_to_original, only: :show before_action :redirect_to_original, only: :show
before_action :verify_embed_allowed, only: :embed
after_action :set_link_headers after_action :set_link_headers
@ -40,8 +41,6 @@ class StatusesController < ApplicationController
end end
def embed def embed
return not_found if @status.hidden? || @status.reblog?
expires_in 180, public: true expires_in 180, public: true
response.headers.delete('X-Frame-Options') response.headers.delete('X-Frame-Options')
@ -50,6 +49,10 @@ class StatusesController < ApplicationController
private private
def verify_embed_allowed
not_found if @status.hidden? || @status.reblog?
end
def set_link_headers def set_link_headers
response.headers['Link'] = LinkHeader.new( response.headers['Link'] = LinkHeader.new(
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]] [[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]

View File

@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
end end
when 'UserRole' when 'UserRole'
link_to log.human_identifier, admin_roles_path(log.target_id) 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' when 'Report'
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'

View File

@ -66,7 +66,7 @@ module ApplicationHelper
def provider_sign_in_link(provider) 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) 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 end
def locale_direction def locale_direction
@ -102,6 +102,16 @@ module ApplicationHelper
policy(record).public_send(:"#{action}?") policy(record).public_send(:"#{action}?")
end 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 = {}) def material_symbol(icon, attributes = {})
safe_join( safe_join(
[ [
@ -233,6 +243,10 @@ module ApplicationHelper
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options) tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
end 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) def recent_tag_usage(tag)
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts 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 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' 'https://play.google.com/store/apps/details?id=org.joinmastodon.android'
end end
def within_authorization_flow?
session[:user_return_to].present? && Rails.application.routes.recognize_path(session[:user_return_to])[:controller] == 'oauth/authorizations'
end
private private
def storage_host_var def storage_host_var

View File

@ -26,6 +26,12 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' }, 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: { interaction_policies: {
'gts' => 'https://gotosocial.org/ns#', 'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
@ -33,6 +39,12 @@ module ContextHelper
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' }, 'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@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 }.freeze
def full_context def full_context

View File

@ -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

View File

@ -27,7 +27,9 @@ module FormattingHelper
module_function :extract_status_plain_text module_function :extract_status_plain_text
def status_content_format(status) 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 end
def rss_status_content_format(status) def rss_status_content_format(status)
@ -65,12 +67,12 @@ module FormattingHelper
end end
def rss_content_preroll(status) def rss_content_preroll(status)
if status.spoiler_text? return unless status.spoiler_text?
safe_join [
tag.p { spoiler_with_warning(status) }, safe_join [
tag.hr, tag.p { spoiler_with_warning(status) },
] tag.hr,
end ]
end end
def spoiler_with_warning(status) def spoiler_with_warning(status)
@ -81,10 +83,10 @@ module FormattingHelper
end end
def rss_content_postroll(status) def rss_content_postroll(status)
if status.preloadable_poll return unless status.preloadable_poll
tag.p do
poll_option_tags(status) tag.p do
end poll_option_tags(status)
end end
end end

View File

@ -21,7 +21,13 @@ module HomeHelper
end end
end end
else 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 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) 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 + end +
@ -39,18 +45,8 @@ module HomeHelper
end end
end end
def obscured_counter(count) def field_verified_class(verified)
if count <= 0 if verified
'0'
elsif count == 1
'1'
else
'1+'
end
end
def custom_field_classes(field)
if field.verified?
'verified' 'verified'
else else
'emojify' 'emojify'

View File

@ -134,7 +134,7 @@ module JsonLdHelper
patch_for_forwarding!(value, compacted_value) patch_for_forwarding!(value, compacted_value)
elsif value.is_a?(Array) elsif value.is_a?(Array)
compacted_value = [compacted_value] unless compacted_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| compacted[key] = value.zip(compacted_value).map do |v, vc|
if v.is_a?(Hash) && vc.is_a?(Hash) if v.is_a?(Hash) && vc.is_a?(Hash)

View File

@ -107,6 +107,7 @@ module LanguagesHelper
mk: ['Macedonian', 'македонски јазик'].freeze, mk: ['Macedonian', 'македонски јазик'].freeze,
ml: ['Malayalam', 'മലയാളം'].freeze, ml: ['Malayalam', 'മലയാളം'].freeze,
mn: ['Mongolian', 'Монгол хэл'].freeze, mn: ['Mongolian', 'Монгол хэл'].freeze,
'mn-Mong': ['Traditional Mongolian', 'ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ'].freeze,
mr: ['Marathi', 'मराठी'].freeze, mr: ['Marathi', 'मराठी'].freeze,
ms: ['Malay', 'Bahasa Melayu'].freeze, ms: ['Malay', 'Bahasa Melayu'].freeze,
'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze, 'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze,

View File

@ -57,6 +57,20 @@ module StatusesHelper
components.compact_blank.join("\n\n") components.compact_blank.join("\n\n")
end 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) def visibility_icon(status)
VISIBLITY_ICONS[status.visibility.to_sym] VISIBLITY_ICONS[status.visibility.to_sym]
end end
@ -64,4 +78,16 @@ module StatusesHelper
def prefers_autoplay? def prefers_autoplay?
ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif
end 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 end

View File

@ -24,24 +24,24 @@ module ThemeHelper
end end
def custom_stylesheet def custom_stylesheet
if active_custom_stylesheet.present? return if active_custom_stylesheet.blank?
stylesheet_link_tag(
custom_css_path(active_custom_stylesheet), stylesheet_link_tag(
host: root_url, custom_css_path(active_custom_stylesheet),
media: :all, host: root_url,
skip_pipeline: true media: :all,
) skip_pipeline: true
end )
end end
private private
def active_custom_stylesheet def active_custom_stylesheet
if cached_custom_css_digest.present? return if cached_custom_css_digest.blank?
[:custom, cached_custom_css_digest.to_s.first(8)]
.compact_blank [:custom, cached_custom_css_digest.to_s.first(8)]
.join('-') .compact_blank
end .join('-')
end end
def cached_custom_css_digest def cached_custom_css_digest

View File

@ -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": {}
}
}

View File

@ -1,6 +1,7 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs'; import Rails from '@rails/ujs';
import { decode, ValidationError } from 'blurhash';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
@ -362,6 +363,46 @@ ready(() => {
document.querySelectorAll('[data-admin-component]').forEach((element) => { document.querySelectorAll('[data-admin-component]').forEach((element) => {
void mountReactComponent(element); void mountReactComponent(element);
}); });
document
.querySelectorAll<HTMLCanvasElement>('canvas[data-blurhash]')
.forEach((canvas) => {
const blurhash = canvas.dataset.blurhash;
if (blurhash) {
try {
// decode returns a Uint8ClampedArray<ArrayBufferLike> not Uint8ClampedArray<ArrayBuffer>
const pixels = decode(
blurhash,
32,
32,
) as Uint8ClampedArray<ArrayBuffer>;
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<HTMLDivElement>('.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) => { }).catch((reason: unknown) => {
throw reason; throw reason;
}); });

View File

@ -70,7 +70,7 @@ function loaded() {
}; };
document.querySelectorAll('.emojify').forEach((content) => { 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 document
@ -145,6 +145,10 @@ function loaded() {
); );
}); });
updateDefaultQuotePrivacyFromPrivacy(
document.querySelector('#user_settings_attributes_default_privacy'),
);
const reactComponents = document.querySelectorAll('[data-component]'); const reactComponents = document.querySelectorAll('[data-component]');
if (reactComponents.length > 0) { if (reactComponents.length > 0) {
@ -347,6 +351,31 @@ const setInputDisabled = (
} }
}; };
const setInputHint = (
input: HTMLInputElement | HTMLSelectElement,
hintPrefix: string,
) => {
const fieldWrapper = input.closest<HTMLElement>('.fields-group > .input');
if (!fieldWrapper) return;
const hint = fieldWrapper.dataset[`${hintPrefix}Hint`];
const hintElement =
fieldWrapper.querySelector<HTMLSpanElement>(':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( Rails.delegate(
document, document,
'#account_statuses_cleanup_policy_enabled', '#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<HTMLSelectElement>(
'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 // Empty the honeypot fields in JS in case something like an extension
// automatically filled them. // automatically filled them.
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {

View File

@ -1 +1,3 @@
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons). 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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -84,6 +84,7 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({ const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, 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' }, open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
@ -96,12 +97,17 @@ export const ensureComposeIsVisible = (getState) => {
}; };
export function setComposeToStatus(status, text, spoiler_text) { export function setComposeToStatus(status, text, spoiler_text) {
return{ return (dispatch, getState) => {
type: COMPOSE_SET_STATUS, const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
status,
text, dispatch({
spoiler_text, type: COMPOSE_SET_STATUS,
}; status,
text,
spoiler_text,
maxOptions,
});
}
} }
export function changeCompose(text) { export function changeCompose(text) {
@ -146,7 +152,7 @@ export function resetCompose() {
}; };
} }
export const focusCompose = (defaultText) => (dispatch, getState) => { export const focusCompose = (defaultText = '') => (dispatch, getState) => {
dispatch({ dispatch({
type: COMPOSE_FOCUS, type: COMPOSE_FOCUS,
defaultText, defaultText,
@ -183,13 +189,15 @@ export function directCompose(account) {
}; };
} }
export function submitCompose() { export function submitCompose(successCallback) {
return function (dispatch, getState) { return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], ''); const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
const statusId = getState().getIn(['compose', 'id'], null); 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; return;
} }
@ -215,19 +223,22 @@ export function submitCompose() {
}); });
} }
const visibility = getState().getIn(['compose', 'privacy']);
api().request({ api().request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put', method: statusId === null ? 'post' : 'put',
data: { data: {
status, status,
spoiler_text,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')), media_ids: media.map(item => item.get('id')),
media_attributes, media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']), sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', visibility: visibility,
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null), poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']), 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: { headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@ -239,6 +250,9 @@ export function submitCompose() {
dispatch(insertIntoTagHistory(response.data.tags, status)); dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data })); dispatch(submitComposeSuccess({ ...response.data }));
if (typeof successCallback === 'function') {
successCallback(response.data);
}
// To make the app more responsive, immediately push the status // To make the app more responsive, immediately push the status
// into the columns // into the columns
@ -298,6 +312,11 @@ export function submitComposeFail(error) {
export function uploadCompose(files) { export function uploadCompose(files) {
return function (dispatch, getState) { 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 uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']);

View File

@ -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 type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { apiUpdateMedia } from 'mastodon/api/compose'; import { apiUpdateMedia } from 'mastodon/api/compose';
import { apiGetSearch } from 'mastodon/api/search';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
import type { MediaAttachment } from 'mastodon/models/media_attachment'; 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 & { type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
unattached?: boolean; unattached?: boolean;
@ -68,3 +106,111 @@ export const changeUploadCompose = createDataLoadingThunk(
useLoadingBar: false, 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<ApiQuotePolicy>(
'compose/setQuotePolicy',
);

View File

@ -21,6 +21,15 @@ export function normalizeFilterResult(result) {
return normalResult; 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) { export function normalizeStatus(status, normalOldStatus) {
const normalStatus = { ...status }; const normalStatus = { ...status };
@ -72,7 +81,7 @@ export function normalizeStatus(status, normalOldStatus) {
} else { } else {
// If the status has a CW but no contents, treat the CW as if it were the // 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. // 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.content = normalStatus.spoiler_text;
normalStatus.spoiler_text = ''; normalStatus.spoiler_text = '';
} }
@ -86,6 +95,11 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; 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://'))) { if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
normalStatus.url = null; normalStatus.url = null;
} }
@ -125,6 +139,11 @@ export function normalizeStatusTranslation(translation, status) {
spoiler_text: translation.spoiler_text, 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; return normalTranslation;
} }

View File

@ -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 type { StatusVisibility } from 'mastodon/models/status';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { importFetchedStatus } from './importer'; import { importFetchedStatus, importFetchedStatuses } from './importer';
export const reblog = createDataLoadingThunk( export const reblog = createDataLoadingThunk(
'status/reblog', 'status/reblog',
@ -33,3 +38,35 @@ export const unreblog = createDataLoadingThunk(
return discardLoadData; 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));
},
);

View File

@ -30,8 +30,21 @@ import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { NOTIFICATIONS_FILTER_SET } from './notifications'; import { NOTIFICATIONS_FILTER_SET } from './notifications';
import { saveSettings } from './settings'; 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) { function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter((item) => item !== filter); return allNotificationTypes.filter(
(item) => notificationTypeForQuickFilter(item) !== filter,
);
} }
function getExcludedTypes(state: RootState) { function getExcludedTypes(state: RootState) {
@ -155,13 +168,17 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
const showInColumn = const showInColumn =
activeFilter === 'all' activeFilter === 'all'
? notificationShows[notification.type] !== false ? notificationShows[notificationTypeForFilter(notification.type)] !==
: activeFilter === notification.type; false
: activeFilter === notificationTypeForQuickFilter(notification.type);
if (!showInColumn) return; if (!showInColumn) return;
if ( if (
(notification.type === 'mention' || notification.type === 'update') && (notification.type === 'mention' ||
notification.type === 'quote' ||
notification.type === 'update' ||
notification.type === 'quoted_update') &&
notification.status?.filtered notification.status?.filtered
) { ) {
const filters = notification.status.filtered.filter((result) => const filters = notification.status.filtered.filter((result) =>

View File

@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
let filtered = false; 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')); const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
if (filters.some(result => result.filter.filter_action === 'hide')) { if (filters.some(result => result.filter.filter_action === 'hide')) {

View File

@ -1,9 +1,12 @@
import { defineMessages } from 'react-intl';
import { browserHistory } from 'mastodon/components/router'; import { browserHistory } from 'mastodon/components/router';
import api from '../api'; import api from '../api';
import { showAlert } from './alerts';
import { ensureComposeIsVisible, setComposeToStatus } from './compose'; import { ensureComposeIsVisible, setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; import { importFetchedStatus, importFetchedAccount } from './importer';
import { fetchContext } from './statuses_typed'; import { fetchContext } from './statuses_typed';
import { deleteFromTimelines } from './timelines'; 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_FAIL = 'STATUS_TRANSLATE_FAIL';
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const messages = defineMessages({
deleteSuccess: { id: 'status.delete.success', defaultMessage: 'Post deleted' },
});
export function fetchStatusRequest(id, skipLoading) { export function fetchStatusRequest(id, skipLoading) {
return { return {
type: STATUS_FETCH_REQUEST, 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) => { return (dispatch, getState) => {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; 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(importFetchedStatus(response.data));
dispatch(fetchStatusSuccess(skipLoading)); dispatch(fetchStatusSuccess(skipLoading));
}).catch(error => { }).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 { return {
type: STATUS_FETCH_FAIL, type: STATUS_FETCH_FAIL,
id, id,
error, error,
parentQuotePostId,
skipLoading, skipLoading,
skipAlert: true, skipAlert: true,
}; };
} }
export function redraft(status, raw_text) { export function redraft(status, raw_text) {
return { return (dispatch, getState) => {
type: REDRAFT, const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
status,
raw_text, dispatch({
type: REDRAFT,
status,
raw_text,
maxOptions,
});
}; };
} }
@ -137,7 +161,7 @@ export function deleteStatus(id, withRedraft = false) {
dispatch(deleteStatusRequest(id)); 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(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id)); dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account)); dispatch(importFetchedAccount(response.data.account));
@ -145,9 +169,14 @@ export function deleteStatus(id, withRedraft = false) {
if (withRedraft) { if (withRedraft) {
dispatch(redraft(status, response.data.text)); dispatch(redraft(status, response.data.text));
ensureComposeIsVisible(getState); ensureComposeIsVisible(getState);
} else {
dispatch(showAlert({ message: messages.deleteSuccess }));
} }
return response;
}).catch(error => { }).catch(error => {
dispatch(deleteStatusFail(id, error)); dispatch(deleteStatusFail(id, error));
throw error;
}); });
}; };
} }

View File

@ -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 { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import type { ApiQuotePolicy } from '../api_types/quotes';
import { importFetchedStatuses } from './importer'; import { importFetchedStatuses } from './importer';
export const fetchContext = createDataLoadingThunk( export const fetchContext = createDataLoadingThunk(
'status/context', 'status/context',
({ statusId }: { statusId: string }) => apiGetContext(statusId), ({ statusId }: { statusId: string; prefetchOnly?: boolean }) =>
(context, { dispatch }) => { apiGetContext(statusId),
({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => {
const statuses = context.ancestors.concat(context.descendants); const statuses = context.ancestors.concat(context.descendants);
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
return { return {
context, 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);
},
);

Some files were not shown because too many files have changed in this diff Show More