This commit is contained in:
Philipp Heckel 2022-12-08 20:50:48 -05:00
parent 2e1ddc9ae1
commit 92bf7ebc52
11 changed files with 237 additions and 37 deletions

View file

@ -76,14 +76,15 @@ type UserPrefs struct {
} }
type UserSubscription struct { type UserSubscription struct {
ID string `json:"id"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
Topic string `json:"topic"` Topic string `json:"topic"`
} }
type UserNotificationPrefs struct { type UserNotificationPrefs struct {
Sound string `json:"sound"` Sound string `json:"sound,omitempty"`
MinPriority string `json:"min_priority"` MinPriority int `json:"min_priority,omitempty"`
DeleteAfter int `json:"delete_after"` DeleteAfter int `json:"delete_after,omitempty"`
} }
// Grant is a struct that represents an access control entry to a topic // Grant is a struct that represents an access control entry to a topic

View file

@ -40,6 +40,7 @@ import (
auto-refresh tokens from UI auto-refresh tokens from UI
pricing page pricing page
home 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$`) 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)$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js" webConfigPath = "/config.js"
userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account
userTokenPath = "/user/token" userTokenPath = "/user/token"
userAccountPath = "/user/account" userAccountPath = "/user/account"
matrixPushPath = "/_matrix/push/v1/notify" userSubscriptionPath = "/user/subscription"
staticRegex = regexp.MustCompile(`^/static/.+`) userSubscriptionDeleteRegex = regexp.MustCompile(`^/user/subscription/([-_A-Za-z0-9]{16})$`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) matrixPushPath = "/_matrix/push/v1/notify"
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) staticRegex = regexp.MustCompile(`^/static/.+`)
disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
urlRegex = regexp.MustCompile(`^https?://`) 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 //go:embed site
webFs embed.FS 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) return s.handleUserAccount(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userAccountPath { } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userAccountPath {
return s.handleUserAccountUpdate(w, r, v) 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 { } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
return s.handleMatrixDiscovery(w) return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
@ -461,10 +468,12 @@ type userPlanResponse struct {
} }
type userAccountResponse struct { type userAccountResponse struct {
Username string `json:"username"` Username string `json:"username"`
Role string `json:"role,omitempty"` Role string `json:"role,omitempty"`
Plan *userPlanResponse `json:"plan,omitempty"` Plan *userPlanResponse `json:"plan,omitempty"`
Settings *auth.UserPrefs `json:"settings,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 { 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 { if v.user != nil {
response.Username = v.user.Name response.Username = v.user.Name
response.Role = string(v.user.Role) 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 { } else {
response = &userAccountResponse{ response = &userAccountResponse{
Username: auth.Everyone, Username: auth.Everyone,
@ -516,12 +535,83 @@ func (s *Server) handleUserAccountUpdate(w http.ResponseWriter, r *http.Request,
if newPrefs.Notification.DeleteAfter > 0 { if newPrefs.Notification.DeleteAfter > 0 {
prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter 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) 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 { func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
r.URL.Path = webSiteDir + r.URL.Path r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)

View file

@ -8,7 +8,7 @@ import {
topicUrlJsonPollWithSince, topicUrlJsonPollWithSince,
userAccountUrl, userAccountUrl,
userTokenUrl, userTokenUrl,
userStatsUrl userStatsUrl, userSubscriptionUrl, userSubscriptionDeleteUrl
} from "./utils"; } from "./utils";
import userManager from "./UserManager"; import userManager from "./UserManager";
@ -186,6 +186,35 @@ class Api {
throw new Error(`Unexpected server response ${response.status}`); 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(); const api = new Api();

View file

@ -18,17 +18,43 @@ class SubscriptionManager {
} }
async add(baseUrl, topic) { async add(baseUrl, topic) {
const id = topicUrl(baseUrl, topic);
const existingSubscription = await this.get(id);
if (existingSubscription) {
return existingSubscription;
}
const subscription = { const subscription = {
id: topicUrl(baseUrl, topic), id: topicUrl(baseUrl, topic),
baseUrl: baseUrl, baseUrl: baseUrl,
topic: topic, topic: topic,
mutedUntil: 0, mutedUntil: 0,
last: null last: null,
remoteId: null
}; };
await db.subscriptions.put(subscription); await db.subscriptions.put(subscription);
return 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) { async updateState(subscriptionId, state) {
db.subscriptions.update(subscriptionId, { state: 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) { async pruneNotifications(thresholdTimestamp) {
await db.notifications await db.notifications
.where("time").below(thresholdTimestamp) .where("time").below(thresholdTimestamp)

View file

@ -21,6 +21,8 @@ export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topi
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
export const userTokenUrl = (baseUrl) => `${baseUrl}/user/token`; export const userTokenUrl = (baseUrl) => `${baseUrl}/user/token`;
export const userAccountUrl = (baseUrl) => `${baseUrl}/user/account`; 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 shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`; export const expandSecureUrl = (url) => `https://${url}`;

View file

@ -32,7 +32,6 @@ import Button from "@mui/material/Button";
const ActionBar = (props) => { const ActionBar = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const username = session.username();
let title = "ntfy"; let title = "ntfy";
if (props.selected) { if (props.selected) {
title = topicDisplayName(props.selected); title = topicDisplayName(props.selected);
@ -112,9 +111,12 @@ const SettingsIcons = (props) => {
}; };
const handleUnsubscribe = async (event) => { const handleUnsubscribe = async (event) => {
console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`); console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`, props.subscription);
handleClose(event); handleClose(event);
await subscriptionManager.remove(props.subscription.id); 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 const newSelected = await subscriptionManager.first(); // May be undefined
if (newSelected) { if (newSelected) {
navigate(routes.forSubscription(newSelected)); navigate(routes.forSubscription(newSelected));

View file

@ -96,10 +96,19 @@ const Layout = () => {
if (account.notification.sound) { if (account.notification.sound) {
await prefs.setSound(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 ( return (
<Box sx={{display: 'flex'}}> <Box sx={{display: 'flex'}}>
<CssBaseline/> <CssBaseline/>

View file

@ -28,12 +28,8 @@ const Login = () => {
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault(); event.preventDefault();
const data = new FormData(event.currentTarget); const data = new FormData(event.currentTarget);
console.log({
email: data.get('email'),
password: data.get('password'),
});
const user = { const user = {
username: data.get('email'), username: data.get('username'),
password: data.get('password'), password: data.get('password'),
} }
const token = await api.login("http://localhost:2586"/*window.location.origin*/, user); const token = await api.login("http://localhost:2586"/*window.location.origin*/, user);
@ -63,10 +59,9 @@ const Login = () => {
margin="normal" margin="normal"
required required
fullWidth fullWidth
id="email" id="username"
label="Email Address" label="Username"
name="email" name="username"
autoComplete="email"
autoFocus autoFocus
/> />
<TextField <TextField

View file

@ -72,6 +72,13 @@ const Sound = () => {
const sound = useLiveQuery(async () => prefs.sound()); const sound = useLiveQuery(async () => prefs.sound());
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setSound(ev.target.value); await prefs.setSound(ev.target.value);
if (session.exists()) {
await api.updateUserAccount("http://localhost:2586", session.token(), {
notification: {
sound: ev.target.value
}
});
}
} }
if (!sound) { if (!sound) {
return null; // While loading return null; // While loading
@ -105,6 +112,13 @@ const MinPriority = () => {
const minPriority = useLiveQuery(async () => prefs.minPriority()); const minPriority = useLiveQuery(async () => prefs.minPriority());
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value); 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) { if (!minPriority) {
return null; // While loading return null; // While loading
@ -148,6 +162,13 @@ const DeleteAfter = () => {
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value); 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" if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
return null; // While loading return null; // While loading
@ -445,9 +466,11 @@ const Language = () => {
const handleChange = async (ev) => { const handleChange = async (ev) => {
await i18n.changeLanguage(ev.target.value); await i18n.changeLanguage(ev.target.value);
await api.updateUserAccount("http://localhost:2586", session.token(), { if (session.exists()) {
language: ev.target.value 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. // Remember: Flags are not languages. Don't put flags next to the language in the list.

View file

@ -15,6 +15,7 @@ import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller"; import poller from "../app/Poller";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import session from "../app/Session";
const publicBaseUrl = "https://ntfy.sh"; const publicBaseUrl = "https://ntfy.sh";
@ -26,6 +27,13 @@ const SubscribeDialog = (props) => {
const handleSuccess = async () => { const handleSuccess = async () => {
const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin; const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin;
const subscription = await subscriptionManager.add(actualBaseUrl, topic); 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! poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription); props.onSuccess(subscription);
} }

View file

@ -7,6 +7,8 @@ import routes from "./routes";
import connectionManager from "../app/ConnectionManager"; import connectionManager from "../app/ConnectionManager";
import poller from "../app/Poller"; import poller from "../app/Poller";
import pruner from "../app/Pruner"; 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 * 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)}`); console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
(async () => { (async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic); 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! poller.pollInBackground(subscription); // Dangle!
})(); })();
} }