From 92bf7ebc5214febc09f91badfee8ff29a7ca3282 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 8 Dec 2022 20:50:48 -0500 Subject: [PATCH] blerp --- auth/auth.go | 7 +- server/server.go | 124 ++++++++++++++++++++++---- web/src/app/Api.js | 31 ++++++- web/src/app/SubscriptionManager.js | 34 ++++++- web/src/app/utils.js | 2 + web/src/components/ActionBar.js | 6 +- web/src/components/App.js | 11 ++- web/src/components/Login.js | 13 +-- web/src/components/Preferences.js | 29 +++++- web/src/components/SubscribeDialog.js | 8 ++ web/src/components/hooks.js | 9 ++ 11 files changed, 237 insertions(+), 37 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index c737c584..58539f74 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -76,14 +76,15 @@ type UserPrefs struct { } type UserSubscription struct { + ID string `json:"id"` BaseURL string `json:"base_url"` Topic string `json:"topic"` } type UserNotificationPrefs struct { - Sound string `json:"sound"` - MinPriority string `json:"min_priority"` - DeleteAfter int `json:"delete_after"` + Sound string `json:"sound,omitempty"` + MinPriority int `json:"min_priority,omitempty"` + DeleteAfter int `json:"delete_after,omitempty"` } // Grant is a struct that represents an access control entry to a topic diff --git a/server/server.go b/server/server.go index 2879b92a..40150e46 100644 --- a/server/server.go +++ b/server/server.go @@ -40,6 +40,7 @@ import ( auto-refresh tokens from UI pricing page home page + reserve topics @@ -80,16 +81,18 @@ var ( authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) - webConfigPath = "/config.js" - userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account - userTokenPath = "/user/token" - userAccountPath = "/user/account" - matrixPushPath = "/_matrix/push/v1/notify" - staticRegex = regexp.MustCompile(`^/static/.+`) - docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) - fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) - disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app - urlRegex = regexp.MustCompile(`^https?://`) + webConfigPath = "/config.js" + userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account + userTokenPath = "/user/token" + userAccountPath = "/user/account" + userSubscriptionPath = "/user/subscription" + userSubscriptionDeleteRegex = regexp.MustCompile(`^/user/subscription/([-_A-Za-z0-9]{16})$`) + matrixPushPath = "/_matrix/push/v1/notify" + staticRegex = regexp.MustCompile(`^/static/.+`) + docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) + fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) + disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app + urlRegex = regexp.MustCompile(`^https?://`) //go:embed site webFs embed.FS @@ -325,6 +328,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.handleUserAccount(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userAccountPath { return s.handleUserAccountUpdate(w, r, v) + } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userSubscriptionPath { + return s.handleUserSubscriptionAdd(w, r, v) + } else if r.Method == http.MethodDelete && userSubscriptionDeleteRegex.MatchString(r.URL.Path) { + return s.handleUserSubscriptionDelete(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { @@ -461,10 +468,12 @@ type userPlanResponse struct { } type userAccountResponse struct { - Username string `json:"username"` - Role string `json:"role,omitempty"` - Plan *userPlanResponse `json:"plan,omitempty"` - Settings *auth.UserPrefs `json:"settings,omitempty"` + Username string `json:"username"` + Role string `json:"role,omitempty"` + Plan *userPlanResponse `json:"plan,omitempty"` + Language string `json:"language,omitempty"` + Notification *auth.UserNotificationPrefs `json:"notification,omitempty"` + Subscriptions []*auth.UserSubscription `json:"subscriptions,omitempty"` } func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -474,7 +483,17 @@ func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *vi if v.user != nil { response.Username = v.user.Name response.Role = string(v.user.Role) - response.Settings = v.user.Prefs + if v.user.Prefs != nil { + if v.user.Prefs.Language != "" { + response.Language = v.user.Prefs.Language + } + if v.user.Prefs.Notification != nil { + response.Notification = v.user.Prefs.Notification + } + if v.user.Prefs.Subscriptions != nil { + response.Subscriptions = v.user.Prefs.Subscriptions + } + } } else { response = &userAccountResponse{ Username: auth.Everyone, @@ -516,12 +535,83 @@ func (s *Server) handleUserAccountUpdate(w http.ResponseWriter, r *http.Request, if newPrefs.Notification.DeleteAfter > 0 { prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter } - // ... + if newPrefs.Notification.Sound != "" { + prefs.Notification.Sound = newPrefs.Notification.Sound + } + if newPrefs.Notification.MinPriority > 0 { + prefs.Notification.MinPriority = newPrefs.Notification.MinPriority + } } - // ... return s.auth.ChangeSettings(v.user) } +func (s *Server) handleUserSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { + if v.user == nil { + return errors.New("no user") + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this + body, err := util.Peek(r.Body, 4096) // FIXME + if err != nil { + return err + } + defer r.Body.Close() + var newSubscription auth.UserSubscription + if err := json.NewDecoder(body).Decode(&newSubscription); err != nil { + return err + } + if v.user.Prefs == nil { + v.user.Prefs = &auth.UserPrefs{} + } + newSubscription.ID = "" // Client cannot set ID + for _, subscription := range v.user.Prefs.Subscriptions { + if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic { + newSubscription = *subscription + break + } + } + if newSubscription.ID == "" { + newSubscription.ID = util.RandomString(16) + v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, &newSubscription) + if err := s.auth.ChangeSettings(v.user); err != nil { + return err + } + } + if err := json.NewEncoder(w).Encode(newSubscription); err != nil { + return err + } + return nil +} + +func (s *Server) handleUserSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + if v.user == nil { + return errors.New("no user") + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this + matches := userSubscriptionDeleteRegex.FindStringSubmatch(r.URL.Path) + if len(matches) != 2 { + return errHTTPInternalErrorInvalidFilePath // FIXME + } + subscriptionID := matches[1] + if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil { + return nil + } + newSubscriptions := make([]*auth.UserSubscription, 0) + for _, subscription := range v.user.Prefs.Subscriptions { + if subscription.ID != subscriptionID { + newSubscriptions = append(newSubscriptions, subscription) + } + } + if len(newSubscriptions) < len(v.user.Prefs.Subscriptions) { + v.user.Prefs.Subscriptions = newSubscriptions + if err := s.auth.ChangeSettings(v.user); err != nil { + return err + } + } + return nil +} + func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error { r.URL.Path = webSiteDir + r.URL.Path util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) diff --git a/web/src/app/Api.js b/web/src/app/Api.js index ced005f3..6046a600 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -8,7 +8,7 @@ import { topicUrlJsonPollWithSince, userAccountUrl, userTokenUrl, - userStatsUrl + userStatsUrl, userSubscriptionUrl, userSubscriptionDeleteUrl } from "./utils"; import userManager from "./UserManager"; @@ -186,6 +186,35 @@ class Api { throw new Error(`Unexpected server response ${response.status}`); } } + + async userSubscriptionAdd(baseUrl, token, payload) { + const url = userSubscriptionUrl(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 !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + const subscription = await response.json(); + console.log(`[Api] Subscription`, subscription); + return subscription; + } + + async userSubscriptionDelete(baseUrl, token, remoteId) { + const url = userSubscriptionDeleteUrl(baseUrl, remoteId); + console.log(`[Api] Removing user subscription ${url}`); + const response = await fetch(url, { + method: "DELETE", + headers: maybeWithBearerAuth({}, token) + }); + if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } } const api = new Api(); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index f6573489..f793ddf7 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -18,17 +18,43 @@ class SubscriptionManager { } async add(baseUrl, topic) { + const id = topicUrl(baseUrl, topic); + const existingSubscription = await this.get(id); + if (existingSubscription) { + return existingSubscription; + } const subscription = { id: topicUrl(baseUrl, topic), baseUrl: baseUrl, topic: topic, mutedUntil: 0, - last: null + last: null, + remoteId: null }; await db.subscriptions.put(subscription); return subscription; } + async syncFromRemote(remoteSubscriptions) { + // Add remote subscriptions + let remoteIds = []; + for (let i = 0; i < remoteSubscriptions.length; i++) { + const remote = remoteSubscriptions[i]; + const local = await this.add(remote.base_url, remote.topic); + await this.setRemoteId(local.id, remote.id); + remoteIds.push(remote.id); + } + + // Remove local subscriptions that do not exist remotely + const localSubscriptions = await db.subscriptions.toArray(); + for (let i = 0; i < localSubscriptions.length; i++) { + const local = localSubscriptions[i]; + if (local.remoteId && !remoteIds.includes(local.remoteId)) { + await this.remove(local.id); + } + } + } + async updateState(subscriptionId, state) { db.subscriptions.update(subscriptionId, { state: state }); } @@ -139,6 +165,12 @@ class SubscriptionManager { }); } + async setRemoteId(subscriptionId, remoteId) { + await db.subscriptions.update(subscriptionId, { + remoteId: remoteId + }); + } + async pruneNotifications(thresholdTimestamp) { await db.notifications .where("time").below(thresholdTimestamp) diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 2ed023fe..e98ac77a 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -21,6 +21,8 @@ export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topi export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; export const userTokenUrl = (baseUrl) => `${baseUrl}/user/token`; export const userAccountUrl = (baseUrl) => `${baseUrl}/user/account`; +export const userSubscriptionUrl = (baseUrl) => `${baseUrl}/user/subscription`; +export const userSubscriptionDeleteUrl = (baseUrl, id) => `${baseUrl}/user/subscription/${id}`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandSecureUrl = (url) => `https://${url}`; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index ffd08751..c79aec9c 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -32,7 +32,6 @@ import Button from "@mui/material/Button"; const ActionBar = (props) => { const { t } = useTranslation(); const location = useLocation(); - const username = session.username(); let title = "ntfy"; if (props.selected) { title = topicDisplayName(props.selected); @@ -112,9 +111,12 @@ const SettingsIcons = (props) => { }; const handleUnsubscribe = async (event) => { - console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`); + console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`, props.subscription); handleClose(event); await subscriptionManager.remove(props.subscription.id); + if (session.exists() && props.subscription.remoteId) { + await api.userSubscriptionDelete("http://localhost:2586", session.token(), props.subscription.remoteId); + } const newSelected = await subscriptionManager.first(); // May be undefined if (newSelected) { navigate(routes.forSubscription(newSelected)); diff --git a/web/src/components/App.js b/web/src/components/App.js index e69cfead..772961d4 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -96,10 +96,19 @@ const Layout = () => { if (account.notification.sound) { await prefs.setSound(account.notification.sound); } + if (account.notification.delete_after) { + await prefs.setDeleteAfter(account.notification.delete_after); + } + if (account.notification.min_priority) { + await prefs.setMinPriority(account.notification.min_priority); + } + } + if (account.subscriptions) { + await subscriptionManager.syncFromRemote(account.subscriptions); } } })(); - }); + }, []); return ( diff --git a/web/src/components/Login.js b/web/src/components/Login.js index 8ae73a0a..7f1469d3 100644 --- a/web/src/components/Login.js +++ b/web/src/components/Login.js @@ -28,12 +28,8 @@ const Login = () => { const handleSubmit = async (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); - console.log({ - email: data.get('email'), - password: data.get('password'), - }); const user = { - username: data.get('email'), + username: data.get('username'), password: data.get('password'), } const token = await api.login("http://localhost:2586"/*window.location.origin*/, user); @@ -63,10 +59,9 @@ const Login = () => { margin="normal" required fullWidth - id="email" - label="Email Address" - name="email" - autoComplete="email" + id="username" + label="Username" + name="username" autoFocus /> { const sound = useLiveQuery(async () => prefs.sound()); const handleChange = async (ev) => { await prefs.setSound(ev.target.value); + if (session.exists()) { + await api.updateUserAccount("http://localhost:2586", session.token(), { + notification: { + sound: ev.target.value + } + }); + } } if (!sound) { return null; // While loading @@ -105,6 +112,13 @@ const MinPriority = () => { const minPriority = useLiveQuery(async () => prefs.minPriority()); const handleChange = async (ev) => { await prefs.setMinPriority(ev.target.value); + if (session.exists()) { + await api.updateUserAccount("http://localhost:2586", session.token(), { + notification: { + min_priority: ev.target.value + } + }); + } } if (!minPriority) { return null; // While loading @@ -148,6 +162,13 @@ const DeleteAfter = () => { const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); const handleChange = async (ev) => { await prefs.setDeleteAfter(ev.target.value); + if (session.exists()) { + await api.updateUserAccount("http://localhost:2586", session.token(), { + notification: { + delete_after: ev.target.value + } + }); + } } if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0" return null; // While loading @@ -445,9 +466,11 @@ const Language = () => { const handleChange = async (ev) => { await i18n.changeLanguage(ev.target.value); - await api.updateUserAccount("http://localhost:2586", session.token(), { - language: ev.target.value - }); + if (session.exists()) { + await api.updateUserAccount("http://localhost:2586", session.token(), { + language: ev.target.value + }); + } }; // Remember: Flags are not languages. Don't put flags next to the language in the list. diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 2baecd61..925dec80 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -15,6 +15,7 @@ import subscriptionManager from "../app/SubscriptionManager"; import poller from "../app/Poller"; import DialogFooter from "./DialogFooter"; import {useTranslation} from "react-i18next"; +import session from "../app/Session"; const publicBaseUrl = "https://ntfy.sh"; @@ -26,6 +27,13 @@ const SubscribeDialog = (props) => { const handleSuccess = async () => { const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin; const subscription = await subscriptionManager.add(actualBaseUrl, topic); + if (session.exists()) { + const remoteSubscription = await api.userSubscriptionAdd("http://localhost:2586", session.token(), { + base_url: actualBaseUrl, + topic: topic + }); + await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); + } poller.pollInBackground(subscription); // Dangle! props.onSuccess(subscription); } diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 3714a9d0..0fa46dbe 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -7,6 +7,8 @@ import routes from "./routes"; 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"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -61,6 +63,13 @@ export const useAutoSubscribe = (subscriptions, selected) => { console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); (async () => { const subscription = await subscriptionManager.add(baseUrl, params.topic); + if (session.exists()) { + const remoteSubscription = await api.userSubscriptionAdd("http://localhost:2586", session.token(), { + base_url: baseUrl, + topic: params.topic + }); + await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); + } poller.pollInBackground(subscription); // Dangle! })(); }