gearheads
/
mastodon
Archived
2
0
Fork 0

Update to v4.1.1

gh/stable
Ducky 2023-04-14 22:58:31 +01:00
commit 2eacc77328
65 changed files with 599 additions and 174 deletions

View File

@ -12,6 +12,7 @@ on:
- Dockerfile - Dockerfile
permissions: permissions:
contents: read contents: read
packages: write
jobs: jobs:
build-image: build-image:
@ -26,15 +27,28 @@ jobs:
- uses: hadolint/hadolint-action@v3.1.0 - uses: hadolint/hadolint-action@v3.1.0
- uses: docker/setup-qemu-action@v2 - uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2 - uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
if: github.event_name != 'pull_request' if: github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request'
- name: Log in to the Github Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
if: github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request'
- uses: docker/metadata-action@v4 - uses: docker/metadata-action@v4
id: meta id: meta
with: with:
images: tootsuite/mastodon images: |
tootsuite/mastodon
ghcr.io/mastodon/mastodon
flavor: | flavor: |
latest=auto latest=auto
tags: | tags: |
@ -42,13 +56,15 @@ jobs:
type=pep440,pattern={{raw}} type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}} type=pep440,pattern=v{{major}}.{{minor}}
type=ref,event=pr type=ref,event=pr
- uses: docker/build-push-action@v4 - uses: docker/build-push-action@v4
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
provenance: false provenance: false
builder: ${{ steps.buildx.outputs.name }} builder: ${{ steps.buildx.outputs.name }}
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max

View File

@ -3,6 +3,57 @@ Changelog
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.1.1] - 2023-03-16
### Added
- Add redirection from paths with url-encoded `@` to their decoded form ([thijskh](https://github.com/mastodon/mastodon/pull/23593))
- Add `lang` attribute to native language names in language picker in Web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23749))
- Add headers to outgoing mails to avoid auto-replies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23597))
- Add support for refreshing many accounts at once with `tootctl accounts refresh` ([9p4](https://github.com/mastodon/mastodon/pull/23304))
- Add confirmation modal when clicking to edit a post with a non-empty compose form ([PauloVilarinho](https://github.com/mastodon/mastodon/pull/23936))
- Add support for the HAproxy PROXY protocol through the `PROXY_PROTO_V1` environment variable ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24064))
- Add `SENDFILE_HEADER` environment variable ([Gargron](https://github.com/mastodon/mastodon/pull/24123))
- Add cache headers to static files served through Rails ([Gargron](https://github.com/mastodon/mastodon/pull/24120))
### Changed
- Increase contrast of upload progress bar background ([toolmantim](https://github.com/mastodon/mastodon/pull/23836))
- Change post auto-deletion throttling constants to better scale with server size ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23320))
- Change order of bookmark and favourite sidebar entries in single-column UI for consistency ([TerryGarcia](https://github.com/mastodon/mastodon/pull/23701))
- Change `ActivityPub::DeliveryWorker` retries to be spread out more ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21956))
### Fixed
- Fix “Remove all followers from the selected domains” also removing follows and notifications ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23805))
- Fix streaming metrics format ([emilweth](https://github.com/mastodon/mastodon/pull/23519), [emilweth](https://github.com/mastodon/mastodon/pull/23520))
- Fix case-sensitive check for previously used hashtags in hashtag autocompletion ([deanveloper](https://github.com/mastodon/mastodon/pull/23526))
- Fix focus point of already-attached media not saving after edit ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23566))
- Fix sidebar behavior in settings/admin UI on mobile ([wxt2005](https://github.com/mastodon/mastodon/pull/23764))
- Fix inefficiency when searching accounts per username in admin interface ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23801))
- Fix duplicate “Publish” button on mobile ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23804))
- Fix server error when failing to follow back followers from `/relationships` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23787))
- Fix server error when attempting to display the edit history of a trendable post in the admin interface ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23574))
- Fix `tootctl accounts migrate` crashing because of a typo ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23567))
- Fix original account being unfollowed on migration before the follow request to the new account could be sent ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21957))
- Fix the “Back” button in column headers sometimes leaving Mastodon ([c960657](https://github.com/mastodon/mastodon/pull/23953))
- Fix pgBouncer resetting application name on every transaction ([Gargron](https://github.com/mastodon/mastodon/pull/23958))
- Fix unconfirmed accounts being counted as active users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23803))
- Fix `/api/v1/streaming` sub-paths not being redirected ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23988))
- Fix drag'n'drop upload area text that spans multiple lines not being centered ([vintprox](https://github.com/mastodon/mastodon/pull/24029))
- Fix sidekiq jobs not triggering Elasticsearch index updates ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24046))
- Fix tags being unnecessarily stripped from plain-text short site description ([c960657](https://github.com/mastodon/mastodon/pull/23975))
- Fix HTML entities not being un-escaped in extracted plain-text from remote posts ([c960657](https://github.com/mastodon/mastodon/pull/24019))
- Fix dashboard crash on ElasticSearch server error ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23751))
- Fix incorrect post links in strikes when the account is remote ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23611))
- Fix misleading error code when receiving invalid WebAuthn credentials ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23568))
- Fix duplicate mails being sent when the SMTP server is too slow to close the connection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23750))
### Security
- Change user backups to use expiring URLs for download when possible ([Gargron](https://github.com/mastodon/mastodon/pull/24136))
- Add warning for object storage misconfiguration ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24137))
## [4.1.0] - 2023-02-10 ## [4.1.0] - 2023-02-10
### Added ### Added

View File

@ -8,13 +8,11 @@
[![Build Status](https://img.shields.io/circleci/project/github/mastodon/mastodon.svg)][circleci] [![Build Status](https://img.shields.io/circleci/project/github/mastodon/mastodon.svg)][circleci]
[![Code Climate](https://img.shields.io/codeclimate/maintainability/mastodon/mastodon.svg)][code_climate] [![Code Climate](https://img.shields.io/codeclimate/maintainability/mastodon/mastodon.svg)][code_climate]
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] [![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
[![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker]
[releases]: https://github.com/mastodon/mastodon/releases [releases]: https://github.com/mastodon/mastodon/releases
[circleci]: https://circleci.com/gh/mastodon/mastodon [circleci]: https://circleci.com/gh/mastodon/mastodon
[code_climate]: https://codeclimate.com/github/mastodon/mastodon [code_climate]: https://codeclimate.com/github/mastodon/mastodon
[crowdin]: https://crowdin.com/project/mastodon [crowdin]: https://crowdin.com/project/mastodon
[docker]: https://hub.docker.com/r/tootsuite/mastodon/
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!) Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
@ -31,6 +29,7 @@ Click below to **learn more** in a video:
- [View sponsors](https://joinmastodon.org/sponsors) - [View sponsors](https://joinmastodon.org/sponsors)
- [Blog](https://blog.joinmastodon.org) - [Blog](https://blog.joinmastodon.org)
- [Documentation](https://docs.joinmastodon.org) - [Documentation](https://docs.joinmastodon.org)
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
- [Browse Mastodon servers](https://joinmastodon.org/communities) - [Browse Mastodon servers](https://joinmastodon.org/communities)
- [Browse Mastodon apps](https://joinmastodon.org/apps) - [Browse Mastodon apps](https://joinmastodon.org/apps)

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class BackupsController < ApplicationController
include RoutingHelper
skip_before_action :require_functional!
before_action :authenticate_user!
before_action :set_backup
def download
case Paperclip::Attachment.default_options[:storage]
when :s3
redirect_to @backup.dump.expiring_url(10)
when :fog
redirect_to @backup.dump.expiring_url(Time.now.utc + 10)
when :filesystem
redirect_to full_asset_url(@backup.dump.url)
end
end
private
def set_backup
@backup = current_user.backups.find(params[:id])
end
end

View File

@ -19,6 +19,8 @@ class RelationshipsController < ApplicationController
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
# Do nothing # Do nothing
rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound
flash[:alert] = I18n.t('relationships.follow_failure') if action_from_button == 'follow'
ensure ensure
redirect_to relationships_path(filter_params) redirect_to relationships_path(filter_params)
end end
@ -60,8 +62,8 @@ class RelationshipsController < ApplicationController
'unfollow' 'unfollow'
elsif params[:remove_from_followers] elsif params[:remove_from_followers]
'remove_from_followers' 'remove_from_followers'
elsif params[:block_domains] elsif params[:block_domains] || params[:remove_domains_from_followers]
'block_domains' 'remove_domains_from_followers'
end end
end end

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 = :internal_server_error status = :unprocessable_entity
end end
else else
flash[:error] = t('webauthn_credentials.create.error') flash[:error] = t('webauthn_credentials.create.error')

View File

@ -165,11 +165,19 @@ export function submitCompose(routerHistory) {
// API call. // API call.
let media_attributes; let media_attributes;
if (statusId !== null) { if (statusId !== null) {
media_attributes = media.map(item => ({ media_attributes = media.map(item => {
let focus;
if (item.getIn(['meta', 'focus'])) {
focus = `${item.getIn(['meta', 'focus', 'x']).toFixed(2)},${item.getIn(['meta', 'focus', 'y']).toFixed(2)}`;
}
return {
id: item.get('id'), id: item.get('id'),
description: item.get('description'), description: item.get('description'),
focus: item.get('focus'), focus,
})); };
});
} }
api(getState).request({ api(getState).request({

View File

@ -15,10 +15,10 @@ export default class ColumnBackButton extends React.PureComponent {
}; };
handleClick = () => { handleClick = () => {
if (window.history && window.history.length === 1) { if (window.history && window.history.state) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack(); this.context.router.history.goBack();
} else {
this.context.router.history.push('/');
} }
}; };

View File

@ -43,14 +43,6 @@ class ColumnHeader extends React.PureComponent {
animating: false, animating: false,
}; };
historyBack = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
};
handleToggleClick = (e) => { handleToggleClick = (e) => {
e.stopPropagation(); e.stopPropagation();
this.setState({ collapsed: !this.state.collapsed, animating: true }); this.setState({ collapsed: !this.state.collapsed, animating: true });
@ -69,7 +61,11 @@ class ColumnHeader extends React.PureComponent {
}; };
handleBackClick = () => { handleBackClick = () => {
this.historyBack(); if (window.history && window.history.state) {
this.context.router.history.goBack();
} else {
this.context.router.history.push('/');
}
}; };
handleTransitionEnd = () => { handleTransitionEnd = () => {

View File

@ -56,6 +56,8 @@ const messages = defineMessages({
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
}); });
@ -149,7 +151,18 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}, },
onEdit (status, history) { onEdit (status, history) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.editMessage),
confirm: intl.formatMessage(messages.editConfirm),
onConfirm: () => dispatch(editStatus(status.get('id'), history)),
}));
} else {
dispatch(editStatus(status.get('id'), history)); dispatch(editStatus(status.get('id'), history));
}
});
}, },
onTranslate (status) { onTranslate (status) {

View File

@ -210,7 +210,7 @@ class LanguageDropdownMenu extends React.PureComponent {
return ( return (
<div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}> <div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
<span className='language-dropdown__dropdown__results__item__native-name'>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span> <span className='language-dropdown__dropdown__results__item__native-name' lang={lang[0]}>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
</div> </div>
); );
}; };

View File

@ -22,8 +22,8 @@ const mapDispatchToProps = (dispatch) => ({
}, },
}); });
export default @connect(null, mapDispatchToProps) export default @withRouter
@withRouter @connect(null, mapDispatchToProps)
class Header extends React.PureComponent { class Header extends React.PureComponent {
static contextTypes = { static contextTypes = {

View File

@ -82,8 +82,8 @@ class NavigationPanel extends React.Component {
{signedIn && ( {signedIn && (
<React.Fragment> <React.Fragment>
<ColumnLink transparent to='/conversations' icon='at' text={intl.formatMessage(messages.direct)} /> <ColumnLink transparent to='/conversations' icon='at' text={intl.formatMessage(messages.direct)} />
<ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
<ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} /> <ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} />
<ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
<ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} /> <ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
<ListPanel /> <ListPanel />

View File

@ -474,10 +474,10 @@ class UI extends React.PureComponent {
}; };
handleHotkeyBack = () => { handleHotkeyBack = () => {
if (window.history && window.history.length === 1) { if (window.history && window.history.state) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack(); this.context.router.history.goBack();
} else {
this.context.router.history.push('/');
} }
}; };

View File

@ -162,6 +162,8 @@
"confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?", "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
"confirmations.domain_block.confirm": "Block entire domain", "confirmations.domain_block.confirm": "Block entire domain",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.", "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
"confirmations.edit.confirm": "Edit",
"confirmations.edit.message": "Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.logout.confirm": "Log out", "confirmations.logout.confirm": "Log out",
"confirmations.logout.message": "Are you sure you want to log out?", "confirmations.logout.message": "Are you sure you want to log out?",
"confirmations.mute.confirm": "Mute", "confirmations.mute.confirm": "Mute",

View File

@ -186,11 +186,12 @@ const ignoreSuggestion = (state, position, token, completion, path) => {
}; };
const sortHashtagsByUse = (state, tags) => { const sortHashtagsByUse = (state, tags) => {
const personalHistory = state.get('tagHistory'); const personalHistory = state.get('tagHistory').map(tag => tag.toLowerCase());
return tags.sort((a, b) => { const tagsWithLowercase = tags.map(t => ({ ...t, lowerName: t.name.toLowerCase() }));
const usedA = personalHistory.includes(a.name); const sorted = tagsWithLowercase.sort((a, b) => {
const usedB = personalHistory.includes(b.name); const usedA = personalHistory.includes(a.lowerName);
const usedB = personalHistory.includes(b.lowerName);
if (usedA === usedB) { if (usedA === usedB) {
return 0; return 0;
@ -200,6 +201,8 @@ const sortHashtagsByUse = (state, tags) => {
return 1; return 1;
} }
}); });
sorted.forEach(tag => delete tag.lowerName);
return sorted;
}; };
const insertEmoji = (state, position, emojiData, needsSpace) => { const insertEmoji = (state, position, emojiData, needsSpace) => {

View File

@ -254,6 +254,10 @@ html {
border-color: $ui-base-color; border-color: $ui-base-color;
} }
.upload-progress__backdrop {
background: $ui-base-color;
}
// Change the background colors of statuses // Change the background colors of statuses
.focusable:focus { .focusable:focus {
background: $ui-base-color; background: $ui-base-color;

View File

@ -384,7 +384,7 @@ $content-width: 840px;
position: fixed; position: fixed;
z-index: 10; z-index: 10;
width: 100%; width: 100%;
height: calc(100vh - 56px); height: calc(100% - 56px);
left: 0; left: 0;
bottom: 0; bottom: 0;
overflow-y: auto; overflow-y: auto;

View File

@ -4482,6 +4482,7 @@ a.status-card.compact:hover {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center;
color: $secondary-text-color; color: $secondary-text-color;
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
@ -4516,7 +4517,7 @@ a.status-card.compact:hover {
width: 100%; width: 100%;
height: 6px; height: 6px;
border-radius: 6px; border-radius: 6px;
background: $ui-base-lighter-color; background: darken($simple-background-color, 8%);
position: relative; position: relative;
margin-top: 5px; margin-top: 5px;
} }

View File

@ -2,6 +2,7 @@
class Admin::SystemCheck class Admin::SystemCheck
ACTIVE_CHECKS = [ ACTIVE_CHECKS = [
Admin::SystemCheck::MediaPrivacyCheck,
Admin::SystemCheck::DatabaseSchemaCheck, Admin::SystemCheck::DatabaseSchemaCheck,
Admin::SystemCheck::SidekiqProcessCheck, Admin::SystemCheck::SidekiqProcessCheck,
Admin::SystemCheck::RulesCheck, Admin::SystemCheck::RulesCheck,

View File

@ -31,7 +31,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
def running_version def running_version
@running_version ||= begin @running_version ||= begin
Chewy.client.info['version']['number'] Chewy.client.info['version']['number']
rescue Faraday::ConnectionFailed rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
nil nil
end end
end end

View File

@ -0,0 +1,105 @@
# frozen_string_literal: true
class Admin::SystemCheck::MediaPrivacyCheck < Admin::SystemCheck::BaseCheck
include RoutingHelper
def skip?
!current_user.can?(:view_devops)
end
def pass?
check_media_uploads!
@failure_message.nil?
end
def message
Admin::SystemCheck::Message.new(@failure_message, @failure_value, @failure_action, true)
end
private
def check_media_uploads!
if Rails.configuration.x.use_s3
check_media_listing_inaccessible_s3!
else
check_media_listing_inaccessible!
end
end
def check_media_listing_inaccessible!
full_url = full_asset_url(media_attachment.file.url(:original, false))
# Check if we can list the uploaded file. If true, that's an error
directory_url = Addressable::URI.parse(full_url)
directory_url.query = nil
filename = directory_url.path.gsub(%r{.*/}, '')
directory_url.path = directory_url.path.gsub(%r{/[^/]+\Z}, '/')
Request.new(:get, directory_url, allow_local: true).perform do |res|
if res.truncated_body&.include?(filename)
@failure_message = use_storage? ? :upload_check_privacy_error_object_storage : :upload_check_privacy_error
@failure_action = 'https://docs.joinmastodon.org/admin/optional/object-storage/#FS'
end
end
rescue
nil
end
def check_media_listing_inaccessible_s3!
urls_to_check = []
paperclip_options = Paperclip::Attachment.default_options
s3_protocol = paperclip_options[:s3_protocol]
s3_host_alias = paperclip_options[:s3_host_alias]
s3_host_name = paperclip_options[:s3_host_name]
bucket_name = paperclip_options.dig(:s3_credentials, :bucket)
urls_to_check << "#{s3_protocol}://#{s3_host_alias}/" if s3_host_alias.present?
urls_to_check << "#{s3_protocol}://#{s3_host_name}/#{bucket_name}/"
urls_to_check.uniq.each do |full_url|
check_s3_listing!(full_url)
break if @failure_message.present?
end
rescue
nil
end
def check_s3_listing!(full_url)
bucket_url = Addressable::URI.parse(full_url)
bucket_url.path = bucket_url.path.delete_suffix(media_attachment.file.path(:original))
bucket_url.query = "max-keys=1&x-random=#{SecureRandom.hex(10)}"
Request.new(:get, bucket_url, allow_local: true).perform do |res|
if res.truncated_body&.include?('ListBucketResult')
@failure_message = :upload_check_privacy_error_object_storage
@failure_action = 'https://docs.joinmastodon.org/admin/optional/object-storage/#S3'
end
end
end
def media_attachment
@media_attachment ||= begin
attachment = Account.representative.media_attachments.first
if attachment.present?
attachment.touch # rubocop:disable Rails/SkipsModelValidations
attachment
else
create_test_attachment!
end
end
end
def create_test_attachment!
Tempfile.create(%w(test-upload .jpg), binmode: true) do |tmp_file|
tmp_file.write(
Base64.decode64(
'/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' \
'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' \
'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' \
'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/x' \
'ABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAA' \
'AAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q=='
)
)
tmp_file.flush
Account.representative.media_attachments.create!(file: tmp_file)
end
end
end

View File

@ -1,11 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::SystemCheck::Message class Admin::SystemCheck::Message
attr_reader :key, :value, :action attr_reader :key, :value, :action, :critical
def initialize(key, value = nil, action = nil) def initialize(key, value = nil, action = nil, critical = false)
@key = key @key = key
@value = value @value = value
@action = action @action = action
@critical = critical
end end
end end

View File

@ -18,7 +18,7 @@ class PlainTextFormatter
if local? if local?
text text
else else
strip_tags(insert_newlines).chomp html_entities.decode(strip_tags(insert_newlines)).chomp
end end
end end
@ -27,4 +27,8 @@ class PlainTextFormatter
def insert_newlines def insert_newlines
text.gsub(NEWLINE_TAGS_RE) { |match| "#{match}\n" } text.gsub(NEWLINE_TAGS_RE) { |match| "#{match}\n" }
end end
def html_entities
HTMLEntities.new
end
end end

View File

@ -7,6 +7,8 @@ class ApplicationMailer < ActionMailer::Base
helper :instance helper :instance
helper :formatting helper :formatting
after_action :set_autoreply_headers!
protected protected
def locale_for_account(account) def locale_for_account(account)
@ -14,4 +16,10 @@ class ApplicationMailer < ActionMailer::Base
yield yield
end end
end end
def set_autoreply_headers!
headers['Precedence'] = 'list'
headers['X-Auto-Response-Suppress'] = 'All'
headers['Auto-Submitted'] = 'auto-generated'
end
end end

View File

@ -107,7 +107,7 @@ class Account < ApplicationRecord
scope :bots, -> { where(actor_type: %w(Application Service)) } scope :bots, -> { where(actor_type: %w(Application Service)) }
scope :groups, -> { where(actor_type: 'Group') } scope :groups, -> { where(actor_type: 'Group') }
scope :alphabetic, -> { order(domain: :asc, username: :asc) } scope :alphabetic, -> { order(domain: :asc, username: :asc) }
scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) } scope :matches_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") }
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) } scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) }

View File

@ -17,6 +17,6 @@
class Backup < ApplicationRecord class Backup < ApplicationRecord
belongs_to :user, inverse_of: :backups belongs_to :user, inverse_of: :backups
has_attached_file :dump has_attached_file :dump, s3_permissions: 'private'
do_not_validate_attachment_file_type :dump do_not_validate_attachment_file_type :dump
end end

View File

@ -17,8 +17,8 @@ class Form::AccountBatch
unfollow! unfollow!
when 'remove_from_followers' when 'remove_from_followers'
remove_from_followers! remove_from_followers!
when 'block_domains' when 'remove_domains_from_followers'
block_domains! remove_domains_from_followers!
when 'approve' when 'approve'
approve! approve!
when 'reject' when 'reject'
@ -35,9 +35,15 @@ class Form::AccountBatch
private private
def follow! def follow!
error = nil
accounts.each do |target_account| accounts.each do |target_account|
FollowService.new.call(current_account, target_account) FollowService.new.call(current_account, target_account)
rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound => e
error ||= e
end end
raise error if error.present?
end end
def unfollow! def unfollow!
@ -50,10 +56,8 @@ class Form::AccountBatch
RemoveFromFollowersService.new.call(current_account, account_ids) RemoveFromFollowersService.new.call(current_account, account_ids)
end end
def block_domains! def remove_domains_from_followers!
AfterAccountDomainBlockWorker.push_bulk(account_domains) do |domain| RemoveDomainsFromFollowersService.new.call(current_account, account_domains)
[current_account.id, domain]
end
end end
def account_domains def account_domains

View File

@ -504,11 +504,14 @@ class User < ApplicationRecord
def prepare_new_user! def prepare_new_user!
BootstrapTimelineWorker.perform_async(account_id) BootstrapTimelineWorker.perform_async(account_id)
ActivityTracker.increment('activity:accounts:local') ActivityTracker.increment('activity:accounts:local')
ActivityTracker.record('activity:logins', id)
UserMailer.welcome(self).deliver_later UserMailer.welcome(self).deliver_later
TriggerWebhookWorker.perform_async('account.approved', 'Account', account_id) TriggerWebhookWorker.perform_async('account.approved', 'Account', account_id)
end end
def prepare_returning_user! def prepare_returning_user!
return unless confirmed?
ActivityTracker.record('activity:logins', id) ActivityTracker.record('activity:logins', id)
regenerate_feed! if needs_feed_update? regenerate_feed! if needs_feed_update?
end end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
class FollowMigrationService < FollowService
# Follow an account with the same settings as another account, and unfollow the old account once the request is sent
# @param [Account] source_account From which to follow
# @param [Account] target_account Account to follow
# @param [Account] old_target_account Account to unfollow once the follow request has been sent to the new one
# @option [Boolean] bypass_locked Whether to immediately follow the new account even if it is locked
def call(source_account, target_account, old_target_account, bypass_locked: false)
@old_target_account = old_target_account
follow = source_account.active_relationships.find_by(target_account: old_target_account)
reblogs = follow&.show_reblogs?
notify = follow&.notify?
languages = follow&.languages
super(source_account, target_account, reblogs: reblogs, notify: notify, languages: languages, bypass_locked: bypass_locked, bypass_limit: true)
end
private
def request_follow!
follow_request = @source_account.request_follow!(@target_account, **follow_options.merge(rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]))
if @target_account.local?
LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
UnfollowService.new.call(@source_account, @old_target_account, skip_unmerge: true)
elsif @target_account.activitypub?
ActivityPub::MigratedFollowDeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url, @old_target_account.id)
end
follow_request
end
def direct_follow!
follow = super
UnfollowService.new.call(@source_account, @old_target_account, skip_unmerge: true)
follow
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class RemoveDomainsFromFollowersService < BaseService
include Payloadable
def call(source_account, target_domains)
source_account.passive_relationships.where(account_id: Account.where(domain: target_domains)).find_each do |follow|
follow.destroy
create_notification(follow) if source_account.local? && !follow.account.local? && follow.account.activitypub?
end
end
private
def create_notification(follow)
ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.target_account_id, follow.account.inbox_url)
end
def build_json(follow)
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
end
end

View File

@ -12,7 +12,7 @@
- unless @system_checks.empty? - unless @system_checks.empty?
.flash-message-stack .flash-message-stack
- @system_checks.each do |message| - @system_checks.each do |message|
.flash-message.warning .flash-message{ class: message.critical ? 'alert' : 'warning' }
= t("admin.system_checks.#{message.key}.message_html", value: message.value ? content_tag(:strong, message.value) : nil) = t("admin.system_checks.#{message.key}.message_html", value: message.value ? content_tag(:strong, message.value) : nil)
- if message.action - if message.action
= link_to t("admin.system_checks.#{message.key}.action"), message.action = link_to t("admin.system_checks.#{message.key}.action"), message.action

View File

@ -54,14 +54,14 @@
.strike-card__statuses-list__item .strike-card__statuses-list__item
- if (status = status_map[status_id.to_i]) - if (status = status_map[status_id.to_i])
.one-liner .one-liner
= link_to short_account_status_url(@report.target_account, status_id), class: 'emojify' do .emojify= one_line_preview(status)
= one_line_preview(status)
- status.ordered_media_attachments.each do |media_attachment| - status.ordered_media_attachments.each do |media_attachment|
%abbr{ title: media_attachment.description } %abbr{ title: media_attachment.description }
= fa_icon 'link' = fa_icon 'link'
= media_attachment.file_file_name = media_attachment.file_file_name
.strike-card__statuses-list__item__meta .strike-card__statuses-list__item__meta
= link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank' do
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
- unless status.application.nil? - unless status.application.nil?
· ·

View File

@ -34,7 +34,7 @@
%td %td
- if @status.trend.allowed? - if @status.trend.allowed?
%abbr{ title: t('admin.trends.tags.current_score', score: @status.trend.score) }= t('admin.trends.tags.trending_rank', rank: @status.trend.rank) %abbr{ title: t('admin.trends.tags.current_score', score: @status.trend.score) }= t('admin.trends.tags.trending_rank', rank: @status.trend.rank)
- elsif @status.trend.requires_review? - elsif @status.requires_review?
= t('admin.trends.pending_review') = t('admin.trends.pending_review')
- else - else
= t('admin.trends.not_allowed_to_trend') = t('admin.trends.not_allowed_to_trend')

View File

@ -3,7 +3,7 @@
= image_tag @instance_presenter.thumbnail&.file&.url(:'@1x') || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title = image_tag @instance_presenter.thumbnail&.file&.url(:'@1x') || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title
.hero-widget__text .hero-widget__text
%p= @instance_presenter.description.html_safe.presence || t('about.about_mastodon_html') %p= @instance_presenter.description.presence || t('about.about_mastodon_html')
- if Setting.trends && !(user_signed_in? && !current_user.setting_trends) - if Setting.trends && !(user_signed_in? && !current_user.setting_trends)
- trends = Trends.tags.query.allowed.limit(3) - trends = Trends.tags.query.allowed.limit(3)

View File

@ -50,14 +50,14 @@
.strike-card__statuses-list__item .strike-card__statuses-list__item
- if (status = status_map[status_id.to_i]) - if (status = status_map[status_id.to_i])
.one-liner .one-liner
= link_to short_account_status_url(@strike.target_account, status_id), class: 'emojify' do .emojify= one_line_preview(status)
= one_line_preview(status)
- status.ordered_media_attachments.each do |media_attachment| - status.ordered_media_attachments.each do |media_attachment|
%abbr{ title: media_attachment.description } %abbr{ title: media_attachment.description }
= fa_icon 'link' = fa_icon 'link'
= media_attachment.file_file_name = media_attachment.file_file_name
.strike-card__statuses-list__item__meta .strike-card__statuses-list__item__meta
= link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank' do
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
- unless status.application.nil? - unless status.application.nil?
· ·

View File

@ -48,7 +48,7 @@
= f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_followers')]), name: :remove_from_followers, class: 'table-action-link', type: :submit, data: { confirm: t('relationships.confirm_remove_selected_followers') } unless following_relationship? = f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_followers')]), name: :remove_from_followers, class: 'table-action-link', type: :submit, data: { confirm: t('relationships.confirm_remove_selected_followers') } unless following_relationship?
= f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_domains')]), name: :block_domains, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } if followed_by_relationship? = f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_domains')]), name: :remove_domains_from_followers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } if followed_by_relationship?
.batch-table__body .batch-table__body
- if @accounts.empty? - if @accounts.empty?
= nothing_here 'nothing-here--under-tabs' = nothing_here 'nothing-here--under-tabs'

View File

@ -64,6 +64,6 @@
%td= l backup.created_at %td= l backup.created_at
- if backup.processed? - if backup.processed?
%td= number_to_human_size backup.dump_file_size %td= number_to_human_size backup.dump_file_size
%td= table_link_to 'download', t('exports.archive_takeout.download'), backup.dump.url %td= table_link_to 'download', t('exports.archive_takeout.download'), download_backup_url(backup)
- else - else
%td{ colspan: 2 }= t('exports.archive_takeout.in_progress') %td{ colspan: 2 }= t('exports.archive_takeout.in_progress')

View File

@ -1,5 +1,5 @@
- thumbnail = @instance_presenter.thumbnail - thumbnail = @instance_presenter.thumbnail
- description ||= strip_tags(@instance_presenter.description.presence || t('about.about_mastodon_html')) - description ||= @instance_presenter.description.presence || strip_tags(t('about.about_mastodon_html'))
%meta{ name: 'description', content: description }/ %meta{ name: 'description', content: description }/

View File

@ -55,5 +55,5 @@
%tbody %tbody
%tr %tr
%td.button-primary %td.button-primary
= link_to full_asset_url(@backup.dump.url) do = link_to download_backup_url(@backup) do
%span= t 'exports.archive_takeout.download' %span= t 'exports.archive_takeout.download'

View File

@ -4,4 +4,4 @@
<%= t 'user_mailer.backup_ready.explanation' %> <%= t 'user_mailer.backup_ready.explanation' %>
=> <%= full_asset_url(@backup.dump.url) %> => <%= download_backup_url(@backup) %>

View File

@ -10,6 +10,16 @@ class ActivityPub::DeliveryWorker
sidekiq_options queue: 'push', retry: 16, dead: false sidekiq_options queue: 'push', retry: 16, dead: false
# Unfortunately, we cannot control Sidekiq's jitter, so add our own
sidekiq_retry_in do |count|
# This is Sidekiq's default delay
delay = (count**4) + 15
# Our custom jitter, that will be added to Sidekiq's built-in one.
# Sidekiq's built-in jitter is `rand(10) * (count + 1)`
jitter = rand(0.5 * (count**4))
delay + jitter
end
HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
def perform(json, source_account_id, inbox_url, options = {}) def perform(json, source_account_id, inbox_url, options = {})

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class ActivityPub::MigratedFollowDeliveryWorker < ActivityPub::DeliveryWorker
def perform(json, source_account_id, inbox_url, old_target_account_id, options = {})
super(json, source_account_id, inbox_url, options)
unfollow_old_account!(old_target_account_id)
end
private
def unfollow_old_account!(old_target_account_id)
old_target_account = Account.find(old_target_account_id)
UnfollowService.new.call(@source_account, old_target_account, skip_unmerge: true)
rescue StandardError
true
end
end

View File

@ -7,7 +7,7 @@ class Scheduler::AccountsStatusesCleanupScheduler
# This limit is mostly to be nice to the fediverse at large and not # This limit is mostly to be nice to the fediverse at large and not
# generate too much traffic. # generate too much traffic.
# This also helps limiting the running time of the scheduler itself. # This also helps limiting the running time of the scheduler itself.
MAX_BUDGET = 50 MAX_BUDGET = 150
# This is an attempt to spread the load across instances, as various # This is an attempt to spread the load across instances, as various
# accounts are likely to have various followers. # accounts are likely to have various followers.
@ -15,28 +15,22 @@ class Scheduler::AccountsStatusesCleanupScheduler
# This is an attempt to limit the workload generated by status removal # This is an attempt to limit the workload generated by status removal
# jobs to something the particular instance can handle. # jobs to something the particular instance can handle.
PER_THREAD_BUDGET = 5 PER_THREAD_BUDGET = 6
# Those avoid loading an instance that is already under load # Those avoid loading an instance that is already under load
MAX_DEFAULT_SIZE = 2 MAX_DEFAULT_SIZE = 200
MAX_DEFAULT_LATENCY = 5 MAX_DEFAULT_LATENCY = 5
MAX_PUSH_SIZE = 5 MAX_PUSH_SIZE = 500
MAX_PUSH_LATENCY = 10 MAX_PUSH_LATENCY = 10
# 'pull' queue has lower priority jobs, and it's unlikely that pushing # 'pull' queue has lower priority jobs, and it's unlikely that pushing
# deletes would cause much issues with this queue if it didn't cause issues # deletes would cause much issues with this queue if it didn't cause issues
# with default and push. Yet, do not enqueue deletes if the instance is # with default and push. Yet, do not enqueue deletes if the instance is
# lagging behind too much. # lagging behind too much.
MAX_PULL_SIZE = 500 MAX_PULL_SIZE = 10_000
MAX_PULL_LATENCY = 300 MAX_PULL_LATENCY = 5.minutes.to_i
# This is less of an issue in general, but deleting old statuses is likely sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.day.to_i
# to cause delivery errors, and thus increase the number of jobs to be retried.
# This doesn't directly translate to load, but connection errors and a high
# number of dead instances may lead to this spiraling out of control if
# unchecked.
MAX_RETRY_SIZE = 50_000
sidekiq_options retry: 0, lock: :until_executed
def perform def perform
return if under_load? return if under_load?
@ -72,7 +66,6 @@ class Scheduler::AccountsStatusesCleanupScheduler
end end
def under_load? def under_load?
return true if Sidekiq::Stats.new.retry_size > MAX_RETRY_SIZE
queue_under_load?('default', MAX_DEFAULT_SIZE, MAX_DEFAULT_LATENCY) || queue_under_load?('push', MAX_PUSH_SIZE, MAX_PUSH_LATENCY) || queue_under_load?('pull', MAX_PULL_SIZE, MAX_PULL_LATENCY) queue_under_load?('default', MAX_DEFAULT_SIZE, MAX_DEFAULT_LATENCY) || queue_under_load?('push', MAX_PUSH_SIZE, MAX_PUSH_LATENCY) || queue_under_load?('pull', MAX_PULL_SIZE, MAX_PULL_LATENCY)
end end

View File

@ -10,13 +10,7 @@ class UnfollowFollowWorker
old_target_account = Account.find(old_target_account_id) old_target_account = Account.find(old_target_account_id)
new_target_account = Account.find(new_target_account_id) new_target_account = Account.find(new_target_account_id)
follow = follower_account.active_relationships.find_by(target_account: old_target_account) FollowMigrationService.new.call(follower_account, new_target_account, old_target_account, bypass_locked: bypass_locked)
reblogs = follow&.show_reblogs?
notify = follow&.notify?
languages = follow&.languages
FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, notify: notify, languages: languages, bypass_locked: bypass_locked, bypass_limit: true)
UnfollowService.new.call(follower_account, old_target_account, skip_unmerge: true)
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
true true
end end

View File

@ -5,7 +5,9 @@ require_relative '../config/boot'
require_relative '../lib/cli' require_relative '../lib/cli'
begin begin
Chewy.strategy(:mastodon) do
Mastodon::CLI.start(ARGV) Mastodon::CLI.start(ARGV)
end
rescue Interrupt rescue Interrupt
exit(130) exit(130)
end end

View File

@ -35,6 +35,7 @@ require_relative '../lib/terrapin/multi_pipe_extensions'
require_relative '../lib/mastodon/snowflake' require_relative '../lib/mastodon/snowflake'
require_relative '../lib/mastodon/version' require_relative '../lib/mastodon/version'
require_relative '../lib/mastodon/rack_middleware' require_relative '../lib/mastodon/rack_middleware'
require_relative '../lib/public_file_server_middleware'
require_relative '../lib/devise/two_factor_ldap_authenticatable' require_relative '../lib/devise/two_factor_ldap_authenticatable'
require_relative '../lib/devise/two_factor_pam_authenticatable' require_relative '../lib/devise/two_factor_pam_authenticatable'
require_relative '../lib/chewy/strategy/mastodon' require_relative '../lib/chewy/strategy/mastodon'
@ -181,6 +182,10 @@ module Mastodon
config.active_job.queue_adapter = :sidekiq config.active_job.queue_adapter = :sidekiq
config.action_mailer.deliver_later_queue_name = 'mailers' config.action_mailer.deliver_later_queue_name = 'mailers'
# We use our own middleware for this
config.public_file_server.enabled = false
config.middleware.use PublicFileServerMiddleware if Rails.env.development? || ENV['RAILS_SERVE_STATIC_FILES'] == 'true'
config.middleware.use Rack::Attack config.middleware.use Rack::Attack
config.middleware.use Mastodon::RackMiddleware config.middleware.use Mastodon::RackMiddleware

View File

@ -5,6 +5,7 @@ default: &default
connect_timeout: 15 connect_timeout: 15
encoding: unicode encoding: unicode
sslmode: <%= ENV['DB_SSLMODE'] || "prefer" %> sslmode: <%= ENV['DB_SSLMODE'] || "prefer" %>
application_name: ''
development: development:
<<: *default <<: *default

View File

@ -16,12 +16,7 @@ Rails.application.configure do
# Run rails dev:cache to toggle caching. # Run rails dev:cache to toggle caching.
if Rails.root.join('tmp/caching-dev.txt').exist? if Rails.root.join('tmp/caching-dev.txt').exist?
config.action_controller.perform_caching = true config.action_controller.perform_caching = true
config.cache_store = :redis_cache_store, REDIS_CACHE_PARAMS config.cache_store = :redis_cache_store, REDIS_CACHE_PARAMS
config.public_file_server.headers = {
'Cache-Control' => "public, max-age=#{2.days.to_i}",
}
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false

View File

@ -19,27 +19,16 @@ Rails.application.configure do
# or in config/master.key. This key is used to decrypt credentials (and other encrypted files). # or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
# config.require_master_key = true # config.require_master_key = true
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
ActiveSupport::Logger.new(STDOUT).tap do |logger| ActiveSupport::Logger.new(STDOUT).tap do |logger|
logger.formatter = config.log_formatter logger.formatter = config.log_formatter
config.logger = ActiveSupport::TaggedLogging.new(logger) config.logger = ActiveSupport::TaggedLogging.new(logger)
end end
# Compress JavaScripts and CSS.
# config.assets.js_compressor = Uglifier.new(mangle: false)
# config.assets.css_compressor = :sass
# Do not fallback to assets pipeline if a precompiled asset is missed. # Do not fallback to assets pipeline if a precompiled asset is missed.
config.assets.compile = false config.assets.compile = false
# `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
# Specifies the header that your server uses for sending files. # Specifies the header that your server uses for sending files.
# config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache config.action_dispatch.x_sendfile_header = ENV['SENDFILE_HEADER'] if ENV['SENDFILE_HEADER'].present?
config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
# Allow to specify public IP of reverse proxy if it's needed # Allow to specify public IP of reverse proxy if it's needed
config.action_dispatch.trusted_proxies = ENV['TRUSTED_PROXY_IP'].split(/(?:\s*,\s*|\s+)/).map { |item| IPAddr.new(item) } if ENV['TRUSTED_PROXY_IP'].present? config.action_dispatch.trusted_proxies = ENV['TRUSTED_PROXY_IP'].split(/(?:\s*,\s*|\s+)/).map { |item| IPAddr.new(item) } if ENV['TRUSTED_PROXY_IP'].present?
@ -67,7 +56,7 @@ Rails.application.configure do
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# English when a translation cannot be found). # English when a translation cannot be found).
config.i18n.fallbacks = [:en] config.i18n.fallbacks = true
# Send deprecation notices to registered listeners. # Send deprecation notices to registered listeners.
config.active_support.deprecation = :notify config.active_support.deprecation = :notify
@ -128,6 +117,7 @@ Rails.application.configure do
enable_starttls_auto: enable_starttls_auto, enable_starttls_auto: enable_starttls_auto,
tls: ENV['SMTP_TLS'].presence && ENV['SMTP_TLS'] == 'true', tls: ENV['SMTP_TLS'].presence && ENV['SMTP_TLS'] == 'true',
ssl: ENV['SMTP_SSL'].presence && ENV['SMTP_SSL'] == 'true', ssl: ENV['SMTP_SSL'].presence && ENV['SMTP_SSL'] == 'true',
read_timeout: 20,
} }
config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym

View File

@ -12,11 +12,6 @@ Rails.application.configure do
# preloads Rails for running tests, you may have to set it to true. # preloads Rails for running tests, you may have to set it to true.
config.eager_load = false config.eager_load = false
# Configure public file server for tests with Cache-Control for performance.
config.public_file_server.enabled = true
config.public_file_server.headers = {
'Cache-Control' => "public, max-age=#{1.hour.to_i}"
}
config.assets.digest = false config.assets.digest = false
# Show full error reports and disable caching. # Show full error reports and disable caching.

View File

@ -19,7 +19,6 @@ Chewy.settings = {
# cycle, which takes care of checking if Elasticsearch is enabled # cycle, which takes care of checking if Elasticsearch is enabled
# or not. However, mind that for the Rails console, the :urgent # or not. However, mind that for the Rails console, the :urgent
# strategy is set automatically with no way to override it. # strategy is set automatically with no way to override it.
Chewy.root_strategy = :mastodon
Chewy.request_strategy = :mastodon Chewy.request_strategy = :mastodon
Chewy.use_after_commit_callbacks = false Chewy.use_after_commit_callbacks = false

View File

@ -805,6 +805,12 @@ en:
message_html: You haven't defined any server rules. message_html: You haven't defined any server rules.
sidekiq_process_check: sidekiq_process_check:
message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration
upload_check_privacy_error:
action: Check here for more information
message_html: "<strong>Your web server is misconfigured. The privacy of your users is at risk.</strong>"
upload_check_privacy_error_object_storage:
action: Check here for more information
message_html: "<strong>Your object storage is misconfigured. The privacy of your users is at risk.</strong>"
tags: tags:
review: Review status review: Review status
updated_msg: Hashtag settings updated successfully updated_msg: Hashtag settings updated successfully
@ -1399,6 +1405,7 @@ en:
confirm_remove_selected_followers: Are you sure you want to remove selected followers? confirm_remove_selected_followers: Are you sure you want to remove selected followers?
confirm_remove_selected_follows: Are you sure you want to remove selected follows? confirm_remove_selected_follows: Are you sure you want to remove selected follows?
dormant: Dormant dormant: Dormant
follow_failure: Could not follow some of the selected accounts.
follow_selected_followers: Follow selected followers follow_selected_followers: Follow selected followers
followers: Followers followers: Followers
following: Following following: Following

View File

@ -22,3 +22,5 @@ on_worker_boot do
end end
plugin :tmp_restart plugin :tmp_restart
set_remote_address(proxy_protocol: :v1) if ENV['PROXY_PROTO_V1'] == 'true'

View File

@ -110,6 +110,8 @@ Rails.application.routes.draw do
resource :inbox, only: [:create], module: :activitypub resource :inbox, only: [:create], module: :activitypub
get '/:encoded_at(*path)', to: redirect("/@%{path}"), constraints: { encoded_at: /%40/ }
constraints(username: /[^@\/.]+/) do constraints(username: /[^@\/.]+/) do
get '/@:username', to: 'accounts#show', as: :short_account get '/@:username', to: 'accounts#show', as: :short_account
get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies
@ -218,6 +220,7 @@ Rails.application.routes.draw do
resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update] resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update]
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy, format: false get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy, format: false
get '/backups/:id/download', to: 'backups#download', as: :download_backup, format: false
resource :authorize_interaction, only: [:show, :create] resource :authorize_interaction, only: [:show, :create]
resource :share, only: [:show, :create] resource :share, only: [:show, :create]
@ -470,7 +473,9 @@ Rails.application.routes.draw do
resources :list, only: :show resources :list, only: :show
end end
resources :streaming, only: [:index] get '/streaming', to: 'streaming#index'
get '/streaming/(*any)', to: 'streaming#index'
resources :custom_emojis, only: [:index] resources :custom_emojis, only: [:index]
resources :suggestions, only: [:index, :destroy] resources :suggestions, only: [:index, :destroy]
resources :scheduled_statuses, only: [:index, :show, :update, :destroy] resources :scheduled_statuses, only: [:index, :show, :update, :destroy]

View File

@ -56,7 +56,7 @@ services:
web: web:
build: . build: .
image: tootsuite/mastodon image: ghcr.io/mastodon/mastodon
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000" command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
@ -77,7 +77,7 @@ services:
streaming: streaming:
build: . build: .
image: tootsuite/mastodon image: ghcr.io/mastodon/mastodon
restart: always restart: always
env_file: .env.production env_file: .env.production
command: node ./streaming command: node ./streaming
@ -95,7 +95,7 @@ services:
sidekiq: sidekiq:
build: . build: .
image: tootsuite/mastodon image: ghcr.io/mastodon/mastodon
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec sidekiq command: bundle exec sidekiq

View File

@ -372,16 +372,16 @@ module Mastodon
option :concurrency, type: :numeric, default: 5, aliases: [:c] option :concurrency, type: :numeric, default: 5, aliases: [:c]
option :verbose, type: :boolean, aliases: [:v] option :verbose, type: :boolean, aliases: [:v]
option :dry_run, type: :boolean option :dry_run, type: :boolean
desc 'refresh [USERNAME]', 'Fetch remote user data and files' desc 'refresh [USERNAMES]', 'Fetch remote user data and files'
long_desc <<-LONG_DESC long_desc <<-LONG_DESC
Fetch remote user data and files for one or multiple accounts. Fetch remote user data and files for one or multiple accounts.
With the --all option, all remote accounts will be processed. With the --all option, all remote accounts will be processed.
Through the --domain option, this can be narrowed down to a Through the --domain option, this can be narrowed down to a
specific domain only. Otherwise, a single remote account must specific domain only. Otherwise, remote accounts must be
be specified with USERNAME. specified with space-separated USERNAMES.
LONG_DESC LONG_DESC
def refresh(username = nil) def refresh(*usernames)
dry_run = options[:dry_run] ? ' (DRY RUN)' : '' dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
if options[:domain] || options[:all] if options[:domain] || options[:all]
@ -397,19 +397,25 @@ module Mastodon
end end
say("Refreshed #{processed} accounts#{dry_run}", :green, true) say("Refreshed #{processed} accounts#{dry_run}", :green, true)
elsif username.present? elsif !usernames.empty?
username, domain = username.split('@') usernames.each do |user|
account = Account.find_remote(username, domain) user, domain = user.split('@')
account = Account.find_remote(user, domain)
if account.nil? if account.nil?
say('No such account', :red) say('No such account', :red)
exit(1) exit(1)
end end
unless options[:dry_run] next if options[:dry_run]
begin
account.reset_avatar! account.reset_avatar!
account.reset_header! account.reset_header!
account.save account.save
rescue Mastodon::UnexpectedResponseError
say("Account failed: #{user}@#{domain}", :red)
end
end end
say("OK#{dry_run}", :green) say("OK#{dry_run}", :green)
@ -631,7 +637,7 @@ module Mastodon
exit(1) exit(1)
end end
unless options[:force] || migration.target_acount_id == account.moved_to_account_id unless options[:force] || migration.target_account_id == account.moved_to_account_id
say('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway', :red) say('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway', :red)
exit(1) exit(1)
end end

View File

@ -3,8 +3,8 @@
class Mastodon::SidekiqMiddleware class Mastodon::SidekiqMiddleware
BACKTRACE_LIMIT = 3 BACKTRACE_LIMIT = 3
def call(*) def call(*, &block)
yield Chewy.strategy(:mastodon, &block)
rescue Mastodon::HostValidationError rescue Mastodon::HostValidationError
# Do not retry # Do not retry
rescue => e rescue => e

View File

@ -13,7 +13,7 @@ module Mastodon
end end
def patch def patch
0 1
end end
def flags def flags

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'action_dispatch/middleware/static'
class PublicFileServerMiddleware
SERVICE_WORKER_TTL = 7.days.to_i
CACHE_TTL = 28.days.to_i
def initialize(app)
@app = app
@file_handler = ActionDispatch::FileHandler.new(Rails.application.paths['public'].first)
end
def call(env)
file = @file_handler.attempt(env)
# If the request is not a static file, move on!
return @app.call(env) if file.nil?
status, headers, response = file
# Set cache headers on static files. Some paths require different cache headers
headers['Cache-Control'] = begin
request_path = env['REQUEST_PATH']
if request_path.start_with?('/sw.js')
"public, max-age=#{SERVICE_WORKER_TTL}, must-revalidate"
elsif request_path.start_with?(paperclip_root_url)
"public, max-age=#{CACHE_TTL}, immutable"
else
"public, max-age=#{CACHE_TTL}, must-revalidate"
end
end
[status, headers, response]
end
private
def paperclip_root_url
ENV.fetch('PAPERCLIP_ROOT_URL', '/system')
end
end

View File

@ -55,7 +55,7 @@ describe RelationshipsController do
end end
context 'when select parameter is provided' do context 'when select parameter is provided' do
subject { patch :update, params: { form_account_batch: { account_ids: [poopfeast.id] }, block_domains: '' } } subject { patch :update, params: { form_account_batch: { account_ids: [poopfeast.id] }, remove_domains_from_followers: '' } }
it 'soft-blocks followers from selected domains' do it 'soft-blocks followers from selected domains' do
poopfeast.follow!(user.account) poopfeast.follow!(user.account)
@ -66,6 +66,15 @@ describe RelationshipsController do
expect(poopfeast.following?(user.account)).to be false expect(poopfeast.following?(user.account)).to be false
end end
it 'does not unfollow users from selected domains' do
user.account.follow!(poopfeast)
sign_in user, scope: :user
subject
expect(user.account.following?(poopfeast)).to be true
end
include_examples 'authenticate user' include_examples 'authenticate user'
include_examples 'redirects back to followers page' include_examples 'redirects back to followers page'
end end

View File

@ -248,7 +248,7 @@ describe Settings::TwoFactorAuthentication::WebauthnCredentialsController do
post :create, params: { credential: new_webauthn_credential, nickname: 'USB Key' } post :create, params: { credential: new_webauthn_credential, nickname: 'USB Key' }
expect(response).to have_http_status(500) expect(response).to have_http_status(422)
expect(flash[:error]).to be_present expect(flash[:error]).to be_present
end end
end end
@ -268,7 +268,7 @@ describe Settings::TwoFactorAuthentication::WebauthnCredentialsController do
post :create, params: { credential: new_webauthn_credential, nickname: nickname } post :create, params: { credential: new_webauthn_credential, nickname: nickname }
expect(response).to have_http_status(500) expect(response).to have_http_status(422)
expect(flash[:error]).to be_present expect(flash[:error]).to be_present
end end
end end

View File

@ -4,7 +4,7 @@ RSpec.describe PlainTextFormatter do
describe '#to_s' do describe '#to_s' do
subject { described_class.new(status.text, status.local?).to_s } subject { described_class.new(status.text, status.local?).to_s }
context 'given a post with local status' do context 'when status is local' do
let(:status) { Fabricate(:status, text: '<p>a text by a nerd who uses an HTML tag in text</p>', uri: nil) } let(:status) { Fabricate(:status, text: '<p>a text by a nerd who uses an HTML tag in text</p>', uri: nil) }
it 'returns the raw text' do it 'returns the raw text' do
@ -12,12 +12,63 @@ RSpec.describe PlainTextFormatter do
end end
end end
context 'given a post with remote status' do context 'when status is remote' do
let(:remote_account) { Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') } let(:remote_account) { Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') }
let(:status) { Fabricate(:status, account: remote_account, text: '<p>Hello</p><script>alert("Hello")</script>') }
it 'returns tag-stripped text' do context 'when text contains inline HTML tags' do
is_expected.to eq 'Hello' let(:status) { Fabricate(:status, account: remote_account, text: '<b>Lorem</b> <em>ipsum</em>') }
it 'strips the tags' do
expect(subject).to eq 'Lorem ipsum'
end
end
context 'when text contains <p> tags' do
let(:status) { Fabricate(:status, account: remote_account, text: '<p>Lorem</p><p>ipsum</p>') }
it 'inserts a newline' do
expect(subject).to eq "Lorem\nipsum"
end
end
context 'when text contains a single <br> tag' do
let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem<br>ipsum') }
it 'inserts a newline' do
expect(subject).to eq "Lorem\nipsum"
end
end
context 'when text contains consecutive <br> tag' do
let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem<br><br><br>ipsum') }
it 'inserts a single newline' do
expect(subject).to eq "Lorem\nipsum"
end
end
context 'when text contains HTML entity' do
let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem &amp; ipsum &#x2764;') }
it 'unescapes the entity' do
expect(subject).to eq 'Lorem & ipsum ❤'
end
end
context 'when text contains <script> tag' do
let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem <script> alert("Booh!") </script>ipsum') }
it 'strips the tag and its contents' do
expect(subject).to eq 'Lorem ipsum'
end
end
context 'when text contains an HTML comment tags' do
let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem <!-- Booh! -->ipsum') }
it 'strips the comment' do
expect(subject).to eq 'Lorem ipsum'
end
end end
end end
end end

View File

@ -23,7 +23,6 @@ describe Scheduler::AccountsStatusesCleanupScheduler do
}, },
] ]
end end
let(:retry_size) { 0 }
before do before do
queue_stub = double queue_stub = double
@ -33,7 +32,6 @@ describe Scheduler::AccountsStatusesCleanupScheduler do
allow(Sidekiq::ProcessSet).to receive(:new).and_return(process_set_stub) allow(Sidekiq::ProcessSet).to receive(:new).and_return(process_set_stub)
sidekiq_stats_stub = double sidekiq_stats_stub = double
allow(sidekiq_stats_stub).to receive(:retry_size).and_return(retry_size)
allow(Sidekiq::Stats).to receive(:new).and_return(sidekiq_stats_stub) allow(Sidekiq::Stats).to receive(:new).and_return(sidekiq_stats_stub)
# Create a bunch of old statuses # Create a bunch of old statuses
@ -70,14 +68,6 @@ describe Scheduler::AccountsStatusesCleanupScheduler do
expect(subject.under_load?).to be true expect(subject.under_load?).to be true
end end
end end
context 'when there is a huge amount of jobs to retry' do
let(:retry_size) { 1_000_000 }
it 'returns true' do
expect(subject.under_load?).to be true
end
end
end end
describe '#get_budget' do describe '#get_budget' do

View File

@ -856,15 +856,15 @@ const startWorker = async (workerId) => {
res.write('# TYPE connected_channels gauge\n'); res.write('# TYPE connected_channels gauge\n');
res.write('# HELP connected_channels The number of Redis channels the streaming server is subscribed to\n'); res.write('# HELP connected_channels The number of Redis channels the streaming server is subscribed to\n');
res.write(`connected_channels ${Object.keys(subs).length}.0\n`); res.write(`connected_channels ${Object.keys(subs).length}.0\n`);
res.write('# TYPE pg.pool.total_connections gauge \n'); res.write('# TYPE pg_pool_total_connections gauge\n');
res.write('# HELP pg.pool.total_connections The total number of clients existing within the pool\n'); res.write('# HELP pg_pool_total_connections The total number of clients existing within the pool\n');
res.write(`pg.pool.total_connections ${pgPool.totalCount}.0\n`); res.write(`pg_pool_total_connections ${pgPool.totalCount}.0\n`);
res.write('# TYPE pg.pool.idle_connections gauge \n'); res.write('# TYPE pg_pool_idle_connections gauge\n');
res.write('# HELP pg.pool.idle_connections The number of clients which are not checked out but are currently idle in the pool\n'); res.write('# HELP pg_pool_idle_connections The number of clients which are not checked out but are currently idle in the pool\n');
res.write(`pg.pool.idle_connections ${pgPool.idleCount}.0\n`); res.write(`pg_pool_idle_connections ${pgPool.idleCount}.0\n`);
res.write('# TYPE pg.pool.waiting_queries gauge \n'); res.write('# TYPE pg_pool_waiting_queries gauge\n');
res.write('# HELP pg.pool.waiting_queries The number of queued requests waiting on a client when all clients are checked out\n'); res.write('# HELP pg_pool_waiting_queries The number of queued requests waiting on a client when all clients are checked out\n');
res.write(`pg.pool.waiting_queries ${pgPool.waitingCount}.0\n`); res.write(`pg_pool_waiting_queries ${pgPool.waitingCount}.0\n`);
res.write('# EOF\n'); res.write('# EOF\n');
res.end(); res.end();
})); }));