Compare commits

...

25 Commits

Author SHA1 Message Date
Emelia Smith
070d28aaa9
Merge a2a34fbadd into 002632c3bb 2025-11-26 17:03:11 +00:00
renovate[bot]
002632c3bb
chore(deps): update dependency aws-sdk-core to v3.239.2 (#37015)
Some checks are pending
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
Haml Linting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 16:18:51 +00:00
renovate[bot]
81510455d1
chore(deps): update yarn to v4.12.0 (#36797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 16:18:48 +00:00
diondiondion
ee7e756e89
Fix null access error in card component (#37022) 2025-11-26 14:55:40 +00:00
diondiondion
f87f30c1ac
Refactor Card component to TypeScript (#36982) 2025-11-26 12:56:17 +00:00
renovate[bot]
1757a0f0f3
chore(deps): update dependency public_suffix to v7 (#36920)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 10:48:12 +00:00
Matt Jankowski
cb4f1cc89c
Improve SessionActivation.activate spec (#36983) 2025-11-26 10:26:39 +00:00
Claire
00163e89bf
Fix tootctl status remove removing quoted posts and remote quotes of local posts (#37009) 2025-11-26 10:26:26 +00:00
diondiondion
59e48657cf
Fix issues in new theme tokens (#37019) 2025-11-26 10:25:49 +00:00
github-actions[bot]
384594f462
New Crowdin Translations (automated) (#37018)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-11-26 10:25:41 +00:00
renovate[bot]
cd9d166312
chore(deps): update dependency rqrcode to v3.1.1 (#37010)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 09:54:11 +00:00
renovate[bot]
6f4f9942b9
chore(deps): update dependency connection_pool to v2.5.5 (#37003)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 09:53:38 +00:00
Emelia Smith
a2a34fbadd
Migrate Web::PushSubscription and SessionActivation records to the new access token after refresh 2025-04-23 20:58:17 +02:00
Emelia Smith
677e0b8a37
Add 'none' to token_endpoint_auth_methods_supported to indicate public client support 2025-04-23 20:58:17 +02:00
Emelia Smith
81b79fefb7
WIP: refresh tokens 2025-04-23 20:58:17 +02:00
Emelia Smith
9e0eb99747
Change /oauth/token request specs to use client_secret_basic authentication 2025-04-23 20:58:17 +02:00
Emelia Smith
fcd238cb4b
Fix issues with null appearing for user owned access tokens 2025-04-23 20:58:16 +02:00
Emelia Smith
463d5dd4d5
Try to fix the usage of doorkeeper configuration 2025-04-23 20:57:58 +02:00
Emelia Smith
1af6ae19b9
Add offline_access scope 2025-04-23 20:56:51 +02:00
Emelia Smith
7898619d74
Prevent only using offline_access scope 2025-04-23 20:56:51 +02:00
Emelia Smith
2250aead46
WIP 2025-04-23 20:56:51 +02:00
Emelia Smith
47e4f8478f
Enable expiry of OAuth Access Tokens granted to public clients 2025-04-23 20:56:50 +02:00
Emelia Smith
fad8f7b148
Only return client_secret for confidential clients 2025-04-23 20:56:50 +02:00
Emelia Smith
b21e7d8fdb
Add support for public clients to OAuth Application creation - parameter name TBD 2025-04-23 20:56:50 +02:00
Emelia Smith
5c6ad1a0e5
Ensure only confidential clients can use Client Credentials grant flow 2025-04-23 20:56:50 +02:00
34 changed files with 646 additions and 372 deletions

View File

@ -71,7 +71,7 @@ gem 'oj', '~> 3.14'
gem 'ox', '~> 2.14' gem 'ox', '~> 2.14'
gem 'parslet' gem 'parslet'
gem 'premailer-rails' gem 'premailer-rails'
gem 'public_suffix', '~> 6.0' gem 'public_suffix', '~> 7.0'
gem 'pundit', '~> 2.3' gem 'pundit', '~> 2.3'
gem 'rack-attack', '~> 6.6' gem 'rack-attack', '~> 6.6'
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'

View File

@ -97,7 +97,7 @@ GEM
attr_required (1.0.2) attr_required (1.0.2)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1186.0) aws-partitions (1.1186.0)
aws-sdk-core (3.239.1) aws-sdk-core (3.239.2)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@ -167,7 +167,7 @@ GEM
cocoon (1.2.15) cocoon (1.2.15)
color_diff (0.1) color_diff (0.1)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.4) connection_pool (2.5.5)
cose (1.3.1) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
@ -618,7 +618,7 @@ GEM
psych (5.2.6) psych (5.2.6)
date date
stringio stringio
public_suffix (6.0.2) public_suffix (7.0.0)
puma (7.1.0) puma (7.1.0)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.5.2) pundit (2.5.2)
@ -717,10 +717,10 @@ GEM
rotp (6.3.0) rotp (6.3.0)
rouge (4.6.1) rouge (4.6.1)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (3.1.0) rqrcode (3.1.1)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.0.0) rqrcode_core (2.0.1)
rspec (3.13.1) rspec (3.13.1)
rspec-core (~> 3.13.0) rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0) rspec-expectations (~> 3.13.0)
@ -1036,7 +1036,7 @@ DEPENDENCIES
premailer-rails premailer-rails
prometheus_exporter (~> 2.2) prometheus_exporter (~> 2.2)
propshaft propshaft
public_suffix (~> 6.0) public_suffix (~> 7.0)
puma (~> 7.0) puma (~> 7.0)
pundit (~> 2.3) pundit (~> 2.3)
rack-attack (~> 6.6) rack-attack (~> 6.6)

View File

@ -2,6 +2,9 @@
class Api::V1::AppsController < Api::BaseController class Api::V1::AppsController < Api::BaseController
skip_before_action :require_authenticated_user! skip_before_action :require_authenticated_user!
before_action :validate_token_endpoint_auth_method!
TOKEN_ENDPOINT_AUTH_METHODS = %w(none client_secret_basic client_secret_post).freeze
def create def create
@app = Doorkeeper::Application.create!(application_options) @app = Doorkeeper::Application.create!(application_options)
@ -16,14 +19,25 @@ class Api::V1::AppsController < Api::BaseController
redirect_uri: app_params[:redirect_uris], redirect_uri: app_params[:redirect_uris],
scopes: app_scopes_or_default, scopes: app_scopes_or_default,
website: app_params[:website], website: app_params[:website],
confidential: !app_public?,
} }
end end
def validate_token_endpoint_auth_method!
return unless app_params.include? :token_endpoint_auth_method
bad_request unless TOKEN_ENDPOINT_AUTH_METHODS.include? app_params[:token_endpoint_auth_method]
end
def app_public?
app_params[:token_endpoint_auth_method] == 'none'
end
def app_scopes_or_default def app_scopes_or_default
app_params[:scopes] || Doorkeeper.configuration.default_scopes app_params[:scopes] || Doorkeeper.configuration.default_scopes
end end
def app_params def app_params
params.permit(:client_name, :scopes, :website, :redirect_uris, redirect_uris: []) params.permit(:client_name, :scopes, :website, :token_endpoint_auth_method, :redirect_uris, redirect_uris: [])
end end
end end

View File

@ -21,7 +21,13 @@ class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
end end
def render_success def render_success
if skip_authorization? || (matching_token? && !truthy_param?('force_login')) # FIXME: Find a better way to apply this validation: if the scopes only
# includes offline_access, then it's not valid, since offline_access doesn't
# actually give access to resources:
if pre_auth.scopes.all?('offline_access')
error = Doorkeeper::OAuth::InvalidRequestResponse.new(reason: :offline_access_only, missing_param: nil)
render :error, locals: { error_response: error }, status: 400
elsif skip_authorization? || (matching_token? && !truthy_param?('force_login'))
redirect_or_render authorize_response redirect_or_render authorize_response
elsif Doorkeeper.configuration.api_only elsif Doorkeeper.configuration.api_only
render json: pre_auth render json: pre_auth

View File

@ -51,7 +51,7 @@ export interface ApiPreviewCardJSON {
html: string; html: string;
width: number; width: number;
height: number; height: number;
image: string; image: string | null;
image_description: string; image_description: string;
embed_url: string; embed_url: string;
blurhash: string; blurhash: string;

View File

@ -538,9 +538,8 @@ class Status extends ImmutablePureComponent {
} else if (status.get('card') && !status.get('quote')) { } else if (status.get('card') && !status.get('quote')) {
media = ( media = (
<Card <Card
onOpenMedia={this.handleOpenMedia} key={`${status.get('id')}-${status.get('edited_at')}`}
card={status.get('card')} card={status.get('card')}
compact
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
/> />
); );

View File

@ -1,254 +0,0 @@
import punycode from 'punycode';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { is } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { MoreFromAuthor } from 'mastodon/components/more_from_author';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { useBlurhash } from 'mastodon/initial_state';
const IDNA_PREFIX = 'xn--';
const decodeIDNA = domain => {
return domain
.split('.')
.map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
.join('.');
};
const getHostname = url => {
const parser = document.createElement('a');
parser.href = url;
return parser.hostname;
};
const domParser = new DOMParser();
const handleIframeUrl = (html, url, providerName) => {
const document = domParser.parseFromString(html, 'text/html').documentElement;
const iframe = document.querySelector('iframe');
const startTime = new URL(url).searchParams.get('t')
if (iframe) {
const iframeUrl = new URL(iframe.src)
iframeUrl.searchParams.set('autoplay', 1)
iframeUrl.searchParams.set('auto_play', 1)
if (startTime && providerName === "YouTube") iframeUrl.searchParams.set('start', startTime)
iframe.src = iframeUrl.href
// DOM parser creates html/body elements around original HTML fragment,
// so we need to get innerHTML out of the body and not the entire document
return document.querySelector('body').innerHTML;
}
return html;
};
export default class Card extends PureComponent {
static propTypes = {
card: ImmutablePropTypes.map,
onOpenMedia: PropTypes.func.isRequired,
sensitive: PropTypes.bool,
};
state = {
previewLoaded: false,
embedded: false,
revealed: !this.props.sensitive,
};
UNSAFE_componentWillReceiveProps (nextProps) {
if (!is(this.props.card, nextProps.card)) {
this.setState({ embedded: false, previewLoaded: false });
}
if (this.props.sensitive !== nextProps.sensitive) {
this.setState({ revealed: !nextProps.sensitive });
}
}
componentDidMount () {
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentWillUnmount () {
window.removeEventListener('resize', this.handleResize);
}
handleEmbedClick = () => {
this.setState({ embedded: true });
};
handleExternalLinkClick = (e) => {
e.stopPropagation();
};
setRef = c => {
this.node = c;
};
handleImageLoad = () => {
this.setState({ previewLoaded: true });
};
handleReveal = e => {
e.preventDefault();
e.stopPropagation();
this.setState({ revealed: true });
};
renderVideo () {
const { card } = this.props;
const content = { __html: handleIframeUrl(card.get('html'), card.get('url'), card.get('provider_name')) };
return (
<div
ref={this.setRef}
className='status-card__image status-card-video'
dangerouslySetInnerHTML={content}
style={{ aspectRatio: '16 / 9' }}
/>
);
}
render () {
const { card } = this.props;
const { embedded, revealed } = this.state;
if (card === null) {
return null;
}
const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
const interactive = card.get('type') === 'video';
const language = card.get('language') || '';
const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
const showAuthor = !!card.getIn(['authors', 0, 'accountId']);
const description = (
<div className='status-card__content' dir='auto'>
<span className='status-card__host'>
<span lang={language}>{provider}</span>
{card.get('published_at') && <> · <RelativeTimestamp timestamp={card.get('published_at')} /></>}
</span>
<strong className='status-card__title' title={card.get('title')} lang={language}>{card.get('title')}</strong>
{!showAuthor && (card.get('author_name').length > 0 ? <span className='status-card__author'><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{card.get('author_name')}</strong> }} /></span> : <span className='status-card__description' lang={language}>{card.get('description')}</span>)}
</div>
);
const thumbnailStyle = {
visibility: revealed ? null : 'hidden',
};
if (largeImage && card.get('type') === 'video') {
thumbnailStyle.aspectRatio = `16 / 9`;
} else if (largeImage) {
thumbnailStyle.aspectRatio = '1.91 / 1';
} else {
thumbnailStyle.aspectRatio = 1;
}
let embed;
let canvas = (
<Blurhash
className={classNames('status-card__image-preview', {
'status-card__image-preview--hidden': revealed && this.state.previewLoaded,
})}
hash={card.get('blurhash')}
dummy={!useBlurhash}
/>
);
const thumbnailDescription = card.get('image_description');
const thumbnail = <img src={card.get('image')} alt={thumbnailDescription} title={thumbnailDescription} lang={language} style={thumbnailStyle} onLoad={this.handleImageLoad} className='status-card__image-image' />;
let spoilerButton = (
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
<FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
);
spoilerButton = (
<div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
{spoilerButton}
</div>
);
if (interactive) {
if (embedded) {
embed = this.renderVideo();
} else {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
{revealed ? (
<div className='status-card__actions' onClick={this.handleEmbedClick} role='none'>
<div>
<button type='button' onClick={this.handleEmbedClick}><Icon id='play' icon={PlayArrowIcon} /></button>
<a href={card.get('url')} onClick={this.handleExternalLinkClick} target='_blank' rel='noopener'><Icon id='external-link' icon={OpenInNewIcon} /></a>
</div>
</div>
) : spoilerButton}
</div>
);
}
return (
<div className={classNames('status-card', { expanded: largeImage })} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
{embed}
<a href={card.get('url')} target='_blank' rel='noopener'>{description}</a>
</div>
);
} else if (card.get('image')) {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
</div>
);
} else {
embed = (
<div className='status-card__image'>
<Icon id='file-text' icon={DescriptionIcon} />
</div>
);
}
return (
<>
<a href={card.get('url')} className={classNames('status-card', { expanded: largeImage, bottomless: showAuthor })} target='_blank' rel='noopener' ref={this.setRef}>
{embed}
{description}
</a>
{showAuthor && <MoreFromAuthor accountId={card.getIn(['authors', 0, 'accountId'])} />}
</>
);
}
}

View File

@ -0,0 +1,316 @@
import punycode from 'node:punycode';
import { useCallback, useId, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { MoreFromAuthor } from 'mastodon/components/more_from_author';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { useBlurhash } from 'mastodon/initial_state';
import type { Card as CardType } from 'mastodon/models/status';
const IDNA_PREFIX = 'xn--';
const decodeIDNA = (domain: string) => {
return domain
.split('.')
.map((part) =>
part.startsWith(IDNA_PREFIX)
? punycode.decode(part.slice(IDNA_PREFIX.length))
: part,
)
.join('.');
};
const getHostname = (url: string) => {
const parser = document.createElement('a');
parser.href = url;
return parser.hostname;
};
const domParser = new DOMParser();
const handleIframeUrl = (html: string, url: string, providerName: string) => {
const document = domParser.parseFromString(html, 'text/html').documentElement;
const iframe = document.querySelector('iframe');
const startTime = new URL(url).searchParams.get('t');
if (iframe) {
const iframeUrl = new URL(iframe.src);
iframeUrl.searchParams.set('autoplay', '1');
iframeUrl.searchParams.set('auto_play', '1');
if (startTime && providerName === 'YouTube')
iframeUrl.searchParams.set('start', startTime);
iframe.src = iframeUrl.href;
// DOM parser creates html/body elements around original HTML fragment,
// so we need to get innerHTML out of the body and not the entire document
return document.querySelector('body')?.innerHTML ?? '';
}
return html;
};
interface CardProps {
card: CardType | null;
sensitive?: boolean;
}
const CardVideo: React.FC<Pick<CardProps, 'card'>> = ({ card }) => (
<div
className='status-card__image status-card-video'
dangerouslySetInnerHTML={{
__html: card
? handleIframeUrl(
card.get('html'),
card.get('url'),
card.get('provider_name'),
)
: '',
}}
style={{ aspectRatio: '16 / 9' }}
/>
);
const Card: React.FC<CardProps> = ({ card, sensitive }) => {
const [previewLoaded, setPreviewLoaded] = useState(false);
const [embedded, setEmbedded] = useState(false);
const [revealed, setRevealed] = useState(!sensitive);
const handleEmbedClick = useCallback(() => {
setEmbedded(true);
}, []);
const handleExternalLinkClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
const handleImageLoad = useCallback(() => {
setPreviewLoaded(true);
}, []);
const handleReveal = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setRevealed(true);
}, []);
const spoilerButtonId = useId();
if (card === null) {
return null;
}
const provider =
card.get('provider_name').length === 0
? decodeIDNA(getHostname(card.get('url')))
: card.get('provider_name');
const interactive = card.get('type') === 'video';
const language = card.get('language') || '';
const hasImage = (card.get('image')?.length ?? 0) > 0;
const largeImage =
(hasImage && card.get('width') > card.get('height')) || interactive;
const showAuthor = !!card.getIn(['authors', 0, 'accountId']);
const description = (
<div className='status-card__content' dir='auto'>
<span className='status-card__host'>
<span lang={language}>{provider}</span>
{card.get('published_at') && (
<>
{' '}
· <RelativeTimestamp timestamp={card.get('published_at')} />
</>
)}
</span>
<strong
className='status-card__title'
title={card.get('title')}
lang={language}
>
{card.get('title')}
</strong>
{!showAuthor &&
(card.get('author_name').length > 0 ? (
<span className='status-card__author'>
<FormattedMessage
id='link_preview.author'
defaultMessage='By {name}'
values={{ name: <strong>{card.get('author_name')}</strong> }}
/>
</span>
) : (
<span className='status-card__description' lang={language}>
{card.get('description')}
</span>
))}
</div>
);
const thumbnailStyle: React.CSSProperties = {
visibility: revealed ? undefined : 'hidden',
aspectRatio: '1',
};
if (largeImage && card.get('type') === 'video') {
thumbnailStyle.aspectRatio = `16 / 9`;
} else if (largeImage) {
thumbnailStyle.aspectRatio = '1.91 / 1';
}
let embed;
const canvas = (
<Blurhash
className={classNames('status-card__image-preview', {
'status-card__image-preview--hidden': revealed && previewLoaded,
})}
hash={card.get('blurhash')}
dummy={!useBlurhash}
/>
);
const thumbnailDescription = card.get('image_description');
const thumbnail = (
<img
src={card.get('image') ?? undefined}
alt={thumbnailDescription}
title={thumbnailDescription}
lang={language}
style={thumbnailStyle}
onLoad={handleImageLoad}
className='status-card__image-image'
/>
);
const spoilerButton = (
<div
className={classNames('spoiler-button', {
'spoiler-button--minified': revealed,
})}
id={spoilerButtonId}
>
<button
type='button'
onClick={handleReveal}
className='spoiler-button__overlay'
>
<span className='spoiler-button__overlay__label'>
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
<span className='spoiler-button__overlay__action'>
<FormattedMessage
id='status.media.show'
defaultMessage='Click to show'
/>
</span>
</span>
</button>
</div>
);
if (interactive) {
if (embedded) {
embed = <CardVideo card={card} />;
} else {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
{revealed ? (
<div
className='status-card__actions'
onClick={handleEmbedClick}
role='none'
>
<div>
<button type='button' onClick={handleEmbedClick}>
<Icon id='play' icon={PlayArrowIcon} />
</button>
<a
href={card.get('url')}
onClick={handleExternalLinkClick}
target='_blank'
rel='noopener'
>
<Icon id='external-link' icon={OpenInNewIcon} />
</a>
</div>
</div>
) : (
spoilerButton
)}
</div>
);
}
return (
<div className={classNames('status-card', { expanded: largeImage })}>
{embed}
<a
href={card.get('url')}
target='_blank'
rel='noopener'
onClick={revealed ? undefined : handleReveal}
aria-describedby={revealed ? undefined : spoilerButtonId}
>
{description}
</a>
</div>
);
} else if (card.get('image')) {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
</div>
);
} else {
embed = (
<div className='status-card__image'>
<Icon id='file-text' icon={DescriptionIcon} />
</div>
);
}
return (
<>
<a
href={card.get('url')}
className={classNames('status-card', {
expanded: largeImage,
bottomless: showAuthor,
})}
target='_blank'
rel='noopener'
>
{embed}
{description}
</a>
{showAuthor && (
<MoreFromAuthor
accountId={card.getIn(['authors', 0, 'accountId']) as string}
/>
)}
</>
);
};
// eslint-disable-next-line import/no-default-export
export default Card;

View File

@ -262,8 +262,8 @@ export const DetailedStatus: React.FC<{
} else if (status.get('card') && !status.get('quote')) { } else if (status.get('card') && !status.get('quote')) {
media = ( media = (
<Card <Card
key={`${status.get('id')}-${status.get('edited_at')}`}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenMedia={onOpenMedia}
card={status.get('card')} card={status.get('card')}
/> />
); );

View File

@ -45,7 +45,7 @@
"account.follow_request": "Pedir para seguir", "account.follow_request": "Pedir para seguir",
"account.follow_request_cancel": "Cancelar solicitação", "account.follow_request_cancel": "Cancelar solicitação",
"account.follow_request_cancel_short": "Cancelar", "account.follow_request_cancel_short": "Cancelar",
"account.follow_request_short": "Solicitação", "account.follow_request_short": "Solicitar",
"account.followers": "Seguidores", "account.followers": "Seguidores",
"account.followers.empty": "Nada aqui.", "account.followers.empty": "Nada aqui.",
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}", "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
@ -130,18 +130,18 @@
"annual_report.summary.most_used_hashtag.none": "Nenhuma", "annual_report.summary.most_used_hashtag.none": "Nenhuma",
"annual_report.summary.new_posts.new_posts": "novas publicações", "annual_report.summary.new_posts.new_posts": "novas publicações",
"annual_report.summary.percentile.text": "<topLabel>Isso lhe coloca no topo</topLabel><percentage></percentage><bottomLabel>de usuários de {domain}.</bottomLabel>", "annual_report.summary.percentile.text": "<topLabel>Isso lhe coloca no topo</topLabel><percentage></percentage><bottomLabel>de usuários de {domain}.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "Não contaremos à Bernie.", "annual_report.summary.percentile.we_wont_tell_bernie": "Não contaremos ao Bernie.",
"annual_report.summary.thanks": "Obrigada por fazer parte do Mastodon!", "annual_report.summary.thanks": "Obrigada por fazer parte do Mastodon!",
"attachments_list.unprocessed": "(não processado)", "attachments_list.unprocessed": "(não processado)",
"audio.hide": "Ocultar áudio", "audio.hide": "Ocultar áudio",
"block_modal.remote_users_caveat": "Pediremos ao servidor {domain} que respeite sua decisão. No entanto, a conformidade não é garantida, já que alguns servidores podem lidar com bloqueios de maneira diferente. As postagens públicas ainda podem estar visíveis para usuários não logados.", "block_modal.remote_users_caveat": "Pediremos ao servidor {domain} que respeite sua decisão. No entanto, a conformidade não é garantida, já que alguns servidores podem lidar com bloqueios de maneira diferente. As publicações públicas ainda podem estar visíveis para usuários não logados.",
"block_modal.show_less": "Mostrar menos", "block_modal.show_less": "Mostrar menos",
"block_modal.show_more": "Mostrar mais", "block_modal.show_more": "Mostrar mais",
"block_modal.they_cant_mention": "Eles não podem mencionar ou seguir você.", "block_modal.they_cant_mention": "Não poderá mencionar ou seguir você.",
"block_modal.they_cant_see_posts": "Eles não podem ver suas postagens e você não verá as deles.", "block_modal.they_cant_see_posts": "Não poderá ver suas publicações e você não verá as dele/a.",
"block_modal.they_will_know": "Eles podem ver que estão bloqueados.", "block_modal.they_will_know": "Poderá ver que você bloqueou.",
"block_modal.title": "Bloquear usuário?", "block_modal.title": "Bloquear usuário?",
"block_modal.you_wont_see_mentions": "Você não verá publicações que os mencionem.", "block_modal.you_wont_see_mentions": "Você não verá publicações que mencionem o usuário.",
"boost_modal.combo": "Pressione {combo} para pular isso na próxima vez", "boost_modal.combo": "Pressione {combo} para pular isso na próxima vez",
"boost_modal.reblog": "Impulsionar a publicação?", "boost_modal.reblog": "Impulsionar a publicação?",
"boost_modal.undo_reblog": "Retirar o impulsionamento do post?", "boost_modal.undo_reblog": "Retirar o impulsionamento do post?",
@ -196,12 +196,12 @@
"community.column_settings.local_only": "Somente local", "community.column_settings.local_only": "Somente local",
"community.column_settings.media_only": "Somente mídia", "community.column_settings.media_only": "Somente mídia",
"community.column_settings.remote_only": "Somente global", "community.column_settings.remote_only": "Somente global",
"compose.error.blank_post": "A postagem não pode estar em branco.", "compose.error.blank_post": "A publicação não pode estar em branco.",
"compose.language.change": "Alterar idioma", "compose.language.change": "Alterar idioma",
"compose.language.search": "Pesquisar idiomas...", "compose.language.search": "Pesquisar idiomas...",
"compose.published.body": "Publicado.", "compose.published.body": "Publicado.",
"compose.published.open": "Abrir", "compose.published.open": "Abrir",
"compose.saved.body": "Postagem salva.", "compose.saved.body": "Publicação salva.",
"compose_form.direct_message_warning_learn_more": "Saiba mais", "compose_form.direct_message_warning_learn_more": "Saiba mais",
"compose_form.encryption_warning": "As publicações no Mastodon não são criptografadas de ponta-a-ponta. Não compartilhe nenhuma informação sensível no Mastodon.", "compose_form.encryption_warning": "As publicações no Mastodon não são criptografadas de ponta-a-ponta. Não compartilhe nenhuma informação sensível no Mastodon.",
"compose_form.hashtag_warning": "Esta publicação não será exibida sob nenhuma hashtag, já que não é pública. Apenas postagens públicas podem ser pesquisadas por meio de hashtags.", "compose_form.hashtag_warning": "Esta publicação não será exibida sob nenhuma hashtag, já que não é pública. Apenas postagens públicas podem ser pesquisadas por meio de hashtags.",
@ -217,7 +217,7 @@
"compose_form.poll.type": "Estilo", "compose_form.poll.type": "Estilo",
"compose_form.publish": "Publicar", "compose_form.publish": "Publicar",
"compose_form.reply": "Responder", "compose_form.reply": "Responder",
"compose_form.save_changes": "Atualização", "compose_form.save_changes": "Atualizar",
"compose_form.spoiler.marked": "Com Aviso de Conteúdo", "compose_form.spoiler.marked": "Com Aviso de Conteúdo",
"compose_form.spoiler.unmarked": "Sem Aviso de Conteúdo", "compose_form.spoiler.unmarked": "Sem Aviso de Conteúdo",
"compose_form.spoiler_placeholder": "Aviso de conteúdo (opcional)", "compose_form.spoiler_placeholder": "Aviso de conteúdo (opcional)",
@ -231,11 +231,11 @@
"confirmations.delete_list.title": "Excluir lista?", "confirmations.delete_list.title": "Excluir lista?",
"confirmations.discard_draft.confirm": "Descartar e continuar", "confirmations.discard_draft.confirm": "Descartar e continuar",
"confirmations.discard_draft.edit.cancel": "Continuar editando", "confirmations.discard_draft.edit.cancel": "Continuar editando",
"confirmations.discard_draft.edit.message": "Continuar vai descartar quaisquer mudanças feitas ao post sendo editado.", "confirmations.discard_draft.edit.message": "Continuar vai descartar quaisquer mudanças feitas à publicação sendo editada.",
"confirmations.discard_draft.edit.title": "Descartar mudanças no seu post?", "confirmations.discard_draft.edit.title": "Descartar mudanças na sua publicação?",
"confirmations.discard_draft.post.cancel": "Continuar rascunho", "confirmations.discard_draft.post.cancel": "Continuar rascunho",
"confirmations.discard_draft.post.message": "Continuar eliminará a publicação que está sendo elaborada no momento.", "confirmations.discard_draft.post.message": "Continuar eliminará a publicação que está sendo elaborada no momento.",
"confirmations.discard_draft.post.title": "Eliminar seu esboço de publicação?", "confirmations.discard_draft.post.title": "Eliminar seu rascunho de publicação?",
"confirmations.discard_edit_media.confirm": "Descartar", "confirmations.discard_edit_media.confirm": "Descartar",
"confirmations.discard_edit_media.message": "Há mudanças não salvas na descrição ou pré-visualização da mídia. Descartar assim mesmo?", "confirmations.discard_edit_media.message": "Há mudanças não salvas na descrição ou pré-visualização da mídia. Descartar assim mesmo?",
"confirmations.follow_to_list.confirm": "Seguir e adicionar à lista", "confirmations.follow_to_list.confirm": "Seguir e adicionar à lista",
@ -246,13 +246,13 @@
"confirmations.logout.title": "Sair da sessão?", "confirmations.logout.title": "Sair da sessão?",
"confirmations.missing_alt_text.confirm": "Adicione texto alternativo", "confirmations.missing_alt_text.confirm": "Adicione texto alternativo",
"confirmations.missing_alt_text.message": "Seu post contém mídia sem texto alternativo. Adicionar descrições ajuda a tornar seu conteúdo acessível para mais pessoas.", "confirmations.missing_alt_text.message": "Seu post contém mídia sem texto alternativo. Adicionar descrições ajuda a tornar seu conteúdo acessível para mais pessoas.",
"confirmations.missing_alt_text.secondary": "Postar mesmo assim", "confirmations.missing_alt_text.secondary": "Publicar mesmo assim",
"confirmations.missing_alt_text.title": "Adicionar texto alternativo?", "confirmations.missing_alt_text.title": "Adicionar texto alternativo?",
"confirmations.mute.confirm": "Silenciar", "confirmations.mute.confirm": "Silenciar",
"confirmations.private_quote_notify.cancel": "Voltar à edição", "confirmations.private_quote_notify.cancel": "Voltar à edição",
"confirmations.private_quote_notify.confirm": "Publicar postagem", "confirmations.private_quote_notify.confirm": "Publicar citação",
"confirmations.private_quote_notify.do_not_show_again": "Não me mostre esta mensagem novamente", "confirmations.private_quote_notify.do_not_show_again": "Não me mostre esta mensagem novamente",
"confirmations.private_quote_notify.message": "A pessoa que está citando e outras menções serão notificadas e poderão ver sua postagem, mesmo que não sigam você.", "confirmations.private_quote_notify.message": "A pessoa que está sendo citada e outras mencionadas serão notificadas e poderão ver sua publicação, mesmo que não sigam você.",
"confirmations.private_quote_notify.title": "Compartilhar com seguidores e usuários mencionados?", "confirmations.private_quote_notify.title": "Compartilhar com seguidores e usuários mencionados?",
"confirmations.quiet_post_quote_info.dismiss": "Não me lembrar novamente", "confirmations.quiet_post_quote_info.dismiss": "Não me lembrar novamente",
"confirmations.quiet_post_quote_info.got_it": "Entendi", "confirmations.quiet_post_quote_info.got_it": "Entendi",
@ -265,14 +265,14 @@
"confirmations.remove_from_followers.message": "{name} vai parar de te seguir. Tem certeza de que deseja continuar?", "confirmations.remove_from_followers.message": "{name} vai parar de te seguir. Tem certeza de que deseja continuar?",
"confirmations.remove_from_followers.title": "Remover seguidor?", "confirmations.remove_from_followers.title": "Remover seguidor?",
"confirmations.revoke_quote.confirm": "Remover publicação", "confirmations.revoke_quote.confirm": "Remover publicação",
"confirmations.revoke_quote.message": "Essa ação não pode ser desfeita.", "confirmations.revoke_quote.message": "Esta ação não pode ser desfeita.",
"confirmations.revoke_quote.title": "Remover publicação?", "confirmations.revoke_quote.title": "Remover publicação?",
"confirmations.unblock.confirm": "Desbloquear", "confirmations.unblock.confirm": "Desbloquear",
"confirmations.unblock.title": "Desbloquear {name}?", "confirmations.unblock.title": "Desbloquear {name}?",
"confirmations.unfollow.confirm": "Deixar de seguir", "confirmations.unfollow.confirm": "Deixar de seguir",
"confirmations.unfollow.title": "Deixar de seguir {name}?", "confirmations.unfollow.title": "Deixar de seguir {name}?",
"confirmations.withdraw_request.confirm": "Retirar solicitação", "confirmations.withdraw_request.confirm": "Retirar solicitação",
"confirmations.withdraw_request.title": "Cancelar solicitação para seguir {name}?", "confirmations.withdraw_request.title": "Retirar solicitação para seguir {name}?",
"content_warning.hide": "Ocultar publicação", "content_warning.hide": "Ocultar publicação",
"content_warning.show": "Mostrar mesmo assim", "content_warning.show": "Mostrar mesmo assim",
"content_warning.show_more": "Mostrar mais", "content_warning.show_more": "Mostrar mais",

View File

@ -5,7 +5,7 @@
"about.disclaimer": "Mastodon 是一個自由的開源軟體,是 Mastodon gGmbH 之註冊商標。", "about.disclaimer": "Mastodon 是一個自由的開源軟體,是 Mastodon gGmbH 之註冊商標。",
"about.domain_blocks.no_reason_available": "無法存取的原因", "about.domain_blocks.no_reason_available": "無法存取的原因",
"about.domain_blocks.preamble": "Mastodon 基本上允許您瀏覽聯邦宇宙中任何伺服器的內容並與使用者互動。以下是於本伺服器上設定之例外。", "about.domain_blocks.preamble": "Mastodon 基本上允許您瀏覽聯邦宇宙中任何伺服器的內容並與使用者互動。以下是於本伺服器上設定之例外。",
"about.domain_blocks.silenced.explanation": "一般來說您不會看到來自這個伺服器的個人檔案與內容,除非您明確地打開或著跟隨此個人檔案。", "about.domain_blocks.silenced.explanation": "一般來說您不會看到來自這個伺服器的個人檔案與內容,除非您明確地檢視或著跟隨此個人檔案。",
"about.domain_blocks.silenced.title": "已受限", "about.domain_blocks.silenced.title": "已受限",
"about.domain_blocks.suspended.explanation": "來自此伺服器的資料都不會被處理、儲存或交換,也無法和此伺服器上的使用者互動與交流。", "about.domain_blocks.suspended.explanation": "來自此伺服器的資料都不會被處理、儲存或交換,也無法和此伺服器上的使用者互動與交流。",
"about.domain_blocks.suspended.title": "已停權", "about.domain_blocks.suspended.title": "已停權",
@ -90,7 +90,7 @@
"account.unmute": "解除靜音 @{name}", "account.unmute": "解除靜音 @{name}",
"account.unmute_notifications_short": "解除靜音推播通知", "account.unmute_notifications_short": "解除靜音推播通知",
"account.unmute_short": "解除靜音", "account.unmute_short": "解除靜音",
"account_note.placeholder": "按此新增備註", "account_note.placeholder": "點擊以新增備註",
"admin.dashboard.daily_retention": "註冊後使用者存留率(日)", "admin.dashboard.daily_retention": "註冊後使用者存留率(日)",
"admin.dashboard.monthly_retention": "註冊後使用者存留率(月)", "admin.dashboard.monthly_retention": "註冊後使用者存留率(月)",
"admin.dashboard.retention.average": "平均", "admin.dashboard.retention.average": "平均",
@ -138,10 +138,10 @@
"block_modal.show_less": "減少顯示", "block_modal.show_less": "減少顯示",
"block_modal.show_more": "顯示更多", "block_modal.show_more": "顯示更多",
"block_modal.they_cant_mention": "他們無法提及或跟隨您。", "block_modal.they_cant_mention": "他們無法提及或跟隨您。",
"block_modal.they_cant_see_posts": "他們無法讀取您的嘟文,且您不會見到他們。", "block_modal.they_cant_see_posts": "他們無法讀取您的嘟文,且您不會見到他們的嘟文。",
"block_modal.they_will_know": "他們能見到他們已被封鎖。", "block_modal.they_will_know": "他們能見到他們已被封鎖。",
"block_modal.title": "是否封鎖該使用者?", "block_modal.title": "是否封鎖該使用者?",
"block_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。", "block_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。",
"boost_modal.combo": "您下次可以按 {combo} 跳過", "boost_modal.combo": "您下次可以按 {combo} 跳過",
"boost_modal.reblog": "是否要轉嘟?", "boost_modal.reblog": "是否要轉嘟?",
"boost_modal.undo_reblog": "是否要取消轉嘟?", "boost_modal.undo_reblog": "是否要取消轉嘟?",
@ -181,7 +181,7 @@
"column.home": "首頁", "column.home": "首頁",
"column.list_members": "管理列表成員", "column.list_members": "管理列表成員",
"column.lists": "列表", "column.lists": "列表",
"column.mutes": "已靜音使用者", "column.mutes": "已靜音使用者",
"column.notifications": "推播通知", "column.notifications": "推播通知",
"column.pins": "釘選的嘟文", "column.pins": "釘選的嘟文",
"column.public": "聯邦時間軸", "column.public": "聯邦時間軸",
@ -193,9 +193,9 @@
"column_header.show_settings": "顯示設定", "column_header.show_settings": "顯示設定",
"column_header.unpin": "取消釘選", "column_header.unpin": "取消釘選",
"column_search.cancel": "取消", "column_search.cancel": "取消",
"community.column_settings.local_only": "顯示本站", "community.column_settings.local_only": "顯示本站",
"community.column_settings.media_only": "顯示媒體", "community.column_settings.media_only": "顯示媒體",
"community.column_settings.remote_only": "顯示遠端", "community.column_settings.remote_only": "顯示遠端",
"compose.error.blank_post": "嘟文無法為空白。", "compose.error.blank_post": "嘟文無法為空白。",
"compose.language.change": "變更語言", "compose.language.change": "變更語言",
"compose.language.search": "搜尋語言...", "compose.language.search": "搜尋語言...",
@ -204,14 +204,14 @@
"compose.saved.body": "已儲存嘟文。", "compose.saved.body": "已儲存嘟文。",
"compose_form.direct_message_warning_learn_more": "了解更多", "compose_form.direct_message_warning_learn_more": "了解更多",
"compose_form.encryption_warning": "Mastodon 上的嘟文並未進行端到端加密。請不要透過 Mastodon 分享任何敏感資訊。", "compose_form.encryption_warning": "Mastodon 上的嘟文並未進行端到端加密。請不要透過 Mastodon 分享任何敏感資訊。",
"compose_form.hashtag_warning": "由於這則嘟文設定為非公開,將不會列於任何主題標籤下。只有公開的嘟文才能藉由主題標籤被找到。", "compose_form.hashtag_warning": "由於這則嘟文設定為「不公開」,它將不被列於任何主題標籤下。只有公開的嘟文才能藉由主題標籤被找到。",
"compose_form.lock_disclaimer": "您的帳號尚未 {locked}。任何人皆能跟隨您並看到您設定成只對跟隨者顯示的嘟文。", "compose_form.lock_disclaimer": "您的帳號尚未 {locked}。任何人皆能跟隨您並看到您設定成僅有跟隨者可見的嘟文。",
"compose_form.lock_disclaimer.lock": "上鎖", "compose_form.lock_disclaimer.lock": "上鎖",
"compose_form.placeholder": "正在想些什麼嗎?", "compose_form.placeholder": "正在想些什麼嗎?",
"compose_form.poll.duration": "投票期限", "compose_form.poll.duration": "投票期限",
"compose_form.poll.multiple": "多選", "compose_form.poll.multiple": "多選",
"compose_form.poll.option_placeholder": "選項 {number}", "compose_form.poll.option_placeholder": "選項 {number}",
"compose_form.poll.single": "單", "compose_form.poll.single": "單選",
"compose_form.poll.switch_to_multiple": "變更投票為允許多個選項", "compose_form.poll.switch_to_multiple": "變更投票為允許多個選項",
"compose_form.poll.switch_to_single": "變更投票為允許單一選項", "compose_form.poll.switch_to_single": "變更投票為允許單一選項",
"compose_form.poll.type": "投票方式", "compose_form.poll.type": "投票方式",
@ -231,7 +231,7 @@
"confirmations.delete_list.title": "是否刪除該列表?", "confirmations.delete_list.title": "是否刪除該列表?",
"confirmations.discard_draft.confirm": "捨棄並繼續", "confirmations.discard_draft.confirm": "捨棄並繼續",
"confirmations.discard_draft.edit.cancel": "恢復編輯", "confirmations.discard_draft.edit.cancel": "恢復編輯",
"confirmations.discard_draft.edit.message": "繼續將捨棄任何您對正在編輯的此嘟文進行之任何變更。", "confirmations.discard_draft.edit.message": "繼續將捨棄任何您對正在編輯的此嘟文進行之任何變更。",
"confirmations.discard_draft.edit.title": "是否捨棄對您的嘟文之變更?", "confirmations.discard_draft.edit.title": "是否捨棄對您的嘟文之變更?",
"confirmations.discard_draft.post.cancel": "恢復草稿", "confirmations.discard_draft.post.cancel": "恢復草稿",
"confirmations.discard_draft.post.message": "繼續將捨棄您正在撰寫中之嘟文。", "confirmations.discard_draft.post.message": "繼續將捨棄您正在撰寫中之嘟文。",
@ -259,20 +259,20 @@
"confirmations.quiet_post_quote_info.message": "當引用不於公開時間軸顯示之嘟文時,您的嘟文將自熱門時間軸隱藏。", "confirmations.quiet_post_quote_info.message": "當引用不於公開時間軸顯示之嘟文時,您的嘟文將自熱門時間軸隱藏。",
"confirmations.quiet_post_quote_info.title": "引用不於公開時間軸顯示之嘟文", "confirmations.quiet_post_quote_info.title": "引用不於公開時間軸顯示之嘟文",
"confirmations.redraft.confirm": "刪除並重新編輯", "confirmations.redraft.confirm": "刪除並重新編輯",
"confirmations.redraft.message": "您確定要刪除這則嘟文並重新編輯嗎?您將失去這則嘟文之轉嘟及最愛,且對此嘟文之回覆會變成獨立的嘟文。", "confirmations.redraft.message": "您確定要刪除這則嘟文並重新編輯嗎?您將失去此嘟文之轉嘟及最愛,且對原嘟文之回覆將變成獨立嘟文。",
"confirmations.redraft.title": "是否刪除並重新編輯該嘟文?", "confirmations.redraft.title": "是否刪除並重新編輯該嘟文?",
"confirmations.remove_from_followers.confirm": "移除跟隨者", "confirmations.remove_from_followers.confirm": "移除跟隨者",
"confirmations.remove_from_followers.message": "{name} 將停止跟隨您。您確定要繼續嗎?", "confirmations.remove_from_followers.message": "{name} 將停止跟隨您。您確定要繼續嗎?",
"confirmations.remove_from_followers.title": "是否移除該跟隨者?", "confirmations.remove_from_followers.title": "是否移除該跟隨者?",
"confirmations.revoke_quote.confirm": "移除嘟文", "confirmations.revoke_quote.confirm": "移除嘟文",
"confirmations.revoke_quote.message": "此動作無法復原。", "confirmations.revoke_quote.message": "此動作無法復原。",
"confirmations.revoke_quote.title": "是否確定移除嘟文?", "confirmations.revoke_quote.title": "是否移除嘟文?",
"confirmations.unblock.confirm": "解除封鎖", "confirmations.unblock.confirm": "解除封鎖",
"confirmations.unblock.title": "解除封鎖 {name}", "confirmations.unblock.title": "是否解除封鎖 {name}",
"confirmations.unfollow.confirm": "取消跟隨", "confirmations.unfollow.confirm": "取消跟隨",
"confirmations.unfollow.title": "取消跟隨 {name}", "confirmations.unfollow.title": "是否取消跟隨 {name}",
"confirmations.withdraw_request.confirm": "收回跟隨請求", "confirmations.withdraw_request.confirm": "收回跟隨請求",
"confirmations.withdraw_request.title": "收回對 {name} 之跟隨請求?", "confirmations.withdraw_request.title": "是否收回對 {name} 之跟隨請求?",
"content_warning.hide": "隱藏嘟文", "content_warning.hide": "隱藏嘟文",
"content_warning.show": "仍要顯示", "content_warning.show": "仍要顯示",
"content_warning.show_more": "顯示更多", "content_warning.show_more": "顯示更多",
@ -284,7 +284,7 @@
"copypaste.copied": "已複製", "copypaste.copied": "已複製",
"copypaste.copy_to_clipboard": "複製到剪貼簿", "copypaste.copy_to_clipboard": "複製到剪貼簿",
"directory.federated": "來自已知聯邦宇宙", "directory.federated": "來自已知聯邦宇宙",
"directory.local": "僅來自 {domain} 網域", "directory.local": "僅來自 {domain}",
"directory.new_arrivals": "新人", "directory.new_arrivals": "新人",
"directory.recently_active": "最近活躍", "directory.recently_active": "最近活躍",
"disabled_account_banner.account_settings": "帳號設定", "disabled_account_banner.account_settings": "帳號設定",
@ -298,9 +298,9 @@
"domain_block_modal.they_cant_follow": "來自此伺服器之使用者將無法跟隨您。", "domain_block_modal.they_cant_follow": "來自此伺服器之使用者將無法跟隨您。",
"domain_block_modal.they_wont_know": "他們不會知道他們已被封鎖。", "domain_block_modal.they_wont_know": "他們不會知道他們已被封鎖。",
"domain_block_modal.title": "是否封鎖該網域?", "domain_block_modal.title": "是否封鎖該網域?",
"domain_block_modal.you_will_lose_num_followers": "您將失去 {followersCount, plural, other {{followersCountDisplay} 個跟隨者}} 與 {followingCount, plural, other {{followingCountDisplay} 個您跟隨之帳號}}.", "domain_block_modal.you_will_lose_num_followers": "您將失去 {followersCount, plural, other {{followersCountDisplay} 個跟隨者}} 與 {followingCount, plural, other {{followingCountDisplay} 個您跟隨之帳號}}",
"domain_block_modal.you_will_lose_relationships": "您將失去所有的跟隨者與您自此伺服器跟隨之帳號。", "domain_block_modal.you_will_lose_relationships": "您將失去所有的跟隨者與您自此伺服器跟隨之帳號。",
"domain_block_modal.you_wont_see_posts": "您不會見到來自此伺服器使用者之任何嘟文或推播通知。", "domain_block_modal.you_wont_see_posts": "您不會見到來自此伺服器使用者之任何嘟文或推播通知。",
"domain_pill.activitypub_lets_connect": "它使您能於 Mastodon 及其他不同的社群應用程式與人連結及互動。", "domain_pill.activitypub_lets_connect": "它使您能於 Mastodon 及其他不同的社群應用程式與人連結及互動。",
"domain_pill.activitypub_like_language": "ActivityPub 像是 Mastodon 與其他社群網路溝通時所用的語言。", "domain_pill.activitypub_like_language": "ActivityPub 像是 Mastodon 與其他社群網路溝通時所用的語言。",
"domain_pill.server": "伺服器", "domain_pill.server": "伺服器",
@ -567,8 +567,8 @@
"mute_modal.they_can_mention_and_follow": "他們仍可提及或跟隨您,但您不會見到他們。", "mute_modal.they_can_mention_and_follow": "他們仍可提及或跟隨您,但您不會見到他們。",
"mute_modal.they_wont_know": "他們不會知道他們已被靜音。", "mute_modal.they_wont_know": "他們不會知道他們已被靜音。",
"mute_modal.title": "是否靜音該使用者?", "mute_modal.title": "是否靜音該使用者?",
"mute_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。", "mute_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。",
"mute_modal.you_wont_see_posts": "他們仍可讀取您的嘟文,但您不會見到他們的。", "mute_modal.you_wont_see_posts": "他們仍可讀取您的嘟文,但您不會見到他們的嘟文。",
"navigation_bar.about": "關於", "navigation_bar.about": "關於",
"navigation_bar.account_settings": "密碼與安全性", "navigation_bar.account_settings": "密碼與安全性",
"navigation_bar.administration": "管理介面", "navigation_bar.administration": "管理介面",
@ -631,7 +631,7 @@
"notification.moderation_warning.action_disable": "您的帳號已被停用。", "notification.moderation_warning.action_disable": "您的帳號已被停用。",
"notification.moderation_warning.action_mark_statuses_as_sensitive": "某些您的嘟文已被標記為敏感內容。", "notification.moderation_warning.action_mark_statuses_as_sensitive": "某些您的嘟文已被標記為敏感內容。",
"notification.moderation_warning.action_none": "您的帳號已收到管理員警告。", "notification.moderation_warning.action_none": "您的帳號已收到管理員警告。",
"notification.moderation_warning.action_sensitive": "即日起,您的嘟文將被標記為敏感內容。", "notification.moderation_warning.action_sensitive": "即日起,您的嘟文將被標記為敏感內容。",
"notification.moderation_warning.action_silence": "您的帳號已被限制。", "notification.moderation_warning.action_silence": "您的帳號已被限制。",
"notification.moderation_warning.action_suspend": "您的帳號已被停權。", "notification.moderation_warning.action_suspend": "您的帳號已被停權。",
"notification.own_poll": "您的投票已結束", "notification.own_poll": "您的投票已結束",
@ -649,10 +649,10 @@
"notification_requests.accept": "接受", "notification_requests.accept": "接受",
"notification_requests.accept_multiple": "{count, plural, other {接受 # 則請求...}}", "notification_requests.accept_multiple": "{count, plural, other {接受 # 則請求...}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, other {接受請求}}", "notification_requests.confirm_accept_multiple.button": "{count, plural, other {接受請求}}",
"notification_requests.confirm_accept_multiple.message": "您將接受 {count, plural, other {# 則推播通知請求}}。您確定要繼續", "notification_requests.confirm_accept_multiple.message": "您將接受 {count, plural, other {# 則推播通知請求}}。您確定要繼續",
"notification_requests.confirm_accept_multiple.title": "是否接受推播通知請求?", "notification_requests.confirm_accept_multiple.title": "是否接受推播通知請求?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, other {忽略請求}}", "notification_requests.confirm_dismiss_multiple.button": "{count, plural, other {忽略請求}}",
"notification_requests.confirm_dismiss_multiple.message": "您將忽略 {count, plural, other {# 則推播通知請求}}。您將不再能輕易存取{count, plural, other {這些}}推播通知。您確定要繼續", "notification_requests.confirm_dismiss_multiple.message": "您將忽略 {count, plural, other {# 則推播通知請求}}。您將不再能輕易存取{count, plural, other {這些}}推播通知。您確定要繼續",
"notification_requests.confirm_dismiss_multiple.title": "是否忽略推播通知請求?", "notification_requests.confirm_dismiss_multiple.title": "是否忽略推播通知請求?",
"notification_requests.dismiss": "關閉", "notification_requests.dismiss": "關閉",
"notification_requests.dismiss_multiple": "{count, plural, other {忽略 # 則請求...}}", "notification_requests.dismiss_multiple": "{count, plural, other {忽略 # 則請求...}}",
@ -759,7 +759,7 @@
"privacy.quote.anyone": "{visibility},任何人皆可引用", "privacy.quote.anyone": "{visibility},任何人皆可引用",
"privacy.quote.disabled": "{visibility},停用引用嘟文", "privacy.quote.disabled": "{visibility},停用引用嘟文",
"privacy.quote.limited": "{visibility},受限的引用嘟文", "privacy.quote.limited": "{visibility},受限的引用嘟文",
"privacy.unlisted.additional": "此與公開嘟文完全相同,但嘟文不會出現於即時內容或主題標籤、探索、及 Mastodon 搜尋中,即使您帳戶設定中選擇加入。", "privacy.unlisted.additional": "此與公開嘟文完全相同,但嘟文不會出現於即時內容或主題標籤、探索、及 Mastodon 搜尋中,即使您帳戶設定中選擇加入。",
"privacy.unlisted.long": "不顯示於 Mastodon 之搜尋結果、熱門趨勢、及公開時間軸上", "privacy.unlisted.long": "不顯示於 Mastodon 之搜尋結果、熱門趨勢、及公開時間軸上",
"privacy.unlisted.short": "不公開", "privacy.unlisted.short": "不公開",
"privacy_policy.last_updated": "最後更新:{date}", "privacy_policy.last_updated": "最後更新:{date}",
@ -792,7 +792,7 @@
"reply_indicator.cancel": "取消", "reply_indicator.cancel": "取消",
"reply_indicator.poll": "投票", "reply_indicator.poll": "投票",
"report.block": "封鎖", "report.block": "封鎖",
"report.block_explanation": "您將不再看到他們的嘟文。他們將無法看到您的嘟文或是跟隨您。他們會發現他們已被封鎖。", "report.block_explanation": "您將不再看到他們的嘟文。他們將無法檢視您的嘟文或是跟隨您。他們會發現他們已被封鎖。",
"report.categories.legal": "合法性", "report.categories.legal": "合法性",
"report.categories.other": "其他", "report.categories.other": "其他",
"report.categories.spam": "垃圾訊息", "report.categories.spam": "垃圾訊息",
@ -806,7 +806,7 @@
"report.forward": "轉寄到 {target}", "report.forward": "轉寄到 {target}",
"report.forward_hint": "這個帳號屬於其他伺服器。要向該伺服器發送匿名的檢舉訊息嗎?", "report.forward_hint": "這個帳號屬於其他伺服器。要向該伺服器發送匿名的檢舉訊息嗎?",
"report.mute": "靜音", "report.mute": "靜音",
"report.mute_explanation": "您將不再看到他們的嘟文。他們仍能可以跟隨您以及察看您的嘟文,並且不會知道他們已被靜音。", "report.mute_explanation": "您將不再看到他們的嘟文。他們仍能可以跟隨您以及檢視您的嘟文,並且不會知道他們已被靜音。",
"report.next": "繼續", "report.next": "繼續",
"report.placeholder": "其他備註", "report.placeholder": "其他備註",
"report.reasons.dislike": "我不喜歡", "report.reasons.dislike": "我不喜歡",
@ -996,7 +996,7 @@
"upload_error.limit": "已達到檔案上傳限制。", "upload_error.limit": "已達到檔案上傳限制。",
"upload_error.poll": "不允許於投票時上傳檔案。", "upload_error.poll": "不允許於投票時上傳檔案。",
"upload_error.quote": "引用嘟文無法上傳檔案。", "upload_error.quote": "引用嘟文無法上傳檔案。",
"upload_form.drag_and_drop.instructions": "請按空白鍵或 Enter 鍵取多媒體附加檔案。使用方向鍵移動多媒體附加檔案。按下空白鍵或 Enter 鍵於新位置放置多媒體附加檔案,或按下 ESC 鍵取消。", "upload_form.drag_and_drop.instructions": "請按空白鍵或 Enter 鍵取多媒體附加檔案。使用方向鍵移動多媒體附加檔案。按下空白鍵或 Enter 鍵於新位置放置多媒體附加檔案,或按下 ESC 鍵取消。",
"upload_form.drag_and_drop.on_drag_cancel": "移動已取消。多媒體附加檔案 {item} 已被放置。", "upload_form.drag_and_drop.on_drag_cancel": "移動已取消。多媒體附加檔案 {item} 已被放置。",
"upload_form.drag_and_drop.on_drag_end": "多媒體附加檔案 {item} 已被放置。", "upload_form.drag_and_drop.on_drag_end": "多媒體附加檔案 {item} 已被放置。",
"upload_form.drag_and_drop.on_drag_over": "多媒體附加檔案 {item} 已被移動。", "upload_form.drag_and_drop.on_drag_over": "多媒體附加檔案 {item} 已被移動。",

View File

@ -122,7 +122,11 @@ $content-width: 840px;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 15px; padding: 15px;
color: var(--color-text-secondary); color: color-mix(
in oklab,
var(--color-text-primary),
var(--color-text-secondary)
);
text-decoration: none; text-decoration: none;
transition: all 200ms linear; transition: all 200ms linear;
transition-property: color, background-color; transition-property: color, background-color;

View File

@ -4494,7 +4494,7 @@ a.status-card {
z-index: 1; z-index: 1;
background: radial-gradient( background: radial-gradient(
ellipse, ellipse,
rgb(from var(--color-bg-brand-softer-base) r g b / 23%) 0%, rgb(from var(--color-bg-brand-base) r g b / 23%) 0%,
transparent 60% transparent 60%
); );
} }

View File

@ -79,7 +79,7 @@
// Utility // Utility
--color-bg-ambient: var(--color-bg-primary); --color-bg-ambient: var(--color-bg-primary);
--color-bg-elevated: var(--color-grey-800); --color-bg-elevated: var(--color-bg-primary);
--color-bg-inverted: var(--color-grey-50); --color-bg-inverted: var(--color-grey-50);
--color-bg-media-base: var(--color-black); --color-bg-media-base: var(--color-black);
--color-bg-media-strength: 65%; --color-bg-media-strength: 65%;
@ -87,7 +87,7 @@
var(--color-bg-media-base), var(--color-bg-media-base),
var(--color-bg-media-strength) var(--color-bg-media-strength)
)}; )};
--color-bg-overlay: var(--color-bg-primary); --color-bg-overlay: var(--color-black);
--color-bg-disabled: var(--color-grey-700); --color-bg-disabled: var(--color-grey-700);
// Brand // Brand

View File

@ -14,6 +14,8 @@ class ScopeTransformer < Parslet::Transform
# # override for profile scope which is read only # # override for profile scope which is read only
@access = %w(read) if @term == 'profile' @access = %w(read) if @term == 'profile'
# Override offline_access since it doesn't imply read or write access:
@access = %w(offline) if @term == 'offline_access'
end end
def key def key

View File

@ -38,9 +38,7 @@ class SessionActivation < ApplicationRecord
end end
def activate(**) def activate(**)
activation = create!(**) create!(**).tap { purge_old }
purge_old
activation
end end
def deactivate(id) def deactivate(id)
@ -65,12 +63,22 @@ class SessionActivation < ApplicationRecord
end end
def access_token_attributes def access_token_attributes
app = Doorkeeper::Application.find_by(superapp: true)
scopes = Doorkeeper::OAuth::Scopes.from_array(DEFAULT_SCOPES)
context = Doorkeeper::OAuth::Authorization::Token.build_context(
app,
Doorkeeper::OAuth::AUTHORIZATION_CODE,
scopes,
user_id
)
{ {
application_id: Doorkeeper::Application.find_by(superapp: true)&.id, application_id: context.client.id,
resource_owner_id: user_id, resource_owner_id: context.resource_owner,
scopes: DEFAULT_SCOPES.join(' '), scopes: context.scopes,
expires_in: Doorkeeper.configuration.access_token_expires_in, expires_in: Doorkeeper::OAuth::Authorization::Token.access_token_expires_in(Doorkeeper.config, context),
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?, use_refresh_token: Doorkeeper::OAuth::Authorization::Token.refresh_token_enabled?(Doorkeeper.config, context),
} }
end end
end end

View File

@ -278,10 +278,12 @@ class User < ApplicationRecord
def token_for_app(app) def token_for_app(app)
return nil if app.nil? || app.owner != self return nil if app.nil? || app.owner != self
Doorkeeper::AccessToken.find_or_create_by(application_id: app.id, resource_owner_id: id) do |t| context = Doorkeeper::OAuth::Authorization::Token.build_context(app, Doorkeeper::OAuth::AUTHORIZATION_CODE, app.scopes, app.owner.id)
t.scopes = app.scopes
t.expires_in = Doorkeeper.configuration.access_token_expires_in Doorkeeper::AccessToken.find_or_create_by(application_id: context.client.id, resource_owner_id: context.resource_owner) do |t|
t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled? t.scopes = context.scopes
t.expires_in = Doorkeeper::OAuth::Authorization::Token.access_token_expires_in(Doorkeeper.config, context)
t.use_refresh_token = Doorkeeper::OAuth::Authorization::Token.refresh_token_enabled?(Doorkeeper.config, context)
end end
end end

View File

@ -61,7 +61,7 @@ class OAuthMetadataPresenter < ActiveModelSerializers::Model
end end
def token_endpoint_auth_methods_supported def token_endpoint_auth_methods_supported
%w(client_secret_basic client_secret_post) %w(none client_secret_basic client_secret_post)
end end
def code_challenge_methods_supported def code_challenge_methods_supported

View File

@ -8,7 +8,7 @@ class REST::CredentialApplicationSerializer < REST::ApplicationSerializer
end end
def client_secret def client_secret
object.secret object.secret if object.confidential?
end end
# Added for future forwards compatibility when we may decide to expire OAuth # Added for future forwards compatibility when we may decide to expire OAuth

View File

@ -27,12 +27,14 @@ class AppSignUpService < BaseService
end end
def create_access_token! def create_access_token!
context = Doorkeeper::OAuth::Authorization::Token.build_context(@app, Doorkeeper::OAuth::AUTHORIZATION_CODE, @app.scopes, @user.id)
@access_token = Doorkeeper::AccessToken.create!( @access_token = Doorkeeper::AccessToken.create!(
application: @app, application: context.client,
resource_owner_id: @user.id, resource_owner_id: context.resource_owner,
scopes: @app.scopes, scopes: context.scopes,
expires_in: Doorkeeper.configuration.access_token_expires_in, expires_in: Doorkeeper::OAuth::Authorization::Token.access_token_expires_in(Doorkeeper.config, context),
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled? use_refresh_token: Doorkeeper::OAuth::Authorization::Token.refresh_token_enabled?(Doorkeeper.config, context)
) )
end end

View File

@ -31,10 +31,30 @@ Doorkeeper.configure do
# If you want to disable expiration, set this to nil. # If you want to disable expiration, set this to nil.
access_token_expires_in nil access_token_expires_in nil
# Assign a custom TTL for implicit grants. # context.grant_type to compare with Doorkeeper::OAUTH grant type constants
# custom_access_token_expires_in do |oauth_client| # context.client for client (Doorkeeper::Application)
# oauth_client.application.additional_settings.implicit_oauth_expiration # context.scopes for scopes
# end custom_access_token_expires_in do |context|
# If the client is confidential (all clients pre 4.3) and it hasn't
# requested offline_access, then we don't want to expire access tokens.
# Applications created by users are also considered confidential.
if context.client.confidential? && !context.scopes.exists?('offline_access')
nil
else
15.minutes.to_i
end
end
use_refresh_token do |context|
context.scopes.exists?('offline_access')
end
after_successful_strategy_response do |request, _response|
if request.is_a? Doorkeeper::OAuth::RefreshTokenRequest
Web::PushSubscription.where(access_token_id: request.refresh_token.id).update!(access_token_id: request.access_token.id)
SessionActivation.where(access_token_id: request.refresh_token.id).update!(access_token_id: request.access_token.id)
end
end
# Use a custom class for generating the access token. # Use a custom class for generating the access token.
# https://github.com/doorkeeper-gem/doorkeeper#custom-access-token-generator # https://github.com/doorkeeper-gem/doorkeeper#custom-access-token-generator
@ -71,6 +91,7 @@ Doorkeeper.configure do
# https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes
default_scopes :read default_scopes :read
optional_scopes :profile, optional_scopes :profile,
:offline_access,
:write, :write,
:'write:accounts', :'write:accounts',
:'write:blocks', :'write:blocks',
@ -120,7 +141,9 @@ Doorkeeper.configure do
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
# falls back to the `:client_id` and `:client_secret` params from the `params` object. # falls back to the `:client_id` and `:client_secret` params from the `params` object.
# Check out the wiki for more information on customization # Check out the wiki for more information on customization
# client_credentials :from_basic, :from_params #
# This is the default value:
client_credentials :from_basic, :from_params
# Change the way access token is authenticated from the request object. # Change the way access token is authenticated from the request object.
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
@ -165,7 +188,17 @@ Doorkeeper.configure do
# http://tools.ietf.org/html/rfc6819#section-4.4.3 # http://tools.ietf.org/html/rfc6819#section-4.4.3
# #
grant_flows %w(authorization_code client_credentials) grant_flows %w(authorization_code client_credentials refresh_token)
# If the client is not a confidential client, it should not be able to use the
# client_credentials grant flow, since it cannot keep a secret.
allow_grant_flow_for_client do |grant_flow, client|
if grant_flow == Doorkeeper::OAuth::CLIENT_CREDENTIALS
client.confidential?
else
true
end
end
# Under some circumstances you might want to have applications auto-approved, # Under some circumstances you might want to have applications auto-approved,
# so that the user skips the authorization step. # so that the user skips the authorization step.

View File

@ -1593,13 +1593,13 @@ da:
invalid: Denne invitation er ikke gyldig invalid: Denne invitation er ikke gyldig
invited_by: 'Du blev inviteret af:' invited_by: 'Du blev inviteret af:'
max_uses: max_uses:
one: 1 benyttelse one: 1 anvendelse
other: "%{count} benyttelser" other: "%{count} anvendelser"
max_uses_prompt: Ubegrænset max_uses_prompt: Ubegrænset
prompt: Generér og del links med andre for at give dem adgang til denne server prompt: Generér og del links med andre for at give dem adgang til denne server
table: table:
expires_at: Udløber expires_at: Udløber
uses: Benyttelser uses: Anvendelser
title: Invitér personer title: Invitér personer
link_preview: link_preview:
author_html: Af %{name} author_html: Af %{name}
@ -2122,7 +2122,7 @@ da:
feature_audience_title: Opbyg et publikum i tillid feature_audience_title: Opbyg et publikum i tillid
feature_control: Man ved selv bedst, hvad man ønsker at se på sit hjemmefeed. Ingen algoritmer eller annoncer til at spilde tiden. Følg alle på tværs af enhver Mastodon-server fra en enkelt konto og modtag deres indlæg i kronologisk rækkefølge, og gør dette hjørne af internet lidt mere personligt. feature_control: Man ved selv bedst, hvad man ønsker at se på sit hjemmefeed. Ingen algoritmer eller annoncer til at spilde tiden. Følg alle på tværs af enhver Mastodon-server fra en enkelt konto og modtag deres indlæg i kronologisk rækkefølge, og gør dette hjørne af internet lidt mere personligt.
feature_control_title: Hold styr på egen tidslinje feature_control_title: Hold styr på egen tidslinje
feature_creativity: Mastodon understøtter indlæg med lyd, video og billede, tilgængelighedsbeskrivelser, meningsmålinger, indhold advarsler, animerede avatarer, tilpassede emojis, miniaturebeskæringskontrol og mere, for at gøre det lettere at udtrykke sig online. Uanset om man udgiver sin kunst, musik eller podcast, så står Mastodon til rådighed. feature_creativity: Mastodon understøtter lyd-, video- og billedindlæg, tilgængelighedsbeskrivelser, afstemninger, indholdsadvarsler, animerede avatarer, brugerdefinerede emojis, kontrol over beskæring af miniaturebilleder og meget mere, så du lettere kan udtrykke dig online. Uanset om du udgiver din kunst, din musik eller din podcast, er Mastodon der for dig.
feature_creativity_title: Uovertruffen kreativitet feature_creativity_title: Uovertruffen kreativitet
feature_moderation: Mastodon lægger beslutningstagning tilbage i brugerens hænder. Hver server opretter deres egne regler og reguleringer, som håndhæves lokalt og ikke ovenfra som virksomhedernes sociale medier, hvilket gør det til den mest fleksible mht. at reagere på behovene hos forskellige persongrupper. Deltag på en server med de regler, man er enige med, eller driv en egen server. feature_moderation: Mastodon lægger beslutningstagning tilbage i brugerens hænder. Hver server opretter deres egne regler og reguleringer, som håndhæves lokalt og ikke ovenfra som virksomhedernes sociale medier, hvilket gør det til den mest fleksible mht. at reagere på behovene hos forskellige persongrupper. Deltag på en server med de regler, man er enige med, eller driv en egen server.
feature_moderation_title: Moderering af måden, tingene bør være på feature_moderation_title: Moderering af måden, tingene bør være på

View File

@ -89,6 +89,7 @@ en:
invalid_request: invalid_request:
missing_param: 'Missing required parameter: %{value}.' missing_param: 'Missing required parameter: %{value}.'
request_not_authorized: Request need to be authorized. Required parameter for authorizing request is missing or invalid. request_not_authorized: Request need to be authorized. Required parameter for authorizing request is missing or invalid.
offline_access_only: The offline_access scope can only be used with other scopes.
unknown: The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed. unknown: The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.
invalid_resource_owner: The provided resource owner credentials are not valid, or resource owner cannot be found invalid_resource_owner: The provided resource owner credentials are not valid, or resource owner cannot be found
invalid_scope: The requested scope is invalid, unknown, or malformed. invalid_scope: The requested scope is invalid, unknown, or malformed.
@ -118,6 +119,7 @@ en:
read: Read-only access read: Read-only access
read/write: Read and write access read/write: Read and write access
write: Write-only access write: Write-only access
offline: Access for an extended period of time
title: title:
accounts: Accounts accounts: Accounts
admin/accounts: Administration of accounts admin/accounts: Administration of accounts
@ -138,6 +140,7 @@ en:
notifications: Notifications notifications: Notifications
profile: Your Mastodon profile profile: Your Mastodon profile
push: Push notifications push: Push notifications
offline_access: Offline access
reports: Reports reports: Reports
search: Search search: Search
statuses: Posts statuses: Posts

View File

@ -227,7 +227,7 @@ da:
inbox_url: URL til videreformidlingsindbakken inbox_url: URL til videreformidlingsindbakken
irreversible: Fjern istedet for skjul irreversible: Fjern istedet for skjul
locale: Grænsefladesprog locale: Grænsefladesprog
max_uses: Maks. antal afbenyttelser max_uses: Maks. antal anvendelser
new_password: Ny adgangskode new_password: Ny adgangskode
note: Biografi note: Biografi
otp_attempt: Tofaktorkode otp_attempt: Tofaktorkode

View File

@ -62,6 +62,8 @@ module Mastodon::CLI
AND NOT EXISTS (SELECT 1 FROM mentions WHERE statuses.id = mentions.status_id AND mentions.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL)) AND NOT EXISTS (SELECT 1 FROM mentions WHERE statuses.id = mentions.status_id AND mentions.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))
AND NOT EXISTS (SELECT 1 FROM favourites WHERE statuses.id = favourites.status_id AND favourites.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL)) AND NOT EXISTS (SELECT 1 FROM favourites WHERE statuses.id = favourites.status_id AND favourites.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))
AND NOT EXISTS (SELECT 1 FROM bookmarks WHERE statuses.id = bookmarks.status_id AND bookmarks.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL)) AND NOT EXISTS (SELECT 1 FROM bookmarks WHERE statuses.id = bookmarks.status_id AND bookmarks.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))
AND NOT EXISTS (SELECT 1 FROM quotes JOIN statuses statuses1 ON quotes.status_id = statuses1.id WHERE quotes.quoted_status_id = statuses.id AND (statuses1.uri IS NULL OR statuses1.local))
AND NOT EXISTS (SELECT 1 FROM quotes JOIN statuses statuses1 ON quotes.quoted_status_id = statuses1.id WHERE quotes.status_id = statuses.id AND (statuses1.uri IS NULL OR statuses1.local))
#{clean_followed_sql} #{clean_followed_sql}
SQL SQL

View File

@ -1,7 +1,7 @@
{ {
"name": "@mastodon/mastodon", "name": "@mastodon/mastodon",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"packageManager": "yarn@4.10.3", "packageManager": "yarn@4.12.0",
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },

View File

@ -34,12 +34,19 @@ RSpec.describe OAuth::AuthorizationsController do
context 'when app is already authorized' do context 'when app is already authorized' do
before do before do
context = Doorkeeper::OAuth::Authorization::Token.build_context(
app,
Doorkeeper::OAuth::AUTHORIZATION_CODE,
app.scopes,
user.id
)
Doorkeeper::AccessToken.find_or_create_for( Doorkeeper::AccessToken.find_or_create_for(
application: app, application: context.client,
resource_owner: user.id, resource_owner: context.resource_owner,
scopes: app.scopes, scopes: context.scopes,
expires_in: Doorkeeper.configuration.access_token_expires_in, expires_in: Doorkeeper::OAuth::Authorization::Token.access_token_expires_in(Doorkeeper.config, context),
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled? use_refresh_token: Doorkeeper::OAuth::Authorization::Token.refresh_token_enabled?(Doorkeeper.config, context)
) )
end end

View File

@ -3,5 +3,5 @@
Fabricator(:application, from: Doorkeeper::Application) do Fabricator(:application, from: Doorkeeper::Application) do
name 'Example' name 'Example'
website 'http://example.com' website 'http://example.com'
redirect_uri 'http://example.com/callback' redirect_uri 'urn:ietf:wg:oauth:2.0:oob'
end end

View File

@ -23,6 +23,12 @@ RSpec.describe ScopeTransformer do
it_behaves_like 'a scope', nil, 'profile', 'read' it_behaves_like 'a scope', nil, 'profile', 'read'
end end
context 'with scope "offline_access"' do
let(:input) { 'offline_access' }
it_behaves_like 'a scope', nil, 'offline_access', 'offline'
end
context 'with scope "read"' do context 'with scope "read"' do
let(:input) { 'read' } let(:input) { 'read' }

View File

@ -39,20 +39,24 @@ RSpec.describe SessionActivation do
end end
describe '.activate' do describe '.activate' do
let(:options) { { user: Fabricate(:user), session_id: '1' } } let(:user) { Fabricate :user }
let!(:session_activation) { Fabricate :session_activation, user: }
it 'calls create! and purge_old' do around do |example|
allow(described_class).to receive(:create!).with(**options) original = Rails.configuration.x.max_session_activations
allow(described_class).to receive(:purge_old) Rails.configuration.x.max_session_activations = 1
example.run
described_class.activate(**options) Rails.configuration.x.max_session_activations = original
expect(described_class).to have_received(:create!).with(**options)
expect(described_class).to have_received(:purge_old)
end end
it 'returns an instance of SessionActivation' do it 'creates a new activation and purges older ones' do
expect(described_class.activate(**options)).to be_a described_class result = described_class.activate(user: user, session_id: '123')
expect(result)
.to be_a(described_class)
.and have_attributes(session_id: '123', user:)
expect { session_activation.reload }
.to raise_error(ActiveRecord::RecordNotFound)
end end
end end

View File

@ -36,6 +36,7 @@ RSpec.describe 'Apps' do
expect(app).to be_present expect(app).to be_present
expect(app.scopes.to_s).to eq scopes expect(app.scopes.to_s).to eq scopes
expect(app.redirect_uris).to eq redirect_uris expect(app.redirect_uris).to eq redirect_uris
expect(app.confidential).to be true
expect(response.parsed_body).to match( expect(response.parsed_body).to match(
a_hash_including( a_hash_including(
@ -55,6 +56,76 @@ RSpec.describe 'Apps' do
end end
end end
context 'without being a confidential application' do
let(:client_name) { 'Test confidential app' }
let(:params) do
{
client_name: client_name,
redirect_uris: redirect_uris,
scopes: scopes,
website: website,
token_endpoint_auth_method: 'none',
}
end
it 'creates an public OAuth app', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(response.content_type)
.to start_with('application/json')
app = Doorkeeper::Application.find_by(name: client_name)
expect(app).to be_present
expect(app.scopes.to_s).to eq scopes
expect(app.redirect_uris).to eq redirect_uris
expect(app.confidential).to be false
expect(response.parsed_body).to match(
a_hash_including(
id: app.id.to_s,
client_id: app.uid,
client_secret: nil,
client_secret_expires_at: 0,
name: client_name,
website: website,
scopes: ['read', 'write'],
redirect_uris: redirect_uris,
# Deprecated properties as of 4.3:
redirect_uri: redirect_uri,
vapid_key: Rails.configuration.x.vapid_public_key
)
)
end
end
context 'when token_endpoint_auth_method is unknown' do
let(:client_name) { 'Test unknown auth app' }
let(:params) do
{
client_name: client_name,
redirect_uris: redirect_uris,
scopes: scopes,
website: website,
# Not yet supported:
token_endpoint_auth_method: 'private_key_jwt',
}
end
it 'does not create an OAuth app', :aggregate_failures do
subject
expect(response).to have_http_status(400)
expect(response.content_type)
.to start_with('application/json')
app = Doorkeeper::Application.find_by(name: client_name)
expect(app).to_not be_present
end
end
context 'without scopes being supplied' do context 'without scopes being supplied' do
let(:scopes) { nil } let(:scopes) { nil }

View File

@ -5,17 +5,19 @@ require 'rails_helper'
RSpec.describe 'Managing OAuth Tokens' do RSpec.describe 'Managing OAuth Tokens' do
describe 'POST /oauth/token' do describe 'POST /oauth/token' do
subject do subject do
post '/oauth/token', params: params post '/oauth/token', params: params, headers: {
# This is using the OAuth client_secret_basic client authentication method
Authorization: ActionController::HttpAuthentication::Basic.encode_credentials(application.uid, application.secret),
}
end end
let(:application) do let(:application) do
Fabricate(:application, scopes: 'read write follow', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob') Fabricate(:application, scopes: 'read write follow', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob')
end end
let(:params) do let(:params) do
{ {
grant_type: grant_type, grant_type: grant_type,
client_id: application.uid,
client_secret: application.secret,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
code: code, code: code,
scope: scope, scope: scope,
@ -103,6 +105,53 @@ RSpec.describe 'Managing OAuth Tokens' do
end end
end end
end end
context "with grant_type 'refresh_token'" do
let(:grant_type) { 'refresh_token' }
let!(:user) { Fabricate(:user) }
let!(:application) { Fabricate(:application, scopes: 'read offline_access') }
let!(:access_token) do
Fabricate(
:accessible_access_token,
resource_owner_id: user.id,
application: application,
# Even though the `application` uses the `offline_access` scope, the
# generation of a refresh token only happens when the model is created
# with `use_refresh_token: true`.
#
# This is normally determined from the request to create the access
# token, but here we are just creating the access token model, so we
# need to force the `access_token` to have `use_refresh_token: true`
use_refresh_token: true
)
end
let!(:web_push_subscription) { Fabricate(:web_push_subscription, user: user, access_token: access_token) }
let(:params) do
{
grant_type: grant_type,
refresh_token: access_token.refresh_token,
}
end
it 'updates the Web::PushSubscription when refreshed' do
expect { subject }
.to change { access_token.reload.revoked_at }.from(nil).to(be_present)
expect(response).to have_http_status(200)
new_token = Doorkeeper::AccessToken.by_token(response.parsed_body[:access_token])
expect(web_push_subscription.reload.access_token_id).to eq(new_token.id)
# Assert that there are definitely no subscriptions left for the
# previous access token:
expect(Web::PushSubscription.where(access_token: access_token.id).count)
.to eq(0)
end
end
end end
describe 'POST /oauth/revoke' do describe 'POST /oauth/revoke' do

View File

@ -25,7 +25,7 @@ RSpec.describe 'The /.well-known/oauth-authorization-server request' do
scopes_supported: Doorkeeper.configuration.scopes.map(&:to_s), scopes_supported: Doorkeeper.configuration.scopes.map(&:to_s),
response_types_supported: Doorkeeper.configuration.authorization_response_types, response_types_supported: Doorkeeper.configuration.authorization_response_types,
response_modes_supported: Doorkeeper.configuration.authorization_response_flows.flat_map(&:response_mode_matches).uniq, response_modes_supported: Doorkeeper.configuration.authorization_response_flows.flat_map(&:response_mode_matches).uniq,
token_endpoint_auth_methods_supported: %w(client_secret_basic client_secret_post), token_endpoint_auth_methods_supported: %w(none client_secret_basic client_secret_post),
grant_types_supported: grant_types_supported, grant_types_supported: grant_types_supported,
code_challenge_methods_supported: Doorkeeper.configuration.pkce_code_challenge_methods_supported, code_challenge_methods_supported: Doorkeeper.configuration.pkce_code_challenge_methods_supported,
# non-standard extension: # non-standard extension:

View File

@ -1,7 +1,7 @@
{ {
"name": "@mastodon/streaming", "name": "@mastodon/streaming",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"packageManager": "yarn@4.10.3", "packageManager": "yarn@4.12.0",
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },