From 2757f10bfcf168bdc5eb504a7ab7dca86c9ec124 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Wed, 27 Sep 2023 18:58:47 -0400 Subject: [PATCH] [add] settings (pw & time offset), [fix] PWA issues, [fix] misc styling issues --- api/api.go | 5 + api/app-routes.go | 111 ++++++++++++++++++++ api/auth.go | 5 +- api/ko-routes.go | 2 +- assets/manifest.json | 1 - config/config.go | 2 +- database/models.go | 14 +-- database/query.sql | 22 +++- database/query.sql.go | 70 +++++++++---- sqlc.yaml | 30 +++++- templates/base.html | 25 +++-- templates/login.html | 11 ++ templates/settings.html | 217 ++++++++++++++++++++++++++++++++++++++++ utils/utils.go | 90 +++++++++-------- 14 files changed, 512 insertions(+), 93 deletions(-) create mode 100644 templates/settings.html diff --git a/api/api.go b/api/api.go index 78b1e04..f69dfa0 100644 --- a/api/api.go +++ b/api/api.go @@ -14,6 +14,7 @@ import ( "reichard.io/bbank/config" "reichard.io/bbank/database" "reichard.io/bbank/graph" + "reichard.io/bbank/utils" ) type API struct { @@ -74,11 +75,13 @@ func (api *API) registerWebAppRoutes() { render := multitemplate.NewRenderer() helperFuncs := template.FuncMap{ "GetSVGGraphData": graph.GetSVGGraphData, + "GetUTCOffsets": utils.GetUTCOffsets, } render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html") render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html") render.AddFromFilesFuncs("graphs", helperFuncs, "templates/base.html", "templates/graphs.html") + render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html") render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html") render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html") render.AddFromFilesFuncs("document", helperFuncs, "templates/base.html", "templates/document.html") @@ -93,6 +96,8 @@ func (api *API) registerWebAppRoutes() { api.Router.POST("/register", api.authFormRegister) api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home")) + api.Router.GET("/settings", api.authWebAppMiddleware, api.createAppResourcesRoute("settings")) + api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings) api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity")) api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents")) api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document")) diff --git a/api/app-routes.go b/api/app-routes.go index 09114a3..a590898 100644 --- a/api/app-routes.go +++ b/api/app-routes.go @@ -1,6 +1,7 @@ package api import ( + "crypto/md5" "fmt" "mime/multipart" "net/http" @@ -9,6 +10,7 @@ import ( "strings" "time" + argon2 "github.com/alexedwards/argon2id" "github.com/gabriel-vasile/mimetype" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" @@ -40,6 +42,12 @@ type requestDocumentIdentify struct { ISBN *string `form:"isbn"` } +type requestSettingsEdit struct { + Password *string `form:"password"` + NewPassword *string `form:"new_password"` + TimeOffset *string `form:"time_offset"` +} + func baseResourceRoute(template string, args ...map[string]any) func(c *gin.Context) { variables := gin.H{"RouteName": template} if len(args) > 0 { @@ -167,6 +175,27 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any "DatabaseInfo": database_info, "GraphData": read_graph_data, } + } else if routeName == "settings" { + user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string)) + if err != nil { + log.Error("[createAppResourcesRoute] GetUser DB Error:", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) + return + } + + devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string)) + if err != nil { + log.Error("[createAppResourcesRoute] GetDevices DB Error:", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) + return + } + + templateVars["Data"] = gin.H{ + "Settings": gin.H{ + "TimeOffset": *user.TimeOffset, + }, + "Devices": devices, + } } else if routeName == "login" { templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled } @@ -471,6 +500,88 @@ func (api *API) identifyDocument(c *gin.Context) { c.HTML(http.StatusOK, "document", templateVars) } +func (api *API) editSettings(c *gin.Context) { + rUser, _ := c.Get("AuthorizedUser") + + var rUserSettings requestSettingsEdit + if err := c.ShouldBind(&rUserSettings); err != nil { + log.Error("[editSettings] Invalid Form Bind") + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) + return + } + + // Validate Something Exists + if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.TimeOffset == nil { + log.Error("[editSettings] Missing Form Values") + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) + return + } + + templateVars := gin.H{ + "User": rUser, + } + newUserSettings := database.UpdateUserParams{ + UserID: rUser.(string), + } + + // Set New Password + if rUserSettings.Password != nil && rUserSettings.NewPassword != nil { + password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password))) + authorized := api.authorizeCredentials(rUser.(string), password) + if authorized == true { + password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword))) + hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) + if err != nil { + templateVars["PasswordErrorMessage"] = "Unknown Error" + } else { + templateVars["PasswordMessage"] = "Password Updated" + newUserSettings.Password = &hashedPassword + } + } else { + templateVars["PasswordErrorMessage"] = "Invalid Password" + } + } + + // Set Time Offset + if rUserSettings.TimeOffset != nil { + templateVars["TimeOffsetMessage"] = "Time Offset Updated" + newUserSettings.TimeOffset = rUserSettings.TimeOffset + } + + // Update User + _, err := api.DB.Queries.UpdateUser(api.DB.Ctx, newUserSettings) + if err != nil { + log.Error("[editSettings] UpdateUser DB Error:", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) + return + } + + // Get User + user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string)) + if err != nil { + log.Error("[editSettings] GetUser DB Error:", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) + return + } + + // Get Devices + devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string)) + if err != nil { + log.Error("[editSettings] GetDevices DB Error:", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) + return + } + + templateVars["Data"] = gin.H{ + "Settings": gin.H{ + "TimeOffset": *user.TimeOffset, + }, + "Devices": devices, + } + + c.HTML(http.StatusOK, "settings", templateVars) +} + func bindQueryParams(c *gin.Context) queryParams { var qParams queryParams c.BindQuery(&qParams) diff --git a/api/auth.go b/api/auth.go index dee8357..3e8a1a8 100644 --- a/api/auth.go +++ b/api/auth.go @@ -24,7 +24,7 @@ func (api *API) authorizeCredentials(username string, password string) (authoriz return false } - if match, err := argon2.ComparePasswordAndHash(password, user.Pass); err != nil || match != true { + if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true { return false } @@ -94,7 +94,6 @@ func (api *API) authFormLogin(c *gin.Context) { // MD5 - KOSync Compatiblity password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) - if authorized := api.authorizeCredentials(username, password); authorized != true { c.HTML(http.StatusUnauthorized, "login", gin.H{ "RegistrationEnabled": api.Config.RegistrationEnabled, @@ -140,7 +139,7 @@ func (api *API) authFormRegister(c *gin.Context) { rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{ ID: username, - Pass: hashedPassword, + Pass: &hashedPassword, }) // SQL Error diff --git a/api/ko-routes.go b/api/ko-routes.go index 115e446..ece43a0 100644 --- a/api/ko-routes.go +++ b/api/ko-routes.go @@ -107,7 +107,7 @@ func (api *API) createUser(c *gin.Context) { rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{ ID: rUser.Username, - Pass: hashedPassword, + Pass: &hashedPassword, }) if err != nil { log.Error("[createUser] CreateUser DB Error:", err) diff --git a/assets/manifest.json b/assets/manifest.json index 1008563..01f6606 100644 --- a/assets/manifest.json +++ b/assets/manifest.json @@ -2,6 +2,5 @@ "short_name": "Book Manager", "name": "Book Manager", "theme_color": "#1F2937", - "background_color": "#1F2937", "display": "standalone" } diff --git a/config/config.go b/config/config.go index f0c7ea6..4ef4b7e 100644 --- a/config/config.go +++ b/config/config.go @@ -26,7 +26,7 @@ type Config struct { func Load() *Config { return &Config{ - Version: "0.0.1", + Version: "0.0.2", DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")), DBName: trimLowerString(getEnv("DATABASE_NAME", "book_manager")), DBPassword: getEnv("DATABASE_PASSWORD", ""), diff --git a/database/models.go b/database/models.go index dc6d919..6692337 100644 --- a/database/models.go +++ b/database/models.go @@ -21,11 +21,11 @@ type Activity struct { } type Device struct { - ID string `json:"id"` - UserID string `json:"user_id"` - DeviceName string `json:"device_name"` - CreatedAt time.Time `json:"created_at"` - Sync bool `json:"sync"` + ID string `json:"id"` + UserID string `json:"user_id"` + DeviceName string `json:"device_name"` + CreatedAt string `json:"created_at"` + Sync bool `json:"sync"` } type Document struct { @@ -90,8 +90,8 @@ type RescaledActivity struct { type User struct { ID string `json:"id"` - Pass string `json:"-"` + Pass *string `json:"-"` Admin bool `json:"-"` - TimeOffset string `json:"time_offset"` + TimeOffset *string `json:"time_offset"` CreatedAt time.Time `json:"created_at"` } diff --git a/database/query.sql b/database/query.sql index 4f205f9..2ee3389 100644 --- a/database/query.sql +++ b/database/query.sql @@ -21,6 +21,14 @@ ON CONFLICT DO NOTHING; SELECT * FROM users WHERE id = $user_id LIMIT 1; +-- name: UpdateUser :one +UPDATE users +SET + pass = COALESCE($password, pass), + time_offset = COALESCE($time_offset, time_offset) +WHERE id = $user_id +RETURNING *; + -- name: UpsertDocument :one INSERT INTO documents ( id, @@ -287,11 +295,15 @@ LIMIT $limit OFFSET $offset; -- name: GetDevices :many -SELECT * FROM devices -WHERE user_id = $user_id -ORDER BY created_at DESC -LIMIT $limit -OFFSET $offset; +SELECT + devices.device_name, + CAST(DATETIME(devices.created_at, users.time_offset) AS TEXT) AS created_at, + CAST(DATETIME(MAX(activity.created_at), users.time_offset) AS TEXT) AS last_sync +FROM activity +JOIN devices ON devices.id = activity.device_id +JOIN users ON users.id = $user_id +WHERE devices.user_id = $user_id +GROUP BY activity.device_id; -- name: GetDocumentReadStats :one SELECT diff --git a/database/query.sql.go b/database/query.sql.go index be6e78b..64ba745 100644 --- a/database/query.sql.go +++ b/database/query.sql.go @@ -121,8 +121,8 @@ ON CONFLICT DO NOTHING ` type CreateUserParams struct { - ID string `json:"id"` - Pass string `json:"-"` + ID string `json:"id"` + Pass *string `json:"-"` } func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) { @@ -376,35 +376,33 @@ func (q *Queries) GetDevice(ctx context.Context, deviceID string) (Device, error } const getDevices = `-- name: GetDevices :many -SELECT id, user_id, device_name, created_at, sync FROM devices -WHERE user_id = ?1 -ORDER BY created_at DESC -LIMIT ?3 -OFFSET ?2 +SELECT + devices.device_name, + CAST(DATETIME(devices.created_at, users.time_offset) AS TEXT) AS created_at, + CAST(DATETIME(MAX(activity.created_at), users.time_offset) AS TEXT) AS last_sync +FROM activity +JOIN devices ON devices.id = activity.device_id +JOIN users ON users.id = ?1 +WHERE devices.user_id = ?1 +GROUP BY activity.device_id ` -type GetDevicesParams struct { - UserID string `json:"user_id"` - Offset int64 `json:"offset"` - Limit int64 `json:"limit"` +type GetDevicesRow struct { + DeviceName string `json:"device_name"` + CreatedAt string `json:"created_at"` + LastSync string `json:"last_sync"` } -func (q *Queries) GetDevices(ctx context.Context, arg GetDevicesParams) ([]Device, error) { - rows, err := q.db.QueryContext(ctx, getDevices, arg.UserID, arg.Offset, arg.Limit) +func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) { + rows, err := q.db.QueryContext(ctx, getDevices, userID) if err != nil { return nil, err } defer rows.Close() - var items []Device + var items []GetDevicesRow for rows.Next() { - var i Device - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.DeviceName, - &i.CreatedAt, - &i.Sync, - ); err != nil { + var i GetDevicesRow + if err := rows.Scan(&i.DeviceName, &i.CreatedAt, &i.LastSync); err != nil { return nil, err } items = append(items, i) @@ -1272,6 +1270,34 @@ func (q *Queries) UpdateProgress(ctx context.Context, arg UpdateProgressParams) return i, err } +const updateUser = `-- name: UpdateUser :one +UPDATE users +SET + pass = COALESCE(?1, pass), + time_offset = COALESCE(?2, time_offset) +WHERE id = ?3 +RETURNING id, pass, admin, time_offset, created_at +` + +type UpdateUserParams struct { + Password *string `json:"-"` + TimeOffset *string `json:"time_offset"` + UserID string `json:"user_id"` +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUser, arg.Password, arg.TimeOffset, arg.UserID) + var i User + err := row.Scan( + &i.ID, + &i.Pass, + &i.Admin, + &i.TimeOffset, + &i.CreatedAt, + ) + return i, err +} + const upsertDevice = `-- name: UpsertDevice :one INSERT INTO devices (id, user_id, device_name) VALUES (?, ?, ?) diff --git a/sqlc.yaml b/sqlc.yaml index 8d4c7fb..e94f307 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -9,7 +9,7 @@ sql: out: "database" emit_json_tags: true overrides: - # Type pointers needed for JSON + # Documents - column: "documents.md5" go_type: type: "string" @@ -63,6 +63,7 @@ sql: type: "string" pointer: true + # Metadata - column: "metadata.title" go_type: type: "string" @@ -92,6 +93,33 @@ sql: type: "string" pointer: true + # Devices + - column: "devices.id" + go_type: + type: "string" + - column: "devices.user_id" + go_type: + type: "string" + - column: "devices.device_name" + go_type: + type: "string" + - column: "devices.sync" + go_type: + type: "bool" + - column: "devices.created_at" + go_type: + type: "string" + + # Devices + - column: "users.pass" + go_type: + type: "string" + pointer: true + - column: "users.time_offset" + go_type: + type: "string" + pointer: true + # Do not generate JSON - column: "documents.synced" go_struct_tag: 'json:"-"' diff --git a/templates/base.html b/templates/base.html index 93a3be3..b238c16 100644 --- a/templates/base.html +++ b/templates/base.html @@ -2,22 +2,24 @@ + + Book Manager - {{block "title" .}}{{end}} - +
- - - + + +
-

{{block "header" .}}{{end}}

+

{{block "header" .}}{{end}}

@@ -134,6 +136,15 @@ aria-orientation="vertical" aria-labelledby="options-menu" > + + + Settings + +