diff --git a/server/server.go b/server/server.go index 6f1546c..8846547 100644 --- a/server/server.go +++ b/server/server.go @@ -36,14 +36,19 @@ import ( /* TODO - use token auth in "SubscribeDialog" - upload files based on user limit database migration - publishXHR + poll should pick current user, not from userManager reserve topics purge accounts that were not logged into in X reset daily limits for users - store users + "user list" shows * twice + "ntfy access everyone user4topic " twice -> UNIQUE constraint error + Account usage not updated "in real time" + Sync: + - "mute" setting + - figure out what settings are "web" or "phone" + UI: + - Subscription dotmenu dropdown: Move to nav bar, or make same as profile dropdown + - "Logout and delete local storage" option Pages: - Home - Password reset @@ -52,7 +57,6 @@ import ( - Polishing: aria-label for everything - Tests: - APIs - CRUD tokens diff --git a/user/manager.go b/user/manager.go index 70b3dc8..5df8c84 100644 --- a/user/manager.go +++ b/user/manager.go @@ -83,11 +83,11 @@ const ( WHERE t.token = ? ` selectTopicPermsQuery = ` - SELECT read, write - FROM user_access - JOIN user ON user.user = '*' OR user.user = ? - WHERE ? LIKE user_access.topic - ORDER BY user.user DESC + SELECT read, write + FROM user_access a + JOIN user u ON u.id = a.user_id + WHERE (u.user = '*' OR u.user = ?) AND ? LIKE a.topic + ORDER BY u.user DESC ` ) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 7ca3082..5562856 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -175,6 +175,7 @@ "prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month", "prefs_users_title": "Manage users", "prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.", + "prefs_users_description_no_sync": "Users and passwords are not synchronized to your account.", "prefs_users_table": "Users table", "prefs_users_add_button": "Add user", "prefs_users_edit_button": "Edit user", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index d156589..70622a8 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -6,8 +6,8 @@ import { accountTokenUrl, accountUrl, fetchLinesIterator, - maybeWithBasicAuth, - maybeWithBearerAuth, + withBasicAuth, + withBearerAuth, topicShortUrl, topicUrl, topicUrlAuth, @@ -31,7 +31,7 @@ class AccountApi { console.log(`[AccountApi] Checking auth for ${url}`); const response = await fetch(url, { method: "POST", - headers: maybeWithBasicAuth({}, user) + headers: withBasicAuth({}, user.username, user.password) }); if (response.status === 401 || response.status === 403) { throw new UnauthorizedError(); @@ -50,7 +50,7 @@ class AccountApi { console.log(`[AccountApi] Logging out from ${url} using token ${token}`); const response = await fetch(url, { method: "DELETE", - headers: maybeWithBearerAuth({}, token) + headers: withBearerAuth({}, token) }); if (response.status === 401 || response.status === 403) { throw new UnauthorizedError(); @@ -83,7 +83,7 @@ class AccountApi { const url = accountUrl(config.baseUrl); console.log(`[AccountApi] Fetching user account ${url}`); const response = await fetch(url, { - headers: maybeWithBearerAuth({}, session.token()) + headers: withBearerAuth({}, session.token()) }); if (response.status === 401 || response.status === 403) { throw new UnauthorizedError(); @@ -100,7 +100,7 @@ class AccountApi { console.log(`[AccountApi] Deleting user account ${url}`); const response = await fetch(url, { method: "DELETE", - headers: maybeWithBearerAuth({}, session.token()) + headers: withBearerAuth({}, session.token()) }); if (response.status === 401 || response.status === 403) { throw new UnauthorizedError(); @@ -114,7 +114,7 @@ class AccountApi { console.log(`[AccountApi] Changing account password ${url}`); const response = await fetch(url, { method: "POST", - headers: maybeWithBearerAuth({}, session.token()), + headers: withBearerAuth({}, session.token()), body: JSON.stringify({ password: newPassword }) @@ -131,7 +131,7 @@ class AccountApi { console.log(`[AccountApi] Extending user access token ${url}`); const response = await fetch(url, { method: "PATCH", - headers: maybeWithBearerAuth({}, session.token()) + headers: withBearerAuth({}, session.token()) }); if (response.status === 401 || response.status === 403) { throw new UnauthorizedError(); @@ -146,7 +146,7 @@ class AccountApi { console.log(`[AccountApi] Updating user account ${url}: ${body}`); const response = await fetch(url, { method: "PATCH", - headers: maybeWithBearerAuth({}, session.token()), + headers: withBearerAuth({}, session.token()), body: body }); if (response.status === 401 || response.status === 403) { @@ -162,7 +162,7 @@ class AccountApi { console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); const response = await fetch(url, { method: "POST", - headers: maybeWithBearerAuth({}, session.token()), + headers: withBearerAuth({}, session.token()), body: body }); if (response.status === 401 || response.status === 403) { @@ -181,7 +181,7 @@ class AccountApi { console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); const response = await fetch(url, { method: "PATCH", - headers: maybeWithBearerAuth({}, session.token()), + headers: withBearerAuth({}, session.token()), body: body }); if (response.status === 401 || response.status === 403) { @@ -199,7 +199,7 @@ class AccountApi { console.log(`[AccountApi] Removing user subscription ${url}`); const response = await fetch(url, { method: "DELETE", - headers: maybeWithBearerAuth({}, session.token()) + headers: withBearerAuth({}, session.token()) }); if (response.status === 401 || response.status === 403) { throw new UnauthorizedError(); @@ -208,6 +208,10 @@ class AccountApi { } } + sync() { + // TODO + } + startWorker() { if (this.timer !== null) { return; diff --git a/web/src/app/Api.js b/web/src/app/Api.js index f9eecff..4e3214a 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -5,9 +5,9 @@ import { accountSubscriptionUrl, accountTokenUrl, accountUrl, - fetchLinesIterator, - maybeWithBasicAuth, - maybeWithBearerAuth, + fetchLinesIterator, maybeWithAuth, + withBasicAuth, + withBearerAuth, topicShortUrl, topicUrl, topicUrlAuth, @@ -24,7 +24,7 @@ class Api { ? topicUrlJsonPollWithSince(baseUrl, topic, since) : topicUrlJsonPoll(baseUrl, topic); const messages = []; - const headers = maybeWithBasicAuth({}, user); + const headers = maybeWithAuth({}, user); console.log(`[Api] Polling ${url}`); for await (let line of fetchLinesIterator(url, headers)) { console.log(`[Api, ${shortUrl}] Received message ${line}`); @@ -45,7 +45,7 @@ class Api { const response = await fetch(baseUrl, { method: 'PUT', body: JSON.stringify(body), - headers: maybeWithBasicAuth(headers, user) + headers: maybeWithAuth(headers, user) }); if (response.status < 200 || response.status > 299) { throw new Error(`Unexpected response: ${response.status}`); @@ -111,7 +111,7 @@ class Api { const url = topicUrlAuth(baseUrl, topic); console.log(`[Api] Checking auth for ${url}`); const response = await fetch(url, { - headers: maybeWithBasicAuth({}, user) + headers: maybeWithAuth({}, user) }); if (response.status >= 200 && response.status <= 299) { return true; diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 5577802..8b79537 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,4 +1,4 @@ -import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils"; +import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils"; const retryBackoffSeconds = [5, 10, 15, 20, 30]; @@ -96,12 +96,18 @@ class Connection { params.push(`since=${this.since}`); } if (this.user) { - const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password)); - params.push(`auth=${auth}`); + params.push(`auth=${this.authParam()}`); } const wsUrl = topicUrlWs(this.baseUrl, this.topic); return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`; } + + authParam() { + if (this.user.password) { + return encodeBase64Url(basicAuth(this.user.username, this.user.password)); + } + return encodeBase64Url(bearerAuth(this.user.token)); + } } export class ConnectionState { diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index 565cfe9..c825ff1 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -109,7 +109,7 @@ class ConnectionManager { const makeConnectionId = async (subscription, user) => { return (user) - ? hashCode(`${subscription.id}|${user.username}|${user.password}`) + ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`); } diff --git a/web/src/app/UserManager.js b/web/src/app/UserManager.js index 25ad41e..4f3da86 100644 --- a/web/src/app/UserManager.js +++ b/web/src/app/UserManager.js @@ -1,21 +1,46 @@ import db from "./db"; +import session from "./Session"; class UserManager { async all() { - return db.users.toArray(); + const users = await db.users.toArray(); + if (session.exists()) { + users.unshift(this.localUser()); + } + return users; } async get(baseUrl) { + if (session.exists() && baseUrl === config.baseUrl) { + return this.localUser(); + } return db.users.get(baseUrl); } async save(user) { + if (user.baseUrl === config.baseUrl) { + return; + } await db.users.put(user); } async delete(baseUrl) { + if (session.exists() && baseUrl === config.baseUrl) { + return; + } await db.users.delete(baseUrl); } + + localUser() { + if (!session.exists()) { + return null; + } + return { + baseUrl: config.baseUrl, + username: session.username(), + token: session.token() // Not "password"! + }; + } } const userManager = new UserManager(); diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 7224746..6170cc2 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -99,17 +99,17 @@ export const unmatchedTags = (tags) => { else return tags.filter(tag => !(tag in emojis)); } -export const maybeWithBasicAuth = (headers, user) => { - if (user) { - headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`; +export const maybeWithAuth = (headers, user) => { + if (user && user.password) { + return withBasicAuth(headers, user.username, user.password); + } else if (user && user.token) { + return withBearerAuth(headers, user.token); } return headers; } -export const maybeWithBearerAuth = (headers, token) => { - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } +export const withBasicAuth = (headers, username, password) => { + headers['Authorization'] = basicAuth(username, password); return headers; } @@ -117,6 +117,15 @@ export const basicAuth = (username, password) => { return `Basic ${encodeBase64(`${username}:${password}`)}`; } +export const withBearerAuth = (headers, token) => { + headers['Authorization'] = bearerAuth(token); + return headers; +} + +export const bearerAuth = (token) => { + return `Bearer ${token}`; +} + export const encodeBase64 = (s) => { return Base64.encode(s); } diff --git a/web/src/components/Account.js b/web/src/components/Account.js index edc8a85..4490c32 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -88,7 +88,7 @@ const Stats = () => { 0 ? normalize(account.stats.emails, account.limits.emails) : 100} /> - +
{formatBytes(account.stats.attachment_total_size)} {account.limits.attachment_total_size > 0 ? t("of {{limit}}", { limit: formatBytes(account.limits.attachment_total_size) }) : t("Unlimited")} @@ -153,8 +153,7 @@ const ChangePassword = () => { } catch (e) { console.log(`[Account] Error changing password`, e); if ((e instanceof UnauthorizedError)) { - session.reset(); - window.location.href = routes.login; + session.resetAndRedirect(routes.login); } // TODO show error } @@ -238,13 +237,11 @@ const DeleteAccount = () => { setDialogOpen(false); console.debug(`[Account] Account deleted`); // TODO delete local storage - session.reset(); - window.location.href = routes.app; + session.resetAndRedirect(routes.app); } catch (e) { console.log(`[Account] Error deleting account`, e); if ((e instanceof UnauthorizedError)) { - session.reset(); - window.location.href = routes.login; + session.resetAndRedirect(routes.login); } // TODO show error } diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 70ee7e6..0b6b0a9 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -124,8 +124,7 @@ const SettingsIcons = (props) => { } catch (e) { console.log(`[ActionBar] Error unsubscribing`, e); if ((e instanceof UnauthorizedError)) { - session.reset(); - window.location.href = routes.login; + session.resetAndRedirect(routes.login); } } } @@ -272,8 +271,7 @@ const ProfileIcon = (props) => { try { await accountApi.logout(); } finally { - session.reset(); - window.location.href = routes.app; + session.resetAndRedirect(routes.app); } }; return ( diff --git a/web/src/components/App.js b/web/src/components/App.js index a89b150..f80fd2c 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -123,8 +123,7 @@ const Layout = () => { } catch (e) { console.log(`[App] Error fetching account`, e); if ((e instanceof UnauthorizedError)) { - session.reset(); - window.location.href = routes.login; + session.resetAndRedirect(routes.login); } } })(); diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 29e2989..e7b0586 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -270,6 +270,7 @@ const Users = () => { {t("prefs_users_description")} + {session.exists() && <>{" " + t("prefs_users_description_no_sync")}} {users?.length > 0 && } @@ -319,52 +320,49 @@ const UserTable = (props) => { } }; return ( -
- - - - {t("prefs_users_table_user_header")} - {t("prefs_users_table_base_url_header")} - +
+ + + {t("prefs_users_table_user_header")} + {t("prefs_users_table_base_url_header")} + + + + + {props.users?.map(user => ( + + {user.username} + {user.baseUrl} + + {user.baseUrl !== config.baseUrl && + <> + handleEditClick(user)} + aria-label={t("prefs_users_edit_button")}> + + + handleDeleteClick(user)} + aria-label={t("prefs_users_delete_button")}> + + + + } + - - - {props.users?.map(user => ( - - {user.username} - {user.baseUrl} - - handleEditClick(user)} - aria-label={t("prefs_users_edit_button")}> - - - handleDeleteClick(user)} - aria-label={t("prefs_users_delete_button")}> - - - - - ))} - - -
- {session.exists() && - - xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx - - } -
+ ))} + + + ); }; diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index 46fa29f..65dea5d 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -17,7 +17,15 @@ 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 {formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils"; +import { + formatBytes, + maybeWithAuth, + withBasicAuth, + topicShortUrl, + topicUrl, + validTopic, + validUrl +} from "../app/utils"; import Box from "@mui/material/Box"; import AttachmentIcon from "./AttachmentIcon"; import DialogFooter from "./DialogFooter"; @@ -132,7 +140,7 @@ const PublishDialog = (props) => { const body = (attachFile) ? attachFile : message; try { const user = await userManager.get(baseUrl); - const headers = maybeWithBasicAuth({}, user); + const headers = maybeWithAuth({}, user); const progressFn = (ev) => { if (ev.loaded > 0 && ev.total > 0) { setStatus(t("publish_dialog_progress_uploading_detail", { @@ -180,8 +188,7 @@ const PublishDialog = (props) => { } catch (e) { console.log(`[PublishDialog] Retrieving attachment limits failed`, e); if ((e instanceof UnauthorizedError)) { - session.reset(); - window.location.href = routes.login; + session.resetAndRedirect(routes.login); } else { setAttachFileError(""); // Reset error (rely on server-side checking) } diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 141fe7b..d31b077 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -40,8 +40,7 @@ const SubscribeDialog = (props) => { } catch (e) { console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); if ((e instanceof UnauthorizedError)) { - session.reset(); - window.location.href = routes.login; + session.resetAndRedirect(routes.login); } } } diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 640e2a6..1c4e872 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -74,8 +74,7 @@ export const useAutoSubscribe = (subscriptions, selected) => { } catch (e) { console.log(`[App] Auto-subscribing failed`, e); if ((e instanceof UnauthorizedError)) { - session.reset(); - window.location.href = routes.login; + session.resetAndRedirect(routes.login); } } }