Merge commit '71db616fed817893d0efa363f0e7dbfcf23866a0' into glitch-soc/merge-upstream

This commit is contained in:
Claire 2023-07-13 22:40:15 +02:00
commit d9adda1a99
27 changed files with 280 additions and 130 deletions

View File

@ -15,7 +15,6 @@
// Ignore major version bumps for these node packages // Ignore major version bumps for these node packages
matchManagers: ['npm'], matchManagers: ['npm'],
matchPackageNames: [ matchPackageNames: [
'@rails/ujs', // Needs to match the major Rails version
'tesseract.js', // Requires code changes 'tesseract.js', // Requires code changes
'react-hotkeys', // Requires code changes 'react-hotkeys', // Requires code changes
@ -51,12 +50,6 @@
'sidekiq', // Requires manual upgrade 'sidekiq', // Requires manual upgrade
'sidekiq-unique-jobs', // Requires manual upgrades and sync with Sidekiq version 'sidekiq-unique-jobs', // Requires manual upgrades and sync with Sidekiq version
'redis', // Requires manual upgrade and sync with Sidekiq version 'redis', // Requires manual upgrade and sync with Sidekiq version
'fog-openstack', // TODO: was ignored in https://github.com/mastodon/mastodon/pull/13964
// Needs major Rails version bump
'rack',
'rails',
'rails-i18n',
], ],
matchUpdateTypes: ['major'], matchUpdateTypes: ['major'],
enabled: false, enabled: false,

View File

@ -1,25 +1,36 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::Web::EmbedsController < Api::Web::BaseController class Api::Web::EmbedsController < Api::Web::BaseController
before_action :require_user! include Authorization
def create before_action :set_status
status = StatusFinder.new(params[:url]).status
return not_found if status.hidden? def show
return not_found if @status.hidden?
render json: status, serializer: OEmbedSerializer, width: 400 if @status.local?
rescue ActiveRecord::RecordNotFound render json: @status, serializer: OEmbedSerializer, width: 400
oembed = FetchOEmbedService.new.call(params[:url]) else
return not_found unless user_signed_in?
return not_found if oembed.nil? url = ActivityPub::TagManager.instance.url_for(@status)
oembed = FetchOEmbedService.new.call(url)
return not_found if oembed.nil?
begin begin
oembed[:html] = Sanitize.fragment(oembed[:html], Sanitize::Config::MASTODON_OEMBED) oembed[:html] = Sanitize.fragment(oembed[:html], Sanitize::Config::MASTODON_OEMBED)
rescue ArgumentError rescue ArgumentError
return not_found return not_found
end
render json: oembed
end end
end
render json: oembed def set_status
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end end
end end

View File

@ -0,0 +1,23 @@
import type { PropsWithChildren } from 'react';
import React from 'react';
import type { History } from 'history';
import { createBrowserHistory } from 'history';
import { Router as OriginalRouter } from 'react-router';
import { layoutFromWindow } from 'mastodon/is_mobile';
const browserHistory = createBrowserHistory();
const originalPush = browserHistory.push.bind(browserHistory);
browserHistory.push = (path: string, state: History.LocationState) => {
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
originalPush(`/deck${path}`, state);
} else {
originalPush(path, state);
}
};
export const Router: React.FC<PropsWithChildren> = ({ children }) => {
return <OriginalRouter history={browserHistory}>{children}</OriginalRouter>;
};

View File

@ -258,7 +258,7 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick }); menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick });
} }
if (publicStatus) { if (publicStatus && (signedIn || !isRemote)) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
} }

View File

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { BrowserRouter, Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { Provider as ReduxProvider } from 'react-redux'; import { Provider as ReduxProvider } from 'react-redux';
@ -12,6 +12,7 @@ import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
import { hydrateStore } from 'mastodon/actions/store'; import { hydrateStore } from 'mastodon/actions/store';
import { connectUserStream } from 'mastodon/actions/streaming'; import { connectUserStream } from 'mastodon/actions/streaming';
import ErrorBoundary from 'mastodon/components/error_boundary'; import ErrorBoundary from 'mastodon/components/error_boundary';
import { Router } from 'mastodon/components/router';
import UI from 'mastodon/features/ui'; import UI from 'mastodon/features/ui';
import initialState, { title as siteTitle } from 'mastodon/initial_state'; import initialState, { title as siteTitle } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
@ -75,11 +76,11 @@ export default class Mastodon extends PureComponent {
<IntlProvider> <IntlProvider>
<ReduxProvider store={store}> <ReduxProvider store={store}>
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter> <Router>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}> <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Route path='/' component={UI} /> <Route path='/' component={UI} />
</ScrollContext> </ScrollContext>
</BrowserRouter> </Router>
<Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} /> <Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
</ErrorBoundary> </ErrorBoundary>

View File

@ -139,7 +139,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch(openModal({ dispatch(openModal({
modalType: 'EMBED', modalType: 'EMBED',
modalProps: { modalProps: {
url: status.get('url'), id: status.get('id'),
onError: error => dispatch(showAlertForError(error)), onError: error => dispatch(showAlertForError(error)),
}, },
})); }));

View File

@ -142,7 +142,7 @@ class GettingStarted extends ImmutablePureComponent {
{!multiColumn && <div className='flex-spacer' />} {!multiColumn && <div className='flex-spacer' />}
<LinkFooter /> <LinkFooter multiColumn />
</div> </div>
{(multiColumn && showTrends) && <TrendsContainer />} {(multiColumn && showTrends) && <TrendsContainer />}

View File

@ -205,7 +205,7 @@ class ActionBar extends PureComponent {
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare }); menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare });
} }
if (publicStatus) { if (publicStatus && (signedIn || !isRemote)) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
} }

View File

@ -110,7 +110,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(openModal({ dispatch(openModal({
modalType: 'EMBED', modalType: 'EMBED',
modalProps: { modalProps: {
url: status.get('url'), id: status.get('id'),
onError: error => dispatch(showAlertForError(error)), onError: error => dispatch(showAlertForError(error)),
}, },
})); }));

View File

@ -449,7 +449,7 @@ class Status extends ImmutablePureComponent {
handleEmbed = (status) => { handleEmbed = (status) => {
this.props.dispatch(openModal({ this.props.dispatch(openModal({
modalType: 'EMBED', modalType: 'EMBED',
modalProps: { url: status.get('url') }, modalProps: { id: status.get('id') },
})); }));
}; };

View File

@ -14,7 +14,7 @@ const messages = defineMessages({
class EmbedModal extends ImmutablePureComponent { class EmbedModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
url: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired, onError: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -26,11 +26,11 @@ class EmbedModal extends ImmutablePureComponent {
}; };
componentDidMount () { componentDidMount () {
const { url } = this.props; const { id } = this.props;
this.setState({ loading: true }); this.setState({ loading: true });
api().post('/api/web/embed', { url }).then(res => { api().get(`/api/web/embeds/${id}`).then(res => {
this.setState({ loading: false, oembed: res.data }); this.setState({ loading: false, oembed: res.data });
const iframeDocument = this.iframe.contentWindow.document; const iframeDocument = this.iframe.contentWindow.document;

View File

@ -38,6 +38,7 @@ class LinkFooter extends PureComponent {
}; };
static propTypes = { static propTypes = {
multiColumn: PropTypes.bool,
onLogout: PropTypes.func.isRequired, onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -53,6 +54,7 @@ class LinkFooter extends PureComponent {
render () { render () {
const { signedIn, permissions } = this.context.identity; const { signedIn, permissions } = this.context.identity;
const { multiColumn } = this.props;
const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS); const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS);
const canProfileDirectory = profileDirectory; const canProfileDirectory = profileDirectory;
@ -64,7 +66,7 @@ class LinkFooter extends PureComponent {
<p> <p>
<strong>{domain}</strong>: <strong>{domain}</strong>:
{' '} {' '}
<Link to='/about'><FormattedMessage id='footer.about' defaultMessage='About' /></Link> <Link to='/about' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
{statusPageUrl && ( {statusPageUrl && (
<> <>
{DividingCircle} {DividingCircle}
@ -84,7 +86,7 @@ class LinkFooter extends PureComponent {
</> </>
)} )}
{DividingCircle} {DividingCircle}
<Link to='/privacy-policy'><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link> <Link to='/privacy-policy' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
</p> </p>
<p> <p>

View File

@ -8,6 +8,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, trendsEnabled } from 'mastodon/initial_state'; import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
import { transientSingleColumn } from 'mastodon/is_mobile';
import ColumnLink from './column_link'; import ColumnLink from './column_link';
import DisabledAccountBanner from './disabled_account_banner'; import DisabledAccountBanner from './disabled_account_banner';
@ -29,6 +30,7 @@ const messages = defineMessages({
followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' }, followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
about: { id: 'navigation_bar.about', defaultMessage: 'About' }, about: { id: 'navigation_bar.about', defaultMessage: 'About' },
search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
}); });
class NavigationPanel extends Component { class NavigationPanel extends Component {
@ -54,6 +56,12 @@ class NavigationPanel extends Component {
<div className='navigation-panel'> <div className='navigation-panel'>
<div className='navigation-panel__logo'> <div className='navigation-panel__logo'>
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link> <Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
{transientSingleColumn && (
<a href={`/deck${location.pathname}`} className='button button--block'>
{intl.formatMessage(messages.advancedInterface)}
</a>
)}
<hr /> <hr />
</div> </div>

View File

@ -126,11 +126,11 @@ class SwitchingColumnsArea extends PureComponent {
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
location: PropTypes.object, location: PropTypes.object,
mobile: PropTypes.bool, singleColumn: PropTypes.bool,
}; };
UNSAFE_componentWillMount () { UNSAFE_componentWillMount () {
if (this.props.mobile) { if (this.props.singleColumn) {
document.body.classList.toggle('layout-single-column', true); document.body.classList.toggle('layout-single-column', true);
document.body.classList.toggle('layout-multiple-columns', false); document.body.classList.toggle('layout-multiple-columns', false);
} else { } else {
@ -144,9 +144,9 @@ class SwitchingColumnsArea extends PureComponent {
this.node.handleChildrenContentChange(); this.node.handleChildrenContentChange();
} }
if (prevProps.mobile !== this.props.mobile) { if (prevProps.singleColumn !== this.props.singleColumn) {
document.body.classList.toggle('layout-single-column', this.props.mobile); document.body.classList.toggle('layout-single-column', this.props.singleColumn);
document.body.classList.toggle('layout-multiple-columns', !this.props.mobile); document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn);
} }
} }
@ -157,16 +157,17 @@ class SwitchingColumnsArea extends PureComponent {
}; };
render () { render () {
const { children, mobile } = this.props; const { children, singleColumn } = this.props;
const { signedIn } = this.context.identity; const { signedIn } = this.context.identity;
const pathName = this.props.location.pathname;
let redirect; let redirect;
if (signedIn) { if (signedIn) {
if (mobile) { if (singleColumn) {
redirect = <Redirect from='/' to='/home' exact />; redirect = <Redirect from='/' to='/home' exact />;
} else { } else {
redirect = <Redirect from='/' to='/getting-started' exact />; redirect = <Redirect from='/' to='/deck/getting-started' exact />;
} }
} 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 />;
@ -177,10 +178,13 @@ class SwitchingColumnsArea extends PureComponent {
} }
return ( return (
<ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}> <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
<WrappedSwitch> <WrappedSwitch>
{redirect} {redirect}
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null}
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/about' component={About} content={children} /> <WrappedRoute path='/about' component={About} content={children} />
@ -573,7 +577,7 @@ class UI extends PureComponent {
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}> <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
<Header /> <Header />
<SwitchingColumnsArea location={location} mobile={layout === 'mobile' || layout === 'single-column'}> <SwitchingColumnsArea location={location} singleColumn={layout === 'mobile' || layout === 'single-column'}>
{children} {children}
</SwitchingColumnsArea> </SwitchingColumnsArea>

View File

@ -11,13 +11,21 @@ import BundleContainer from '../containers/bundle_container';
// Small wrapper to pass multiColumn to the route components // Small wrapper to pass multiColumn to the route components
export class WrappedSwitch extends PureComponent { export class WrappedSwitch extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
render () { render () {
const { multiColumn, children } = this.props; const { multiColumn, children } = this.props;
const { location } = this.context.router.route;
const decklessLocation = multiColumn && location.pathname.startsWith('/deck')
? {...location, pathname: location.pathname.slice(5)}
: location;
return ( return (
<Switch> <Switch location={decklessLocation}>
{Children.map(children, child => cloneElement(child, { multiColumn }))} {Children.map(children, child => child ? cloneElement(child, { multiColumn }) : null)}
</Switch> </Switch>
); );
} }

View File

@ -95,6 +95,13 @@ const element = document.getElementById('initial-state');
/** @type {InitialState | undefined} */ /** @type {InitialState | undefined} */
const initialState = element?.textContent && JSON.parse(element.textContent); const initialState = element?.textContent && JSON.parse(element.textContent);
/** @type {string} */
const initialPath = document.querySelector("head meta[name=initialPath]")?.getAttribute("content") ?? '';
/** @type {boolean} */
export const hasMultiColumnPath = initialPath === '/'
|| initialPath === '/getting-started'
|| initialPath.startsWith('/deck');
/** /**
* @template {keyof InitialStateMeta} K * @template {keyof InitialStateMeta} K
* @param {K} prop * @param {K} prop

View File

@ -1,19 +1,21 @@
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import { forceSingleColumn } from './initial_state'; import { forceSingleColumn, hasMultiColumnPath } from './initial_state';
const LAYOUT_BREAKPOINT = 630; const LAYOUT_BREAKPOINT = 630;
export const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT; export const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT;
export const transientSingleColumn = !forceSingleColumn && !hasMultiColumnPath;
export type LayoutType = 'mobile' | 'single-column' | 'multi-column'; export type LayoutType = 'mobile' | 'single-column' | 'multi-column';
export const layoutFromWindow = (): LayoutType => { export const layoutFromWindow = (): LayoutType => {
if (isMobile(window.innerWidth)) { if (isMobile(window.innerWidth)) {
return 'mobile'; return 'mobile';
} else if (forceSingleColumn) { } else if (!forceSingleColumn && !transientSingleColumn) {
return 'single-column';
} else {
return 'multi-column'; return 'multi-column';
} else {
return 'single-column';
} }
}; };

View File

@ -385,6 +385,7 @@
"mute_modal.hide_notifications": "Hide notifications from this user?", "mute_modal.hide_notifications": "Hide notifications from this user?",
"mute_modal.indefinite": "Indefinite", "mute_modal.indefinite": "Indefinite",
"navigation_bar.about": "About", "navigation_bar.about": "About",
"navigation_bar.advanced_interface": "Open in advanced web interface",
"navigation_bar.blocks": "Blocked users", "navigation_bar.blocks": "Blocked users",
"navigation_bar.bookmarks": "Bookmarks", "navigation_bar.bookmarks": "Bookmarks",
"navigation_bar.community_timeline": "Local timeline", "navigation_bar.community_timeline": "Local timeline",

View File

@ -368,6 +368,7 @@
"mute_modal.hide_notifications": "Masquer les notifications de cette personne?", "mute_modal.hide_notifications": "Masquer les notifications de cette personne?",
"mute_modal.indefinite": "Indéfinie", "mute_modal.indefinite": "Indéfinie",
"navigation_bar.about": "À propos", "navigation_bar.about": "À propos",
"navigation_bar.advanced_interface": "Ouvrir dans linterface avancée",
"navigation_bar.blocks": "Comptes bloqués", "navigation_bar.blocks": "Comptes bloqués",
"navigation_bar.bookmarks": "Marque-pages", "navigation_bar.bookmarks": "Marque-pages",
"navigation_bar.community_timeline": "Fil public local", "navigation_bar.community_timeline": "Fil public local",

View File

@ -106,7 +106,9 @@ class Status < ApplicationRecord
scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) } scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
scope :tagged_with_all, lambda { |tag_ids| scope :tagged_with_all, lambda { |tag_ids|
Array(tag_ids).map(&:to_i).reduce(self) do |result, id| Array(tag_ids).map(&:to_i).reduce(self) do |result, id|
result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}") result.where(<<~SQL.squish, tag_id: id)
EXISTS(SELECT 1 FROM statuses_tags WHERE statuses_tags.status_id = statuses.id AND statuses_tags.tag_id = :tag_id)
SQL
end end
} }
scope :tagged_with_none, lambda { |tag_ids| scope :tagged_with_none, lambda { |tag_ids|

View File

@ -3,6 +3,7 @@
= preload_pack_asset 'features/compose.js', crossorigin: 'anonymous' = preload_pack_asset 'features/compose.js', crossorigin: 'anonymous'
= preload_pack_asset 'features/home_timeline.js', crossorigin: 'anonymous' = preload_pack_asset 'features/home_timeline.js', crossorigin: 'anonymous'
= preload_pack_asset 'features/notifications.js', crossorigin: 'anonymous' = preload_pack_asset 'features/notifications.js', crossorigin: 'anonymous'
%meta{ name: 'initialPath', content: request.path }
%meta{ name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key } %meta{ name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key }

View File

@ -1,28 +1,5 @@
{ {
"ignored_warnings": [ "ignored_warnings": [
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "19df3740b8d02a9fe0eb52c939b4b87d3a2a591162a6adfa8d64e9c26aeebe6d",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/status.rb",
"line": 106,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "result.joins(\"INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")",
"render_path": null,
"location": {
"type": "method",
"class": "Status",
"method": null
},
"user_input": "id",
"confidence": "Weak",
"cwe_id": [
89
],
"note": ""
},
{ {
"warning_type": "Cross-Site Scripting", "warning_type": "Cross-Site Scripting",
"warning_code": 2, "warning_code": 2,
@ -206,6 +183,6 @@
"note": "" "note": ""
} }
], ],
"updated": "2023-07-11 16:08:58 +0200", "updated": "2023-07-12 11:20:51 -0400",
"brakeman_version": "6.0.0" "brakeman_version": "6.0.0"
} }

View File

@ -31,6 +31,7 @@ Rails.application.routes.draw do
/mutes /mutes
/followed_tags /followed_tags
/statuses/(*any) /statuses/(*any)
/deck/(*any)
).freeze ).freeze
root 'home#index' root 'home#index'

View File

@ -298,7 +298,7 @@ namespace :api, format: false do
namespace :web do namespace :web do
resource :settings, only: [:update] resource :settings, only: [:update]
resource :embed, only: [:create] resources :embeds, only: [:show]
resources :push_subscriptions, only: [:create] do resources :push_subscriptions, only: [:create] do
member do member do
put :update put :update

View File

@ -109,6 +109,7 @@
"react-overlays": "^5.2.1", "react-overlays": "^5.2.1",
"react-redux": "^8.0.4", "react-redux": "^8.0.4",
"react-redux-loading-bar": "^5.0.4", "react-redux-loading-bar": "^5.0.4",
"react-router": "^4.3.1",
"react-router-dom": "^4.1.1", "react-router-dom": "^4.1.1",
"react-router-scroll-4": "^1.0.0-beta.1", "react-router-scroll-4": "^1.0.0-beta.1",
"react-select": "^5.7.3", "react-select": "^5.7.3",

View File

@ -1,54 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe Api::Web::EmbedsController do
render_views
let(:user) { Fabricate(:user) }
before { sign_in user }
describe 'POST #create' do
subject(:body) { JSON.parse(response.body, symbolize_names: true) }
let(:response) { post :create, params: { url: url } }
context 'when successfully finds status' do
let(:status) { Fabricate(:status) }
let(:url) { "http://#{Rails.configuration.x.web_domain}/@#{status.account.username}/#{status.id}" }
it 'returns a right response' do
expect(response).to have_http_status 200
expect(body[:author_name]).to eq status.account.username
end
end
context 'when fails to find status' do
let(:url) { 'https://host.test/oembed.html' }
let(:service_instance) { instance_double(FetchOEmbedService) }
before do
allow(FetchOEmbedService).to receive(:new) { service_instance }
allow(service_instance).to receive(:call) { call_result }
end
context 'when successfully fetching oembed' do
let(:call_result) { { result: :ok } }
it 'returns a right response' do
expect(response).to have_http_status 200
expect(body[:result]).to eq 'ok'
end
end
context 'when fails to fetch oembed' do
let(:call_result) { nil }
it 'returns a right response' do
expect(response).to have_http_status 404
end
end
end
end
end

View File

@ -0,0 +1,161 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe '/api/web/embed' do
subject { get "/api/web/embeds/#{id}", headers: headers }
context 'when accessed anonymously' do
let(:headers) { {} }
context 'when the requested status is local' do
let(:id) { status.id }
context 'when the requested status is public' do
let(:status) { Fabricate(:status, visibility: :public) }
it 'returns JSON with an html attribute' do
subject
expect(response).to have_http_status(200)
expect(body_as_json[:html]).to be_present
end
end
context 'when the requested status is private' do
let(:status) { Fabricate(:status, visibility: :private) }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
context 'when the requested status is remote' do
let(:remote_account) { Fabricate(:account, domain: 'example.com') }
let(:status) { Fabricate(:status, visibility: :public, account: remote_account, url: 'https://example.com/statuses/1') }
let(:id) { status.id }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
context 'when the requested status does not exist' do
let(:id) { -1 }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
context 'with an API token' do
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
context 'when the requested status is local' do
let(:id) { status.id }
context 'when the requested status is public' do
let(:status) { Fabricate(:status, visibility: :public) }
it 'returns JSON with an html attribute' do
subject
expect(response).to have_http_status(200)
expect(body_as_json[:html]).to be_present
end
context 'when the requesting user is blocked' do
before do
status.account.block!(user.account)
end
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
context 'when the requested status is private' do
let(:status) { Fabricate(:status, visibility: :private) }
before do
user.account.follow!(status.account)
end
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
context 'when the requested status is remote' do
let(:remote_account) { Fabricate(:account, domain: 'example.com') }
let(:status) { Fabricate(:status, visibility: :public, account: remote_account, url: 'https://example.com/statuses/1') }
let(:id) { status.id }
let(:service_instance) { instance_double(FetchOEmbedService) }
before do
allow(FetchOEmbedService).to receive(:new) { service_instance }
allow(service_instance).to receive(:call) { call_result }
end
context 'when the requesting user is blocked' do
before do
status.account.block!(user.account)
end
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
context 'when successfully fetching OEmbed' do
let(:call_result) { { html: 'ok' } }
it 'returns JSON with an html attribute' do
subject
expect(response).to have_http_status(200)
expect(body_as_json[:html]).to be_present
end
end
context 'when failing to fetch OEmbed' do
let(:call_result) { nil }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
context 'when the requested status does not exist' do
let(:id) { -1 }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
end