Merge remote-tracking branch 'mastodon/main' into custom/odakyudon

This commit is contained in:
kyori19 2023-07-10 09:03:34 +00:00
commit 2b808e90c2
No known key found for this signature in database
GPG Key ID: F7BDE7DD42BF366A
91 changed files with 1036 additions and 777 deletions

View File

@ -49,8 +49,10 @@ jobs:
images: | images: |
tootsuite/mastodon tootsuite/mastodon
ghcr.io/mastodon/mastodon ghcr.io/mastodon/mastodon
# Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release
flavor: | flavor: |
latest=auto latest=${{ startsWith(github.ref, 'refs/tags/v4.1.') && 'auto' || 'false' }}
tags: | tags: |
type=edge,branch=main type=edge,branch=main
type=pep440,pattern={{raw}} type=pep440,pattern={{raw}}

View File

@ -809,7 +809,6 @@ Style/FrozenStringLiteralComment:
- 'config/initializers/httplog.rb' - 'config/initializers/httplog.rb'
- 'config/initializers/inflections.rb' - 'config/initializers/inflections.rb'
- 'config/initializers/mail_delivery_job.rb' - 'config/initializers/mail_delivery_job.rb'
- 'config/initializers/makara.rb'
- 'config/initializers/mime_types.rb' - 'config/initializers/mime_types.rb'
- 'config/initializers/oj.rb' - 'config/initializers/oj.rb'
- 'config/initializers/omniauth.rb' - 'config/initializers/omniauth.rb'

View File

@ -2,6 +2,62 @@
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.4] - 2023-07-07
### Fixed
- Fix branding:generate_app_icons failing because of disallowed ICO coder ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25794))
- Fix crash in admin interface when viewing a remote user with verified links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25796))
- Fix processing of media files with unusual names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25788))
## [4.1.3] - 2023-07-06
### Added
- Add fallback redirection when getting a webfinger query `LOCAL_DOMAIN@LOCAL_DOMAIN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23600))
### Changed
- Change OpenGraph-based embeds to allow fullscreen ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25058))
- Change AccessTokensVacuum to also delete expired tokens ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24868))
- Change profile updates to be sent to recently-mentioned servers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24852))
- Change automatic post deletion thresholds and load detection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24614))
- Change `/api/v1/statuses/:id/history` to always return at least one item ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25510))
- Change auto-linking to allow carets in URL query params ([renchap](https://github.com/mastodon/mastodon/pull/25216))
### Removed
- Remove invalid `X-Frame-Options: ALLOWALL` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25070))
### Fixed
- Fix wrong view being displayed when a webhook fails validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25464))
- Fix soft-deleted post cleanup scheduler overwhelming the streaming server ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25519))
- Fix incorrect pagination headers in `/api/v2/admin/accounts` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25477))
- Fix multiple inefficiencies in automatic post cleanup worker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24607), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24785), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24840))
- Fix performance of streaming by parsing message JSON once ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25278), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25361))
- Fix CSP headers when `S3_ALIAS_HOST` includes a path component ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25273))
- Fix `tootctl accounts approve --number N` not approving N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605))
- Fix reports not being closed when performing batch suspensions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24988))
- Fix being able to vote on your own polls ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25015))
- Fix race condition when reblogging a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25016))
- Fix “Authorized applications” inefficiently and incorrectly getting last use date ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25060))
- Fix “Authorized applications” crashing when listing apps with certain admin API scopes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25713))
- Fix multiple N+1s in ConversationsController ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25134), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25399), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25499))
- Fix user archive takeouts when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24431))
- Fix searching for remote content by URL not working under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25637))
- Fix inefficiencies in indexing content for search ([VyrCossont](https://github.com/mastodon/mastodon/pull/24285), [VyrCossont](https://github.com/mastodon/mastodon/pull/24342))
### Security
- Add finer permission requirements for managing webhooks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25463))
- Update dependencies
- Add hardening headers for user-uploaded files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25756))
- Fix verified links possibly hiding important parts of the URL (CVE-2023-36462)
- Fix timeout handling of outbound HTTP requests (CVE-2023-36461)
- Fix arbitrary file creation through media processing (CVE-2023-36460)
- Fix possible XSS in preview cards (CVE-2023-36459)
## [4.1.2] - 2023-04-04 ## [4.1.2] - 2023-04-04
### Fixed ### Fixed

View File

@ -11,7 +11,6 @@ gem 'rack', '~> 2.2.7'
gem 'haml-rails', '~>2.0' gem 'haml-rails', '~>2.0'
gem 'pg', '~> 1.5' gem 'pg', '~> 1.5'
gem 'makara', '~> 0.5'
gem 'pghero' gem 'pghero'
gem 'dotenv-rails', '~> 2.8' gem 'dotenv-rails', '~> 2.8'

View File

@ -399,8 +399,6 @@ GEM
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
makara (0.5.1)
activerecord (>= 5.2.0)
marcel (1.0.2) marcel (1.0.2)
mario-redis-lock (1.2.1) mario-redis-lock (1.2.1)
redis (>= 3.0.5) redis (>= 3.0.5)
@ -432,7 +430,7 @@ GEM
net-protocol net-protocol
net-ssh (7.1.0) net-ssh (7.1.0)
nio4r (2.5.9) nio4r (2.5.9)
nokogiri (1.15.2) nokogiri (1.15.3)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.15.0) oj (3.15.0)
@ -628,7 +626,7 @@ GEM
fugit (~> 1.1, >= 1.1.6) fugit (~> 1.1, >= 1.1.6)
safety_net_attestation (0.4.0) safety_net_attestation (0.4.0)
jwt (~> 2.0) jwt (~> 2.0)
sanitize (6.0.1) sanitize (6.0.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
scenic (1.7.0) scenic (1.7.0)
@ -815,7 +813,6 @@ DEPENDENCIES
letter_opener_web (~> 2.0) letter_opener_web (~> 2.0)
link_header (~> 0.0) link_header (~> 0.0)
lograge (~> 0.12) lograge (~> 0.12)
makara (~> 0.5)
mario-redis-lock (~> 1.2) mario-redis-lock (~> 1.2)
memory_profiler memory_profiler
mime-types (~> 3.4.1) mime-types (~> 3.4.1)

View File

@ -6,13 +6,17 @@ class Api::V1::Timelines::HomeController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? } after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show def show
ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
@statuses = load_statuses @statuses = load_statuses
@relationships = StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
@account_relationships = AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
render json: @statuses, render json: @statuses,
each_serializer: REST::StatusSerializer, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), relationships: @relationships,
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id), account_relationships: @account_relationships,
status: account_home_feed.regenerating? ? 206 : 200 status: account_home_feed.regenerating? ? 206 : 200
end end

View File

@ -59,6 +59,10 @@ module FormattingHelper
end end
def account_field_value_format(field, with_rel_me: true) def account_field_value_format(field, with_rel_me: true)
if field.verified? && !field.account.local?
TextFormatter.shortened_link(field.value_for_verification)
else
html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false) html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false)
end end
end end
end

View File

@ -12,52 +12,48 @@ export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR'; export const ALERT_CLEAR = 'ALERT_CLEAR';
export const ALERT_NOOP = 'ALERT_NOOP'; export const ALERT_NOOP = 'ALERT_NOOP';
export function dismissAlert(alert) { export const dismissAlert = alert => ({
return {
type: ALERT_DISMISS, type: ALERT_DISMISS,
alert, alert,
}; });
}
export function clearAlert() { export const clearAlert = () => ({
return {
type: ALERT_CLEAR, type: ALERT_CLEAR,
}; });
}
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) { export const showAlert = alert => ({
return {
type: ALERT_SHOW, type: ALERT_SHOW,
title, alert,
message, });
message_values,
};
}
export function showAlertForError(error, skipNotFound = false) { export const showAlertForError = (error, skipNotFound = false) => {
if (error.response) { if (error.response) {
const { data, status, statusText, headers } = error.response; const { data, status, statusText, headers } = error.response;
if (skipNotFound && (status === 404 || status === 410)) {
// Skip these errors as they are reflected in the UI // Skip these errors as they are reflected in the UI
if (skipNotFound && (status === 404 || status === 410)) {
return { type: ALERT_NOOP }; return { type: ALERT_NOOP };
} }
// Rate limit errors
if (status === 429 && headers['x-ratelimit-reset']) { if (status === 429 && headers['x-ratelimit-reset']) {
const reset_date = new Date(headers['x-ratelimit-reset']); return showAlert({
return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); title: messages.rateLimitedTitle,
message: messages.rateLimitedMessage,
values: { 'retry_time': new Date(headers['x-ratelimit-reset']) },
});
} }
let message = statusText; return showAlert({
let title = `${status}`; title: `${status}`,
message: data.error || statusText,
if (data.error) { });
message = data.error;
} }
return showAlert(title, message);
} else {
console.error(error); console.error(error);
return showAlert();
} return showAlert({
title: messages.unexpectedTitle,
message: messages.unexpectedMessage,
});
} }

View File

@ -84,6 +84,8 @@ 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.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
}); });
export const ensureComposeIsVisible = (getState, routerHistory) => { export const ensureComposeIsVisible = (getState, routerHistory) => {
@ -260,6 +262,13 @@ export function submitCompose(routerHistory) {
insertIfOnline('public'); insertIfOnline('public');
insertIfOnline(`account:${response.data.account.id}`); insertIfOnline(`account:${response.data.account.id}`);
} }
dispatch(showAlert({
message: messages.published,
action: messages.open,
dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
}));
}).catch(function (error) { }).catch(function (error) {
dispatch(submitComposeFail(error)); dispatch(submitComposeFail(error));
}); });
@ -292,15 +301,16 @@ export function uploadCompose(files) {
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']);
const progress = new Array(files.length).fill(0); const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0); let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size + pending > uploadLimit) { if (files.length + media.size + pending > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit)); dispatch(showAlert({ message: messages.uploadErrorLimit }));
return; return;
} }
if (getState().getIn(['compose', 'poll'])) { if (getState().getIn(['compose', 'poll'])) {
dispatch(showAlert(undefined, messages.uploadErrorPoll)); dispatch(showAlert({ message: messages.uploadErrorPoll }));
return; return;
} }

View File

@ -8,15 +8,15 @@ import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { counterRenderer } from 'mastodon/components/common_counter';
import { EmptyAccount } from 'mastodon/components/empty_account'; import { EmptyAccount } from 'mastodon/components/empty_account';
import ShortNumber from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge'; import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from '../initial_state'; import { me } from '../initial_state';
import { Avatar } from './avatar'; import { Avatar } from './avatar';
import Button from './button'; import Button from './button';
import { FollowersCounter } from './counters';
import { DisplayName } from './display_name'; import { DisplayName } from './display_name';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp'; import { RelativeTimestamp } from './relative_timestamp';
@ -160,7 +160,7 @@ class Account extends ImmutablePureComponent {
<DisplayName account={account} /> <DisplayName account={account} />
{!minimal && ( {!minimal && (
<div className='account__details'> <div className='account__details'>
<ShortNumber value={account.get('followers_count')} renderer={counterRenderer('followers')} /> {verification} {muteTimeRemaining} <ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} /> {verification} {muteTimeRemaining}
</div> </div>
)} )}
</div> </div>

View File

@ -4,7 +4,7 @@ import { TransitionMotion, spring } from 'react-motion';
import { reduceMotion } from '../initial_state'; import { reduceMotion } from '../initial_state';
import ShortNumber from './short_number'; import { ShortNumber } from './short_number';
const obfuscatedCount = (count: number) => { const obfuscatedCount = (count: number) => {
if (count < 0) { if (count < 0) {

View File

@ -1,6 +1,6 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ShortNumber from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
interface Props { interface Props {
tag: { tag: {

View File

@ -1,60 +0,0 @@
// @ts-check
import { FormattedMessage } from 'react-intl';
/**
* Returns custom renderer for one of the common counter types
* @param {"statuses" | "following" | "followers"} counterType
* Type of the counter
* @param {boolean} isBold Whether display number must be displayed in bold
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
* Renderer function
* @throws If counterType is not covered by this function
*/
export function counterRenderer(counterType, isBold = true) {
/**
* @type {(displayNumber: JSX.Element) => JSX.Element}
*/
const renderCounter = isBold
? (displayNumber) => <strong>{displayNumber}</strong>
: (displayNumber) => displayNumber;
switch (counterType) {
case 'statuses': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'following': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'followers': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
}
}

View File

@ -0,0 +1,45 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
export const StatusesCounter = (
displayNumber: React.ReactNode,
pluralReady: number
) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowingCounter = (
displayNumber: React.ReactNode,
pluralReady: number
) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowersCounter = (
displayNumber: React.ReactNode,
pluralReady: number
) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);

View File

@ -1,55 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import { bannerSettings } from 'mastodon/settings';
import { IconButton } from './icon_button';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
class DismissableBanner extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.node,
intl: PropTypes.object.isRequired,
};
state = {
visible: !bannerSettings.get(this.props.id),
};
handleDismiss = () => {
const { id } = this.props;
this.setState({ visible: false }, () => bannerSettings.set(id, true));
};
render () {
const { visible } = this.state;
if (!visible) {
return null;
}
const { children, intl } = this.props;
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>
{children}
</div>
<div className='dismissable-banner__action'>
<IconButton icon='times' title={intl.formatMessage(messages.dismiss)} onClick={this.handleDismiss} />
</div>
</div>
);
}
}
export default injectIntl(DismissableBanner);

View File

@ -0,0 +1,47 @@
import type { PropsWithChildren } from 'react';
import { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { bannerSettings } from 'mastodon/settings';
import { IconButton } from './icon_button';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
interface Props {
id: string;
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const [visible, setVisible] = useState(!bannerSettings.get(id));
const intl = useIntl();
const handleDismiss = useCallback(() => {
setVisible(false);
bannerSettings.set(id, true);
}, [id]);
if (!visible) {
return null;
}
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>{children}</div>
<div className='dismissable-banner__action'>
<IconButton
icon='times'
title={intl.formatMessage(messages.dismiss)}
onClick={handleDismiss}
/>
</div>
</div>
);
};

View File

@ -11,7 +11,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { Sparklines, SparklinesCurve } from 'react-sparklines'; import { Sparklines, SparklinesCurve } from 'react-sparklines';
import ShortNumber from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton'; import { Skeleton } from 'mastodon/components/skeleton';
class SilentErrorBoundary extends Component { class SilentErrorBoundary extends Component {

View File

@ -327,7 +327,10 @@ class MediaGallery extends PureComponent {
if (uncached) { if (uncached) {
spoilerButton = ( spoilerButton = (
<button type='button' disabled className='spoiler-button__overlay'> <button type='button' disabled className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span> <span className='spoiler-button__overlay__label'>
<FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' />
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span>
</span>
</button> </button>
); );
} else if (visible) { } else if (visible) {
@ -335,7 +338,10 @@ class MediaGallery extends PureComponent {
} else { } else {
spoilerButton = ( spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span> <span className='spoiler-button__overlay__label'>
{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button> </button>
); );
} }

View File

@ -9,7 +9,7 @@ import { connect } from 'react-redux';
import { fetchServer } from 'mastodon/actions/server'; import { fetchServer } from 'mastodon/actions/server';
import { ServerHeroImage } from 'mastodon/components/server_hero_image'; import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import ShortNumber from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton'; import { Skeleton } from 'mastodon/components/skeleton';
import Account from 'mastodon/containers/account_container'; import Account from 'mastodon/containers/account_container';
import { domain } from 'mastodon/initial_state'; import { domain } from 'mastodon/initial_state';

View File

@ -1,115 +0,0 @@
import PropTypes from 'prop-types';
import { memo } from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
// @ts-check
/**
* @callback ShortNumberRenderer
* @param {JSX.Element} displayNumber Number to display
* @param {number} pluralReady Number used for pluralization
* @returns {JSX.Element} Final render of number
*/
/**
* @typedef {object} ShortNumberProps
* @property {number} value Number to display in short variant
* @property {ShortNumberRenderer} [renderer]
* Custom renderer for numbers, provided as a prop. If another renderer
* passed as a child of this component, this prop won't be used.
* @property {ShortNumberRenderer} [children]
* Custom renderer for numbers, provided as a child. If another renderer
* passed as a prop of this component, this one will be used instead.
*/
/**
* Component that renders short big number to a shorter version
* @param {ShortNumberProps} param0 Props for the component
* @returns {JSX.Element} Rendered number
*/
function ShortNumber({ value, renderer, children }) {
const shortNumber = toShortNumber(value);
const [, division] = shortNumber;
if (children != null && renderer != null) {
console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
}
const customRenderer = children != null ? children : renderer;
const displayNumber = <ShortNumberCounter value={shortNumber} />;
return customRenderer != null
? customRenderer(displayNumber, pluralReady(value, division))
: displayNumber;
}
ShortNumber.propTypes = {
value: PropTypes.number.isRequired,
renderer: PropTypes.func,
children: PropTypes.func,
};
/**
* @typedef {object} ShortNumberCounterProps
* @property {import('../utils/number').ShortNumber} value Short number
*/
/**
* Renders short number into corresponding localizable react fragment
* @param {ShortNumberCounterProps} param0 Props for the component
* @returns {JSX.Element} FormattedMessage ready to be embedded in code
*/
function ShortNumberCounter({ value }) {
const [rawNumber, unit, maxFractionDigits = 0] = value;
const count = (
<FormattedNumber
value={rawNumber}
maximumFractionDigits={maxFractionDigits}
/>
);
let values = { count, rawNumber };
switch (unit) {
case DECIMAL_UNITS.THOUSAND: {
return (
<FormattedMessage
id='units.short.thousand'
defaultMessage='{count}K'
values={values}
/>
);
}
case DECIMAL_UNITS.MILLION: {
return (
<FormattedMessage
id='units.short.million'
defaultMessage='{count}M'
values={values}
/>
);
}
case DECIMAL_UNITS.BILLION: {
return (
<FormattedMessage
id='units.short.billion'
defaultMessage='{count}B'
values={values}
/>
);
}
// Not sure if we should go farther - @Sasha-Sorokin
default: return count;
}
}
ShortNumberCounter.propTypes = {
value: PropTypes.arrayOf(PropTypes.number),
};
export default memo(ShortNumber);

View File

@ -0,0 +1,90 @@
import { memo } from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
type ShortNumberRenderer = (
displayNumber: JSX.Element,
pluralReady: number
) => JSX.Element;
interface ShortNumberProps {
value: number;
renderer?: ShortNumberRenderer;
children?: ShortNumberRenderer;
}
export const ShortNumberRenderer: React.FC<ShortNumberProps> = ({
value,
renderer,
children,
}) => {
const shortNumber = toShortNumber(value);
const [, division] = shortNumber;
if (children && renderer) {
console.warn(
'Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'
);
}
const customRenderer = children || renderer || null;
const displayNumber = <ShortNumberCounter value={shortNumber} />;
return (
customRenderer?.(displayNumber, pluralReady(value, division)) ||
displayNumber
);
};
export const ShortNumber = memo(ShortNumberRenderer);
interface ShortNumberCounterProps {
value: number[];
}
const ShortNumberCounter: React.FC<ShortNumberCounterProps> = ({ value }) => {
const [rawNumber, unit, maxFractionDigits = 0] = value;
const count = (
<FormattedNumber
value={rawNumber}
maximumFractionDigits={maxFractionDigits}
/>
);
const values = { count, rawNumber };
switch (unit) {
case DECIMAL_UNITS.THOUSAND: {
return (
<FormattedMessage
id='units.short.thousand'
defaultMessage='{count}K'
values={values}
/>
);
}
case DECIMAL_UNITS.MILLION: {
return (
<FormattedMessage
id='units.short.million'
defaultMessage='{count}M'
values={values}
/>
);
}
case DECIMAL_UNITS.BILLION: {
return (
<FormattedMessage
id='units.short.billion'
defaultMessage='{count}B'
values={values}
/>
);
}
// Not sure if we should go farther - @Sasha-Sorokin
default:
return count;
}
};

View File

@ -252,7 +252,6 @@ class StatusActionBar extends ImmutablePureComponent {
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
const { signedIn, permissions } = this.context.identity; const { signedIn, permissions } = this.context.identity;
const anonymousAccess = !signedIn;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility')); const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
const mutingConversation = status.get('muted'); const mutingConversation = status.get('muted');
@ -278,6 +277,7 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
} }
if (signedIn) {
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
@ -346,6 +346,7 @@ class StatusActionBar extends ImmutablePureComponent {
} }
} }
} }
}
let replyIcon; let replyIcon;
let replyTitle; let replyTitle;
@ -387,7 +388,6 @@ class StatusActionBar extends ImmutablePureComponent {
<div className='status__action-bar__dropdown'> <div className='status__action-bar__dropdown'>
<DropdownMenuContainer <DropdownMenuContainer
scrollKey={scrollKey} scrollKey={scrollKey}
disabled={anonymousAccess}
status={status} status={status}
items={menu} items={menu}
icon='ellipsis-h' icon='ellipsis-h'

View File

@ -45,7 +45,7 @@ class TranslateButton extends PureComponent {
} }
return ( return (
<button className='status__content__read-more-button' onClick={onClick}> <button className='status__content__translate-button' onClick={onClick}>
<FormattedMessage id='status.translate' defaultMessage='Translate' /> <FormattedMessage id='status.translate' defaultMessage='Translate' />
</button> </button>
); );

View File

@ -11,10 +11,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import Button from 'mastodon/components/button'; import Button from 'mastodon/components/button';
import { counterRenderer } from 'mastodon/components/common_counter'; import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import ShortNumber from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif, me, domain } from 'mastodon/initial_state'; import { autoPlayGif, me, domain } from 'mastodon/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
@ -290,7 +290,6 @@ class Header extends ImmutablePureComponent {
if (isRemote) { if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') }); menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
menu.push(null);
} }
if ('share' in navigator) { if ('share' in navigator) {
@ -451,21 +450,21 @@ class Header extends ImmutablePureComponent {
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}> <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
<ShortNumber <ShortNumber
value={account.get('statuses_count')} value={account.get('statuses_count')}
renderer={counterRenderer('statuses')} renderer={StatusesCounter}
/> />
</NavLink> </NavLink>
<NavLink exact activeClassName='active' to={`/@${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}> <NavLink exact activeClassName='active' to={`/@${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}>
<ShortNumber <ShortNumber
value={account.get('following_count')} value={account.get('following_count')}
renderer={counterRenderer('following')} renderer={FollowingCounter}
/> />
</NavLink> </NavLink>
<NavLink exact activeClassName='active' to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}> <NavLink exact activeClassName='active' to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
<ShortNumber <ShortNumber
value={account.get('followers_count')} value={account.get('followers_count')}
renderer={counterRenderer('followers')} renderer={FollowersCounter}
/> />
</NavLink> </NavLink>
</div> </div>

View File

@ -7,7 +7,7 @@ import { Helmet } from 'react-helmet';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import DismissableBanner from 'mastodon/components/dismissable_banner'; import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { domain } from 'mastodon/initial_state'; import { domain } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';

View File

@ -19,7 +19,7 @@ import { openModal } from 'mastodon/actions/modal';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import Button from 'mastodon/components/button'; import Button from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name'; import { DisplayName } from 'mastodon/components/display_name';
import ShortNumber from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state'; import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
import { makeGetAccount } from 'mastodon/selectors'; import { makeGetAccount } from 'mastodon/selectors';

View File

@ -5,7 +5,7 @@ import classNames from 'classnames';
import { Blurhash } from 'mastodon/components/blurhash'; import { Blurhash } from 'mastodon/components/blurhash';
import { accountsCountRenderer } from 'mastodon/components/hashtag'; import { accountsCountRenderer } from 'mastodon/components/hashtag';
import ShortNumber from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton'; import { Skeleton } from 'mastodon/components/skeleton';
export default class Story extends PureComponent { export default class Story extends PureComponent {

View File

@ -11,7 +11,7 @@ import { connect } from 'react-redux';
import Column from 'mastodon/components/column'; import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import Search from 'mastodon/features/compose/containers/search_container'; import Search from 'mastodon/features/compose/containers/search_container';
import { showTrends } from 'mastodon/initial_state'; import { trendsEnabled } from 'mastodon/initial_state';
import Links from './links'; import Links from './links';
import SearchResults from './results'; import SearchResults from './results';
@ -26,7 +26,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
layout: state.getIn(['meta', 'layout']), layout: state.getIn(['meta', 'layout']),
isSearching: state.getIn(['search', 'submitted']) || !showTrends, isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled,
}); });
class Explore extends PureComponent { class Explore extends PureComponent {

View File

@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchTrendingLinks } from 'mastodon/actions/trends'; import { fetchTrendingLinks } from 'mastodon/actions/trends';
import DismissableBanner from 'mastodon/components/dismissable_banner'; import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import Story from './components/story'; import Story from './components/story';

View File

@ -9,7 +9,7 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends'; import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
import DismissableBanner from 'mastodon/components/dismissable_banner'; import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import StatusList from 'mastodon/components/status_list'; import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors'; import { getStatusList } from 'mastodon/selectors';

View File

@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchTrendingHashtags } from 'mastodon/actions/trends'; import { fetchTrendingHashtags } from 'mastodon/actions/trends';
import DismissableBanner from 'mastodon/components/dismissable_banner'; import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';

View File

@ -10,7 +10,7 @@ import { addColumn } from 'mastodon/actions/columns';
import { changeSetting } from 'mastodon/actions/settings'; import { changeSetting } from 'mastodon/actions/settings';
import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming'; import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming';
import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines'; import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines';
import DismissableBanner from 'mastodon/components/dismissable_banner'; import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import initialState, { domain } from 'mastodon/initial_state'; import initialState, { domain } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';

View File

@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import background from 'mastodon/../images/friends-cropped.png'; import background from 'mastodon/../images/friends-cropped.png';
import DismissableBanner from 'mastodon/components/dismissable_banner'; import { DismissableBanner } from 'mastodon/components/dismissable_banner';
export const ExplorePrompt = () => ( export const ExplorePrompt = () => (

View File

@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (permission === 'granted') { if (permission === 'granted') {
dispatch(changePushNotifications(path.slice(1), checked)); dispatch(changePushNotifications(path.slice(1), checked));
} else { } else {
dispatch(showAlert(undefined, messages.permissionDenied)); dispatch(showAlert({ message: messages.permissionDenied }));
} }
})); }));
} else { } else {
@ -47,7 +47,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (permission === 'granted') { if (permission === 'granted') {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
} else { } else {
dispatch(showAlert(undefined, messages.permissionDenied)); dispatch(showAlert({ message: messages.permissionDenied }));
} }
})); }));
} else { } else {

View File

@ -7,7 +7,7 @@ import { Helmet } from 'react-helmet';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import DismissableBanner from 'mastodon/components/dismissable_banner'; import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { domain } from 'mastodon/initial_state'; import { domain } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';

View File

@ -203,21 +203,23 @@ class ActionBar extends PureComponent {
let menu = []; let menu = [];
if (publicStatus) { if (publicStatus && isRemote) {
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') }); menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
} }
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy }); menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
if ('share' in navigator) { if (publicStatus && 'share' in navigator) {
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare }); menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare });
} }
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
menu.push(null);
} }
if (signedIn) {
menu.push(null);
if (writtenByMe) { if (writtenByMe) {
if (pinnableStatus) { if (pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
@ -271,6 +273,7 @@ class ActionBar extends PureComponent {
} }
} }
} }
}
let replyIcon; let replyIcon;
if (status.get('in_reply_to_id', null) === null) { if (status.get('in_reply_to_id', null) === null) {
@ -301,7 +304,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div> <div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
<div className='detailed-status__action-bar-dropdown'> <div className='detailed-status__action-bar-dropdown'>
<DropdownMenuContainer size={18} icon='ellipsis-h' disabled={!signedIn} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} /> <DropdownMenuContainer size={18} icon='ellipsis-h' status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
</div> </div>
</div> </div>
); );

View File

@ -7,7 +7,7 @@ import { Link } from 'react-router-dom';
import { WordmarkLogo } from 'mastodon/components/logo'; import { WordmarkLogo } from 'mastodon/components/logo';
import NavigationPortal from 'mastodon/components/navigation_portal'; import NavigationPortal from 'mastodon/components/navigation_portal';
import { timelinePreview, showTrends } from 'mastodon/initial_state'; import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
import ColumnLink from './column_link'; import ColumnLink from './column_link';
import DisabledAccountBanner from './disabled_account_banner'; import DisabledAccountBanner from './disabled_account_banner';
@ -65,7 +65,7 @@ class NavigationPanel extends Component {
</> </>
)} )}
{showTrends ? ( {trendsEnabled ? (
<ColumnLink transparent to='/explore' icon='hashtag' text={intl.formatMessage(messages.explore)} /> <ColumnLink transparent to='/explore' icon='hashtag' text={intl.formatMessage(messages.explore)} />
) : ( ) : (
<ColumnLink transparent to='/search' icon='search' text={intl.formatMessage(messages.search)} /> <ColumnLink transparent to='/search' icon='search' text={intl.formatMessage(messages.search)} />

View File

@ -7,26 +7,27 @@ import { NotificationStack } from 'react-notification';
import { dismissAlert } from '../../../actions/alerts'; import { dismissAlert } from '../../../actions/alerts';
import { getAlerts } from '../../../selectors'; import { getAlerts } from '../../../selectors';
const mapStateToProps = (state, { intl }) => { const formatIfNeeded = (intl, message, values) => {
const notifications = getAlerts(state); if (typeof message === 'object') {
return intl.formatMessage(message, values);
notifications.forEach(notification => ['title', 'message'].forEach(key => {
const value = notification[key];
if (typeof value === 'object') {
notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
} }
}));
return { notifications }; return message;
}; };
const mapDispatchToProps = (dispatch) => { const mapStateToProps = (state, { intl }) => ({
return { notifications: getAlerts(state).map(alert => ({
onDismiss: alert => { ...alert,
action: formatIfNeeded(intl, alert.action, alert.values),
title: formatIfNeeded(intl, alert.title, alert.values),
message: formatIfNeeded(intl, alert.message, alert.values),
})),
});
const mapDispatchToProps = (dispatch) => ({
onDismiss (alert) {
dispatch(dismissAlert(alert)); dispatch(dismissAlert(alert));
}, },
}; });
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack)); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));

View File

@ -22,7 +22,7 @@ import { clearHeight } from '../../actions/height_cache';
import { expandNotifications } from '../../actions/notifications'; import { expandNotifications } from '../../actions/notifications';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state'; import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding } from '../../initial_state';
import BundleColumnError from './components/bundle_column_error'; import BundleColumnError from './components/bundle_column_error';
import Header from './components/header'; import Header from './components/header';
@ -170,7 +170,7 @@ class SwitchingColumnsArea extends PureComponent {
} }
} else if (singleUserMode && owner && initialState?.accounts[owner]) { } else if (singleUserMode && owner && initialState?.accounts[owner]) {
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />; redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
} else if (showTrends && trendsAsLanding) { } else if (trendsEnabled && trendsAsLanding) {
redirect = <Redirect from='/' to='/explore' exact />; redirect = <Redirect from='/' to='/explore' exact />;
} else { } else {
redirect = <Redirect from='/' to='/about' exact />; redirect = <Redirect from='/' to='/about' exact />;

View File

@ -69,12 +69,13 @@
* @property {boolean} reduce_motion * @property {boolean} reduce_motion
* @property {string} repository * @property {string} repository
* @property {boolean} search_enabled * @property {boolean} search_enabled
* @property {boolean} trends_enabled
* @property {boolean} single_user_mode * @property {boolean} single_user_mode
* @property {string} source_url * @property {string} source_url
* @property {string} streaming_api_base_url * @property {string} streaming_api_base_url
* @property {boolean} timeline_preview * @property {boolean} timeline_preview
* @property {string} title * @property {string} title
* @property {boolean} trends * @property {boolean} show_trends
* @property {boolean} trends_as_landing_page * @property {boolean} trends_as_landing_page
* @property {boolean} unfollow_modal * @property {boolean} unfollow_modal
* @property {boolean} use_blurhash * @property {boolean} use_blurhash
@ -121,7 +122,8 @@ export const reduceMotion = getMeta('reduce_motion');
export const registrationsOpen = getMeta('registrations_open'); export const registrationsOpen = getMeta('registrations_open');
export const repository = getMeta('repository'); export const repository = getMeta('repository');
export const searchEnabled = getMeta('search_enabled'); export const searchEnabled = getMeta('search_enabled');
export const showTrends = getMeta('trends'); export const trendsEnabled = getMeta('trends_enabled');
export const showTrends = getMeta('show_trends');
export const singleUserMode = getMeta('single_user_mode'); export const singleUserMode = getMeta('single_user_mode');
export const source_url = getMeta('source_url'); export const source_url = getMeta('source_url');
export const timelinePreview = getMeta('timeline_preview'); export const timelinePreview = getMeta('timeline_preview');

View File

@ -135,6 +135,8 @@
"community.column_settings.remote_only": "Remote only", "community.column_settings.remote_only": "Remote only",
"compose.language.change": "Change language", "compose.language.change": "Change language",
"compose.language.search": "Search languages...", "compose.language.search": "Search languages...",
"compose.published.body": "Post published.",
"compose.published.open": "Open",
"compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.", "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.", "compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",
@ -619,6 +621,8 @@
"status.history.created": "{name} created {date}", "status.history.created": "{name} created {date}",
"status.history.edited": "{name} edited {date}", "status.history.edited": "{name} edited {date}",
"status.load_more": "Load more", "status.load_more": "Load more",
"status.media.open": "Click to open",
"status.media.show": "Click to show",
"status.media_hidden": "Media hidden", "status.media_hidden": "Media hidden",
"status.mention": "Mention @{name}", "status.mention": "Mention @{name}",
"status.more": "More", "status.more": "More",
@ -651,7 +655,7 @@
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}", "status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}",
"status.translate": "Translate", "status.translate": "Translate",
"status.translated_from_with": "Translated from {lang} using {provider}", "status.translated_from_with": "Translated from {lang} using {provider}",
"status.uncached_media_warning": "Not available", "status.uncached_media_warning": "Preview not available",
"status.unlisted_quote": "Unlisted quote", "status.unlisted_quote": "Unlisted quote",
"status.unmute_conversation": "Unmute conversation", "status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile", "status.unpin": "Unpin from profile",

View File

@ -1,4 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { import {
ALERT_SHOW, ALERT_SHOW,
@ -8,17 +8,20 @@ import {
const initialState = ImmutableList([]); const initialState = ImmutableList([]);
let id = 0;
const addAlert = (state, alert) =>
state.push({
key: id++,
...alert,
});
export default function alerts(state = initialState, action) { export default function alerts(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ALERT_SHOW: case ALERT_SHOW:
return state.push(ImmutableMap({ return addAlert(state, action.alert);
key: state.size > 0 ? state.last().get('key') + 1 : 0,
title: action.title,
message: action.message,
message_values: action.message_values,
}));
case ALERT_DISMISS: case ALERT_DISMISS:
return state.filterNot(item => item.get('key') === action.alert.key); return state.filterNot(item => item.key === action.alert.key);
case ALERT_CLEAR: case ALERT_CLEAR:
return state.clear(); return state.clear();
default: default:

View File

@ -26,7 +26,6 @@ import lists from './lists';
import markers from './markers'; import markers from './markers';
import media_attachments from './media_attachments'; import media_attachments from './media_attachments';
import meta from './meta'; import meta from './meta';
import { missedUpdatesReducer } from './missed_updates';
import { modalReducer } from './modal'; import { modalReducer } from './modal';
import mutes from './mutes'; import mutes from './mutes';
import notifications from './notifications'; import notifications from './notifications';
@ -82,7 +81,6 @@ const reducers = {
suggestions, suggestions,
polls, polls,
trends, trends,
missed_updates: missedUpdatesReducer,
markers, markers,
picture_in_picture, picture_in_picture,
history, history,

View File

@ -1,33 +0,0 @@
import { Record } from 'immutable';
import type { Action } from 'redux';
import { focusApp, unfocusApp } from '../actions/app';
import { NOTIFICATIONS_UPDATE } from '../actions/notifications';
interface MissedUpdatesState {
focused: boolean;
unread: number;
}
const initialState = Record<MissedUpdatesState>({
focused: true,
unread: 0,
})();
export function missedUpdatesReducer(
state = initialState,
action: Action<string>
) {
switch (action.type) {
case focusApp.type:
return state.set('focused', true).set('unread', 0);
case unfocusApp.type:
return state.set('focused', false);
case NOTIFICATIONS_UPDATE:
return state.get('focused')
? state
: state.update('unread', (x) => x + 1);
default:
return state;
}
}

View File

@ -117,26 +117,16 @@ export const makeGetPictureInPicture = () => {
})); }));
}; };
const getAlertsBase = state => state.get('alerts'); const ALERT_DEFAULTS = {
export const getAlerts = createSelector([getAlertsBase], (base) => {
let arr = [];
base.forEach(item => {
arr.push({
message: item.get('message'),
message_values: item.get('message_values'),
title: item.get('title'),
key: item.get('key'),
dismissAfter: 5000, dismissAfter: 5000,
barStyle: { style: false,
zIndex: 200, };
},
});
});
return arr; export const getAlerts = createSelector(state => state.get('alerts'), alerts =>
}); alerts.map(item => ({
...ALERT_DEFAULTS,
...item,
})).toArray());
export const makeGetNotification = () => createSelector([ export const makeGetNotification = () => createSelector([
(_, base) => base, (_, base) => base,

View File

@ -15,7 +15,8 @@
.status__content a, .status__content a,
.link-footer a, .link-footer a,
.reply-indicator__content a, .reply-indicator__content a,
.status__content__read-more-button { .status__content__read-more-button,
.status__content__translate-button {
text-decoration: underline; text-decoration: underline;
&:hover, &:hover,

View File

@ -989,7 +989,8 @@ body > [data-popper-placement] {
} }
} }
.status__content__read-more-button { .status__content__read-more-button,
.status__content__translate-button {
display: block; display: block;
font-size: 15px; font-size: 15px;
line-height: 22px; line-height: 22px;
@ -4286,34 +4287,31 @@ a.status-card.compact:hover {
} }
&__overlay { &__overlay {
display: block; display: flex;
background: transparent; align-items: center;
justify-content: center;
background: rgba($black, 0.5);
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0;
margin: 0;
border: 0; border: 0;
border-radius: 4px;
&__label { &__label {
display: inline-block; display: flex;
background: rgba($base-overlay-background, 0.5); align-items: center;
border-radius: 8px; justify-content: center;
padding: 8px 12px; gap: 8px;
flex-direction: column;
color: $primary-text-color; color: $primary-text-color;
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 14px;
} }
&:hover, &__action {
&:focus, font-weight: 400;
&:active { font-size: 13px;
.spoiler-button__overlay__label {
background: rgba($base-overlay-background, 0.8);
}
}
&:disabled {
.spoiler-button__overlay__label {
background: rgba($base-overlay-background, 0.5);
}
} }
} }
} }
@ -9150,3 +9148,62 @@ noscript {
} }
} }
} }
.notification-list {
position: fixed;
bottom: 2rem;
inset-inline-start: 0;
z-index: 999;
display: flex;
flex-direction: column;
gap: 4px;
}
.notification-bar {
flex: 0 0 auto;
position: relative;
inset-inline-start: -100%;
width: auto;
padding: 15px;
margin: 0;
color: $primary-text-color;
background: rgba($black, 0.85);
backdrop-filter: blur(8px);
border: 1px solid rgba(lighten($ui-base-color, 4%), 0.85);
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba($base-shadow-color, 0.25),
0 4px 6px -4px rgba($base-shadow-color, 0.25);
cursor: default;
transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1);
transform: translateZ(0);
font-size: 15px;
line-height: 21px;
&.notification-bar-active {
inset-inline-start: 1rem;
}
}
.notification-bar-title {
margin-inline-end: 5px;
}
.notification-bar-title,
.notification-bar-action {
font-weight: 700;
}
.notification-bar-action {
text-transform: uppercase;
margin-inline-start: 10px;
cursor: pointer;
color: $highlight-text-color;
border-radius: 4px;
padding: 0 4px;
&:hover,
&:focus,
&:active {
background: rgba($ui-base-color, 0.85);
}
}

View File

@ -4,13 +4,14 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity
def perform def perform
return if skip_reports? return if skip_reports?
target_accounts = object_uris.filter_map { |uri| account_from_uri(uri) }.select(&:local?) target_accounts = object_uris.filter_map { |uri| account_from_uri(uri) }
target_statuses_by_account = object_uris.filter_map { |uri| status_from_uri(uri) }.select(&:local?).group_by(&:account_id) target_statuses_by_account = object_uris.filter_map { |uri| status_from_uri(uri) }.group_by(&:account_id)
target_accounts.each do |target_account| target_accounts.each do |target_account|
target_statuses = target_statuses_by_account[target_account.id] target_statuses = target_statuses_by_account[target_account.id]
replied_to_accounts = Account.local.where(id: target_statuses.filter_map(&:in_reply_to_account_id))
next if target_account.suspended? next if target_account.suspended? || (!target_account.local? && replied_to_accounts.none?)
ReportService.new.call( ReportService.new.call(
@account, @account,

View File

@ -7,11 +7,48 @@ require 'resolv'
# Monkey-patch the HTTP.rb timeout class to avoid using a timeout block # Monkey-patch the HTTP.rb timeout class to avoid using a timeout block
# around the Socket#open method, since we use our own timeout blocks inside # around the Socket#open method, since we use our own timeout blocks inside
# that method # that method
#
# Also changes how the read timeout behaves so that it is cumulative (closer
# to HTTP::Timeout::Global, but still having distinct timeouts for other
# operation types)
class HTTP::Timeout::PerOperation class HTTP::Timeout::PerOperation
def connect(socket_class, host, port, nodelay = false) def connect(socket_class, host, port, nodelay = false)
@socket = socket_class.open(host, port) @socket = socket_class.open(host, port)
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
end end
# Reset deadline when the connection is re-used for different requests
def reset_counter
@deadline = nil
end
# Read data from the socket
def readpartial(size, buffer = nil)
@deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
timeout = false
loop do
result = @socket.read_nonblock(size, buffer, exception: false)
return :eof if result.nil?
remaining_time = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
raise HTTP::TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout || remaining_time <= 0
return result if result != :wait_readable
# marking the socket for timeout. Why is this not being raised immediately?
# it seems there is some race-condition on the network level between calling
# #read_nonblock and #wait_readable, in which #read_nonblock signalizes waiting
# for reads, and when waiting for x seconds, it returns nil suddenly without completing
# the x seconds. In a normal case this would be a timeout on wait/read, but it can
# also mean that the socket has been closed by the server. Therefore we "mark" the
# socket for timeout and try to read more bytes. If it returns :eof, it's all good, no
# timeout. Else, the first timeout was a proper timeout.
# This hack has to be done because io/wait#wait_readable doesn't provide a value for when
# the socket is closed by the server, and HTTP::Parser doesn't provide the limit for the chunks.
timeout = true unless @socket.to_io.wait_readable(remaining_time)
end
end
end end
class Request class Request

View File

@ -52,6 +52,26 @@ class TextFormatter
html.html_safe # rubocop:disable Rails/OutputSafety html.html_safe # rubocop:disable Rails/OutputSafety
end end
class << self
include ERB::Util
def shortened_link(url, rel_me: false)
url = Addressable::URI.parse(url).to_s
rel = rel_me ? (DEFAULT_REL + %w(me)) : DEFAULT_REL
prefix = url.match(URL_PREFIX_REGEX).to_s
display_url = url[prefix.length, 30]
suffix = url[prefix.length + 30..-1]
cutoff = url[prefix.length..-1].length > 30
<<~HTML.squish.html_safe # rubocop:disable Rails/OutputSafety
<a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}" translate="no"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
HTML
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
h(url)
end
end
private private
def rewrite def rewrite
@ -74,19 +94,7 @@ class TextFormatter
end end
def link_to_url(entity) def link_to_url(entity)
url = Addressable::URI.parse(entity[:url]).to_s TextFormatter.shortened_link(entity[:url], rel_me: with_rel_me?)
rel = with_rel_me? ? (DEFAULT_REL + %w(me)) : DEFAULT_REL
prefix = url.match(URL_PREFIX_REGEX).to_s
display_url = url[prefix.length, 30]
suffix = url[prefix.length + 30..-1]
cutoff = url[prefix.length..-1].length > 30
<<~HTML.squish
<a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}" translate="no"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
HTML
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
h(entity[:url])
end end
def link_to_hashtag(entity) def link_to_hashtag(entity)

View File

@ -6,45 +6,52 @@ class AdminMailer < ApplicationMailer
helper :accounts helper :accounts
helper :languages helper :languages
def new_report(recipient, report) before_action :process_params
before_action :set_instance
default to: -> { @me.user_email }
def new_report(report)
@report = report @report = report
@me = recipient
@instance = Rails.configuration.x.local_domain
locale_for_account(@me) do locale_for_account(@me) do
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_report.subject', instance: @instance, id: @report.id) mail subject: default_i18n_subject(instance: @instance, id: @report.id)
end end
end end
def new_appeal(recipient, appeal) def new_appeal(appeal)
@appeal = appeal @appeal = appeal
@me = recipient
@instance = Rails.configuration.x.local_domain
locale_for_account(@me) do locale_for_account(@me) do
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_appeal.subject', instance: @instance, username: @appeal.account.username) mail subject: default_i18n_subject(instance: @instance, username: @appeal.account.username)
end end
end end
def new_pending_account(recipient, user) def new_pending_account(user)
@account = user.account @account = user.account
@me = recipient
@instance = Rails.configuration.x.local_domain
locale_for_account(@me) do locale_for_account(@me) do
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_pending_account.subject', instance: @instance, username: @account.username) mail subject: default_i18n_subject(instance: @instance, username: @account.username)
end end
end end
def new_trends(recipient, links, tags, statuses) def new_trends(links, tags, statuses)
@links = links @links = links
@tags = tags @tags = tags
@statuses = statuses @statuses = statuses
@me = recipient
@instance = Rails.configuration.x.local_domain
locale_for_account(@me) do locale_for_account(@me) do
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.subject', instance: @instance) mail subject: default_i18n_subject(instance: @instance)
end end
end end
private
def process_params
@me = params[:recipient]
end
def set_instance
@instance = Rails.configuration.x.local_domain
end
end end

View File

@ -1,83 +1,76 @@
# frozen_string_literal: true # frozen_string_literal: true
class NotificationMailer < ApplicationMailer class NotificationMailer < ApplicationMailer
helper :accounts helper :accounts,
helper :statuses :statuses,
:routing
helper RoutingHelper before_action :process_params
before_action :set_status, only: [:mention, :favourite, :reblog]
before_action :set_account, only: [:follow, :favourite, :reblog, :follow_request]
def mention(recipient, notification) default to: -> { email_address_with_name(@user.email, @me.username) }
@me = recipient
@user = recipient.user
@type = 'mention'
@status = notification.target_status
def mention
return unless @user.functional? && @status.present? return unless @user.functional? && @status.present?
locale_for_account(@me) do locale_for_account(@me) do
thread_by_conversation(@status.conversation) thread_by_conversation(@status.conversation)
mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct) mail subject: default_i18n_subject(name: @status.account.acct)
end end
end end
def follow(recipient, notification) def follow
@me = recipient
@user = recipient.user
@type = 'follow'
@account = notification.from_account
return unless @user.functional? return unless @user.functional?
locale_for_account(@me) do locale_for_account(@me) do
mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) mail subject: default_i18n_subject(name: @account.acct)
end end
end end
def favourite(recipient, notification) def favourite
@me = recipient
@user = recipient.user
@type = 'favourite'
@account = notification.from_account
@status = notification.target_status
return unless @user.functional? && @status.present? return unless @user.functional? && @status.present?
locale_for_account(@me) do locale_for_account(@me) do
thread_by_conversation(@status.conversation) thread_by_conversation(@status.conversation)
mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct) mail subject: default_i18n_subject(name: @account.acct)
end end
end end
def reblog(recipient, notification) def reblog
@me = recipient
@user = recipient.user
@type = 'reblog'
@account = notification.from_account
@status = notification.target_status
return unless @user.functional? && @status.present? return unless @user.functional? && @status.present?
locale_for_account(@me) do locale_for_account(@me) do
thread_by_conversation(@status.conversation) thread_by_conversation(@status.conversation)
mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct) mail subject: default_i18n_subject(name: @account.acct)
end end
end end
def follow_request(recipient, notification) def follow_request
@me = recipient
@user = recipient.user
@type = 'follow_request'
@account = notification.from_account
return unless @user.functional? return unless @user.functional?
locale_for_account(@me) do locale_for_account(@me) do
mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) mail subject: default_i18n_subject(name: @account.acct)
end end
end end
private private
def process_params
@notification = params[:notification]
@me = params[:recipient]
@user = @me.user
@type = action_name
end
def set_status
@status = @notification.target_status
end
def set_account
@account = @notification.from_account
end
def thread_by_conversation(conversation) def thread_by_conversation(conversation)
return if conversation.nil? return if conversation.nil?

View File

@ -22,15 +22,14 @@ module Attachmentable
included do included do
def self.has_attached_file(name, options = {}) # rubocop:disable Naming/PredicateName def self.has_attached_file(name, options = {}) # rubocop:disable Naming/PredicateName
options = { validate_media_type: false }.merge(options)
super(name, options) super(name, options)
send(:"before_#{name}_post_process") do
send(:"before_#{name}_validate", prepend: true) do
attachment = send(name) attachment = send(name)
check_image_dimension(attachment) check_image_dimension(attachment)
set_file_content_type(attachment) set_file_content_type(attachment)
obfuscate_file_name(attachment) obfuscate_file_name(attachment)
set_file_extension(attachment) set_file_extension(attachment)
Paperclip::Validators::MediaTypeSpoofDetectionValidator.new(attributes: [name]).validate(self)
end end
end end
end end

View File

@ -35,7 +35,7 @@ module Trends
return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty? return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user| User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user|
AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails? AdminMailer.with(recipient: user.account).new_trends(links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
end end
end end

View File

@ -3,6 +3,8 @@
class Trends::Links < Trends::Base class Trends::Links < Trends::Base
PREFIX = 'trending_links' PREFIX = 'trending_links'
BATCH_SIZE = 100
self.default_options = { self.default_options = {
threshold: 5, threshold: 5,
review_threshold: 3, review_threshold: 3,
@ -67,10 +69,23 @@ class Trends::Links < Trends::Base
end end
def refresh(at_time = Time.now.utc) def refresh(at_time = Time.now.utc)
preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + PreviewCardTrend.pluck(:preview_card_id)).uniq) # First, recalculate scores for links that were trending previously. We split the queries
# to avoid having to load all of the IDs into Ruby just to send them back into Postgres
PreviewCard.where(id: PreviewCardTrend.select(:preview_card_id)).find_in_batches(batch_size: BATCH_SIZE) do |preview_cards|
calculate_scores(preview_cards, at_time) calculate_scores(preview_cards, at_time)
end end
# Then, calculate scores for links that were used today. There are potentially some
# duplicate items here that we might process one more time, but that should be fine
PreviewCard.where(id: recently_used_ids(at_time)).find_in_batches(batch_size: BATCH_SIZE) do |preview_cards|
calculate_scores(preview_cards, at_time)
end
# Now that all trends have up-to-date scores, and all the ones below the threshold have
# been removed, we can recalculate their positions
PreviewCardTrend.connection.exec_update('UPDATE preview_card_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM preview_card_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE preview_card_trends.id = t0.id')
end
def request_review def request_review
PreviewCardTrend.pluck('distinct language').flat_map do |language| PreviewCardTrend.pluck('distinct language').flat_map do |language|
score_at_threshold = PreviewCardTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0 score_at_threshold = PreviewCardTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0
@ -139,10 +154,7 @@ class Trends::Links < Trends::Base
to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] } to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] }
to_delete = items.filter { |(score, _)| score < options[:decay_threshold] } to_delete = items.filter { |(score, _)| score < options[:decay_threshold] }
PreviewCardTrend.transaction do
PreviewCardTrend.upsert_all(to_insert.map { |(score, preview_card)| { preview_card_id: preview_card.id, score: score, language: preview_card.language, allowed: preview_card.trendable? || false } }, unique_by: :preview_card_id) if to_insert.any? PreviewCardTrend.upsert_all(to_insert.map { |(score, preview_card)| { preview_card_id: preview_card.id, score: score, language: preview_card.language, allowed: preview_card.trendable? || false } }, unique_by: :preview_card_id) if to_insert.any?
PreviewCardTrend.where(preview_card_id: to_delete.map { |(_, preview_card)| preview_card.id }).delete_all if to_delete.any? PreviewCardTrend.where(preview_card_id: to_delete.map { |(_, preview_card)| preview_card.id }).delete_all if to_delete.any?
PreviewCardTrend.connection.exec_update('UPDATE preview_card_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM preview_card_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE preview_card_trends.id = t0.id')
end
end end
end end

View File

@ -3,6 +3,8 @@
class Trends::Statuses < Trends::Base class Trends::Statuses < Trends::Base
PREFIX = 'trending_statuses' PREFIX = 'trending_statuses'
BATCH_SIZE = 100
self.default_options = { self.default_options = {
threshold: 5, threshold: 5,
review_threshold: 3, review_threshold: 3,
@ -58,10 +60,23 @@ class Trends::Statuses < Trends::Base
end end
def refresh(at_time = Time.now.utc) def refresh(at_time = Time.now.utc)
statuses = Status.where(id: (recently_used_ids(at_time) + StatusTrend.pluck(:status_id)).uniq).includes(:status_stat, :account) # First, recalculate scores for statuses that were trending previously. We split the queries
# to avoid having to load all of the IDs into Ruby just to send them back into Postgres
Status.where(id: StatusTrend.select(:status_id)).includes(:status_stat, :account).find_in_batches(batch_size: BATCH_SIZE) do |statuses|
calculate_scores(statuses, at_time) calculate_scores(statuses, at_time)
end end
# Then, calculate scores for statuses that were used today. There are potentially some
# duplicate items here that we might process one more time, but that should be fine
Status.where(id: recently_used_ids(at_time)).includes(:status_stat, :account).find_in_batches(batch_size: BATCH_SIZE) do |statuses|
calculate_scores(statuses, at_time)
end
# Now that all trends have up-to-date scores, and all the ones below the threshold have
# been removed, we can recalculate their positions
StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id')
end
def request_review def request_review
StatusTrend.pluck('distinct language').flat_map do |language| StatusTrend.pluck('distinct language').flat_map do |language|
score_at_threshold = StatusTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0 score_at_threshold = StatusTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0
@ -117,10 +132,7 @@ class Trends::Statuses < Trends::Base
to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] } to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] }
to_delete = items.filter { |(score, _)| score < options[:decay_threshold] } to_delete = items.filter { |(score, _)| score < options[:decay_threshold] }
StatusTrend.transaction do
StatusTrend.upsert_all(to_insert.map { |(score, status)| { status_id: status.id, account_id: status.account_id, score: score, language: status.language, allowed: status.trendable? || false } }, unique_by: :status_id) if to_insert.any? StatusTrend.upsert_all(to_insert.map { |(score, status)| { status_id: status.id, account_id: status.account_id, score: score, language: status.language, allowed: status.trendable? || false } }, unique_by: :status_id) if to_insert.any?
StatusTrend.where(status_id: to_delete.map { |(_, status)| status.id }).delete_all if to_delete.any? StatusTrend.where(status_id: to_delete.map { |(_, status)| status.id }).delete_all if to_delete.any?
StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id')
end
end end
end end

View File

@ -475,7 +475,7 @@ class User < ApplicationRecord
User.those_who_can(:manage_users).includes(:account).find_each do |u| User.those_who_can(:manage_users).includes(:account).find_each do |u|
next unless u.allows_pending_account_emails? next unless u.allows_pending_account_emails?
AdminMailer.new_pending_account(u.account, self).deliver_later AdminMailer.with(recipient: u.account).new_pending_account(self).deliver_later
end end
end end

View File

@ -25,7 +25,7 @@ class InitialStateSerializer < ActiveModel::Serializer
limited_federation_mode: Rails.configuration.x.whitelist_mode, limited_federation_mode: Rails.configuration.x.whitelist_mode,
mascot: instance_presenter.mascot&.file&.url, mascot: instance_presenter.mascot&.file&.url,
profile_directory: Setting.profile_directory, profile_directory: Setting.profile_directory,
trends: Setting.trends, trends_enabled: Setting.trends,
registrations_open: Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode, registrations_open: Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode,
timeline_preview: Setting.timeline_preview, timeline_preview: Setting.timeline_preview,
activity_api_enabled: Setting.activity_api_enabled, activity_api_enabled: Setting.activity_api_enabled,
@ -47,7 +47,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:advanced_layout] = object.current_account.user.setting_advanced_layout store[:advanced_layout] = object.current_account.user.setting_advanced_layout
store[:use_blurhash] = object.current_account.user.setting_use_blurhash store[:use_blurhash] = object.current_account.user.setting_use_blurhash
store[:use_pending_items] = object.current_account.user.setting_use_pending_items store[:use_pending_items] = object.current_account.user.setting_use_pending_items
store[:trends] = Setting.trends && object.current_account.user.setting_trends store[:show_trends] = Setting.trends && object.current_account.user.setting_trends
store[:crop_images] = object.current_account.user.setting_crop_images store[:crop_images] = object.current_account.user.setting_crop_images
else else
store[:auto_play_gif] = Setting.auto_play_gif store[:auto_play_gif] = Setting.auto_play_gif

View File

@ -11,4 +11,8 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
def image def image
object.image? ? full_asset_url(object.image.url(:original)) : nil object.image? ? full_asset_url(object.image.url(:original)) : nil
end end
def html
Sanitize.fragment(object.html, Sanitize::Config::MASTODON_OEMBED)
end
end end

View File

@ -23,7 +23,7 @@ class AppealService < BaseService
def notify_staff! def notify_staff!
User.those_who_can(:manage_appeals).includes(:account).each do |u| User.those_who_can(:manage_appeals).includes(:account).each do |u|
AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails? AdminMailer.with(recipient: u.account).new_appeal(@appeal).deliver_later if u.allows_appeal_emails?
end end
end end
end end

View File

@ -162,7 +162,12 @@ class NotifyService < BaseService
end end
def send_email! def send_email!
NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later(wait: 2.minutes) if NotificationMailer.respond_to?(@notification.type) return unless NotificationMailer.respond_to?(@notification.type)
NotificationMailer
.with(recipient: @recipient, notification: @notification)
.public_send(@notification.type)
.deliver_later(wait: 2.minutes)
end end
def email_needed? def email_needed?

View File

@ -40,16 +40,20 @@ class ReportService < BaseService
User.those_who_can(:manage_reports).includes(:account).each do |u| User.those_who_can(:manage_reports).includes(:account).each do |u|
LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report') LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report')
AdminMailer.new_report(u.account, @report).deliver_later if u.allows_report_emails? AdminMailer.with(recipient: u.account).new_report(@report).deliver_later if u.allows_report_emails?
end end
end end
def forward_to_origin! def forward_to_origin!
ActivityPub::DeliveryWorker.perform_async( # Send report to the server where the account originates from
payload, ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, @target_account.inbox_url)
some_local_account.id,
@target_account.inbox_url # Send report to servers to which the account was replying to, so they also have a chance to act
) inbox_urls = Account.remote.where(id: Status.where(id: reported_status_ids).where.not(in_reply_to_account_id: nil).select(:in_reply_to_account_id)).inboxes - [@target_account.inbox_url]
inbox_urls.each do |inbox_url|
ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
end
end end
def forward? def forward?

View File

@ -4,6 +4,7 @@ class FeedInsertWorker
include Sidekiq::Worker include Sidekiq::Worker
def perform(status_id, id, type = 'home', options = {}) def perform(status_id, id, type = 'home', options = {})
ApplicationRecord.connected_to(role: :primary) do
@type = type.to_sym @type = type.to_sym
@status = Status.find(status_id) @status = Status.find(status_id)
@options = options.symbolize_keys @options = options.symbolize_keys
@ -15,8 +16,11 @@ class FeedInsertWorker
@list = List.find(id) @list = List.find(id)
@follower = @list.account @follower = @list.account
end end
end
ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
check_and_insert check_and_insert
end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
true true
end end

View File

@ -5,7 +5,14 @@ class MergeWorker
include Redisable include Redisable
def perform(from_account_id, into_account_id) def perform(from_account_id, into_account_id)
FeedManager.instance.merge_into_home(Account.find(from_account_id), Account.find(into_account_id)) ApplicationRecord.connected_to(role: :primary) do
@from_account = Account.find(from_account_id)
@into_account = Account.find(into_account_id)
end
ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
FeedManager.instance.merge_into_home(@from_account, @into_account)
end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
true true
ensure ensure

View File

@ -6,8 +6,13 @@ class RegenerationWorker
sidekiq_options lock: :until_executed sidekiq_options lock: :until_executed
def perform(account_id, _ = :home) def perform(account_id, _ = :home)
account = Account.find(account_id) ApplicationRecord.connected_to(role: :primary) do
PrecomputeFeedService.new.call(account) @account = Account.find(account_id)
end
ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
PrecomputeFeedService.new.call(@account)
end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
true true
end end

View File

@ -6,7 +6,14 @@ class UnmergeWorker
sidekiq_options queue: 'pull' sidekiq_options queue: 'pull'
def perform(from_account_id, into_account_id) def perform(from_account_id, into_account_id)
FeedManager.instance.unmerge_from_home(Account.find(from_account_id), Account.find(into_account_id)) ApplicationRecord.connected_to(role: :primary) do
@from_account = Account.find(from_account_id)
@into_account = Account.find(into_account_id)
end
ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
FeedManager.instance.unmerge_from_home(@from_account, @into_account)
end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
true true
end end

View File

@ -28,6 +28,7 @@ require_relative '../lib/paperclip/url_generator_extensions'
require_relative '../lib/paperclip/attachment_extensions' require_relative '../lib/paperclip/attachment_extensions'
require_relative '../lib/paperclip/lazy_thumbnail' require_relative '../lib/paperclip/lazy_thumbnail'
require_relative '../lib/paperclip/gif_transcoder' require_relative '../lib/paperclip/gif_transcoder'
require_relative '../lib/paperclip/media_type_spoof_detector_extensions'
require_relative '../lib/paperclip/transcoder' require_relative '../lib/paperclip/transcoder'
require_relative '../lib/paperclip/type_corrector' require_relative '../lib/paperclip/type_corrector'
require_relative '../lib/paperclip/response_with_limit_adapter' require_relative '../lib/paperclip/response_with_limit_adapter'

View File

@ -18,6 +18,9 @@
}, },
"user_input": "id", "user_input": "id",
"confidence": "Weak", "confidence": "Weak",
"cwe_id": [
89
],
"note": "" "note": ""
}, },
{ {
@ -38,26 +41,9 @@
}, },
"user_input": "ids.join(\",\")", "user_input": "ids.join(\",\")",
"confidence": "Weak", "confidence": "Weak",
"note": "" "cwe_id": [
}, 89
{ ],
"warning_type": "Redirect",
"warning_code": 18,
"fingerprint": "5fad11cd67f905fab9b1d5739d01384a1748ebe78c5af5ac31518201925265a7",
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/remote_interaction_controller.rb",
"line": 24,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(RemoteFollow.new(resource_params).interact_address_for(Status.find(params[:id])))",
"render_path": null,
"location": {
"type": "method",
"class": "RemoteInteractionController",
"method": "create"
},
"user_input": "RemoteFollow.new(resource_params).interact_address_for(Status.find(params[:id]))",
"confidence": "High",
"note": "" "note": ""
}, },
{ {
@ -88,6 +74,9 @@
}, },
"user_input": "(Unresolved Model).new.strike", "user_input": "(Unresolved Model).new.strike",
"confidence": "Weak", "confidence": "Weak",
"cwe_id": [
79
],
"note": "" "note": ""
}, },
{ {
@ -108,26 +97,9 @@
}, },
"user_input": "SecureRandom.hex(16)", "user_input": "SecureRandom.hex(16)",
"confidence": "Medium", "confidence": "Medium",
"note": "" "cwe_id": [
}, 89
{ ],
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "7631e93d0099506e7c3e5c91ba8d88523b00a41a0834ae30031a5a4e8bb3020a",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v2/search_controller.rb",
"line": 28,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:type, :offset, :min_id, :max_id, :account_id)",
"render_path": null,
"location": {
"type": "method",
"class": "Api::V2::SearchController",
"method": "search_params"
},
"user_input": ":account_id",
"confidence": "High",
"note": "" "note": ""
}, },
{ {
@ -137,7 +109,7 @@
"check_name": "PermitAttributes", "check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment", "message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/admin/reports_controller.rb", "file": "app/controllers/api/v1/admin/reports_controller.rb",
"line": 90, "line": 88,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:resolved, :account_id, :target_account_id)", "code": "params.permit(:resolved, :account_id, :target_account_id)",
"render_path": null, "render_path": null,
@ -148,6 +120,9 @@
}, },
"user_input": ":account_id", "user_input": ":account_id",
"confidence": "High", "confidence": "High",
"cwe_id": [
915
],
"note": "" "note": ""
}, },
{ {
@ -157,7 +132,7 @@
"check_name": "PermitAttributes", "check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment", "message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/notifications_controller.rb", "file": "app/controllers/api/v1/notifications_controller.rb",
"line": 81, "line": 77,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:account_id, :types => ([]), :exclude_types => ([]))", "code": "params.permit(:account_id, :types => ([]), :exclude_types => ([]))",
"render_path": null, "render_path": null,
@ -168,26 +143,9 @@
}, },
"user_input": ":account_id", "user_input": ":account_id",
"confidence": "High", "confidence": "High",
"note": "" "cwe_id": [
}, 915
{ ],
"warning_type": "Redirect",
"warning_code": 18,
"fingerprint": "ba568ac09683f98740f663f3d850c31785900215992e8c090497d359a2563d50",
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/remote_follow_controller.rb",
"line": 21,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(RemoteFollow.new(resource_params).subscribe_address_for(@account))",
"render_path": null,
"location": {
"type": "method",
"class": "RemoteFollowController",
"method": "create"
},
"user_input": "RemoteFollow.new(resource_params).subscribe_address_for(@account)",
"confidence": "High",
"note": "" "note": ""
}, },
{ {
@ -218,6 +176,9 @@
}, },
"user_input": "(Unresolved Model).new.url", "user_input": "(Unresolved Model).new.url",
"confidence": "Weak", "confidence": "Weak",
"cwe_id": [
79
],
"note": "" "note": ""
}, },
{ {
@ -238,9 +199,12 @@
}, },
"user_input": ":account_id", "user_input": ":account_id",
"confidence": "High", "confidence": "High",
"cwe_id": [
915
],
"note": "" "note": ""
} }
], ],
"updated": "2022-03-22 07:48:32 +0100", "updated": "2023-07-05 14:34:42 -0400",
"brakeman_version": "5.2.1" "brakeman_version": "5.4.1"
} }

View File

@ -27,6 +27,7 @@ test:
port: <%= ENV['DB_PORT'] %> port: <%= ENV['DB_PORT'] %>
production: production:
primary:
<<: *default <<: *default
database: <%= ENV['DB_NAME'] || 'mastodon_production' %> database: <%= ENV['DB_NAME'] || 'mastodon_production' %>
username: <%= ENV['DB_USER'] || 'mastodon' %> username: <%= ENV['DB_USER'] || 'mastodon' %>
@ -34,3 +35,12 @@ production:
host: <%= ENV['DB_HOST'] || 'localhost' %> host: <%= ENV['DB_HOST'] || 'localhost' %>
port: <%= ENV['DB_PORT'] || 5432 %> port: <%= ENV['DB_PORT'] || 5432 %>
prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %> prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %>
read:
<<: *default
database: <%= ENV['DB_REPLICA_NAME'] ||ENV['DB_NAME'] || 'mastodon_production' %>
username: <%= ENV['DB_REPLICA_USER'] ||ENV['DB_USER'] || 'mastodon' %>
password: <%= (ENV['DB_REPLICA_PASS'] || ENV['DB_PASS'] || '').to_json %>
host: <%= ENV['DB_REPLICA_HOST'] ||ENV['DB_HOST'] || 'localhost' %>
port: <%= ENV['DB_REPLICA_PORT'] ||ENV['DB_PORT'] || 5432 %>
prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %>
replica: true

View File

@ -63,6 +63,7 @@ ignore_unused:
- 'admin_mailer.new_appeal.actions.*' - 'admin_mailer.new_appeal.actions.*'
- 'statuses.attached.*' - 'statuses.attached.*'
- 'move_handler.carry_{mutes,blocks}_over_text' - 'move_handler.carry_{mutes,blocks}_over_text'
- 'admin_mailer.*.subject'
- 'notification_mailer.*' - 'notification_mailer.*'
- 'imports.overwrite_preambles.{following,blocking,muting,domain_blocking,bookmarks}_html' - 'imports.overwrite_preambles.{following,blocking,muting,domain_blocking,bookmarks}_html'
- 'imports.preambles.{following,blocking,muting,domain_blocking,bookmarks}_html' - 'imports.preambles.{following,blocking,muting,domain_blocking,bookmarks}_html'

View File

@ -0,0 +1,27 @@
<policymap>
<!-- Set some basic system resource limits -->
<policy domain="resource" name="time" value="60" />
<policy domain="module" rights="none" pattern="URL" />
<policy domain="filter" rights="none" pattern="*" />
<!--
Ideally, we would restrict ImageMagick to only accessing its own
disk-backed pixel cache as well as Mastodon-created Tempfiles.
However, those paths depend on the operating system and environment
variables, so they can only be known at runtime.
Furthermore, those paths are not necessarily shared across Mastodon
processes, so even creating a policy.xml at runtime is impractical.
For the time being, only disable indirect reads.
-->
<policy domain="path" rights="none" pattern="@*" />
<!-- Disallow any coder by default, and only enable ones required by Mastodon -->
<policy domain="coder" rights="none" pattern="*" />
<policy domain="coder" rights="read | write" pattern="{PNG,JPEG,GIF,HEIC,WEBP}" />
<policy domain="coder" rights="write" pattern="{HISTOGRAM,RGB,INFO}" />
</policymap>

View File

@ -1,2 +0,0 @@
Makara::Cookie::DEFAULT_OPTIONS[:same_site] = :lax
Makara::Cookie::DEFAULT_OPTIONS[:secure] = Rails.env.production? || ENV['LOCAL_HTTPS'] == 'true'

View File

@ -153,3 +153,10 @@ unless defined?(Seahorse)
end end
end end
end end
# Set our ImageMagick security policy, but allow admins to override it
ENV['MAGICK_CONFIGURE_PATH'] = begin
imagemagick_config_paths = ENV.fetch('MAGICK_CONFIGURE_PATH', '').split(File::PATH_SEPARATOR)
imagemagick_config_paths << Rails.root.join('config', 'imagemagick').expand_path.to_s
imagemagick_config_paths.join(File::PATH_SEPARATOR)
end

2
dist/nginx.conf vendored
View File

@ -109,6 +109,8 @@ server {
location ~ ^/system/ { location ~ ^/system/ {
add_header Cache-Control "public, max-age=2419200, immutable"; add_header Cache-Control "public, max-age=2419200, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
add_header X-Content-Type-Options nosniff;
add_header Content-Security-Policy "default-src 'none'; form-action 'none'";
try_files $uri =404; try_files $uri =404;
} }

View File

@ -13,7 +13,7 @@ module Mastodon
end end
def patch def patch
2 4
end end
def flags def flags

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
module Paperclip
module MediaTypeSpoofDetectorExtensions
def calculated_content_type
return @calculated_content_type if defined?(@calculated_content_type)
@calculated_content_type = type_from_file_command.chomp
# The `file` command fails to recognize some MP3 files as such
@calculated_content_type = type_from_marcel if @calculated_content_type == 'application/octet-stream' && type_from_marcel == 'audio/mpeg'
@calculated_content_type
end
def type_from_marcel
@type_from_marcel ||= Marcel::MimeType.for Pathname.new(@file.path),
name: @file.path
end
end
end
Paperclip::MediaTypeSpoofDetector.prepend(Paperclip::MediaTypeSpoofDetectorExtensions)

View File

@ -19,10 +19,7 @@ module Paperclip
def make def make
metadata = VideoMetadataExtractor.new(@file.path) metadata = VideoMetadataExtractor.new(@file.path)
unless metadata.valid? raise Paperclip::Error, "Error while transcoding #{@file.path}: unsupported file" unless metadata.valid?
Paperclip.log("Unsupported file #{@file.path}")
return File.open(@file.path)
end
update_attachment_type(metadata) update_attachment_type(metadata)
update_options_from_metadata(metadata) update_options_from_metadata(metadata)

View File

@ -32,6 +32,11 @@ class PublicFileServerMiddleware
end end
end end
# Override the default CSP header set by the CSP middleware
headers['Content-Security-Policy'] = "default-src 'none'; form-action 'none'" if request_path.start_with?(paperclip_root_url)
headers['X-Content-Type-Options'] = 'nosniff'
[status, headers, response] [status, headers, response]
end end

View File

@ -92,26 +92,26 @@ class Sanitize
] ]
) )
MASTODON_OEMBED ||= freeze_config merge( MASTODON_OEMBED ||= freeze_config(
RELAXED, elements: %w(audio embed iframe source video),
elements: RELAXED[:elements] + %w(audio embed iframe source video),
attributes: merge( attributes: {
RELAXED[:attributes],
'audio' => %w(controls), 'audio' => %w(controls),
'embed' => %w(height src type width), 'embed' => %w(height src type width),
'iframe' => %w(allowfullscreen frameborder height scrolling src width), 'iframe' => %w(allowfullscreen frameborder height scrolling src width),
'source' => %w(src type), 'source' => %w(src type),
'video' => %w(controls height loop width), 'video' => %w(controls height loop width),
'div' => [:data] },
),
protocols: merge( protocols: {
RELAXED[:protocols],
'embed' => { 'src' => HTTP_PROTOCOLS }, 'embed' => { 'src' => HTTP_PROTOCOLS },
'iframe' => { 'src' => HTTP_PROTOCOLS }, 'iframe' => { 'src' => HTTP_PROTOCOLS },
'source' => { 'src' => HTTP_PROTOCOLS } 'source' => { 'src' => HTTP_PROTOCOLS },
) },
add_attributes: {
'iframe' => { 'sandbox' => 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms' },
}
) )
end end
end end

View File

@ -40,7 +40,7 @@ namespace :branding do
output_dest = Rails.root.join('app', 'javascript', 'icons') output_dest = Rails.root.join('app', 'javascript', 'icons')
rsvg_convert = Terrapin::CommandLine.new('rsvg-convert', '-w :size -h :size --keep-aspect-ratio :input -o :output') rsvg_convert = Terrapin::CommandLine.new('rsvg-convert', '-w :size -h :size --keep-aspect-ratio :input -o :output')
convert = Terrapin::CommandLine.new('convert', ':input :output') convert = Terrapin::CommandLine.new('convert', ':input :output', environment: { 'MAGICK_CONFIGURE_PATH' => nil })
favicon_sizes = [16, 32, 48] favicon_sizes = [16, 32, 48]
apple_icon_sizes = [57, 60, 72, 76, 114, 120, 144, 152, 167, 180, 1024] apple_icon_sizes = [57, 60, 72, 76, 114, 120, 144, 152, 167, 180, 1024]

View File

@ -23,8 +23,6 @@ RSpec.describe Api::V1::ReportsController do
let(:rule_ids) { nil } let(:rule_ids) { nil }
before do before do
allow(AdminMailer).to receive(:new_report)
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward } post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward }
end end
@ -41,7 +39,7 @@ RSpec.describe Api::V1::ReportsController do
end end
it 'sends e-mails to admins' do it 'sends e-mails to admins' do
expect(AdminMailer).to have_received(:new_report).with(admin.account, Report) expect(ActionMailer::Base.deliveries.first.to).to eq([admin.email])
end end
context 'when a status does not belong to the reported account' do context 'when a status does not belong to the reported account' do

View File

@ -14,13 +14,11 @@ RSpec.describe Disputes::AppealsController do
let(:strike) { Fabricate(:account_warning, target_account: current_user.account) } let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
before do before do
allow(AdminMailer).to receive(:new_appeal)
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } } post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } }
end end
it 'notifies staff about new appeal' do it 'notifies staff about new appeal' do
expect(AdminMailer).to have_received(:new_appeal).with(admin.account, Appeal.last) expect(ActionMailer::Base.deliveries.first.to).to eq([admin.email])
end end
it 'redirects back to the strike page' do it 'redirects back to the strike page' do

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
spec/fixtures/files/boop.mp3 vendored Normal file

Binary file not shown.

View File

@ -7,7 +7,7 @@ RSpec.describe AdminMailer do
let(:sender) { Fabricate(:account, username: 'John') } let(:sender) { Fabricate(:account, username: 'John') }
let(:recipient) { Fabricate(:account, username: 'Mike') } let(:recipient) { Fabricate(:account, username: 'Mike') }
let(:report) { Fabricate(:report, account: sender, target_account: recipient) } let(:report) { Fabricate(:report, account: sender, target_account: recipient) }
let(:mail) { described_class.new_report(recipient, report) } let(:mail) { described_class.with(recipient: recipient).new_report(report) }
before do before do
recipient.user.update(locale: :en) recipient.user.update(locale: :en)
@ -27,7 +27,7 @@ RSpec.describe AdminMailer do
describe '.new_appeal' do describe '.new_appeal' do
let(:appeal) { Fabricate(:appeal) } let(:appeal) { Fabricate(:appeal) }
let(:recipient) { Fabricate(:account, username: 'Kurt') } let(:recipient) { Fabricate(:account, username: 'Kurt') }
let(:mail) { described_class.new_appeal(recipient, appeal) } let(:mail) { described_class.with(recipient: recipient).new_appeal(appeal) }
before do before do
recipient.user.update(locale: :en) recipient.user.update(locale: :en)
@ -47,7 +47,7 @@ RSpec.describe AdminMailer do
describe '.new_pending_account' do describe '.new_pending_account' do
let(:recipient) { Fabricate(:account, username: 'Barklums') } let(:recipient) { Fabricate(:account, username: 'Barklums') }
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:mail) { described_class.new_pending_account(recipient, user) } let(:mail) { described_class.with(recipient: recipient).new_pending_account(user) }
before do before do
recipient.user.update(locale: :en) recipient.user.update(locale: :en)
@ -69,7 +69,7 @@ RSpec.describe AdminMailer do
let(:links) { [] } let(:links) { [] }
let(:statuses) { [] } let(:statuses) { [] }
let(:tags) { [] } let(:tags) { [] }
let(:mail) { described_class.new_trends(recipient, links, tags, statuses) } let(:mail) { described_class.with(recipient: recipient).new_trends(links, tags, statuses) }
before do before do
recipient.user.update(locale: :en) recipient.user.update(locale: :en)

View File

@ -23,7 +23,8 @@ RSpec.describe NotificationMailer do
describe 'mention' do describe 'mention' do
let(:mention) { Mention.create!(account: receiver.account, status: foreign_status) } let(:mention) { Mention.create!(account: receiver.account, status: foreign_status) }
let(:mail) { described_class.mention(receiver.account, Notification.create!(account: receiver.account, activity: mention)) } let(:notification) { Notification.create!(account: receiver.account, activity: mention) }
let(:mail) { prepared_mailer_for(receiver.account).mention }
include_examples 'localized subject', 'notification_mailer.mention.subject', name: 'bob' include_examples 'localized subject', 'notification_mailer.mention.subject', name: 'bob'
@ -40,7 +41,8 @@ RSpec.describe NotificationMailer do
describe 'follow' do describe 'follow' do
let(:follow) { sender.follow!(receiver.account) } let(:follow) { sender.follow!(receiver.account) }
let(:mail) { described_class.follow(receiver.account, Notification.create!(account: receiver.account, activity: follow)) } let(:notification) { Notification.create!(account: receiver.account, activity: follow) }
let(:mail) { prepared_mailer_for(receiver.account).follow }
include_examples 'localized subject', 'notification_mailer.follow.subject', name: 'bob' include_examples 'localized subject', 'notification_mailer.follow.subject', name: 'bob'
@ -56,7 +58,8 @@ RSpec.describe NotificationMailer do
describe 'favourite' do describe 'favourite' do
let(:favourite) { Favourite.create!(account: sender, status: own_status) } let(:favourite) { Favourite.create!(account: sender, status: own_status) }
let(:mail) { described_class.favourite(own_status.account, Notification.create!(account: receiver.account, activity: favourite)) } let(:notification) { Notification.create!(account: receiver.account, activity: favourite) }
let(:mail) { prepared_mailer_for(own_status.account).favourite }
include_examples 'localized subject', 'notification_mailer.favourite.subject', name: 'bob' include_examples 'localized subject', 'notification_mailer.favourite.subject', name: 'bob'
@ -73,7 +76,8 @@ RSpec.describe NotificationMailer do
describe 'reblog' do describe 'reblog' do
let(:reblog) { Status.create!(account: sender, reblog: own_status) } let(:reblog) { Status.create!(account: sender, reblog: own_status) }
let(:mail) { described_class.reblog(own_status.account, Notification.create!(account: receiver.account, activity: reblog)) } let(:notification) { Notification.create!(account: receiver.account, activity: reblog) }
let(:mail) { prepared_mailer_for(own_status.account).reblog }
include_examples 'localized subject', 'notification_mailer.reblog.subject', name: 'bob' include_examples 'localized subject', 'notification_mailer.reblog.subject', name: 'bob'
@ -90,7 +94,8 @@ RSpec.describe NotificationMailer do
describe 'follow_request' do describe 'follow_request' do
let(:follow_request) { Fabricate(:follow_request, account: sender, target_account: receiver.account) } let(:follow_request) { Fabricate(:follow_request, account: sender, target_account: receiver.account) }
let(:mail) { described_class.follow_request(receiver.account, Notification.create!(account: receiver.account, activity: follow_request)) } let(:notification) { Notification.create!(account: receiver.account, activity: follow_request) }
let(:mail) { prepared_mailer_for(receiver.account).follow_request }
include_examples 'localized subject', 'notification_mailer.follow_request.subject', name: 'bob' include_examples 'localized subject', 'notification_mailer.follow_request.subject', name: 'bob'
@ -103,4 +108,10 @@ RSpec.describe NotificationMailer do
expect(mail.body.encoded).to match('bob has requested to follow you') expect(mail.body.encoded).to match('bob has requested to follow you')
end end
end end
private
def prepared_mailer_for(recipient)
described_class.with(recipient: recipient, notification: notification)
end
end end

View File

@ -5,16 +5,16 @@
class AdminMailerPreview < ActionMailer::Preview class AdminMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_pending_account # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_pending_account
def new_pending_account def new_pending_account
AdminMailer.new_pending_account(Account.first, User.pending.first) AdminMailer.with(recipient: Account.first).new_pending_account(User.pending.first)
end end
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends
def new_trends def new_trends
AdminMailer.new_trends(Account.first, PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3)) AdminMailer.with(recipient: Account.first).new_trends(PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3))
end end
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal
def new_appeal def new_appeal
AdminMailer.new_appeal(Account.first, Appeal.first) AdminMailer.with(recipient: Account.first).new_appeal(Appeal.first)
end end
end end

View File

@ -5,36 +5,40 @@
class NotificationMailerPreview < ActionMailer::Preview class NotificationMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/mention # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/mention
def mention def mention
m = Mention.last activity = Mention.last
NotificationMailer.mention(m.account, Notification.find_by(activity: m)) mailer_for(activity.account, activity).mention
end end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow
def follow def follow
f = Follow.last activity = Follow.last
NotificationMailer.follow(f.target_account, Notification.find_by(activity: f)) mailer_for(activity.target_account, activity).follow
end end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow_request # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow_request
def follow_request def follow_request
f = Follow.last activity = Follow.last
NotificationMailer.follow_request(f.target_account, Notification.find_by(activity: f)) mailer_for(activity.target_account, activity).follow_request
end end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/favourite # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/favourite
def favourite def favourite
f = Favourite.last activity = Favourite.last
NotificationMailer.favourite(f.status.account, Notification.find_by(activity: f)) mailer_for(activity.status.account, activity).favourite
end end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/reblog # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/reblog
def reblog def reblog
r = Status.where.not(reblog_of_id: nil).first activity = Status.where.not(reblog_of_id: nil).first
NotificationMailer.reblog(r.reblog.account, Notification.find_by(activity: r)) mailer_for(activity.reblog.account, activity).reblog
end end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/digest private
def digest
NotificationMailer.digest(Account.first, since: 90.days.ago) def mailer_for(account, activity)
NotificationMailer.with(
recipient: account,
notification: Notification.find_by(activity: activity)
)
end end
end end

View File

@ -152,6 +152,26 @@ RSpec.describe MediaAttachment, paperclip_processing: true do
end end
end end
describe 'mp3 with large cover art' do
let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('boop.mp3')) }
it 'detects it as an audio file' do
expect(media.type).to eq 'audio'
end
it 'sets meta for the duration' do
expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102)
end
it 'extracts thumbnail' do
expect(media.thumbnail.present?).to be true
end
it 'gives the file a random name' do
expect(media.file_file_name).to_not eq 'boop.mp3'
end
end
describe 'jpeg' do describe 'jpeg' do
let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('attachment.jpg')) } let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('attachment.jpg')) }

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Media API', paperclip_processing: true do
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:scopes) { 'write' }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'POST /api/v2/media' do
it 'returns http success' do
post '/api/v2/media', headers: headers, params: { file: fixture_file_upload('attachment-jpg.123456_abcd', 'image/jpeg') }
expect(File.exist?(user.account.media_attachments.first.file.path(:small))).to be true
expect(response).to have_http_status(200)
end
end
end

View File

@ -17,24 +17,45 @@ RSpec.describe ReportService, type: :service do
context 'with a remote account' do context 'with a remote account' do
let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') }
let(:forward) { false }
before do before do
stub_request(:post, 'http://example.com/inbox').to_return(status: 200) stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
end end
context 'when forward is true' do
let(:forward) { true }
it 'sends ActivityPub payload when forward is true' do it 'sends ActivityPub payload when forward is true' do
subject.call(source_account, remote_account, forward: true) subject.call(source_account, remote_account, forward: forward)
expect(a_request(:post, 'http://example.com/inbox')).to have_been_made expect(a_request(:post, 'http://example.com/inbox')).to have_been_made
end end
it 'does not send anything when forward is false' do it 'has an uri' do
subject.call(source_account, remote_account, forward: false) report = subject.call(source_account, remote_account, forward: forward)
expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made expect(report.uri).to_not be_nil
end end
it 'has an uri' do context 'when reporting a reply' do
report = subject.call(source_account, remote_account, forward: true) let(:remote_thread_account) { Fabricate(:account, domain: 'foo.com', protocol: :activitypub, inbox_url: 'http://foo.com/inbox') }
expect(report.uri).to_not be_nil let(:reported_status) { Fabricate(:status, account: remote_account, thread: Fabricate(:status, account: remote_thread_account)) }
before do
stub_request(:post, 'http://foo.com/inbox').to_return(status: 200)
end
it 'sends ActivityPub payload to the author of the replied-to post' do
subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward)
expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made
end
end
end
context 'when forward is false' do
it 'does not send anything' do
subject.call(source_account, remote_account, forward: forward)
expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made
end
end end
end end