Standalone: support running Soapbox FE on a subdomain

This commit is contained in:
Alex Gleason 2021-08-22 14:34:58 -05:00
parent 1b30468366
commit 0b4b3479ea
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
15 changed files with 222 additions and 32 deletions

View file

@ -16,10 +16,10 @@ export const APP_VERIFY_CREDENTIALS_REQUEST = 'APP_VERIFY_CREDENTIALS_REQUEST';
export const APP_VERIFY_CREDENTIALS_SUCCESS = 'APP_VERIFY_CREDENTIALS_SUCCESS'; export const APP_VERIFY_CREDENTIALS_SUCCESS = 'APP_VERIFY_CREDENTIALS_SUCCESS';
export const APP_VERIFY_CREDENTIALS_FAIL = 'APP_VERIFY_CREDENTIALS_FAIL'; export const APP_VERIFY_CREDENTIALS_FAIL = 'APP_VERIFY_CREDENTIALS_FAIL';
export function createApp(params) { export function createApp(params, baseURL) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: APP_CREATE_REQUEST, params }); dispatch({ type: APP_CREATE_REQUEST, params });
return baseClient().post('/api/v1/apps', params).then(({ data: app }) => { return baseClient(null, baseURL).post('/api/v1/apps', params).then(({ data: app }) => {
dispatch({ type: APP_CREATE_SUCCESS, params, app }); dispatch({ type: APP_CREATE_SUCCESS, params, app });
return app; return app;
}).catch(error => { }).catch(error => {

View file

@ -50,6 +50,7 @@ function createAuthApp() {
client_name: sourceCode.displayName, client_name: sourceCode.displayName,
redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', redirect_uris: 'urn:ietf:wg:oauth:2.0:oob',
scopes: 'read write follow push admin', scopes: 'read write follow push admin',
website: sourceCode.homepage,
}; };
return dispatch(createApp(params)).then(app => { return dispatch(createApp(params)).then(app => {
@ -88,10 +89,8 @@ function createUserToken(username, password) {
password: password, password: password,
}; };
return dispatch(obtainOAuthToken(params)).then(token => { return dispatch(obtainOAuthToken(params))
dispatch(authLoggedIn(token)); .then(token => dispatch(authLoggedIn(token)));
return token;
});
}; };
} }
@ -110,9 +109,8 @@ export function refreshUserToken() {
grant_type: 'refresh_token', grant_type: 'refresh_token',
}; };
return dispatch(obtainOAuthToken(params)).then(token => { return dispatch(obtainOAuthToken(params))
dispatch(authLoggedIn(token)); .then(token => dispatch(authLoggedIn(token)));
});
}; };
} }
@ -126,10 +124,7 @@ export function otpVerify(code, mfa_token) {
code: code, code: code,
challenge_type: 'totp', challenge_type: 'totp',
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
}).then(({ data: token }) => { }).then(({ data: token }) => dispatch(authLoggedIn(token)));
dispatch(authLoggedIn(token));
return token;
});
}; };
} }
@ -209,12 +204,9 @@ export function register(params) {
return (dispatch, getState) => { return (dispatch, getState) => {
params.fullname = params.username; params.fullname = params.username;
return dispatch(createAppAndToken()).then(() => { return dispatch(createAppAndToken())
return dispatch(createAccount(params)); .then(() => dispatch(createAccount(params)))
}).then(({ token }) => { .then(({ token }) => dispatch(authLoggedIn(token)));
dispatch(authLoggedIn(token));
return token;
});
}; };
} }
@ -225,8 +217,8 @@ export function fetchCaptcha() {
} }
export function authLoggedIn(token) { export function authLoggedIn(token) {
return { return (dispatch, getState) => {
type: AUTH_LOGGED_IN, dispatch({ type: AUTH_LOGGED_IN, token });
token, return token;
}; };
} }

View file

@ -0,0 +1,65 @@
/**
* External Auth: workflow for logging in to remote servers.
* @module soapbox/actions/external_auth
* @see module:soapbox/actions/auth
* @see module:soapbox/actions/apps
* @see module:soapbox/actions/oauth
*/
import { createApp } from 'soapbox/actions/apps';
import { obtainOAuthToken } from 'soapbox/actions/oauth';
import { authLoggedIn, verifyCredentials } from 'soapbox/actions/auth';
import { parseBaseURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code';
const scopes = 'read write follow push';
export function createAppAndRedirect(host) {
return (dispatch, getState) => {
const baseURL = parseBaseURL(host) || parseBaseURL(`https://${host}`);
const params = {
client_name: sourceCode.displayName,
redirect_uris: `${window.location.origin}/auth/external`,
website: sourceCode.homepage,
scopes,
};
return dispatch(createApp(params, baseURL)).then(app => {
const { client_id, redirect_uri } = app;
const query = new URLSearchParams({
client_id,
redirect_uri,
response_type: 'code',
scopes,
});
localStorage.setItem('soapbox:external:app', JSON.stringify(app));
localStorage.setItem('soapbox:external:baseurl', baseURL);
window.location.href = `${baseURL}/oauth/authorize?${query.toString()}`;
});
};
}
export function loginWithCode(code) {
return (dispatch, getState) => {
const { client_id, client_secret } = JSON.parse(localStorage.getItem('soapbox:external:app'));
const baseURL = localStorage.getItem('soapbox:external:baseurl');
const params = {
client_id,
client_secret,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'authorization_code',
scope: scopes,
code,
};
return dispatch(obtainOAuthToken(params, baseURL))
.then(token => dispatch(authLoggedIn(token)))
.then(({ access_token }) => dispatch(verifyCredentials(access_token, baseURL)))
.then(() => window.location.href = '/');
};
}

View file

@ -16,10 +16,10 @@ export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST';
export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS'; export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS';
export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL'; export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL';
export function obtainOAuthToken(params) { export function obtainOAuthToken(params, baseURL) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params }); dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params });
return baseClient().post('/oauth/token', params).then(({ data: token }) => { return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => {
dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token }); dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token });
return token; return token;
}).catch(error => { }).catch(error => {

View file

@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Helmet } from'react-helmet'; import { Helmet } from'react-helmet';
import { getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import sourceCode from 'soapbox/utils/code';
const getNotifTotals = state => { const getNotifTotals = state => {
const notifications = state.getIn(['notifications', 'unread'], 0); const notifications = state.getIn(['notifications', 'unread'], 0);
@ -16,7 +17,7 @@ const mapStateToProps = state => {
const settings = getSettings(state); const settings = getSettings(state);
return { return {
siteTitle: state.getIn(['instance', 'title']), siteTitle: state.getIn(['instance', 'title'], sourceCode.displayName),
unreadCount: getNotifTotals(state), unreadCount: getNotifTotals(state),
demetricator: settings.get('demetricator'), demetricator: settings.get('demetricator'),
}; };

View file

@ -13,7 +13,6 @@ import { Switch, BrowserRouter, Route } from 'react-router-dom';
import { ScrollContext } from 'react-router-scroll-4'; import { ScrollContext } from 'react-router-scroll-4';
import UI from '../features/ui'; import UI from '../features/ui';
// import Introduction from '../features/introduction'; // import Introduction from '../features/introduction';
import { fetchCustomEmojis } from '../actions/custom_emojis';
import { preload } from '../actions/preload'; import { preload } from '../actions/preload';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import ErrorBoundary from '../components/error_boundary'; import ErrorBoundary from '../components/error_boundary';
@ -34,7 +33,6 @@ store.dispatch(preload());
store.dispatch(fetchMe()); store.dispatch(fetchMe());
store.dispatch(fetchInstance()); store.dispatch(fetchInstance());
store.dispatch(fetchSoapboxConfig()); store.dispatch(fetchSoapboxConfig());
store.dispatch(fetchCustomEmojis());
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
const me = state.get('me'); const me = state.get('me');

View file

@ -0,0 +1,79 @@
import React from 'react';
import { connect } from 'react-redux';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { SimpleForm, FieldsGroup, TextInput } from 'soapbox/features/forms';
import { createAppAndRedirect, loginWithCode } from 'soapbox/actions/external_auth';
import LoadingIndicator from 'soapbox/components/loading_indicator';
const messages = defineMessages({
instanceLabel: { id: 'login.fields.instance_label', defaultMessage: 'Instance' },
instancePlaceholder: { id: 'login.fields.instance_placeholder', defaultMessage: 'example.com' },
});
export default @connect()
@injectIntl
class ExternalLoginForm extends ImmutablePureComponent {
state = {
host: '',
isLoading: false,
}
handleHostChange = ({ target }) => {
this.setState({ host: target.value });
}
handleSubmit = e => {
const { dispatch } = this.props;
const { host } = this.state;
this.setState({ isLoading: true });
dispatch(createAppAndRedirect(host))
.then(() => this.setState({ isLoading: false }))
.catch(() => this.setState({ isLoading: false }));
}
componentDidMount() {
const code = new URLSearchParams(window.location.search).get('code');
if (code) {
this.setState({ code });
this.props.dispatch(loginWithCode(code));
}
}
render() {
const { intl } = this.props;
const { isLoading, code } = this.state;
if (code) {
return <LoadingIndicator />;
}
return (
<SimpleForm onSubmit={this.handleSubmit}>
<fieldset disabled={isLoading}>
<FieldsGroup>
<TextInput
label={intl.formatMessage(messages.instanceLabel)}
placeholder={intl.formatMessage(messages.instancePlaceholder)}
name='host'
value={this.state.host}
onChange={this.handleHostChange}
autoComplete='off'
required
/>
</FieldsGroup>
</fieldset>
<div className='actions'>
<button name='button' type='submit' className='btn button button-primary'>
<FormattedMessage id='login.log_in' defaultMessage='Log in' />
</button>
</div>
</SimpleForm>
);
}
}

View file

@ -0,0 +1,11 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ExternalLoginForm from './components/external_login_form';
export default class ExternalLoginPage extends ImmutablePureComponent {
render() {
return <ExternalLoginForm />;
}
}

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { Switch, Route } from 'react-router-dom'; import { Switch, Route, Redirect } from 'react-router-dom';
import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container'; import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container';
import ModalContainer from 'soapbox/features/ui/containers/modal_container'; import ModalContainer from 'soapbox/features/ui/containers/modal_container';
import Header from './components/header'; import Header from './components/header';
@ -9,10 +9,23 @@ import Footer from './components/footer';
import LandingPage from '../landing_page'; import LandingPage from '../landing_page';
import AboutPage from '../about'; import AboutPage from '../about';
import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { isPrerendered } from 'soapbox/precheck';
const validInstance = state => {
const v = state.getIn(['instance', 'version']);
return v && typeof v === 'string' && v !== '0.0.0';
};
const isStandalone = state => {
const hasInstance = validInstance(state);
const instanceFetchFailed = state.getIn(['meta', 'instance_fetch_failed']);
return !isPrerendered && !hasInstance && instanceFetchFailed;
};
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
instance: state.get('instance'),
soapbox: getSoapboxConfig(state), soapbox: getSoapboxConfig(state),
standalone: isStandalone(state),
}); });
const wave = ( const wave = (
@ -24,8 +37,11 @@ const wave = (
class PublicLayout extends ImmutablePureComponent { class PublicLayout extends ImmutablePureComponent {
render() { render() {
const { instance } = this.props; const { standalone } = this.props;
if (instance.isEmpty()) return null;
if (standalone) {
return <Redirect to='/auth/external' />;
}
return ( return (
<div className='public-layout'> <div className='public-layout'>

View file

@ -43,6 +43,7 @@ import { isStaff, isAdmin } from 'soapbox/utils/accounts';
import ProfileHoverCard from 'soapbox/components/profile_hover_card'; import ProfileHoverCard from 'soapbox/components/profile_hover_card';
import { getAccessToken } from 'soapbox/utils/auth'; import { getAccessToken } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
import { import {
Status, Status,
@ -79,6 +80,7 @@ import {
// GroupCreate, // GroupCreate,
// GroupEdit, // GroupEdit,
LoginPage, LoginPage,
ExternalLogin,
Preferences, Preferences,
EditProfile, EditProfile,
SoapboxConfig, SoapboxConfig,
@ -195,6 +197,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<Switch> <Switch>
<WrappedRoute path='/auth/sign_in' component={LoginPage} publicRoute exact /> <WrappedRoute path='/auth/sign_in' component={LoginPage} publicRoute exact />
<WrappedRoute path='/auth/reset_password' component={PasswordReset} publicRoute exact /> <WrappedRoute path='/auth/reset_password' component={PasswordReset} publicRoute exact />
<WrappedRoute path='/auth/external' component={ExternalLogin} publicRoute exact />
<WrappedRoute path='/auth/edit' page={DefaultPage} component={SecurityForm} exact /> <WrappedRoute path='/auth/edit' page={DefaultPage} component={SecurityForm} exact />
<WrappedRoute path='/auth/mfa' page={DefaultPage} component={MfaForm} exact /> <WrappedRoute path='/auth/mfa' page={DefaultPage} component={MfaForm} exact />
@ -458,6 +461,8 @@ class UI extends React.PureComponent {
setTimeout(() => this.props.dispatch(fetchScheduledStatuses()), 900); setTimeout(() => this.props.dispatch(fetchScheduledStatuses()), 900);
} }
this.props.dispatch(fetchCustomEmojis());
this.connectStreaming(); this.connectStreaming();
} }
@ -588,7 +593,10 @@ class UI extends React.PureComponent {
const { draggingOver, mobile } = this.state; const { draggingOver, mobile } = this.state;
const { intl, children, location, dropdownMenuIsOpen, me } = this.props; const { intl, children, location, dropdownMenuIsOpen, me } = this.props;
if (me === null || !streamingUrl) return null; // Wait for login to succeed or fail
if (me === null) return null;
// If login didn't fail, wait for streaming to become available
if (me !== false && !streamingUrl) return null;
const handlers = me ? { const handlers = me ? {
help: this.handleHotkeyToggleHelp, help: this.handleHotkeyToggleHelp,

View file

@ -170,6 +170,10 @@ export function LoginPage() {
return import(/* webpackChunkName: "features/auth_login" */'../../auth_login/components/login_page'); return import(/* webpackChunkName: "features/auth_login" */'../../auth_login/components/login_page');
} }
export function ExternalLogin() {
return import(/* webpackChunkName: "features/external_login" */'../../external_login');
}
export function Preferences() { export function Preferences() {
return import(/* webpackChunkName: "features/preferences" */'../../preferences'); return import(/* webpackChunkName: "features/preferences" */'../../preferences');
} }

View file

@ -1,6 +1,7 @@
'use strict'; 'use strict';
import './wdyr'; import './wdyr';
import './precheck';
// FIXME: Push notifications are temporarily removed // FIXME: Push notifications are temporarily removed
// import * as registerPushNotifications from './actions/push_notifications'; // import * as registerPushNotifications from './actions/push_notifications';
// import { default as Soapbox, store } from './containers/soapbox'; // import { default as Soapbox, store } from './containers/soapbox';

11
app/soapbox/precheck.js Normal file
View file

@ -0,0 +1,11 @@
/**
* Precheck: information about the site before anything renders.
* @module soapbox/precheck
*/
const hasTitle = Boolean(document.querySelector('title'));
const hasPrerenderPleroma = Boolean(document.getElementById('initial-results'));
const hasPrerenderMastodon = Boolean(document.getElementById('initial-state'));
export const isPrerendered = hasTitle || hasPrerenderPleroma || hasPrerenderMastodon;

View file

@ -1,6 +1,7 @@
'use strict'; 'use strict';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from 'soapbox/actions/me'; import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from 'soapbox/actions/me';
import { INSTANCE_FETCH_FAIL } from 'soapbox/actions/instance';
import { Map as ImmutableMap, fromJS } from 'immutable'; import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap(); const initialState = ImmutableMap();
@ -19,6 +20,8 @@ export default function meta(state = initialState, action) {
case ME_FETCH_SUCCESS: case ME_FETCH_SUCCESS:
case ME_PATCH_SUCCESS: case ME_PATCH_SUCCESS:
return importAccount(state, fromJS(action.me)); return importAccount(state, fromJS(action.me));
case INSTANCE_FETCH_FAIL:
return state.set('instance_fetch_failed', true);
default: default:
return state; return state;
} }

View file

@ -37,4 +37,5 @@ module.exports = {
url: pkg.repository.url, url: pkg.repository.url,
repository: shortRepoName(pkg.repository.url), repository: shortRepoName(pkg.repository.url),
version: version(pkg), version: version(pkg),
homepage: pkg.homepage,
}; };