diff --git a/auth/auth.go b/auth/auth.go index 2dde3858..c737c584 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -16,6 +16,7 @@ type Auther interface { AuthenticateToken(token string) (*User, error) CreateToken(user *User) (string, error) RemoveToken(user *User) error + ChangeSettings(user *User) error // Authorize returns nil if the given user has access to the given topic using the desired // permission. The user param may be nil to signal an anonymous user. @@ -60,12 +61,29 @@ type Manager interface { // User is a struct that represents a user type User struct { - Name string - Hash string // password hash (bcrypt) - Token string // Only set if token was used to log in - Role Role - Grants []Grant - Language string + Name string + Hash string // password hash (bcrypt) + Token string // Only set if token was used to log in + Role Role + Grants []Grant + Prefs *UserPrefs +} + +type UserPrefs struct { + Language string `json:"language,omitempty"` + Notification *UserNotificationPrefs `json:"notification,omitempty"` + Subscriptions []*UserSubscription `json:"subscriptions,omitempty"` +} + +type UserSubscription struct { + 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"` } // Grant is a struct that represents an access control entry to a topic diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go index d61d176b..73b988a3 100644 --- a/auth/auth_sqlite.go +++ b/auth/auth_sqlite.go @@ -2,6 +2,7 @@ package auth import ( "database/sql" + "encoding/json" "errors" "fmt" _ "github.com/mattn/go-sqlite3" // SQLite driver @@ -32,10 +33,7 @@ const ( user TEXT NOT NULL, pass TEXT NOT NULL, role TEXT NOT NULL, - language TEXT, - notification_sound TEXT, - notification_min_priority INT, - notification_delete_after INT, + settings JSON, FOREIGN KEY (plan_id) REFERENCES plan (id) ); CREATE UNIQUE INDEX idx_user ON user (user); @@ -46,13 +44,7 @@ const ( write INT NOT NULL, PRIMARY KEY (user_id, topic), FOREIGN KEY (user_id) REFERENCES user (id) - ); - CREATE TABLE IF NOT EXISTS user_subscription ( - user_id INT NOT NULL, - base_url TEXT NOT NULL, - topic TEXT NOT NULL, - PRIMARY KEY (user_id, base_url, topic) - ); + ); CREATE TABLE IF NOT EXISTS user_token ( user_id INT NOT NULL, token TEXT NOT NULL, @@ -68,12 +60,12 @@ const ( COMMIT; ` selectUserByNameQuery = ` - SELECT user, pass, role, language + SELECT user, pass, role, settings FROM user WHERE user = ? ` selectUserByTokenQuery = ` - SELECT user, pass, role, language + SELECT user, pass, role, settings FROM user JOIN user_token on user.id = user_token.user_id WHERE token = ? @@ -101,8 +93,9 @@ const ( deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` - insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` - deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` + insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` + deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` + updateUserSettingsQuery = `UPDATE user SET settings = ? WHERE user = ?` ) // Schema management queries @@ -186,6 +179,17 @@ func (a *SQLiteAuth) RemoveToken(user *User) error { return nil } +func (a *SQLiteAuth) ChangeSettings(user *User) error { + settings, err := json.Marshal(user.Prefs) + if err != nil { + return err + } + if _, err := a.db.Exec(updateUserSettingsQuery, string(settings), user.Name); err != nil { + return err + } + return nil +} + // Authorize returns nil if the given user has access to the given topic using the desired // permission. The user param may be nil to signal an anonymous user. func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error { @@ -314,11 +318,11 @@ func (a *SQLiteAuth) userByToken(token string) (*User, error) { func (a *SQLiteAuth) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var username, hash, role string - var language sql.NullString + var prefs sql.NullString if !rows.Next() { return nil, ErrNotFound } - if err := rows.Scan(&username, &hash, &role, &language); err != nil { + if err := rows.Scan(&username, &hash, &role, &prefs); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -327,13 +331,19 @@ func (a *SQLiteAuth) readUser(rows *sql.Rows) (*User, error) { if err != nil { return nil, err } - return &User{ - Name: username, - Hash: hash, - Role: Role(role), - Grants: grants, - Language: language.String, - }, nil + user := &User{ + Name: username, + Hash: hash, + Role: Role(role), + Grants: grants, + } + if prefs.Valid { + user.Prefs = &UserPrefs{} + if err := json.Unmarshal([]byte(prefs.String), user.Prefs); err != nil { + return nil, err + } + } + return user, nil } func (a *SQLiteAuth) everyoneUser() (*User, error) { diff --git a/server/server.go b/server/server.go index 23ef010a..2879b92a 100644 --- a/server/server.go +++ b/server/server.go @@ -323,6 +323,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.handleUserTokenDelete(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == userAccountPath { 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.MethodGet && r.URL.Path == matrixPushPath { return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { @@ -453,29 +455,16 @@ func (s *Server) handleUserTokenDelete(w http.ResponseWriter, r *http.Request, v return nil } -type userSubscriptionResponse struct { - BaseURL string `json:"base_url"` - Topic string `json:"topic"` -} - -type userNotificationSettingsResponse struct { - Sound string `json:"sound"` - MinPriority string `json:"min_priority"` - DeleteAfter int `json:"delete_after"` -} - type userPlanResponse struct { Id int `json:"id"` Name string `json:"name"` } type userAccountResponse struct { - Username string `json:"username"` - Role string `json:"role,omitempty"` - Language string `json:"language,omitempty"` - Plan *userPlanResponse `json:"plan,omitempty"` - Notification *userNotificationSettingsResponse `json:"notification,omitempty"` - Subscriptions []*userSubscriptionResponse `json:"subscriptions,omitempty"` + Username string `json:"username"` + Role string `json:"role,omitempty"` + Plan *userPlanResponse `json:"plan,omitempty"` + Settings *auth.UserPrefs `json:"settings,omitempty"` } func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -485,10 +474,7 @@ 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.Language = v.user.Language - response.Notification = &userNotificationSettingsResponse{ - Sound: "dadum", - } + response.Settings = v.user.Prefs } else { response = &userAccountResponse{ Username: auth.Everyone, @@ -501,6 +487,41 @@ func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *vi return nil } +func (s *Server) handleUserAccountUpdate(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 newPrefs auth.UserPrefs + if err := json.NewDecoder(body).Decode(&newPrefs); err != nil { + return err + } + if v.user.Prefs == nil { + v.user.Prefs = &auth.UserPrefs{} + } + prefs := v.user.Prefs + if newPrefs.Language != "" { + prefs.Language = newPrefs.Language + } + if newPrefs.Notification != nil { + if prefs.Notification == nil { + prefs.Notification = &auth.UserNotificationPrefs{} + } + if newPrefs.Notification.DeleteAfter > 0 { + prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter + } + // ... + } + // ... + return s.auth.ChangeSettings(v.user) +} + 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 6f692a6a..ced005f3 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -172,6 +172,20 @@ class Api { console.log(`[Api] Account`, account); return account; } + + async updateUserAccount(baseUrl, token, payload) { + const url = userAccountUrl(baseUrl); + const body = JSON.stringify(payload); + console.log(`[Api] Updating user account ${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 api = new Api(); diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index f5c23f93..612f1a6f 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -34,6 +34,8 @@ 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 session from "../app/Session"; const Preferences = () => { return ( @@ -443,7 +445,9 @@ const Language = () => { const handleChange = async (ev) => { await i18n.changeLanguage(ev.target.value); - //api.update + 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.