diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1ae40d416..0dddc3e29 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -15,7 +15,6 @@ // Ignore major version bumps for these node packages matchManagers: ['npm'], matchPackageNames: [ - '@rails/ujs', // Needs to match the major Rails version 'tesseract.js', // Requires code changes 'react-hotkeys', // Requires code changes @@ -51,12 +50,6 @@ 'sidekiq', // Requires manual upgrade 'sidekiq-unique-jobs', // Requires manual upgrades 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'], enabled: false, diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index 58f6345e6..63c3f2d90 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -1,25 +1,36 @@ # frozen_string_literal: true class Api::Web::EmbedsController < Api::Web::BaseController - before_action :require_user! + include Authorization - def create - status = StatusFinder.new(params[:url]).status + before_action :set_status - return not_found if status.hidden? + def show + return not_found if @status.hidden? - render json: status, serializer: OEmbedSerializer, width: 400 - rescue ActiveRecord::RecordNotFound - oembed = FetchOEmbedService.new.call(params[:url]) + if @status.local? + render json: @status, serializer: OEmbedSerializer, width: 400 + 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 - oembed[:html] = Sanitize.fragment(oembed[:html], Sanitize::Config::MASTODON_OEMBED) - rescue ArgumentError - return not_found + begin + oembed[:html] = Sanitize.fragment(oembed[:html], Sanitize::Config::MASTODON_OEMBED) + rescue ArgumentError + return not_found + end + + render json: oembed end + end - render json: oembed + def set_status + @status = Status.find(params[:id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found end end diff --git a/app/javascript/mastodon/components/router.tsx b/app/javascript/mastodon/components/router.tsx new file mode 100644 index 000000000..c82711790 --- /dev/null +++ b/app/javascript/mastodon/components/router.tsx @@ -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 = ({ children }) => { + return {children}; +}; diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index ab9dac27e..b713c98c6 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -258,7 +258,7 @@ class StatusActionBar extends ImmutablePureComponent { 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 }); } diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index 4538db050..59efc8057 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import { PureComponent } from 'react'; 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'; @@ -12,6 +12,7 @@ import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis'; import { hydrateStore } from 'mastodon/actions/store'; import { connectUserStream } from 'mastodon/actions/streaming'; import ErrorBoundary from 'mastodon/components/error_boundary'; +import { Router } from 'mastodon/components/router'; import UI from 'mastodon/features/ui'; import initialState, { title as siteTitle } from 'mastodon/initial_state'; import { IntlProvider } from 'mastodon/locales'; @@ -75,11 +76,11 @@ export default class Mastodon extends PureComponent { - + - + diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 6167b404f..8b3d8b46b 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -139,7 +139,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ dispatch(openModal({ modalType: 'EMBED', modalProps: { - url: status.get('url'), + id: status.get('id'), onError: error => dispatch(showAlertForError(error)), }, })); diff --git a/app/javascript/mastodon/features/getting_started/index.jsx b/app/javascript/mastodon/features/getting_started/index.jsx index e31ca79af..85fd6f120 100644 --- a/app/javascript/mastodon/features/getting_started/index.jsx +++ b/app/javascript/mastodon/features/getting_started/index.jsx @@ -142,7 +142,7 @@ class GettingStarted extends ImmutablePureComponent { {!multiColumn &&
} - +
{(multiColumn && showTrends) && } diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index d3295913c..0bacf2965 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -205,7 +205,7 @@ class ActionBar extends PureComponent { 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 }); } diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js index e76790a9f..57d5f61c8 100644 --- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js +++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js @@ -110,7 +110,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(openModal({ modalType: 'EMBED', modalProps: { - url: status.get('url'), + id: status.get('id'), onError: error => dispatch(showAlertForError(error)), }, })); diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index e5faba083..e5f91071e 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -449,7 +449,7 @@ class Status extends ImmutablePureComponent { handleEmbed = (status) => { this.props.dispatch(openModal({ modalType: 'EMBED', - modalProps: { url: status.get('url') }, + modalProps: { id: status.get('id') }, })); }; diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.jsx b/app/javascript/mastodon/features/ui/components/embed_modal.jsx index b08d53ab1..bacbad347 100644 --- a/app/javascript/mastodon/features/ui/components/embed_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/embed_modal.jsx @@ -14,7 +14,7 @@ const messages = defineMessages({ class EmbedModal extends ImmutablePureComponent { static propTypes = { - url: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, onError: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, @@ -26,11 +26,11 @@ class EmbedModal extends ImmutablePureComponent { }; componentDidMount () { - const { url } = this.props; + const { id } = this.props; 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 }); const iframeDocument = this.iframe.contentWindow.document; diff --git a/app/javascript/mastodon/features/ui/components/link_footer.jsx b/app/javascript/mastodon/features/ui/components/link_footer.jsx index b02517440..7aaa887ac 100644 --- a/app/javascript/mastodon/features/ui/components/link_footer.jsx +++ b/app/javascript/mastodon/features/ui/components/link_footer.jsx @@ -38,6 +38,7 @@ class LinkFooter extends PureComponent { }; static propTypes = { + multiColumn: PropTypes.bool, onLogout: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; @@ -53,6 +54,7 @@ class LinkFooter extends PureComponent { render () { const { signedIn, permissions } = this.context.identity; + const { multiColumn } = this.props; const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS); const canProfileDirectory = profileDirectory; @@ -64,7 +66,7 @@ class LinkFooter extends PureComponent {

{domain}: {' '} - + {statusPageUrl && ( <> {DividingCircle} @@ -84,7 +86,7 @@ class LinkFooter extends PureComponent { )} {DividingCircle} - +

diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx index dc406fa55..ab5c78246 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx @@ -8,6 +8,7 @@ import { Link } from 'react-router-dom'; import { WordmarkLogo } from 'mastodon/components/logo'; import NavigationPortal from 'mastodon/components/navigation_portal'; import { timelinePreview, trendsEnabled } from 'mastodon/initial_state'; +import { transientSingleColumn } from 'mastodon/is_mobile'; import ColumnLink from './column_link'; import DisabledAccountBanner from './disabled_account_banner'; @@ -29,6 +30,7 @@ const messages = defineMessages({ followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' }, about: { id: 'navigation_bar.about', defaultMessage: 'About' }, search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, + advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' }, }); class NavigationPanel extends Component { @@ -54,6 +56,12 @@ class NavigationPanel extends Component {

+ + {transientSingleColumn && ( + + {intl.formatMessage(messages.advancedInterface)} + + )}
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index b38acfc14..ae81a354b 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -126,11 +126,11 @@ class SwitchingColumnsArea extends PureComponent { static propTypes = { children: PropTypes.node, location: PropTypes.object, - mobile: PropTypes.bool, + singleColumn: PropTypes.bool, }; UNSAFE_componentWillMount () { - if (this.props.mobile) { + if (this.props.singleColumn) { document.body.classList.toggle('layout-single-column', true); document.body.classList.toggle('layout-multiple-columns', false); } else { @@ -144,9 +144,9 @@ class SwitchingColumnsArea extends PureComponent { this.node.handleChildrenContentChange(); } - if (prevProps.mobile !== this.props.mobile) { - document.body.classList.toggle('layout-single-column', this.props.mobile); - document.body.classList.toggle('layout-multiple-columns', !this.props.mobile); + if (prevProps.singleColumn !== this.props.singleColumn) { + document.body.classList.toggle('layout-single-column', this.props.singleColumn); + document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn); } } @@ -157,16 +157,17 @@ class SwitchingColumnsArea extends PureComponent { }; render () { - const { children, mobile } = this.props; + const { children, singleColumn } = this.props; const { signedIn } = this.context.identity; + const pathName = this.props.location.pathname; let redirect; if (signedIn) { - if (mobile) { + if (singleColumn) { redirect = ; } else { - redirect = ; + redirect = ; } } else if (singleUserMode && owner && initialState?.accounts[owner]) { redirect = ; @@ -177,10 +178,13 @@ class SwitchingColumnsArea extends PureComponent { } return ( - + {redirect} + {singleColumn ? : null} + {singleColumn && pathName.startsWith('/deck/') ? : null} + @@ -573,7 +577,7 @@ class UI extends PureComponent {
- + {children} diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx b/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx index 66cfee970..992772685 100644 --- a/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx +++ b/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx @@ -11,13 +11,21 @@ import BundleContainer from '../containers/bundle_container'; // Small wrapper to pass multiColumn to the route components export class WrappedSwitch extends PureComponent { + static contextTypes = { + router: PropTypes.object, + }; render () { 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 ( - - {Children.map(children, child => cloneElement(child, { multiColumn }))} + + {Children.map(children, child => child ? cloneElement(child, { multiColumn }) : null)} ); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index edac55c1b..f838c75bc 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -95,6 +95,13 @@ const element = document.getElementById('initial-state'); /** @type {InitialState | undefined} */ 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 * @param {K} prop diff --git a/app/javascript/mastodon/is_mobile.ts b/app/javascript/mastodon/is_mobile.ts index 36cde2133..7f339e287 100644 --- a/app/javascript/mastodon/is_mobile.ts +++ b/app/javascript/mastodon/is_mobile.ts @@ -1,19 +1,21 @@ import { supportsPassiveEvents } from 'detect-passive-events'; -import { forceSingleColumn } from './initial_state'; +import { forceSingleColumn, hasMultiColumnPath } from './initial_state'; const LAYOUT_BREAKPOINT = 630; export const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT; +export const transientSingleColumn = !forceSingleColumn && !hasMultiColumnPath; + export type LayoutType = 'mobile' | 'single-column' | 'multi-column'; export const layoutFromWindow = (): LayoutType => { if (isMobile(window.innerWidth)) { return 'mobile'; - } else if (forceSingleColumn) { - return 'single-column'; - } else { + } else if (!forceSingleColumn && !transientSingleColumn) { return 'multi-column'; + } else { + return 'single-column'; } }; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index edecaf60f..8c85cb7be 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -385,6 +385,7 @@ "mute_modal.hide_notifications": "Hide notifications from this user?", "mute_modal.indefinite": "Indefinite", "navigation_bar.about": "About", + "navigation_bar.advanced_interface": "Open in advanced web interface", "navigation_bar.blocks": "Blocked users", "navigation_bar.bookmarks": "Bookmarks", "navigation_bar.community_timeline": "Local timeline", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 75b7890d2..13eb9762e 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -368,6 +368,7 @@ "mute_modal.hide_notifications": "Masquer les notifications de cette personne ?", "mute_modal.indefinite": "Indéfinie", "navigation_bar.about": "À propos", + "navigation_bar.advanced_interface": "Ouvrir dans l’interface avancée", "navigation_bar.blocks": "Comptes bloqués", "navigation_bar.bookmarks": "Marque-pages", "navigation_bar.community_timeline": "Fil public local", diff --git a/app/models/status.rb b/app/models/status.rb index a52098b60..c669fa512 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -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 :tagged_with_all, lambda { |tag_ids| 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 } scope :tagged_with_none, lambda { |tag_ids| diff --git a/app/views/shared/_web_app.html.haml b/app/views/shared/_web_app.html.haml index b9a0ce1fc..67f3ff9b4 100644 --- a/app/views/shared/_web_app.html.haml +++ b/app/views/shared/_web_app.html.haml @@ -3,6 +3,7 @@ = preload_pack_asset 'features/compose.js', crossorigin: 'anonymous' = preload_pack_asset 'features/home_timeline.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 } diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 27d8ff8da..9b9330a75 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,28 +1,5 @@ { "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_code": 2, @@ -206,6 +183,6 @@ "note": "" } ], - "updated": "2023-07-11 16:08:58 +0200", + "updated": "2023-07-12 11:20:51 -0400", "brakeman_version": "6.0.0" } diff --git a/config/routes.rb b/config/routes.rb index 2f15c4fc0..e4a82ebd4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,7 @@ Rails.application.routes.draw do /mutes /followed_tags /statuses/(*any) + /deck/(*any) ).freeze root 'home#index' diff --git a/config/routes/api.rb b/config/routes/api.rb index 040ea7124..a9a974334 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -298,7 +298,7 @@ namespace :api, format: false do namespace :web do resource :settings, only: [:update] - resource :embed, only: [:create] + resources :embeds, only: [:show] resources :push_subscriptions, only: [:create] do member do put :update diff --git a/package.json b/package.json index 0c48b8a9d..bc488dd9e 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "react-overlays": "^5.2.1", "react-redux": "^8.0.4", "react-redux-loading-bar": "^5.0.4", + "react-router": "^4.3.1", "react-router-dom": "^4.1.1", "react-router-scroll-4": "^1.0.0-beta.1", "react-select": "^5.7.3", diff --git a/spec/controllers/api/web/embeds_controller_spec.rb b/spec/controllers/api/web/embeds_controller_spec.rb deleted file mode 100644 index 8c4e1a8f2..000000000 --- a/spec/controllers/api/web/embeds_controller_spec.rb +++ /dev/null @@ -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 diff --git a/spec/requests/api/web/embeds_spec.rb b/spec/requests/api/web/embeds_spec.rb new file mode 100644 index 000000000..6314f43aa --- /dev/null +++ b/spec/requests/api/web/embeds_spec.rb @@ -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