diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js new file mode 100644 index 00000000..d5d7981a --- /dev/null +++ b/web/src/app/AccountApi.js @@ -0,0 +1,205 @@ +import { + accountPasswordUrl, + accountSettingsUrl, + accountSubscriptionSingleUrl, + accountSubscriptionUrl, + accountTokenUrl, + accountUrl, + fetchLinesIterator, + maybeWithBasicAuth, + maybeWithBearerAuth, + topicShortUrl, + topicUrl, + topicUrlAuth, + topicUrlJsonPoll, + topicUrlJsonPollWithSince +} from "./utils"; +import userManager from "./UserManager"; +import session from "./Session"; + +class AccountApi { + async login(user) { + const url = accountTokenUrl(config.baseUrl); + console.log(`[Api] Checking auth for ${url}`); + const response = await fetch(url, { + method: "POST", + headers: maybeWithBasicAuth({}, user) + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + const json = await response.json(); + if (!json.token) { + throw new Error(`Unexpected server response: Cannot find token`); + } + return json.token; + } + + async logout(token) { + const url = accountTokenUrl(config.baseUrl); + console.log(`[Api] Logging out from ${url} using token ${token}`); + const response = await fetch(url, { + method: "DELETE", + headers: maybeWithBearerAuth({}, token) + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + + async create(username, password) { + const url = accountUrl(config.baseUrl); + const body = JSON.stringify({ + username: username, + password: password + }); + console.log(`[Api] Creating user account ${url}`); + const response = await fetch(url, { + method: "POST", + body: body + }); + if (response.status === 409) { + throw new UsernameTakenError(username); + } else if (response.status === 429) { + throw new AccountCreateLimitReachedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + + async get() { + const url = accountUrl(config.baseUrl); + console.log(`[Api] Fetching user account ${url}`); + const response = await fetch(url, { + headers: maybeWithBearerAuth({}, session.token()) + }); + 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(); + console.log(`[Api] Account`, account); + return account; + } + + async delete() { + const url = accountUrl(config.baseUrl); + console.log(`[Api] Deleting user account ${url}`); + const response = await fetch(url, { + method: "DELETE", + headers: maybeWithBearerAuth({}, session.token()) + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + + async changePassword(newPassword) { + const url = accountPasswordUrl(config.baseUrl); + console.log(`[Api] Changing account password ${url}`); + const response = await fetch(url, { + method: "POST", + headers: maybeWithBearerAuth({}, session.token()), + body: JSON.stringify({ + password: newPassword + }) + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + + async extendToken() { + const url = accountTokenUrl(config.baseUrl); + console.log(`[Api] Extending user access token ${url}`); + const response = await fetch(url, { + method: "PATCH", + headers: maybeWithBearerAuth({}, session.token()) + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + + async updateSettings(payload) { + const url = accountSettingsUrl(config.baseUrl); + const body = JSON.stringify(payload); + console.log(`[Api] Updating user account ${url}: ${body}`); + const response = await fetch(url, { + method: "PATCH", + headers: maybeWithBearerAuth({}, session.token()), + body: body + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + + async addSubscription(payload) { + const url = accountSubscriptionUrl(config.baseUrl); + const body = JSON.stringify(payload); + console.log(`[Api] Adding user subscription ${url}: ${body}`); + const response = await fetch(url, { + method: "POST", + headers: maybeWithBearerAuth({}, session.token()), + body: body + }); + 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(); + console.log(`[Api] Subscription`, subscription); + return subscription; + } + + async deleteSubscription(remoteId) { + const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); + console.log(`[Api] Removing user subscription ${url}`); + const response = await fetch(url, { + method: "DELETE", + headers: maybeWithBearerAuth({}, session.token()) + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } +} + +export class UsernameTakenError extends Error { + constructor(username) { + super("Username taken"); + this.username = username; + } +} + +export class AccountCreateLimitReachedError extends Error { + constructor() { + super("Account creation limit reached"); + } +} + +export class UnauthorizedError extends Error { + constructor() { + super("Unauthorized"); + } +} + +const accountApi = new AccountApi(); +export default accountApi; diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 219861f6..f9eecffd 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -122,188 +122,6 @@ class Api { } throw new Error(`Unexpected server response ${response.status}`); } - - async login(baseUrl, user) { - const url = accountTokenUrl(baseUrl); - console.log(`[Api] Checking auth for ${url}`); - const response = await fetch(url, { - method: "POST", - headers: maybeWithBasicAuth({}, user) - }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - const json = await response.json(); - if (!json.token) { - throw new Error(`Unexpected server response: Cannot find token`); - } - return json.token; - } - - async logout(baseUrl, token) { - const url = accountTokenUrl(baseUrl); - console.log(`[Api] Logging out from ${url} using token ${token}`); - const response = await fetch(url, { - method: "DELETE", - headers: maybeWithBearerAuth({}, token) - }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - } - - async createAccount(baseUrl, username, password) { - const url = accountUrl(baseUrl); - const body = JSON.stringify({ - username: username, - password: password - }); - console.log(`[Api] Creating user account ${url}`); - const response = await fetch(url, { - method: "POST", - body: body - }); - if (response.status === 409) { - throw new UsernameTakenError(username); - } else if (response.status === 429) { - throw new AccountCreateLimitReachedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - } - - async getAccount(baseUrl, token) { - const url = accountUrl(baseUrl); - console.log(`[Api] Fetching user account ${url}`); - const response = await fetch(url, { - headers: maybeWithBearerAuth({}, token) - }); - 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(); - console.log(`[Api] Account`, account); - return account; - } - - async deleteAccount(baseUrl, token) { - const url = accountUrl(baseUrl); - console.log(`[Api] Deleting user account ${url}`); - const response = await fetch(url, { - method: "DELETE", - headers: maybeWithBearerAuth({}, token) - }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - } - - async changePassword(baseUrl, token, password) { - const url = accountPasswordUrl(baseUrl); - console.log(`[Api] Changing account password ${url}`); - const response = await fetch(url, { - method: "POST", - headers: maybeWithBearerAuth({}, token), - body: JSON.stringify({ - password: password - }) - }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - } - - async extendToken(baseUrl, token) { - const url = accountTokenUrl(baseUrl); - console.log(`[Api] Extending user access token ${url}`); - const response = await fetch(url, { - method: "PATCH", - headers: maybeWithBearerAuth({}, token) - }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - } - - async updateAccountSettings(baseUrl, token, payload) { - const url = accountSettingsUrl(baseUrl); - const body = JSON.stringify(payload); - console.log(`[Api] Updating user account ${url}: ${body}`); - const response = await fetch(url, { - method: "PATCH", - headers: maybeWithBearerAuth({}, token), - body: body - }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - } - - async addAccountSubscription(baseUrl, token, payload) { - const url = accountSubscriptionUrl(baseUrl); - const body = JSON.stringify(payload); - console.log(`[Api] Adding user subscription ${url}: ${body}`); - const response = await fetch(url, { - method: "POST", - headers: maybeWithBearerAuth({}, token), - body: body - }); - 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(); - console.log(`[Api] Subscription`, subscription); - return subscription; - } - - async deleteAccountSubscription(baseUrl, token, remoteId) { - const url = accountSubscriptionSingleUrl(baseUrl, remoteId); - console.log(`[Api] Removing user subscription ${url}`); - const response = await fetch(url, { - method: "DELETE", - headers: maybeWithBearerAuth({}, token) - }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - } -} - -export class UsernameTakenError extends Error { - constructor(username) { - super("Username taken"); - this.username = username; - } -} - -export class AccountCreateLimitReachedError extends Error { - 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 28cfa27e..edc8a856 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -16,11 +16,11 @@ 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, {UnauthorizedError} from "../app/Api"; import routes from "./routes"; import IconButton from "@mui/material/IconButton"; -import {useNavigate, useOutletContext} from "react-router-dom"; +import {useOutletContext} from "react-router-dom"; import {formatBytes} from "../app/utils"; +import accountApi, {UnauthorizedError} from "../app/AccountApi"; const Account = () => { if (!session.exists()) { @@ -147,7 +147,7 @@ const ChangePassword = () => { }; const handleDialogSubmit = async (newPassword) => { try { - await api.changePassword(config.baseUrl, session.token(), newPassword); + await accountApi.changePassword(newPassword); setDialogOpen(false); console.debug(`[Account] Password changed`); } catch (e) { @@ -234,7 +234,7 @@ const DeleteAccount = () => { }; const handleDialogSubmit = async (newPassword) => { try { - await api.deleteAccount(config.baseUrl, session.token()); + await accountApi.delete(); setDialogOpen(false); console.debug(`[Account] Account deleted`); // TODO delete local storage diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 2aa122cb..2a079be7 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, {UnauthorizedError} from "../app/Api"; +import api from "../app/Api"; import routes from "./routes"; import subscriptionManager from "../app/SubscriptionManager"; import logo from "../img/ntfy.svg"; @@ -31,6 +31,7 @@ import Button from "@mui/material/Button"; import Divider from "@mui/material/Divider"; import {Logout, Person, Settings} from "@mui/icons-material"; import ListItemIcon from "@mui/material/ListItemIcon"; +import accountApi, {UnauthorizedError} from "../app/AccountApi"; const ActionBar = (props) => { const { t } = useTranslation(); @@ -119,7 +120,7 @@ const SettingsIcons = (props) => { await subscriptionManager.remove(props.subscription.id); if (session.exists() && props.subscription.remoteId) { try { - await api.deleteAccountSubscription(config.baseUrl, session.token(), props.subscription.remoteId); + await accountApi.deleteSubscription(props.subscription.remoteId); } catch (e) { console.log(`[ActionBar] Error unsubscribing`, e); if ((e instanceof UnauthorizedError)) { @@ -268,9 +269,12 @@ const ProfileIcon = (props) => { setAnchorEl(null); }; const handleLogout = async () => { - await api.logout(config.baseUrl, session.token()); - session.reset(); - window.location.href = routes.app; + try { + await accountApi.logout(); + } finally { + session.reset(); + window.location.href = routes.app; + } }; return ( <> diff --git a/web/src/components/App.js b/web/src/components/App.js index aa092ebd..a89b1506 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -26,13 +26,13 @@ import {Backdrop, CircularProgress} from "@mui/material"; import Home from "./Home"; import Login from "./Login"; import i18n from "i18next"; -import api, {UnauthorizedError} from "../app/Api"; import prefs from "../app/Prefs"; import session from "../app/Session"; import Pricing from "./Pricing"; import Signup from "./Signup"; import Account from "./Account"; import ResetPassword from "./ResetPassword"; +import accountApi, {UnauthorizedError} from "../app/AccountApi"; // TODO races when two tabs are open // TODO investigate service workers @@ -101,24 +101,24 @@ const Layout = () => { if (!session.token()) { return; } - const acc = await api.getAccount(config.baseUrl, session.token()); - setAccount(acc); - if (acc.language) { - await i18n.changeLanguage(acc.language); + const remoteAccount = await accountApi.get(); + setAccount(remoteAccount); + if (remoteAccount.language) { + await i18n.changeLanguage(remoteAccount.language); } - if (acc.notification) { - if (acc.notification.sound) { - await prefs.setSound(acc.notification.sound); + if (remoteAccount.notification) { + if (remoteAccount.notification.sound) { + await prefs.setSound(remoteAccount.notification.sound); } - if (acc.notification.delete_after) { - await prefs.setDeleteAfter(acc.notification.delete_after); + if (remoteAccount.notification.delete_after) { + await prefs.setDeleteAfter(remoteAccount.notification.delete_after); } - if (acc.notification.min_priority) { - await prefs.setMinPriority(acc.notification.min_priority); + if (remoteAccount.notification.min_priority) { + await prefs.setMinPriority(remoteAccount.notification.min_priority); } } - if (acc.subscriptions) { - await subscriptionManager.syncFromRemote(acc.subscriptions); + if (remoteAccount.subscriptions) { + await subscriptionManager.syncFromRemote(remoteAccount.subscriptions); } } catch (e) { console.log(`[App] Error fetching account`, e); diff --git a/web/src/components/Login.js b/web/src/components/Login.js index 04d0b55b..fe3fc949 100644 --- a/web/src/components/Login.js +++ b/web/src/components/Login.js @@ -1,16 +1,16 @@ import * as React from 'react'; +import {useState} from 'react'; import Typography from "@mui/material/Typography"; 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, {UnauthorizedError} from "../app/Api"; import routes from "./routes"; import session from "../app/Session"; import {NavLink} from "react-router-dom"; import AvatarBox from "./AvatarBox"; import {useTranslation} from "react-i18next"; -import {useState} from "react"; +import accountApi, {UnauthorizedError} from "../app/AccountApi"; const Login = () => { const { t } = useTranslation(); @@ -21,7 +21,7 @@ const Login = () => { event.preventDefault(); const user = { username, password }; try { - const token = await api.login(config.baseUrl, user); + const token = await accountApi.login(user); console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); session.store(user.username, token); window.location.href = routes.app; diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 5c3ff823..0247ff16 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -34,9 +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, {UnauthorizedError} from "../app/Api"; import session from "../app/Session"; import routes from "./routes"; +import accountApi, {UnauthorizedError} from "../app/AccountApi"; const Preferences = () => { return ( @@ -668,7 +668,7 @@ const maybeUpdateAccountSettings = async (payload) => { return; } try { - await api.updateAccountSettings(config.baseUrl, session.token(), payload); + await accountApi.updateSettings(payload); } catch (e) { console.log(`[Preferences] Error updating account settings`, e); if ((e instanceof UnauthorizedError)) { diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index fca52b5a..46fa29f3 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -1,6 +1,5 @@ import * as React from 'react'; import {useEffect, useRef, useState} from 'react'; -import {NotificationItem} from "./Notifications"; import theme from "./theme"; import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material"; import TextField from "@mui/material/TextField"; @@ -18,16 +17,17 @@ import IconButton from "@mui/material/IconButton"; import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon'; import {Close} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; -import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils"; +import {formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils"; import Box from "@mui/material/Box"; import AttachmentIcon from "./AttachmentIcon"; import DialogFooter from "./DialogFooter"; -import api, {UnauthorizedError} from "../app/Api"; +import api 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"; +import accountApi, {UnauthorizedError} from "../app/AccountApi"; const PublishDialog = (props) => { const { t } = useTranslation(); @@ -161,7 +161,7 @@ const PublishDialog = (props) => { const checkAttachmentLimits = async (file) => { try { - const account = await api.getAccount(baseUrl, session.token()); + const account = await accountApi.get(); const fileSizeLimit = account.limits.attachment_file_size ?? 0; const remainingBytes = account.stats.attachment_total_size_remaining; const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js index b2ba2af8..e01c4d2d 100644 --- a/web/src/components/Signup.js +++ b/web/src/components/Signup.js @@ -1,16 +1,16 @@ import * as React from 'react'; +import {useState} from 'react'; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; -import api, {AccountCreateLimitReachedError, UnauthorizedError, UsernameTakenError} from "../app/Api"; import routes from "./routes"; import session from "../app/Session"; import Typography from "@mui/material/Typography"; import {NavLink} from "react-router-dom"; import AvatarBox from "./AvatarBox"; import {useTranslation} from "react-i18next"; -import {useState} from "react"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import accountApi, {AccountCreateLimitReachedError, UsernameTakenError} from "../app/AccountApi"; const Signup = () => { const { t } = useTranslation(); @@ -22,8 +22,8 @@ const Signup = () => { event.preventDefault(); const user = { username, password }; try { - await api.createAccount(config.baseUrl, user.username, user.password); - const token = await api.login(config.baseUrl, user); + await accountApi.create(user.username, user.password); + const token = await accountApi.login(user); console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); session.store(user.username, token); window.location.href = routes.app; diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 45fffa97..141fe7bb 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, {UnauthorizedError} from "../app/Api"; +import api from "../app/Api"; import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; import userManager from "../app/UserManager"; import subscriptionManager from "../app/SubscriptionManager"; @@ -17,6 +17,7 @@ import DialogFooter from "./DialogFooter"; import {useTranslation} from "react-i18next"; import session from "../app/Session"; import routes from "./routes"; +import accountApi, {UnauthorizedError} from "../app/AccountApi"; const publicBaseUrl = "https://ntfy.sh"; @@ -31,7 +32,7 @@ const SubscribeDialog = (props) => { const subscription = await subscriptionManager.add(actualBaseUrl, topic); if (session.exists()) { try { - const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), { + const remoteSubscription = await accountApi.addSubscription({ base_url: actualBaseUrl, topic: topic }); diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 6effc7ce..208daad6 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -8,7 +8,8 @@ import connectionManager from "../app/ConnectionManager"; import poller from "../app/Poller"; import pruner from "../app/Pruner"; import session from "../app/Session"; -import api, {UnauthorizedError} from "../app/Api"; +import {UnauthorizedError} from "../app/AccountApi"; +import accountApi from "../app/AccountApi"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -65,7 +66,7 @@ export const useAutoSubscribe = (subscriptions, selected) => { const subscription = await subscriptionManager.add(baseUrl, params.topic); if (session.exists()) { try { - const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), { + const remoteSubscription = await accountApi.addSubscription({ base_url: baseUrl, topic: params.topic });