diff --git a/server/server.go b/server/server.go index 1bbc8b8f..c3105a4e 100644 --- a/server/server.go +++ b/server/server.go @@ -42,7 +42,6 @@ import ( expire tokens auto-refresh tokens from UI reserve topics - handle invalid session token purge accounts that were not logged into in X sync subscription display name reset daily limits for users diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 4b0ca88b..3d79bac8 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -126,7 +126,7 @@ class Api { headers: maybeWithBasicAuth({}, user) }); if (response.status === 401 || response.status === 403) { - return false; + throw new UnauthorizedError(); } else if (response.status !== 200) { throw new Error(`Unexpected server response ${response.status}`); } @@ -144,7 +144,9 @@ class Api { method: "DELETE", headers: maybeWithBearerAuth({}, token) }); - if (response.status !== 200) { + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { throw new Error(`Unexpected server response ${response.status}`); } } @@ -175,7 +177,9 @@ class Api { const response = await fetch(url, { headers: maybeWithBearerAuth({}, token) }); - if (response.status !== 200) { + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { throw new Error(`Unexpected server response ${response.status}`); } const account = await response.json(); @@ -190,7 +194,9 @@ class Api { method: "DELETE", headers: maybeWithBearerAuth({}, token) }); - if (response.status !== 200) { + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { throw new Error(`Unexpected server response ${response.status}`); } } @@ -205,7 +211,9 @@ class Api { password: password }) }); - if (response.status !== 200) { + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { throw new Error(`Unexpected server response ${response.status}`); } } @@ -219,7 +227,9 @@ class Api { headers: maybeWithBearerAuth({}, token), body: body }); - if (response.status !== 200) { + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { throw new Error(`Unexpected server response ${response.status}`); } } @@ -233,7 +243,9 @@ class Api { headers: maybeWithBearerAuth({}, token), body: body }); - if (response.status !== 200) { + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { throw new Error(`Unexpected server response ${response.status}`); } const subscription = await response.json(); @@ -248,7 +260,9 @@ class Api { method: "DELETE", headers: maybeWithBearerAuth({}, token) }); - if (response.status !== 200) { + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { throw new Error(`Unexpected server response ${response.status}`); } } @@ -256,13 +270,21 @@ class Api { export class UsernameTakenError extends Error { constructor(username) { - super(); + super("Username taken"); this.username = username; } } export class AccountCreateLimitReachedError extends Error { - // Nothing + constructor() { + super("Account creation limit reached"); + } +} + +export class UnauthorizedError extends Error { + constructor() { + super("Unauthorized"); + } } const api = new Api(); diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 1294e212..28cfa27e 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -16,7 +16,7 @@ import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import TextField from "@mui/material/TextField"; import DialogActions from "@mui/material/DialogActions"; -import api from "../app/Api"; +import api, {UnauthorizedError} from "../app/Api"; import routes from "./routes"; import IconButton from "@mui/material/IconButton"; import {useNavigate, useOutletContext} from "react-router-dom"; @@ -152,6 +152,10 @@ const ChangePassword = () => { console.debug(`[Account] Password changed`); } catch (e) { console.log(`[Account] Error changing password`, e); + if ((e instanceof UnauthorizedError)) { + session.reset(); + window.location.href = routes.login; + } // TODO show error } }; @@ -238,6 +242,10 @@ const DeleteAccount = () => { window.location.href = routes.app; } catch (e) { console.log(`[Account] Error deleting account`, e); + if ((e instanceof UnauthorizedError)) { + session.reset(); + window.location.href = routes.login; + } // TODO show error } }; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 6783bbb9..2aa122cb 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -18,7 +18,7 @@ import MenuList from '@mui/material/MenuList'; import MoreVertIcon from "@mui/icons-material/MoreVert"; import NotificationsIcon from '@mui/icons-material/Notifications'; import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; -import api from "../app/Api"; +import api, {UnauthorizedError} from "../app/Api"; import routes from "./routes"; import subscriptionManager from "../app/SubscriptionManager"; import logo from "../img/ntfy.svg"; @@ -118,7 +118,15 @@ const SettingsIcons = (props) => { handleClose(event); await subscriptionManager.remove(props.subscription.id); if (session.exists() && props.subscription.remoteId) { - await api.deleteAccountSubscription(config.baseUrl, session.token(), props.subscription.remoteId); + try { + await api.deleteAccountSubscription(config.baseUrl, session.token(), props.subscription.remoteId); + } catch (e) { + console.log(`[ActionBar] Error unsubscribing`, e); + if ((e instanceof UnauthorizedError)) { + session.reset(); + window.location.href = routes.login; + } + } } const newSelected = await subscriptionManager.first(); // May be undefined if (newSelected) { diff --git a/web/src/components/App.js b/web/src/components/App.js index 360f8215..aa092ebd 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -26,7 +26,7 @@ import {Backdrop, CircularProgress} from "@mui/material"; import Home from "./Home"; import Login from "./Login"; import i18n from "i18next"; -import api from "../app/Api"; +import api, {UnauthorizedError} from "../app/Api"; import prefs from "../app/Prefs"; import session from "../app/Session"; import Pricing from "./Pricing"; @@ -96,8 +96,12 @@ const Layout = () => { useEffect(() => { (async () => { - const acc = await api.getAccount(config.baseUrl, session.token()); - if (acc) { + // TODO this should not live here + try { + if (!session.token()) { + return; + } + const acc = await api.getAccount(config.baseUrl, session.token()); setAccount(acc); if (acc.language) { await i18n.changeLanguage(acc.language); @@ -116,6 +120,12 @@ const Layout = () => { if (acc.subscriptions) { await subscriptionManager.syncFromRemote(acc.subscriptions); } + } catch (e) { + console.log(`[App] Error fetching account`, e); + if ((e instanceof UnauthorizedError)) { + session.reset(); + window.location.href = routes.login; + } } })(); }, []); diff --git a/web/src/components/Login.js b/web/src/components/Login.js index b84e0d86..04d0b55b 100644 --- a/web/src/components/Login.js +++ b/web/src/components/Login.js @@ -4,7 +4,7 @@ import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; -import api from "../app/Api"; +import api, {UnauthorizedError} from "../app/Api"; import routes from "./routes"; import session from "../app/Session"; import {NavLink} from "react-router-dom"; @@ -22,17 +22,14 @@ const Login = () => { const user = { username, password }; try { const token = await api.login(config.baseUrl, user); - if (token) { - console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); - session.store(user.username, token); - window.location.href = routes.app; - } else { - console.log(`[Login] User auth for user ${user.username} failed, access denied`); - setError(t("Login failed: Invalid username or password")); - } + console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); + session.store(user.username, token); + window.location.href = routes.app; } catch (e) { console.log(`[Login] User auth for user ${user.username} failed`, e); - if (e && e.message) { + if ((e instanceof UnauthorizedError)) { + setError(t("Login failed: Invalid username or password")); + } else if (e.message) { setError(e.message); } else { setError(t("Unknown error. Check logs for details.")) diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 476063ce..5c3ff823 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -34,8 +34,9 @@ import DialogActions from "@mui/material/DialogActions"; import userManager from "../app/UserManager"; import {playSound, shuffle, sounds, validTopic, validUrl} from "../app/utils"; import {useTranslation} from "react-i18next"; -import api from "../app/Api"; +import api, {UnauthorizedError} from "../app/Api"; import session from "../app/Session"; +import routes from "./routes"; const Preferences = () => { return ( @@ -72,13 +73,11 @@ const Sound = () => { const sound = useLiveQuery(async () => prefs.sound()); const handleChange = async (ev) => { await prefs.setSound(ev.target.value); - if (session.exists()) { - await api.updateAccountSettings(config.baseUrl, session.token(), { - notification: { - sound: ev.target.value - } - }); - } + await maybeUpdateAccountSettings({ + notification: { + sound: ev.target.value + } + }); } if (!sound) { return null; // While loading @@ -112,13 +111,11 @@ const MinPriority = () => { const minPriority = useLiveQuery(async () => prefs.minPriority()); const handleChange = async (ev) => { await prefs.setMinPriority(ev.target.value); - if (session.exists()) { - await api.updateAccountSettings(config.baseUrl, session.token(), { - notification: { - min_priority: ev.target.value - } - }); - } + await maybeUpdateAccountSettings({ + notification: { + min_priority: ev.target.value + } + }); } if (!minPriority) { return null; // While loading @@ -162,13 +159,11 @@ const DeleteAfter = () => { const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); const handleChange = async (ev) => { await prefs.setDeleteAfter(ev.target.value); - if (session.exists()) { - await api.updateAccountSettings(config.baseUrl, session.token(), { - notification: { - delete_after: ev.target.value - } - }); - } + await maybeUpdateAccountSettings({ + notification: { + delete_after: ev.target.value + } + }); } if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0" return null; // While loading @@ -466,11 +461,9 @@ const Language = () => { const handleChange = async (ev) => { await i18n.changeLanguage(ev.target.value); - if (session.exists()) { - await api.updateAccountSettings(config.baseUrl, session.token(), { - language: ev.target.value - }); - } + await maybeUpdateAccountSettings({ + language: ev.target.value + }); }; // Remember: Flags are not languages. Don't put flags next to the language in the list. @@ -670,4 +663,19 @@ const AccessControlDialog = (props) => { }; */ +const maybeUpdateAccountSettings = async (payload) => { + if (!session.exists()) { + return; + } + try { + await api.updateAccountSettings(config.baseUrl, session.token(), payload); + } catch (e) { + console.log(`[Preferences] Error updating account settings`, e); + if ((e instanceof UnauthorizedError)) { + session.reset(); + window.location.href = routes.login; + } + } +}; + export default Preferences; diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index 0d10191d..fca52b5a 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -22,11 +22,12 @@ import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, val import Box from "@mui/material/Box"; import AttachmentIcon from "./AttachmentIcon"; import DialogFooter from "./DialogFooter"; -import api from "../app/Api"; +import api, {UnauthorizedError} from "../app/Api"; import userManager from "../app/UserManager"; import EmojiPicker from "./EmojiPicker"; import {Trans, useTranslation} from "react-i18next"; import session from "../app/Session"; +import routes from "./routes"; const PublishDialog = (props) => { const { t } = useTranslation(); @@ -178,7 +179,12 @@ const PublishDialog = (props) => { setAttachFileError(""); } catch (e) { console.log(`[PublishDialog] Retrieving attachment limits failed`, e); - setAttachFileError(""); // Reset error (rely on server-side checking) + if ((e instanceof UnauthorizedError)) { + session.reset(); + window.location.href = routes.login; + } else { + setAttachFileError(""); // Reset error (rely on server-side checking) + } } }; diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js index 9ae6bb4c..b2ba2af8 100644 --- a/web/src/components/Signup.js +++ b/web/src/components/Signup.js @@ -2,7 +2,7 @@ import * as React from 'react'; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; -import api, {AccountCreateLimitReachedError, UsernameTakenError} from "../app/Api"; +import api, {AccountCreateLimitReachedError, UnauthorizedError, UsernameTakenError} from "../app/Api"; import routes from "./routes"; import session from "../app/Session"; import Typography from "@mui/material/Typography"; @@ -24,14 +24,9 @@ const Signup = () => { try { await api.createAccount(config.baseUrl, user.username, user.password); const token = await api.login(config.baseUrl, user); - if (token) { - console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); - session.store(user.username, token); - window.location.href = routes.app; - } else { - console.log(`[Signup] Signup for user ${user.username} failed, access denied`); - setError(t("Login failed: Invalid username or password")); - } + console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); + session.store(user.username, token); + window.location.href = routes.app; } catch (e) { console.log(`[Signup] Signup for user ${user.username} failed`, e); if ((e instanceof UsernameTakenError)) { diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 948717f5..45fffa97 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -8,7 +8,7 @@ import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material"; import theme from "./theme"; -import api from "../app/Api"; +import api, {UnauthorizedError} from "../app/Api"; import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; import userManager from "../app/UserManager"; import subscriptionManager from "../app/SubscriptionManager"; @@ -16,6 +16,7 @@ import poller from "../app/Poller"; import DialogFooter from "./DialogFooter"; import {useTranslation} from "react-i18next"; import session from "../app/Session"; +import routes from "./routes"; const publicBaseUrl = "https://ntfy.sh"; @@ -25,14 +26,23 @@ const SubscribeDialog = (props) => { const [showLoginPage, setShowLoginPage] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const handleSuccess = async () => { + console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); const actualBaseUrl = (baseUrl) ? baseUrl : config.baseUrl; const subscription = await subscriptionManager.add(actualBaseUrl, topic); if (session.exists()) { - const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), { - base_url: actualBaseUrl, - topic: topic - }); - await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); + try { + const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), { + base_url: actualBaseUrl, + topic: topic + }); + await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); + } catch (e) { + console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); + if ((e instanceof UnauthorizedError)) { + session.reset(); + window.location.href = routes.login; + } + } } poller.pollInBackground(subscription); // Dangle! props.onSuccess(subscription); diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index f5c7d332..6effc7ce 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -8,7 +8,7 @@ import connectionManager from "../app/ConnectionManager"; import poller from "../app/Poller"; import pruner from "../app/Pruner"; import session from "../app/Session"; -import api from "../app/Api"; +import api, {UnauthorizedError} from "../app/Api"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -64,11 +64,19 @@ export const useAutoSubscribe = (subscriptions, selected) => { (async () => { const subscription = await subscriptionManager.add(baseUrl, params.topic); if (session.exists()) { - const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), { - base_url: baseUrl, - topic: params.topic - }); - await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); + try { + const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), { + base_url: baseUrl, + topic: params.topic + }); + await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); + } catch (e) { + console.log(`[App] Auto-subscribing failed`, e); + if ((e instanceof UnauthorizedError)) { + session.reset(); + window.location.href = routes.login; + } + } } poller.pollInBackground(subscription); // Dangle! })();