diff --git a/README.md b/README.md index a82a1f1..4b7b0c4 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,16 @@ In additional to the compatible KOSync API's, we add: - Additional APIs to automatically upload reading statistics - Automatically upload documents to the server (can download in the "Documents" view) - Book metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started)) -- No JavaScript! All information is rendered server side. +- No JavaScript! All information is generated server side. # Server Docker Image: `docker pull gitea.va.reichard.io/evan/bookmanager:latest` +## KOSync API + +The KOSync compatible API endpoint is located at: `http(s):///api/ko` + ## Quick Start ```bash @@ -61,7 +65,7 @@ docker run \ gitea.va.reichard.io/evan/bookmanager:latest ``` -The service is now accessible at: `http://localhost:8585` +The service is now accessible at: `http://localhost:8585`. I recommend registering an account and then disabling registration unless you expect more users. ## Configuration 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/client/README.md b/client/README.md index be5a5db..7c2d1e8 100644 --- a/client/README.md +++ b/client/README.md @@ -1,15 +1,21 @@ # Book Manager - SyncNinja KOReader Plugin -This is BookManagers KOReader Plugin called `syncninja.koplugin`. +This is BookManagers KOReader Plugin called `syncninja.koplugin`. Features include: -# Installation +- Syncing read activity +- Uploading documents +- Configurable sync settings + +## Installation Copy the `syncninja.koplugin` directory to the `plugins` directory for your KOReader installation. Restart KOReader and SyncNinja will be accessible via the Tools menu. -# Configuration +## Configuration You must configure the BookManager server and credentials in SyncNinja. Afterwhich you'll have the ability to configure the sync cadence as well as whether you'd like the plugin to sync your activity, document metadata, and/or documents themselves. -# KOSync Compatibility +## KOSync Compatibility BookManager implements API's compatible with the KOSync plugin. This means that you can utilize this server for KOSync (and it's recommended!). SyncNinja provides an easy way to merge configurations between both KOSync and itself in the menu. + +The KOSync compatible API endpoint is located at: `http(s):///api/ko`. You can either use the previous mentioned merge feature to automatically configure KOSync once SyncNinja is configured, or you can manually set KOSync's server to the above. 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 + +