diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 68b60205c..128df1027 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -19,6 +19,7 @@ import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import sourceCode from 'soapbox/utils/code'; import { getFeatures } from 'soapbox/utils/features'; import { isStandalone } from 'soapbox/utils/state'; +import KVStore from 'soapbox/storage/kv_store'; export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT'; @@ -31,6 +32,10 @@ export const VERIFY_CREDENTIALS_REQUEST = 'VERIFY_CREDENTIALS_REQUEST'; export const VERIFY_CREDENTIALS_SUCCESS = 'VERIFY_CREDENTIALS_SUCCESS'; export const VERIFY_CREDENTIALS_FAIL = 'VERIFY_CREDENTIALS_FAIL'; +export const AUTH_ACCOUNT_REMEMBER_REQUEST = 'AUTH_ACCOUNT_REMEMBER_REQUEST'; +export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS'; +export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL'; + export const messages = defineMessages({ loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, @@ -157,6 +162,28 @@ export function verifyCredentials(token, accountUrl) { }; } +export function rememberAuthAccount(accountUrl) { + return (dispatch, getState) => { + dispatch({ type: AUTH_ACCOUNT_REMEMBER_REQUEST, accountUrl }); + return KVStore.getItemOrError(`authAccount:${accountUrl}`).then(account => { + dispatch(importFetchedAccount(account)); + dispatch({ type: AUTH_ACCOUNT_REMEMBER_SUCCESS, account, accountUrl }); + if (account.id === getState().get('me')) dispatch(fetchMeSuccess(account)); + return account; + }).catch(error => { + dispatch({ type: AUTH_ACCOUNT_REMEMBER_FAIL, error, accountUrl, skipAlert: true }); + }); + }; +} + +export function loadCredentials(token, accountUrl) { + return (dispatch, getState) => { + return dispatch(rememberAuthAccount(accountUrl)).finally(() => { + return dispatch(verifyCredentials(token, accountUrl)); + }); + }; +} + export function logIn(intl, username, password) { return (dispatch, getState) => { return dispatch(createAppAndToken()).then(() => { diff --git a/app/soapbox/actions/external_auth.js b/app/soapbox/actions/external_auth.js index 194b27cb1..d486b4026 100644 --- a/app/soapbox/actions/external_auth.js +++ b/app/soapbox/actions/external_auth.js @@ -20,7 +20,7 @@ const fetchExternalInstance = baseURL => { .get('/api/v1/instance') .then(({ data: instance }) => fromJS(instance)) .catch(error => { - if (error.response.status === 401) { + if (error.response && error.response.status === 401) { // Authenticated fetch is enabled. // Continue with a limited featureset. return ImmutableMap({ version: '0.0.0' }); diff --git a/app/soapbox/actions/instance.js b/app/soapbox/actions/instance.js index 7e17f6d71..ec77742b2 100644 --- a/app/soapbox/actions/instance.js +++ b/app/soapbox/actions/instance.js @@ -1,62 +1,87 @@ import api from '../api'; import { get } from 'lodash'; import { parseVersion } from 'soapbox/utils/features'; +import { getAuthUserUrl } from 'soapbox/utils/auth'; +import KVStore from 'soapbox/storage/kv_store'; +export const INSTANCE_FETCH_REQUEST = 'INSTANCE_FETCH_REQUEST'; export const INSTANCE_FETCH_SUCCESS = 'INSTANCE_FETCH_SUCCESS'; export const INSTANCE_FETCH_FAIL = 'INSTANCE_FETCH_FAIL'; + +export const INSTANCE_REMEMBER_REQUEST = 'INSTANCE_REMEMBER_REQUEST'; +export const INSTANCE_REMEMBER_SUCCESS = 'INSTANCE_REMEMBER_SUCCESS'; +export const INSTANCE_REMEMBER_FAIL = 'INSTANCE_REMEMBER_FAIL'; + +export const NODEINFO_FETCH_REQUEST = 'NODEINFO_FETCH_REQUEST'; export const NODEINFO_FETCH_SUCCESS = 'NODEINFO_FETCH_SUCCESS'; export const NODEINFO_FETCH_FAIL = 'NODEINFO_FETCH_FAIL'; +const getMeUrl = state => { + const me = state.get('me'); + return state.getIn(['accounts', me, 'url']); +}; + +// Figure out the appropriate instance to fetch depending on the state +const getHost = state => { + const accountUrl = getMeUrl(state) || getAuthUserUrl(state); + + try { + return new URL(accountUrl).host; + } catch { + return null; + } +}; + +export function rememberInstance(host) { + return (dispatch, getState) => { + dispatch({ type: INSTANCE_REMEMBER_REQUEST, host }); + return KVStore.getItemOrError(`instance:${host}`).then(instance => { + dispatch({ type: INSTANCE_REMEMBER_SUCCESS, host, instance }); + return instance; + }).catch(error => { + dispatch({ type: INSTANCE_REMEMBER_FAIL, host, error, skipAlert: true }); + }); + }; +} + +// We may need to fetch nodeinfo on Pleroma < 2.1 +const needsNodeinfo = instance => { + const v = parseVersion(get(instance, 'version')); + return v.software === 'Pleroma' && !get(instance, ['pleroma', 'metadata']); +}; + export function fetchInstance() { return (dispatch, getState) => { - return api(getState).get('/api/v1/instance').then(response => { - dispatch(importInstance(response.data)); - const v = parseVersion(get(response.data, 'version')); - if (v.software === 'Pleroma' && !get(response.data, ['pleroma', 'metadata'])) { + dispatch({ type: INSTANCE_FETCH_REQUEST }); + return api(getState).get('/api/v1/instance').then(({ data: instance }) => { + dispatch({ type: INSTANCE_FETCH_SUCCESS, instance }); + if (needsNodeinfo(instance)) { dispatch(fetchNodeinfo()); // Pleroma < 2.1 backwards compatibility } }).catch(error => { - dispatch(instanceFail(error)); + dispatch({ type: INSTANCE_FETCH_FAIL, error, skipAlert: true }); + }); + }; +} + +// Tries to remember the instance from browser storage before fetching it +export function loadInstance() { + return (dispatch, getState) => { + const host = getHost(getState()); + + return dispatch(rememberInstance(host)).finally(() => { + return dispatch(fetchInstance()); }); }; } export function fetchNodeinfo() { return (dispatch, getState) => { - api(getState).get('/nodeinfo/2.1.json').then(response => { - dispatch(importNodeinfo(response.data)); + dispatch({ type: NODEINFO_FETCH_REQUEST }); + api(getState).get('/nodeinfo/2.1.json').then(({ data: nodeinfo }) => { + dispatch({ type: NODEINFO_FETCH_SUCCESS, nodeinfo }); }).catch(error => { - dispatch(nodeinfoFail(error)); + dispatch({ type: NODEINFO_FETCH_FAIL, error, skipAlert: true }); }); }; } - -export function importInstance(instance) { - return { - type: INSTANCE_FETCH_SUCCESS, - instance, - }; -} - -export function instanceFail(error) { - return { - type: INSTANCE_FETCH_FAIL, - error, - skipAlert: true, - }; -} - -export function importNodeinfo(nodeinfo) { - return { - type: NODEINFO_FETCH_SUCCESS, - nodeinfo, - }; -} - -export function nodeinfoFail(error) { - return { - type: NODEINFO_FETCH_FAIL, - error, - skipAlert: true, - }; -} diff --git a/app/soapbox/actions/me.js b/app/soapbox/actions/me.js index 91388b65c..2dcf36ab9 100644 --- a/app/soapbox/actions/me.js +++ b/app/soapbox/actions/me.js @@ -1,6 +1,6 @@ import api from '../api'; import { importFetchedAccount } from './importer'; -import { verifyCredentials } from './auth'; +import { loadCredentials } from './auth'; import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth'; export const ME_FETCH_REQUEST = 'ME_FETCH_REQUEST'; @@ -38,7 +38,7 @@ export function fetchMe() { } dispatch(fetchMeRequest()); - return dispatch(verifyCredentials(token, accountUrl)).catch(error => { + return dispatch(loadCredentials(token, accountUrl)).catch(error => { dispatch(fetchMeFail(error)); }); }; @@ -66,7 +66,6 @@ export function fetchMeRequest() { export function fetchMeSuccess(me) { return (dispatch, getState) => { - dispatch(importFetchedAccount(me)); dispatch({ type: ME_FETCH_SUCCESS, me, diff --git a/app/soapbox/containers/soapbox.js b/app/soapbox/containers/soapbox.js index 7653ee865..279cad403 100644 --- a/app/soapbox/containers/soapbox.js +++ b/app/soapbox/containers/soapbox.js @@ -16,7 +16,7 @@ import UI from '../features/ui'; import { preload } from '../actions/preload'; import { IntlProvider } from 'react-intl'; import ErrorBoundary from '../components/error_boundary'; -import { fetchInstance } from 'soapbox/actions/instance'; +import { loadInstance } from 'soapbox/actions/instance'; import { fetchSoapboxConfig } from 'soapbox/actions/soapbox'; import { fetchMe } from 'soapbox/actions/me'; import PublicLayout from 'soapbox/features/public_layout'; @@ -38,7 +38,7 @@ store.dispatch(preload()); store.dispatch(fetchMe()) .then(() => { // Postpone for authenticated fetch - store.dispatch(fetchInstance()); + store.dispatch(loadInstance()); store.dispatch(fetchSoapboxConfig()); }) .catch(() => {}); diff --git a/app/soapbox/middleware/errors.js b/app/soapbox/middleware/errors.js index 1e36cc55f..b5d9a36a3 100644 --- a/app/soapbox/middleware/errors.js +++ b/app/soapbox/middleware/errors.js @@ -1,15 +1,18 @@ import { showAlertForError } from '../actions/alerts'; -const defaultFailSuffix = 'FAIL'; +const isFailType = type => type.endsWith('_FAIL'); +const isRememberFailType = type => type.endsWith('_REMEMBER_FAIL'); + +const hasResponse = error => Boolean(error && error.response); + +const shouldShowError = ({ type, skipAlert, error }) => { + return !skipAlert && hasResponse(error) && isFailType(type) && !isRememberFailType(type); +}; export default function errorsMiddleware() { return ({ dispatch }) => next => action => { - if (action.type && !action.skipAlert) { - const isFail = new RegExp(`${defaultFailSuffix}$`, 'g'); - - if (action.type.match(isFail)) { - dispatch(showAlertForError(action.error)); - } + if (shouldShowError(action)) { + dispatch(showAlertForError(action.error)); } return next(action); diff --git a/app/soapbox/reducers/accounts_meta.js b/app/soapbox/reducers/accounts_meta.js index d26ce0911..731568313 100644 --- a/app/soapbox/reducers/accounts_meta.js +++ b/app/soapbox/reducers/accounts_meta.js @@ -4,7 +4,7 @@ */ import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from 'soapbox/actions/me'; -import { VERIFY_CREDENTIALS_SUCCESS } from 'soapbox/actions/auth'; +import { VERIFY_CREDENTIALS_SUCCESS, AUTH_ACCOUNT_REMEMBER_SUCCESS } from 'soapbox/actions/auth'; import { Map as ImmutableMap, fromJS } from 'immutable'; const initialState = ImmutableMap(); @@ -24,6 +24,7 @@ export default function accounts_meta(state = initialState, action) { case ME_PATCH_SUCCESS: return importAccount(state, fromJS(action.me)); case VERIFY_CREDENTIALS_SUCCESS: + case AUTH_ACCOUNT_REMEMBER_SUCCESS: return importAccount(state, fromJS(action.account)); default: return state; diff --git a/app/soapbox/reducers/auth.js b/app/soapbox/reducers/auth.js index c5a7cf5bc..8a74a90e2 100644 --- a/app/soapbox/reducers/auth.js +++ b/app/soapbox/reducers/auth.js @@ -13,6 +13,7 @@ import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { validId, isURL } from 'soapbox/utils/auth'; import { trim } from 'lodash'; import { FE_SUBDIRECTORY } from 'soapbox/build_config'; +import KVStore from 'soapbox/storage/kv_store'; const defaultState = ImmutableMap({ app: ImmutableMap(), @@ -254,6 +255,20 @@ const importMastodonPreload = (state, data) => { }); }; +const persistAuthAccount = account => { + if (account && account.url) { + KVStore.setItem(`authAccount:${account.url}`, account).catch(console.error); + } +}; + +const deleteForbiddenToken = (state, error, token) => { + if (error.response && [401, 403].includes(error.response.status)) { + return deleteToken(state, token); + } else { + return state; + } +}; + const reducer = (state, action) => { switch(action.type) { case AUTH_APP_CREATED: @@ -265,9 +280,10 @@ const reducer = (state, action) => { case AUTH_LOGGED_OUT: return deleteUser(state, action.account); case VERIFY_CREDENTIALS_SUCCESS: + persistAuthAccount(action.account); return importCredentials(state, action.token, action.account); case VERIFY_CREDENTIALS_FAIL: - return [401, 403].includes(action.error.response.status) ? deleteToken(state, action.token) : state; + return deleteForbiddenToken(state, action.error, action.token); case SWITCH_ACCOUNT: return state.set('me', action.account.get('url')); case ME_FETCH_SKIP: diff --git a/app/soapbox/reducers/instance.js b/app/soapbox/reducers/instance.js index 189bfe675..ff94b5123 100644 --- a/app/soapbox/reducers/instance.js +++ b/app/soapbox/reducers/instance.js @@ -1,4 +1,5 @@ import { + INSTANCE_REMEMBER_SUCCESS, INSTANCE_FETCH_SUCCESS, INSTANCE_FETCH_FAIL, NODEINFO_FETCH_SUCCESS, @@ -7,6 +8,7 @@ import { PLEROMA_PRELOAD_IMPORT } from 'soapbox/actions/preload'; import { ADMIN_CONFIG_UPDATE_REQUEST, ADMIN_CONFIG_UPDATE_SUCCESS } from 'soapbox/actions/admin'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { ConfigDB } from 'soapbox/utils/config_db'; +import KVStore from 'soapbox/storage/kv_store'; const nodeinfoToInstance = nodeinfo => { // Match Pleroma's develop branch @@ -37,9 +39,17 @@ const initialState = ImmutableMap({ version: '0.0.0', }); +const importInstance = (state, instance) => { + return initialState.mergeDeep(instance); +}; + +const importNodeinfo = (state, nodeinfo) => { + return nodeinfoToInstance(nodeinfo).mergeDeep(state); +}; + const preloadImport = (state, action, path) => { - const data = action.data[path]; - return data ? initialState.mergeDeep(fromJS(data)) : state; + const instance = action.data[path]; + return instance ? importInstance(state, fromJS(instance)) : state; }; const getConfigValue = (instanceConfig, key) => { @@ -80,16 +90,47 @@ const handleAuthFetch = state => { }).merge(state); }; +const getHost = instance => { + try { + return new URL(instance.uri).host; + } catch { + try { + return new URL(`https://${instance.uri}`).host; + } catch { + return null; + } + } +}; + +const persistInstance = instance => { + const host = getHost(instance); + + if (host) { + KVStore.setItem(`instance:${host}`, instance).catch(console.error); + } +}; + +const handleInstanceFetchFail = (state, error) => { + if (error.response && error.response.status === 401) { + return handleAuthFetch(state); + } else { + return state; + } +}; + export default function instance(state = initialState, action) { switch(action.type) { case PLEROMA_PRELOAD_IMPORT: return preloadImport(state, action, '/api/v1/instance'); + case INSTANCE_REMEMBER_SUCCESS: + return importInstance(state, fromJS(action.instance)); case INSTANCE_FETCH_SUCCESS: - return initialState.mergeDeep(fromJS(action.instance)); + persistInstance(action.instance); + return importInstance(state, fromJS(action.instance)); case INSTANCE_FETCH_FAIL: - return action.error.response.status === 401 ? handleAuthFetch(state) : state; + return handleInstanceFetchFail(state, action.error); case NODEINFO_FETCH_SUCCESS: - return nodeinfoToInstance(fromJS(action.nodeinfo)).mergeDeep(state); + return importNodeinfo(state, fromJS(action.nodeinfo)); case ADMIN_CONFIG_UPDATE_REQUEST: case ADMIN_CONFIG_UPDATE_SUCCESS: return importConfigs(state, fromJS(action.configs)); diff --git a/app/soapbox/reducers/me.js b/app/soapbox/reducers/me.js index d42ace12d..021855a14 100644 --- a/app/soapbox/reducers/me.js +++ b/app/soapbox/reducers/me.js @@ -4,21 +4,35 @@ import { ME_FETCH_SKIP, ME_PATCH_SUCCESS, } from '../actions/me'; -import { AUTH_LOGGED_OUT, VERIFY_CREDENTIALS_SUCCESS } from '../actions/auth'; +import { + AUTH_LOGGED_OUT, + AUTH_ACCOUNT_REMEMBER_SUCCESS, + VERIFY_CREDENTIALS_SUCCESS, +} from '../actions/auth'; const initialState = null; +const handleForbidden = (state, error) => { + if (error.response && [401, 403].includes(error.response.status)) { + return false; + } else { + return state; + } +}; + export default function me(state = initialState, action) { switch(action.type) { case ME_FETCH_SUCCESS: case ME_PATCH_SUCCESS: return action.me.id; case VERIFY_CREDENTIALS_SUCCESS: + case AUTH_ACCOUNT_REMEMBER_SUCCESS: return state || action.account.id; - case ME_FETCH_FAIL: case ME_FETCH_SKIP: case AUTH_LOGGED_OUT: return false; + case ME_FETCH_FAIL: + return handleForbidden(state, action.error); default: return state; } diff --git a/app/soapbox/storage/kv_store.js b/app/soapbox/storage/kv_store.js new file mode 100644 index 000000000..3c957fdc1 --- /dev/null +++ b/app/soapbox/storage/kv_store.js @@ -0,0 +1,24 @@ +import localforage from 'localforage'; + +// localForage +// https://localforage.github.io/localForage/#settings-api-config +export const KVStore = localforage.createInstance({ + name: 'soapbox', + description: 'Soapbox offline data store', + driver: localforage.INDEXEDDB, + storeName: 'keyvaluepairs', +}); + +// localForage returns 'null' when a key isn't found. +// In the Redux action flow, we want it to fail harder. +KVStore.getItemOrError = key => { + return KVStore.getItem(key).then(value => { + if (value === null) { + throw new Error(`KVStore: null value for key ${key}`); + } else { + return value; + } + }); +}; + +export default KVStore; diff --git a/app/soapbox/stream.js b/app/soapbox/stream.js index 15764f4ce..c6d648d7d 100644 --- a/app/soapbox/stream.js +++ b/app/soapbox/stream.js @@ -26,37 +26,45 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ } }; - const subscription = getStream(streamingAPIBaseURL, accessToken, path, { - connected() { - if (pollingRefresh) { - clearPolling(); - } + let subscription; - onConnect(); - }, + // If the WebSocket fails to be created, don't crash the whole page, + // just proceed without a subscription. + try { + subscription = getStream(streamingAPIBaseURL, accessToken, path, { + connected() { + if (pollingRefresh) { + clearPolling(); + } - disconnected() { - if (pollingRefresh) { - polling = setTimeout(() => setupPolling(), randomIntUpTo(40000)); - } + onConnect(); + }, - onDisconnect(); - }, + disconnected() { + if (pollingRefresh) { + polling = setTimeout(() => setupPolling(), randomIntUpTo(40000)); + } - received(data) { - onReceive(data); - }, + onDisconnect(); + }, - reconnected() { - if (pollingRefresh) { - clearPolling(); - pollingRefresh(dispatch); - } + received(data) { + onReceive(data); + }, - onConnect(); - }, + reconnected() { + if (pollingRefresh) { + clearPolling(); + pollingRefresh(dispatch); + } - }); + onConnect(); + }, + + }); + } catch (e) { + console.error(e); + } const disconnect = () => { if (subscription) { diff --git a/package.json b/package.json index f6544915c..be051d030 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "jest-transform-stub": "^2.0.0", "jsdoc": "~3.6.7", "line-awesome": "^1.3.0", + "localforage": "^1.10.0", "lodash": "^4.7.11", "mark-loader": "^0.1.6", "marky": "^1.2.1", diff --git a/webpack/production.js b/webpack/production.js index e74d09dc2..f3aaaaafe 100644 --- a/webpack/production.js +++ b/webpack/production.js @@ -74,9 +74,6 @@ module.exports = merge(sharedConfig, { '**/*.ogg', '**/*.oga', '**/*.mp3', - // Don't serve index.html - // https://github.com/bromite/bromite/issues/1294 - 'index.html', '404.html', 'assets-manifest.json', // It would be nice to serve these, but they bloat up sw.js @@ -89,6 +86,7 @@ module.exports = merge(sharedConfig, { minify: true, }, safeToUseOptionalCaches: true, + appShell: join(FE_SUBDIRECTORY, '/'), }), ], }); diff --git a/yarn.lock b/yarn.lock index 62d92d092..06faba1a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4840,6 +4840,11 @@ ignore@^5.1.4, ignore@^5.1.8: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= + immutable@^4.0.0-rc.14: version "4.0.0-rc.14" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0-rc.14.tgz#29ba96631ec10867d1348515ac4e6bdba462f071" @@ -6048,6 +6053,13 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" + integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4= + dependencies: + immediate "~3.0.5" + line-awesome@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/line-awesome/-/line-awesome-1.3.0.tgz#51d59fe311ed040d22d8e80d3aa0b9a4b57e6cd3" @@ -6141,6 +6153,13 @@ loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" +localforage@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4" + integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg== + dependencies: + lie "3.1.1" + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"