Compare commits
34 Commits
gocomponen
...
evan/api-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c872264f | |||
| 0930054847 | |||
| aa812c6917 | |||
| 8ec3349b7c | |||
| decc3f0195 | |||
| b13f9b362c | |||
| 6c2c4f6b8b | |||
| d38392ac9a | |||
| 63ad73755d | |||
| 784e53c557 | |||
| 9ed63b2695 | |||
| 27e651c4f5 | |||
| 7e96e41ba4 | |||
| ee1d62858b | |||
| 4d133994ab | |||
| ba919bbde4 | |||
| 197a1577c2 | |||
| fd9afe86b0 | |||
| 93707ff513 | |||
| 75e0228fe0 | |||
| b1b8eb297e | |||
| 7c47f2d2eb | |||
| c46dcb440d | |||
| 5cb17bace7 | |||
| ecf77fd105 | |||
| e289d1a29b | |||
| 3e9a193d08 | |||
| 4306d86080 | |||
| d40f8fc375 | |||
| c84bc2522e | |||
| 0704b5d650 | |||
| 4c1789fc16 | |||
| 082f7e926c | |||
| 6031cf06d4 |
@@ -1,6 +0,0 @@
|
|||||||
#:schema https://golangci-lint.run/jsonschema/golangci.jsonschema.json
|
|
||||||
version = "2"
|
|
||||||
|
|
||||||
[[linters.exclusions.rules]]
|
|
||||||
linters = [ "errcheck" ]
|
|
||||||
source = "^\\s*defer\\s+"
|
|
||||||
75
AGENTS.md
Normal file
75
AGENTS.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# AnthoLume Agent Guide
|
||||||
|
|
||||||
|
## 1) Working Style
|
||||||
|
|
||||||
|
- Keep changes targeted.
|
||||||
|
- Do not refactor broadly unless the task requires it.
|
||||||
|
- Validate only what is relevant to the change when practical.
|
||||||
|
- If a fix will require substantial refactoring or wide-reaching changes, stop and ask first.
|
||||||
|
|
||||||
|
## 2) Hard Rules
|
||||||
|
|
||||||
|
- Never edit generated files directly.
|
||||||
|
- Never write ad-hoc SQL.
|
||||||
|
- For Go error wrapping, use `fmt.Errorf("message: %w", err)`.
|
||||||
|
- Do not use `github.com/pkg/errors`.
|
||||||
|
|
||||||
|
## 3) Generated Code
|
||||||
|
|
||||||
|
### OpenAPI
|
||||||
|
Edit:
|
||||||
|
- `api/v1/openapi.yaml`
|
||||||
|
|
||||||
|
Regenerate:
|
||||||
|
- `go generate ./api/v1/generate.go`
|
||||||
|
- `cd frontend && bun run generate:api`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- If you add response headers in `api/v1/openapi.yaml` (for example `Set-Cookie`), `oapi-codegen` will generate typed response header structs in `api/v1/api.gen.go`; update the handler response values to populate those headers explicitly.
|
||||||
|
|
||||||
|
Examples of generated files:
|
||||||
|
- `api/v1/api.gen.go`
|
||||||
|
- `frontend/src/generated/**/*.ts`
|
||||||
|
|
||||||
|
### SQLC
|
||||||
|
Edit:
|
||||||
|
- `database/query.sql`
|
||||||
|
|
||||||
|
Regenerate:
|
||||||
|
- `sqlc generate`
|
||||||
|
|
||||||
|
## 4) Backend / Assets
|
||||||
|
|
||||||
|
### Common commands
|
||||||
|
- Dev server: `make dev`
|
||||||
|
- Direct dev run: `CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve`
|
||||||
|
- Tests: `make tests`
|
||||||
|
- Tailwind asset build: `make build_tailwind`
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- The Go server embeds `templates/*` and `assets/*`.
|
||||||
|
- Root Tailwind output is built to `assets/style.css`.
|
||||||
|
- Be mindful of whether a change affects the embedded server-rendered app, the React frontend, or both.
|
||||||
|
- SQLite timestamps are stored as RFC3339 strings (usually with a trailing `Z`); prefer `parseTime` / `parseTimePtr` instead of ad-hoc `time.Parse` layouts.
|
||||||
|
|
||||||
|
## 5) Frontend
|
||||||
|
|
||||||
|
For frontend-specific implementation notes and commands, also read:
|
||||||
|
- `frontend/AGENTS.md`
|
||||||
|
|
||||||
|
## 6) Regeneration Summary
|
||||||
|
|
||||||
|
- Go API: `go generate ./api/v1/generate.go`
|
||||||
|
- Frontend API client: `cd frontend && bun run generate:api`
|
||||||
|
- SQLC: `sqlc generate`
|
||||||
|
|
||||||
|
## 7) Updating This File
|
||||||
|
|
||||||
|
After completing a task, update this `AGENTS.md` if you learned something general that would help future agents.
|
||||||
|
|
||||||
|
Rules for updates:
|
||||||
|
- Add only repository-wide guidance.
|
||||||
|
- Do not add one-off task history.
|
||||||
|
- Keep updates short, concrete, and organized.
|
||||||
|
- Place new guidance in the most relevant section.
|
||||||
|
- If the new information would help future agents avoid repeated mistakes, add it proactively.
|
||||||
2
Makefile
2
Makefile
@@ -27,7 +27,7 @@ docker_build_release_latest: build_tailwind
|
|||||||
--push .
|
--push .
|
||||||
|
|
||||||
build_tailwind:
|
build_tailwind:
|
||||||
tailwindcss build -o ./assets/tailwind.css --minify
|
tailwindcss build -o ./assets/style.css --minify
|
||||||
|
|
||||||
dev: build_tailwind
|
dev: build_tailwind
|
||||||
GIN_MODE=release \
|
GIN_MODE=release \
|
||||||
|
|||||||
73
api/api.go
73
api/api.go
@@ -16,7 +16,6 @@ import (
|
|||||||
"github.com/gin-contrib/sessions/cookie"
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/pkg/errors"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/antholume/config"
|
"reichard.io/antholume/config"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
@@ -114,6 +113,11 @@ func (api *API) Start() error {
|
|||||||
return api.httpServer.ListenAndServe()
|
return api.httpServer.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handler returns the underlying http.Handler for the Gin router
|
||||||
|
func (api *API) Handler() http.Handler {
|
||||||
|
return api.httpServer.Handler
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) Stop() error {
|
func (api *API) Stop() error {
|
||||||
// Stop server
|
// Stop server
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -136,43 +140,35 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
|||||||
router.GET("/favicon.ico", api.appFaviconIcon)
|
router.GET("/favicon.ico", api.appFaviconIcon)
|
||||||
router.GET("/sw.js", api.appServiceWorker)
|
router.GET("/sw.js", api.appServiceWorker)
|
||||||
|
|
||||||
// Web App - Offline
|
// Local / offline static pages (no template, no auth)
|
||||||
router.GET("/local", api.appLocalDocuments)
|
router.GET("/local", api.appLocalDocuments)
|
||||||
|
|
||||||
// Web App - Reader
|
// Reader (reader page, document progress, devices)
|
||||||
router.GET("/reader", api.appDocumentReader)
|
router.GET("/reader", api.appDocumentReader)
|
||||||
router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices)
|
router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices)
|
||||||
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
|
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
|
||||||
|
|
||||||
// Web App - Templates
|
// Web app
|
||||||
router.GET("/", api.authWebAppMiddleware, api.appGetHome) // DONE
|
router.GET("/", api.authWebAppMiddleware, api.appGetHome)
|
||||||
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity) // DONE
|
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity)
|
||||||
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress) // DONE
|
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress)
|
||||||
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments) // DONE
|
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments)
|
||||||
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument) // DONE
|
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument)
|
||||||
|
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage))
|
||||||
// Web App - Other Routes
|
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage))
|
||||||
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage)) // DONE
|
|
||||||
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage)) // DONE
|
|
||||||
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout) // DONE
|
|
||||||
router.POST("/login", api.appAuthLogin) // DONE
|
|
||||||
router.POST("/register", api.appAuthRegister) // DONE
|
|
||||||
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings) // DONE
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
router.GET("/login", api.appGetLogin)
|
router.GET("/login", api.appGetLogin)
|
||||||
|
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout)
|
||||||
router.GET("/register", api.appGetRegister)
|
router.GET("/register", api.appGetRegister)
|
||||||
|
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
|
||||||
// DONE
|
|
||||||
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
|
|
||||||
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
|
|
||||||
|
|
||||||
// TODO - WIP
|
|
||||||
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
|
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
|
||||||
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
|
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
|
||||||
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
|
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
|
||||||
router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers)
|
router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers)
|
||||||
router.POST("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appUpdateAdminUsers)
|
router.POST("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appUpdateAdminUsers)
|
||||||
|
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
|
||||||
|
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
|
||||||
|
router.POST("/login", api.appAuthLogin)
|
||||||
|
router.POST("/register", api.appAuthRegister)
|
||||||
|
|
||||||
// Demo mode enabled configuration
|
// Demo mode enabled configuration
|
||||||
if api.cfg.DemoMode {
|
if api.cfg.DemoMode {
|
||||||
@@ -182,18 +178,17 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
|||||||
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError)
|
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError)
|
||||||
router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError)
|
router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError)
|
||||||
} else {
|
} else {
|
||||||
router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument) // DONE
|
router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument)
|
||||||
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument) // DONE
|
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument)
|
||||||
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument) // DONE
|
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument)
|
||||||
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocumentNew) // DONE
|
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocument)
|
||||||
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // DONE
|
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search enabled configuration
|
// Search enabled configuration
|
||||||
if api.cfg.SearchEnabled {
|
if api.cfg.SearchEnabled {
|
||||||
router.GET("/search", api.authWebAppMiddleware, api.appGetSearch) // DONE
|
router.GET("/search", api.authWebAppMiddleware, api.appGetSearch)
|
||||||
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) // TODO
|
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +297,7 @@ func (api *API) loadTemplates(
|
|||||||
templateDirectory := fmt.Sprintf("templates/%ss", basePath)
|
templateDirectory := fmt.Sprintf("templates/%ss", basePath)
|
||||||
allFiles, err := fs.ReadDir(api.assets, templateDirectory)
|
allFiles, err := fs.ReadDir(api.assets, templateDirectory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, fmt.Sprintf("unable to read template dir: %s", templateDirectory))
|
return fmt.Errorf("unable to read template dir %s: %w", templateDirectory, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Templates
|
// Generate Templates
|
||||||
@@ -314,7 +309,7 @@ func (api *API) loadTemplates(
|
|||||||
// Read Template
|
// Read Template
|
||||||
b, err := fs.ReadFile(api.assets, templatePath)
|
b, err := fs.ReadFile(api.assets, templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, fmt.Sprintf("unable to read template: %s", templateName))
|
return fmt.Errorf("unable to read template %s: %w", templateName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone? (Pages - Don't Stomp)
|
// Clone? (Pages - Don't Stomp)
|
||||||
@@ -325,7 +320,7 @@ func (api *API) loadTemplates(
|
|||||||
// Parse Template
|
// Parse Template
|
||||||
baseTemplate, err = baseTemplate.New(templateName).Parse(string(b))
|
baseTemplate, err = baseTemplate.New(templateName).Parse(string(b))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, fmt.Sprintf("unable to parse template: %s", templateName))
|
return fmt.Errorf("unable to parse template %s: %w", templateName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
allTemplates[templateName] = baseTemplate
|
allTemplates[templateName] = baseTemplate
|
||||||
@@ -363,13 +358,13 @@ func loggingMiddleware(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get username
|
// Get username
|
||||||
var auth *authData
|
var auth authData
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth = data.(*authData)
|
auth = data.(authData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log user
|
// Log user
|
||||||
if auth != nil && auth.UserName != "" {
|
if auth.UserName != "" {
|
||||||
logData["user"] = auth.UserName
|
logData["user"] = auth.UserName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,10 @@ import (
|
|||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/itchyny/gojq"
|
"github.com/itchyny/gojq"
|
||||||
"github.com/pkg/errors"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
"reichard.io/antholume/utils"
|
"reichard.io/antholume/utils"
|
||||||
"reichard.io/antholume/web/models"
|
|
||||||
"reichard.io/antholume/web/pages"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminAction string
|
type adminAction string
|
||||||
@@ -98,31 +95,21 @@ type importResult struct {
|
|||||||
Error error
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetAdmin(c *gin.Context) {
|
|
||||||
api.renderPage(c, &pages.AdminGeneral{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appPerformAdminAction(c *gin.Context) {
|
func (api *API) appPerformAdminAction(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
||||||
|
|
||||||
var rAdminAction requestAdminAction
|
var rAdminAction requestAdminAction
|
||||||
if err := c.ShouldBind(&rAdminAction); err != nil {
|
if err := c.ShouldBind(&rAdminAction); err != nil {
|
||||||
log.Error("invalid or missing form values")
|
log.Error("Invalid Form Bind: ", err)
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var allNotifications []*models.Notification
|
|
||||||
switch rAdminAction.Action {
|
switch rAdminAction.Action {
|
||||||
case adminRestore:
|
|
||||||
api.processRestoreFile(rAdminAction, c)
|
|
||||||
return
|
|
||||||
case adminBackup:
|
|
||||||
api.processBackup(c, rAdminAction.BackupTypes)
|
|
||||||
return
|
|
||||||
case adminMetadataMatch:
|
case adminMetadataMatch:
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
// TODO
|
||||||
Type: models.NotificationTypeError,
|
// 1. Documents xref most recent metadata table?
|
||||||
Content: "Metadata match not implemented",
|
// 2. Select all / deselect?
|
||||||
})
|
|
||||||
case adminCacheTables:
|
case adminCacheTables:
|
||||||
go func() {
|
go func() {
|
||||||
err := api.db.CacheTempTables(c)
|
err := api.db.CacheTempTables(c)
|
||||||
@@ -130,14 +117,49 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
|
|||||||
log.Error("Unable to cache temp tables: ", err)
|
log.Error("Unable to cache temp tables: ", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
case adminRestore:
|
||||||
|
api.processRestoreFile(rAdminAction, c)
|
||||||
|
return
|
||||||
|
case adminBackup:
|
||||||
|
// Vacuum
|
||||||
|
_, err := api.db.DB.ExecContext(c, "VACUUM;")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to vacuum DB: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
// Set Headers
|
||||||
Type: models.NotificationTypeSuccess,
|
c.Header("Content-type", "application/octet-stream")
|
||||||
Content: "Initiated table cache",
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
|
||||||
|
|
||||||
|
// Stream Backup ZIP Archive
|
||||||
|
c.Stream(func(w io.Writer) bool {
|
||||||
|
var directories []string
|
||||||
|
for _, item := range rAdminAction.BackupTypes {
|
||||||
|
if item == backupCovers {
|
||||||
|
directories = append(directories, "covers")
|
||||||
|
} else if item == backupDocuments {
|
||||||
|
directories = append(directories, "documents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := api.createBackup(c, w, directories)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Backup Error: ", err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
api.renderPage(c, &pages.AdminGeneral{}, allNotifications...)
|
c.HTML(http.StatusOK, "page/admin", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetAdmin(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
||||||
|
c.HTML(http.StatusOK, "page/admin", templateVars)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetAdminLogs(c *gin.Context) {
|
func (api *API) appGetAdminLogs(c *gin.Context) {
|
||||||
@@ -510,40 +532,6 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
|
|||||||
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
|
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) processBackup(c *gin.Context, backupTypes []backupType) {
|
|
||||||
// Vacuum
|
|
||||||
_, err := api.db.DB.ExecContext(c, "VACUUM;")
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Unable to vacuum DB: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Headers
|
|
||||||
c.Header("Content-type", "application/octet-stream")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
|
|
||||||
|
|
||||||
// Stream Backup ZIP Archive
|
|
||||||
c.Stream(func(w io.Writer) bool {
|
|
||||||
var directories []string
|
|
||||||
for _, item := range backupTypes {
|
|
||||||
switch item {
|
|
||||||
case backupCovers:
|
|
||||||
directories = append(directories, "covers")
|
|
||||||
case backupDocuments:
|
|
||||||
directories = append(directories, "documents")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := api.createBackup(c, w, directories)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Backup Error: ", err)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
|
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
|
||||||
// Validate Type & Derive Extension on MIME
|
// Validate Type & Derive Extension on MIME
|
||||||
uploadedFile, err := rAdminAction.RestoreFile.Open()
|
uploadedFile, err := rAdminAction.RestoreFile.Open()
|
||||||
@@ -733,7 +721,7 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str
|
|||||||
// Vacuum DB
|
// Vacuum DB
|
||||||
_, err := api.db.DB.ExecContext(ctx, "VACUUM;")
|
_, err := api.db.DB.ExecContext(ctx, "VACUUM;")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "Unable to vacuum database")
|
return fmt.Errorf("Unable to vacuum database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ar := zip.NewWriter(w)
|
ar := zip.NewWriter(w)
|
||||||
@@ -800,14 +788,14 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = ar.Close()
|
ar.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
|
func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
|
||||||
allUsers, err := api.db.Queries.GetUsers(ctx)
|
allUsers, err := api.db.Queries.GetUsers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err))
|
return false, fmt.Errorf("GetUsers DB Error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAdmin := false
|
hasAdmin := false
|
||||||
@@ -884,7 +872,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
|
|||||||
} else {
|
} else {
|
||||||
user, err := api.db.Queries.GetUser(ctx, user)
|
user, err := api.db.Queries.GetUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err))
|
return fmt.Errorf("GetUser DB Error: %w", err)
|
||||||
}
|
}
|
||||||
updateParams.Admin = user.Admin
|
updateParams.Admin = user.Admin
|
||||||
}
|
}
|
||||||
@@ -922,7 +910,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
|
|||||||
// Update User
|
// Update User
|
||||||
_, err := api.db.Queries.UpdateUser(ctx, updateParams)
|
_, err := api.db.Queries.UpdateUser(ctx, updateParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err))
|
return fmt.Errorf("UpdateUser DB Error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -954,7 +942,7 @@ func (api *API) deleteUser(ctx context.Context, user string) error {
|
|||||||
// Delete User
|
// Delete User
|
||||||
_, err = api.db.Queries.DeleteUser(ctx, user)
|
_, err = api.db.Queries.DeleteUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err))
|
return fmt.Errorf("DeleteUser DB Error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,513 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"crypto/md5"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
argon2 "github.com/alexedwards/argon2id"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
"reichard.io/antholume/metadata"
|
|
||||||
"reichard.io/antholume/pkg/formatters"
|
|
||||||
"reichard.io/antholume/pkg/ptr"
|
|
||||||
"reichard.io/antholume/pkg/sliceutils"
|
|
||||||
"reichard.io/antholume/pkg/utils"
|
|
||||||
"reichard.io/antholume/search"
|
|
||||||
"reichard.io/antholume/web/components/stats"
|
|
||||||
"reichard.io/antholume/web/models"
|
|
||||||
"reichard.io/antholume/web/pages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (api *API) appGetHome(c *gin.Context) {
|
|
||||||
_, auth := api.getBaseTemplateVars("home", c)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get daily read stats")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get daily read stats: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
|
|
||||||
|
|
||||||
start = time.Now()
|
|
||||||
databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get database info")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get database info: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
|
|
||||||
|
|
||||||
start = time.Now()
|
|
||||||
streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get user streaks")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user streaks: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
|
|
||||||
|
|
||||||
start = time.Now()
|
|
||||||
userStatistics, err := api.db.Queries.GetUserStatistics(c)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get user statistics")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user statistics: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
|
|
||||||
|
|
||||||
api.renderPage(c, &pages.Home{
|
|
||||||
Leaderboard: arrangeUserStatistic(userStatistics),
|
|
||||||
Streaks: streaks,
|
|
||||||
DailyStats: dailyStats,
|
|
||||||
RecordInfo: &databaseInfo,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetDocuments(c *gin.Context) {
|
|
||||||
qParams, err := bindQueryParams(c, 9)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind query params")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var query *string
|
|
||||||
if qParams.Search != nil && *qParams.Search != "" {
|
|
||||||
search := "%" + *qParams.Search + "%"
|
|
||||||
query = &search
|
|
||||||
}
|
|
||||||
|
|
||||||
_, auth := api.getBaseTemplateVars("documents", c)
|
|
||||||
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
Query: query,
|
|
||||||
Deleted: ptr.Of(false),
|
|
||||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
|
||||||
Limit: *qParams.Limit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get documents with stats")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get documents with stats: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
length, err := api.db.Queries.GetDocumentsSize(c, query)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get document sizes")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document sizes: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = api.getDocumentsWordCount(c, documents); err != nil {
|
|
||||||
log.WithError(err).Error("failed to get word counts")
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
|
|
||||||
nextPage := *qParams.Page + 1
|
|
||||||
previousPage := *qParams.Page - 1
|
|
||||||
|
|
||||||
api.renderPage(c, pages.Documents{
|
|
||||||
Data: sliceutils.Map(documents, convertDBDocToUI),
|
|
||||||
Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0),
|
|
||||||
Next: utils.Ternary(nextPage <= totalPages, int(nextPage), 0),
|
|
||||||
Limit: int(ptr.Deref(qParams.Limit)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetDocument(c *gin.Context) {
|
|
||||||
var rDocID requestDocumentID
|
|
||||||
if err := c.ShouldBindUri(&rDocID); err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind URI")
|
|
||||||
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, auth := api.getBaseTemplateVars("document", c)
|
|
||||||
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get document")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.renderPage(c, &pages.Document{Data: convertDBDocToUI(*document)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetActivity(c *gin.Context) {
|
|
||||||
qParams, err := bindQueryParams(c, 15)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind query params")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, auth := api.getBaseTemplateVars("activity", c)
|
|
||||||
activity, err := api.db.Queries.GetActivity(c, database.GetActivityParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
|
||||||
Limit: *qParams.Limit,
|
|
||||||
DocFilter: qParams.Document != nil,
|
|
||||||
DocumentID: ptr.Deref(qParams.Document),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get activity")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get activity: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.renderPage(c, &pages.Activity{Data: sliceutils.Map(activity, convertDBActivityToUI)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetProgress(c *gin.Context) {
|
|
||||||
qParams, err := bindQueryParams(c, 15)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind query params")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, auth := api.getBaseTemplateVars("progress", c)
|
|
||||||
progress, err := api.db.Queries.GetProgress(c, database.GetProgressParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
|
||||||
Limit: *qParams.Limit,
|
|
||||||
DocFilter: qParams.Document != nil,
|
|
||||||
DocumentID: ptr.Deref(qParams.Document),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get progress")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get progress: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.renderPage(c, &pages.Progress{Data: sliceutils.Map(progress, convertDBProgressToUI)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appIdentifyDocumentNew(c *gin.Context) {
|
|
||||||
var rDocID requestDocumentID
|
|
||||||
if err := c.ShouldBindUri(&rDocID); err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind URI")
|
|
||||||
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var rDocIdentify requestDocumentIdentify
|
|
||||||
if err := c.ShouldBind(&rDocIdentify); err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind form")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disallow Empty Strings
|
|
||||||
if rDocIdentify.Title != nil && strings.TrimSpace(*rDocIdentify.Title) == "" {
|
|
||||||
rDocIdentify.Title = nil
|
|
||||||
}
|
|
||||||
if rDocIdentify.Author != nil && strings.TrimSpace(*rDocIdentify.Author) == "" {
|
|
||||||
rDocIdentify.Author = nil
|
|
||||||
}
|
|
||||||
if rDocIdentify.ISBN != nil && strings.TrimSpace(*rDocIdentify.ISBN) == "" {
|
|
||||||
rDocIdentify.ISBN = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Values
|
|
||||||
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
|
|
||||||
log.Error("invalid or missing form values")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Metadata
|
|
||||||
var searchResult *models.DocumentMetadata
|
|
||||||
var allNotifications []*models.Notification
|
|
||||||
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
|
||||||
Title: rDocIdentify.Title,
|
|
||||||
Author: rDocIdentify.Author,
|
|
||||||
ISBN10: rDocIdentify.ISBN,
|
|
||||||
ISBN13: rDocIdentify.ISBN,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to search metadata")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to search metadata: %s", err))
|
|
||||||
return
|
|
||||||
} else if firstResult, found := sliceutils.First(metadataResults); found {
|
|
||||||
searchResult = convertMetaToUI(firstResult)
|
|
||||||
|
|
||||||
// Store First Metadata Result
|
|
||||||
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
|
|
||||||
DocumentID: rDocID.DocumentID,
|
|
||||||
Title: firstResult.Title,
|
|
||||||
Author: firstResult.Author,
|
|
||||||
Description: firstResult.Description,
|
|
||||||
Gbid: firstResult.SourceID,
|
|
||||||
Isbn10: firstResult.ISBN10,
|
|
||||||
Isbn13: firstResult.ISBN13,
|
|
||||||
}); err != nil {
|
|
||||||
log.WithError(err).Error("failed to add metadata")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
|
||||||
Type: models.NotificationTypeError,
|
|
||||||
Content: "No Metadata Found",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Auth
|
|
||||||
_, auth := api.getBaseTemplateVars("document", c)
|
|
||||||
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get document")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.renderPage(c, &pages.Document{
|
|
||||||
Data: convertDBDocToUI(*document),
|
|
||||||
Search: searchResult,
|
|
||||||
}, allNotifications...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tabs:
|
|
||||||
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
|
||||||
// - Users
|
|
||||||
// - Metadata
|
|
||||||
func (api *API) appGetSearch(c *gin.Context) {
|
|
||||||
var sParams searchParams
|
|
||||||
if err := c.BindQuery(&sParams); err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind form")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only Handle Query
|
|
||||||
var searchResults []models.SearchResult
|
|
||||||
var searchError string
|
|
||||||
if sParams.Query != nil && sParams.Source != nil {
|
|
||||||
results, err := search.SearchBook(*sParams.Query, *sParams.Source)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to search book")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
searchResults = sliceutils.Map(results, convertSearchToUI)
|
|
||||||
} else if sParams.Query != nil || sParams.Source != nil {
|
|
||||||
searchError = "Invailid Query"
|
|
||||||
}
|
|
||||||
|
|
||||||
api.renderPage(c, &pages.Search{
|
|
||||||
Results: searchResults,
|
|
||||||
Source: ptr.Deref(sParams.Source),
|
|
||||||
Query: ptr.Deref(sParams.Query),
|
|
||||||
Error: searchError,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetSettings(c *gin.Context) {
|
|
||||||
_, auth := api.getBaseTemplateVars("settings", c)
|
|
||||||
|
|
||||||
user, err := api.db.Queries.GetUser(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get user")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get devices")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.renderPage(c, &pages.Settings{
|
|
||||||
Timezone: ptr.Deref(user.Timezone),
|
|
||||||
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appEditSettings(c *gin.Context) {
|
|
||||||
var rUserSettings requestSettingsEdit
|
|
||||||
if err := c.ShouldBind(&rUserSettings); err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind form")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Something Exists
|
|
||||||
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil {
|
|
||||||
log.Error("invalid or missing form values")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, auth := api.getBaseTemplateVars("settings", c)
|
|
||||||
|
|
||||||
newUserSettings := database.UpdateUserParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
Admin: auth.IsAdmin,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set New Password
|
|
||||||
var allNotifications []*models.Notification
|
|
||||||
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
|
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
|
|
||||||
if _, err := api.authorizeCredentials(c, auth.UserName, password); err != nil {
|
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
|
||||||
Type: models.NotificationTypeError,
|
|
||||||
Content: "Invalid Password",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
|
|
||||||
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
|
|
||||||
if err != nil {
|
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
|
||||||
Type: models.NotificationTypeError,
|
|
||||||
Content: "Unknown Error",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
|
||||||
Type: models.NotificationTypeSuccess,
|
|
||||||
Content: "Password Updated",
|
|
||||||
})
|
|
||||||
newUserSettings.Password = &hashedPassword
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Time Offset
|
|
||||||
if rUserSettings.Timezone != nil {
|
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
|
||||||
Type: models.NotificationTypeSuccess,
|
|
||||||
Content: "Time Offset Updated",
|
|
||||||
})
|
|
||||||
newUserSettings.Timezone = rUserSettings.Timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update User
|
|
||||||
_, err := api.db.Queries.UpdateUser(c, newUserSettings)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to update user")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to update user: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get User
|
|
||||||
user, err := api.db.Queries.GetUser(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get user")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Devices
|
|
||||||
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to get devices")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.renderPage(c, &pages.Settings{
|
|
||||||
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
|
|
||||||
Timezone: ptr.Deref(user.Timezone),
|
|
||||||
}, allNotifications...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) renderPage(c *gin.Context, page pages.Page, notifications ...*models.Notification) {
|
|
||||||
// Get Authentication Data
|
|
||||||
auth, err := getAuthData(c)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to acquire auth data")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to acquire auth data: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate Page
|
|
||||||
pageNode, err := page.Generate(models.PageContext{
|
|
||||||
UserInfo: &models.UserInfo{
|
|
||||||
Username: auth.UserName,
|
|
||||||
IsAdmin: auth.IsAdmin,
|
|
||||||
},
|
|
||||||
ServerInfo: &models.ServerInfo{
|
|
||||||
RegistrationEnabled: api.cfg.RegistrationEnabled,
|
|
||||||
SearchEnabled: api.cfg.SearchEnabled,
|
|
||||||
Version: api.cfg.Version,
|
|
||||||
},
|
|
||||||
Notifications: notifications,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to generate page")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to generate page: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render Page
|
|
||||||
err = pageNode.Render(c.Writer)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to render page")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to render page: %s", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortItem[T cmp.Ordered](
|
|
||||||
data []database.GetUserStatisticsRow,
|
|
||||||
accessor func(s database.GetUserStatisticsRow) T,
|
|
||||||
formatter func(s T) string,
|
|
||||||
) []stats.LeaderboardItem {
|
|
||||||
sort.SliceStable(data, func(i, j int) bool {
|
|
||||||
return accessor(data[i]) > accessor(data[j])
|
|
||||||
})
|
|
||||||
|
|
||||||
var items []stats.LeaderboardItem
|
|
||||||
for _, s := range data {
|
|
||||||
items = append(items, stats.LeaderboardItem{
|
|
||||||
UserID: s.UserID,
|
|
||||||
Value: formatter(accessor(s)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func arrangeUserStatistic(data []database.GetUserStatisticsRow) []stats.LeaderboardData {
|
|
||||||
wpmFormatter := func(v float64) string { return fmt.Sprintf("%.2f WPM", v) }
|
|
||||||
return []stats.LeaderboardData{
|
|
||||||
{
|
|
||||||
Name: "WPM",
|
|
||||||
All: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.TotalWpm }, wpmFormatter),
|
|
||||||
Year: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.YearlyWpm }, wpmFormatter),
|
|
||||||
Month: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.MonthlyWpm }, wpmFormatter),
|
|
||||||
Week: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.WeeklyWpm }, wpmFormatter),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Words",
|
|
||||||
All: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.TotalWordsRead }, formatters.FormatNumber),
|
|
||||||
Year: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.YearlyWordsRead }, formatters.FormatNumber),
|
|
||||||
Month: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.MonthlyWordsRead }, formatters.FormatNumber),
|
|
||||||
Week: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.WeeklyWordsRead }, formatters.FormatNumber),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Duration",
|
|
||||||
All: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
|
|
||||||
return time.Duration(r.TotalSeconds) * time.Second
|
|
||||||
}, formatters.FormatDuration),
|
|
||||||
Year: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
|
|
||||||
return time.Duration(r.YearlySeconds) * time.Second
|
|
||||||
}, formatters.FormatDuration),
|
|
||||||
Month: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
|
|
||||||
return time.Duration(r.MonthlySeconds) * time.Second
|
|
||||||
}, formatters.FormatDuration),
|
|
||||||
Week: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
|
|
||||||
return time.Duration(r.WeeklySeconds) * time.Second
|
|
||||||
}, formatters.FormatDuration),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,22 +2,28 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
argon2 "github.com/alexedwards/argon2id"
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
|
"reichard.io/antholume/pkg/ptr"
|
||||||
"reichard.io/antholume/search"
|
"reichard.io/antholume/search"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,6 +101,242 @@ func (api *API) appDocumentReader(c *gin.Context) {
|
|||||||
c.FileFromFS("assets/reader/index.htm", http.FS(api.assets))
|
c.FileFromFS("assets/reader/index.htm", http.FS(api.assets))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetDocuments(c *gin.Context) {
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("documents", c)
|
||||||
|
qParams := bindQueryParams(c, 9)
|
||||||
|
|
||||||
|
var query *string
|
||||||
|
if qParams.Search != nil && *qParams.Search != "" {
|
||||||
|
search := "%" + *qParams.Search + "%"
|
||||||
|
query = &search
|
||||||
|
}
|
||||||
|
|
||||||
|
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Query: query,
|
||||||
|
Deleted: ptr.Of(false),
|
||||||
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
|
Limit: *qParams.Limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocumentsWithStats DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
length, err := api.db.Queries.GetDocumentsSize(c, query)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocumentsSize DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = api.getDocumentsWordCount(c, documents); err != nil {
|
||||||
|
log.Error("Unable to Get Word Counts: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
|
||||||
|
nextPage := *qParams.Page + 1
|
||||||
|
previousPage := *qParams.Page - 1
|
||||||
|
|
||||||
|
if nextPage <= totalPages {
|
||||||
|
templateVars["NextPage"] = nextPage
|
||||||
|
}
|
||||||
|
|
||||||
|
if previousPage >= 0 {
|
||||||
|
templateVars["PreviousPage"] = previousPage
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["PageLimit"] = *qParams.Limit
|
||||||
|
templateVars["Data"] = documents
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/documents", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetDocument(c *gin.Context) {
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("document", c)
|
||||||
|
|
||||||
|
var rDocID requestDocumentID
|
||||||
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocument DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = document
|
||||||
|
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/document", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetProgress(c *gin.Context) {
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("progress", c)
|
||||||
|
|
||||||
|
qParams := bindQueryParams(c, 15)
|
||||||
|
|
||||||
|
progressFilter := database.GetProgressParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
|
Limit: *qParams.Limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if qParams.Document != nil {
|
||||||
|
progressFilter.DocFilter = true
|
||||||
|
progressFilter.DocumentID = *qParams.Document
|
||||||
|
}
|
||||||
|
|
||||||
|
progress, err := api.db.Queries.GetProgress(c, progressFilter)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetProgress DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = progress
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/progress", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetActivity(c *gin.Context) {
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("activity", c)
|
||||||
|
qParams := bindQueryParams(c, 15)
|
||||||
|
|
||||||
|
activityFilter := database.GetActivityParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
|
Limit: *qParams.Limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if qParams.Document != nil {
|
||||||
|
activityFilter.DocFilter = true
|
||||||
|
activityFilter.DocumentID = *qParams.Document
|
||||||
|
}
|
||||||
|
|
||||||
|
activity, err := api.db.Queries.GetActivity(c, activityFilter)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetActivity DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = activity
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/activity", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetHome(c *gin.Context) {
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("home", c)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
graphData, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDailyReadStats DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDatabaseInfo DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStreaks DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
userStatistics, err := api.db.Queries.GetUserStatistics(c)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStatistics DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
templateVars["Data"] = gin.H{
|
||||||
|
"Streaks": streaks,
|
||||||
|
"GraphData": graphData,
|
||||||
|
"DatabaseInfo": databaseInfo,
|
||||||
|
"UserStatistics": arrangeUserStatistics(userStatistics),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/home", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetSettings(c *gin.Context) {
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("settings", c)
|
||||||
|
|
||||||
|
user, err := api.db.Queries.GetUser(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUser DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDevices DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = gin.H{
|
||||||
|
"Timezone": *user.Timezone,
|
||||||
|
"Devices": devices,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/settings", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs:
|
||||||
|
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
||||||
|
// - Users
|
||||||
|
// - Metadata
|
||||||
|
func (api *API) appGetSearch(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("search", c)
|
||||||
|
|
||||||
|
var sParams searchParams
|
||||||
|
err := c.BindQuery(&sParams)
|
||||||
|
if err != nil {
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only Handle Query
|
||||||
|
if sParams.Query != nil && sParams.Source != nil {
|
||||||
|
// Search
|
||||||
|
searchResults, err := search.SearchBook(*sParams.Query, *sParams.Source)
|
||||||
|
if err != nil {
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = searchResults
|
||||||
|
templateVars["Source"] = *sParams.Source
|
||||||
|
} else if sParams.Query != nil || sParams.Source != nil {
|
||||||
|
templateVars["SearchErrorMessage"] = "Invalid Query"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/search", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) appGetLogin(c *gin.Context) {
|
func (api *API) appGetLogin(c *gin.Context) {
|
||||||
templateVars, _ := api.getBaseTemplateVars("login", c)
|
templateVars, _ := api.getBaseTemplateVars("login", c)
|
||||||
templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled
|
templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled
|
||||||
@@ -375,6 +617,85 @@ func (api *API) appDeleteDocument(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "../")
|
c.Redirect(http.StatusFound, "../")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) appIdentifyDocument(c *gin.Context) {
|
||||||
|
var rDocID requestDocumentID
|
||||||
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rDocIdentify requestDocumentIdentify
|
||||||
|
if err := c.ShouldBind(&rDocIdentify); err != nil {
|
||||||
|
log.Error("Invalid Form Bind")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow Empty Strings
|
||||||
|
if rDocIdentify.Title != nil && strings.TrimSpace(*rDocIdentify.Title) == "" {
|
||||||
|
rDocIdentify.Title = nil
|
||||||
|
}
|
||||||
|
if rDocIdentify.Author != nil && strings.TrimSpace(*rDocIdentify.Author) == "" {
|
||||||
|
rDocIdentify.Author = nil
|
||||||
|
}
|
||||||
|
if rDocIdentify.ISBN != nil && strings.TrimSpace(*rDocIdentify.ISBN) == "" {
|
||||||
|
rDocIdentify.ISBN = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Values
|
||||||
|
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
|
||||||
|
log.Error("Invalid Form")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Template Variables
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("document", c)
|
||||||
|
|
||||||
|
// Get Metadata
|
||||||
|
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
||||||
|
Title: rDocIdentify.Title,
|
||||||
|
Author: rDocIdentify.Author,
|
||||||
|
ISBN10: rDocIdentify.ISBN,
|
||||||
|
ISBN13: rDocIdentify.ISBN,
|
||||||
|
})
|
||||||
|
if err == nil && len(metadataResults) > 0 {
|
||||||
|
firstResult := metadataResults[0]
|
||||||
|
|
||||||
|
// Store First Metadata Result
|
||||||
|
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
|
||||||
|
DocumentID: rDocID.DocumentID,
|
||||||
|
Title: firstResult.Title,
|
||||||
|
Author: firstResult.Author,
|
||||||
|
Description: firstResult.Description,
|
||||||
|
Gbid: firstResult.ID,
|
||||||
|
Olid: nil,
|
||||||
|
Isbn10: firstResult.ISBN10,
|
||||||
|
Isbn13: firstResult.ISBN13,
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("AddMetadata DB Error: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Metadata"] = firstResult
|
||||||
|
} else {
|
||||||
|
log.Warn("Metadata Error")
|
||||||
|
templateVars["MetadataError"] = "No Metadata Found"
|
||||||
|
}
|
||||||
|
|
||||||
|
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocument DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = document
|
||||||
|
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/document", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) appSaveNewDocument(c *gin.Context) {
|
func (api *API) appSaveNewDocument(c *gin.Context) {
|
||||||
var rDocAdd requestDocumentAdd
|
var rDocAdd requestDocumentAdd
|
||||||
if err := c.ShouldBind(&rDocAdd); err != nil {
|
if err := c.ShouldBind(&rDocAdd); err != nil {
|
||||||
@@ -512,6 +833,84 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) appEditSettings(c *gin.Context) {
|
||||||
|
var rUserSettings requestSettingsEdit
|
||||||
|
if err := c.ShouldBind(&rUserSettings); err != nil {
|
||||||
|
log.Error("Invalid Form Bind")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Something Exists
|
||||||
|
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil {
|
||||||
|
log.Error("Missing Form Values")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("settings", c)
|
||||||
|
|
||||||
|
newUserSettings := database.UpdateUserParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Admin: auth.IsAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set New Password
|
||||||
|
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
|
||||||
|
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
|
||||||
|
data := api.authorizeCredentials(c, auth.UserName, password)
|
||||||
|
if data == nil {
|
||||||
|
templateVars["PasswordErrorMessage"] = "Invalid Password"
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Time Offset
|
||||||
|
if rUserSettings.Timezone != nil {
|
||||||
|
templateVars["TimeOffsetMessage"] = "Time Offset Updated"
|
||||||
|
newUserSettings.Timezone = rUserSettings.Timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update User
|
||||||
|
_, err := api.db.Queries.UpdateUser(c, newUserSettings)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("UpdateUser DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpdateUser DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get User
|
||||||
|
user, err := api.db.Queries.GetUser(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUser DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Devices
|
||||||
|
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDevices DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = gin.H{
|
||||||
|
"Timezone": *user.Timezone,
|
||||||
|
"Devices": devices,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/settings", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) appDemoModeError(c *gin.Context) {
|
func (api *API) appDemoModeError(c *gin.Context) {
|
||||||
appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
|
appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
|
||||||
}
|
}
|
||||||
@@ -559,10 +958,10 @@ func (api *API) getDocumentsWordCount(ctx context.Context, documents []database.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, *authData) {
|
func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, authData) {
|
||||||
var auth *authData
|
var auth authData
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth = data.(*authData)
|
auth = data.(authData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return gin.H{
|
return gin.H{
|
||||||
@@ -576,11 +975,12 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, *a
|
|||||||
}, auth
|
}, auth
|
||||||
}
|
}
|
||||||
|
|
||||||
func bindQueryParams(c *gin.Context, defaultLimit int64) (*queryParams, error) {
|
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
|
||||||
var qParams queryParams
|
var qParams queryParams
|
||||||
err := c.BindQuery(&qParams)
|
err := c.BindQuery(&qParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
|
||||||
|
return qParams
|
||||||
}
|
}
|
||||||
|
|
||||||
if qParams.Limit == nil {
|
if qParams.Limit == nil {
|
||||||
@@ -595,7 +995,7 @@ func bindQueryParams(c *gin.Context, defaultLimit int64) (*queryParams, error) {
|
|||||||
qParams.Page = &oneValue
|
qParams.Page = &oneValue
|
||||||
}
|
}
|
||||||
|
|
||||||
return &qParams, nil
|
return qParams
|
||||||
}
|
}
|
||||||
|
|
||||||
func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
||||||
@@ -618,3 +1018,80 @@ func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
|||||||
"Message": errorMessage,
|
"Message": errorMessage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H {
|
||||||
|
// Item Sorter
|
||||||
|
sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []map[string]any {
|
||||||
|
sortedData := append([]database.GetUserStatisticsRow(nil), userStatistics...)
|
||||||
|
sort.SliceStable(sortedData, less)
|
||||||
|
|
||||||
|
newData := make([]map[string]any, 0)
|
||||||
|
for _, item := range sortedData {
|
||||||
|
v := reflect.Indirect(reflect.ValueOf(item))
|
||||||
|
|
||||||
|
var value string
|
||||||
|
if strings.Contains(key, "Wpm") {
|
||||||
|
rawVal := v.FieldByName(key).Float()
|
||||||
|
value = fmt.Sprintf("%.2f WPM", rawVal)
|
||||||
|
} else if strings.Contains(key, "Seconds") {
|
||||||
|
rawVal := v.FieldByName(key).Int()
|
||||||
|
value = niceSeconds(rawVal)
|
||||||
|
} else if strings.Contains(key, "Words") {
|
||||||
|
rawVal := v.FieldByName(key).Int()
|
||||||
|
value = niceNumbers(rawVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
newData = append(newData, map[string]any{
|
||||||
|
"UserID": item.UserID,
|
||||||
|
"Value": value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return newData
|
||||||
|
}
|
||||||
|
|
||||||
|
return gin.H{
|
||||||
|
"WPM": gin.H{
|
||||||
|
"All": sortItem(userStatistics, "TotalWpm", func(i, j int) bool {
|
||||||
|
return userStatistics[i].TotalWpm > userStatistics[j].TotalWpm
|
||||||
|
}),
|
||||||
|
"Year": sortItem(userStatistics, "YearlyWpm", func(i, j int) bool {
|
||||||
|
return userStatistics[i].YearlyWpm > userStatistics[j].YearlyWpm
|
||||||
|
}),
|
||||||
|
"Month": sortItem(userStatistics, "MonthlyWpm", func(i, j int) bool {
|
||||||
|
return userStatistics[i].MonthlyWpm > userStatistics[j].MonthlyWpm
|
||||||
|
}),
|
||||||
|
"Week": sortItem(userStatistics, "WeeklyWpm", func(i, j int) bool {
|
||||||
|
return userStatistics[i].WeeklyWpm > userStatistics[j].WeeklyWpm
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"Duration": gin.H{
|
||||||
|
"All": sortItem(userStatistics, "TotalSeconds", func(i, j int) bool {
|
||||||
|
return userStatistics[i].TotalSeconds > userStatistics[j].TotalSeconds
|
||||||
|
}),
|
||||||
|
"Year": sortItem(userStatistics, "YearlySeconds", func(i, j int) bool {
|
||||||
|
return userStatistics[i].YearlySeconds > userStatistics[j].YearlySeconds
|
||||||
|
}),
|
||||||
|
"Month": sortItem(userStatistics, "MonthlySeconds", func(i, j int) bool {
|
||||||
|
return userStatistics[i].MonthlySeconds > userStatistics[j].MonthlySeconds
|
||||||
|
}),
|
||||||
|
"Week": sortItem(userStatistics, "WeeklySeconds", func(i, j int) bool {
|
||||||
|
return userStatistics[i].WeeklySeconds > userStatistics[j].WeeklySeconds
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"Words": gin.H{
|
||||||
|
"All": sortItem(userStatistics, "TotalWordsRead", func(i, j int) bool {
|
||||||
|
return userStatistics[i].TotalWordsRead > userStatistics[j].TotalWordsRead
|
||||||
|
}),
|
||||||
|
"Year": sortItem(userStatistics, "YearlyWordsRead", func(i, j int) bool {
|
||||||
|
return userStatistics[i].YearlyWordsRead > userStatistics[j].YearlyWordsRead
|
||||||
|
}),
|
||||||
|
"Month": sortItem(userStatistics, "MonthlyWordsRead", func(i, j int) bool {
|
||||||
|
return userStatistics[i].MonthlyWordsRead > userStatistics[j].MonthlyWordsRead
|
||||||
|
}),
|
||||||
|
"Week": sortItem(userStatistics, "WeeklyWordsRead", func(i, j int) bool {
|
||||||
|
return userStatistics[i].WeeklyWordsRead > userStatistics[j].WeeklyWordsRead
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
69
api/auth.go
69
api/auth.go
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,31 +29,31 @@ type authKOHeader struct {
|
|||||||
AuthKey string `header:"x-auth-key"`
|
AuthKey string `header:"x-auth-key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (*authData, error) {
|
func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (auth *authData) {
|
||||||
user, err := api.db.Queries.GetUser(ctx, username)
|
user, err := api.db.Queries.GetUser(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
|
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
|
||||||
return nil, err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Auth Cache
|
// Update auth cache
|
||||||
api.userAuthCache[user.ID] = *user.AuthHash
|
api.userAuthCache[user.ID] = *user.AuthHash
|
||||||
|
|
||||||
return &authData{
|
return &authData{
|
||||||
UserName: user.ID,
|
UserName: user.ID,
|
||||||
IsAdmin: user.Admin,
|
IsAdmin: user.Admin,
|
||||||
AuthHash: *user.AuthHash,
|
AuthHash: *user.AuthHash,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authKOMiddleware(c *gin.Context) {
|
func (api *API) authKOMiddleware(c *gin.Context) {
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session First
|
// Check Session First
|
||||||
if auth, ok := api.authorizeSession(c, session); ok {
|
if auth, ok := api.getSession(c, session); ok {
|
||||||
c.Set("Authorization", auth)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -65,25 +64,21 @@ func (api *API) authKOMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
var rHeader authKOHeader
|
var rHeader authKOHeader
|
||||||
if err := c.ShouldBindHeader(&rHeader); err != nil {
|
if err := c.ShouldBindHeader(&rHeader); err != nil {
|
||||||
log.WithError(err).Error("failed to bind auth headers")
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
|
if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
|
||||||
log.Error("invalid authentication headers")
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authData, err := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
|
authData := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
|
||||||
if err != nil {
|
if authData == nil {
|
||||||
log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to authorize credentials")
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := api.setSession(session, authData); err != nil {
|
if err := api.setSession(session, *authData); err != nil {
|
||||||
log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to set session")
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,16 +95,14 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate Auth Fields
|
// Validate Auth Fields
|
||||||
if !hasAuth || user == "" || rawPassword == "" {
|
if !hasAuth || user == "" || rawPassword == "" {
|
||||||
log.Error("invalid authorization headers")
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Auth
|
// Validate Auth
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||||
authData, err := api.authorizeCredentials(c, user, password)
|
authData := api.authorizeCredentials(c, user, password)
|
||||||
if err != nil {
|
if authData == nil {
|
||||||
log.WithField("user", user).WithError(err).Error("failed to authorize credentials")
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -123,7 +116,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session
|
// Check Session
|
||||||
if auth, ok := api.authorizeSession(c, session); ok {
|
if auth, ok := api.getSession(c, session); ok {
|
||||||
c.Set("Authorization", auth)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -136,7 +129,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth := data.(*authData)
|
auth := data.(authData)
|
||||||
if auth.IsAdmin {
|
if auth.IsAdmin {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
@@ -161,9 +154,8 @@ func (api *API) appAuthLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// MD5 - KOSync Compatiblity
|
// MD5 - KOSync Compatiblity
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||||
authData, err := api.authorizeCredentials(c, username, password)
|
authData := api.authorizeCredentials(c, username, password)
|
||||||
if err != nil {
|
if authData == nil {
|
||||||
log.WithField("user", username).WithError(err).Error("failed to authorize credentials")
|
|
||||||
templateVars["Error"] = "Invalid Credentials"
|
templateVars["Error"] = "Invalid Credentials"
|
||||||
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||||
return
|
return
|
||||||
@@ -171,7 +163,7 @@ func (api *API) appAuthLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// Set Session
|
// Set Session
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
if err := api.setSession(session, authData); err != nil {
|
if err := api.setSession(session, *authData); err != nil {
|
||||||
templateVars["Error"] = "Invalid Credentials"
|
templateVars["Error"] = "Invalid Credentials"
|
||||||
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||||
return
|
return
|
||||||
@@ -260,7 +252,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set session
|
// Set session
|
||||||
auth := &authData{
|
auth := authData{
|
||||||
UserName: user.ID,
|
UserName: user.ID,
|
||||||
IsAdmin: user.Admin,
|
IsAdmin: user.Admin,
|
||||||
AuthHash: *user.AuthHash,
|
AuthHash: *user.AuthHash,
|
||||||
@@ -356,40 +348,35 @@ func (api *API) koAuthRegister(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authorizeSession(ctx context.Context, session sessions.Session) (*authData, bool) {
|
func (api *API) getSession(ctx context.Context, session sessions.Session) (auth authData, ok bool) {
|
||||||
// Get Session
|
// Get Session
|
||||||
authorizedUser := session.Get("authorizedUser")
|
authorizedUser := session.Get("authorizedUser")
|
||||||
isAdmin := session.Get("isAdmin")
|
isAdmin := session.Get("isAdmin")
|
||||||
expiresAt := session.Get("expiresAt")
|
expiresAt := session.Get("expiresAt")
|
||||||
authHash := session.Get("authHash")
|
authHash := session.Get("authHash")
|
||||||
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
||||||
return nil, false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Auth Object
|
// Create Auth Object
|
||||||
auth := &authData{
|
auth = authData{
|
||||||
UserName: authorizedUser.(string),
|
UserName: authorizedUser.(string),
|
||||||
IsAdmin: isAdmin.(bool),
|
IsAdmin: isAdmin.(bool),
|
||||||
AuthHash: authHash.(string),
|
AuthHash: authHash.(string),
|
||||||
}
|
}
|
||||||
logger := log.WithField("user", auth.UserName)
|
|
||||||
|
|
||||||
// Validate Auth Hash
|
// Validate Auth Hash
|
||||||
correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
|
correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
|
||||||
if err != nil {
|
if err != nil || correctAuthHash != auth.AuthHash {
|
||||||
logger.WithError(err).Error("failed to get auth hash")
|
return
|
||||||
return nil, false
|
|
||||||
} else if correctAuthHash != auth.AuthHash {
|
|
||||||
logger.Warn("user auth hash mismatch")
|
|
||||||
return nil, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
||||||
logger.Info("refreshing session")
|
log.Info("Refreshing Session")
|
||||||
if err := api.setSession(session, auth); err != nil {
|
if err := api.setSession(session, auth); err != nil {
|
||||||
logger.WithError(err).Error("failed to refresh session")
|
log.Error("unable to get session")
|
||||||
return nil, false
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +384,7 @@ func (api *API) authorizeSession(ctx context.Context, session sessions.Session)
|
|||||||
return auth, true
|
return auth, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) setSession(session sessions.Session, auth *authData) error {
|
func (api *API) setSession(session sessions.Session, auth authData) error {
|
||||||
// Set Session Cookie
|
// Set Session Cookie
|
||||||
session.Set("authorizedUser", auth.UserName)
|
session.Set("authorizedUser", auth.UserName)
|
||||||
session.Set("isAdmin", auth.IsAdmin)
|
session.Set("isAdmin", auth.IsAdmin)
|
||||||
@@ -477,7 +464,9 @@ func (api *API) rotateAllAuthHashes(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transaction Succeeded -> Update Cache
|
// Transaction Succeeded -> Update Cache
|
||||||
maps.Copy(api.userAuthCache, newAuthHashCache)
|
for user, hash := range newAuthHashCache {
|
||||||
|
api.userAuthCache[user] = hash
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,20 +98,20 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attempt Metadata
|
// Attempt Metadata
|
||||||
coverDir := filepath.Join(api.cfg.DataPath, "covers")
|
var coverDir string = filepath.Join(api.cfg.DataPath, "covers")
|
||||||
coverFile := "UNKNOWN"
|
var coverFile string = "UNKNOWN"
|
||||||
|
|
||||||
// Identify Documents & Save Covers
|
// Identify Documents & Save Covers
|
||||||
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
||||||
Title: document.Title,
|
Title: document.Title,
|
||||||
Author: document.Author,
|
Author: document.Author,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err == nil && len(metadataResults) > 0 && metadataResults[0].SourceID != nil {
|
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
|
||||||
firstResult := metadataResults[0]
|
firstResult := metadataResults[0]
|
||||||
|
|
||||||
// Save Cover
|
// Save Cover
|
||||||
fileName, err := metadata.CacheCover(*firstResult.SourceID, coverDir, document.ID, false)
|
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
coverFile = *fileName
|
coverFile = *fileName
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
|
|||||||
Title: firstResult.Title,
|
Title: firstResult.Title,
|
||||||
Author: firstResult.Author,
|
Author: firstResult.Author,
|
||||||
Description: firstResult.Description,
|
Description: firstResult.Description,
|
||||||
Gbid: firstResult.SourceID,
|
Gbid: firstResult.ID,
|
||||||
Olid: nil,
|
Olid: nil,
|
||||||
Isbn10: firstResult.ISBN10,
|
Isbn10: firstResult.ISBN10,
|
||||||
Isbn13: firstResult.ISBN13,
|
Isbn13: firstResult.ISBN13,
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
"reichard.io/antholume/metadata"
|
|
||||||
"reichard.io/antholume/pkg/ptr"
|
|
||||||
"reichard.io/antholume/pkg/utils"
|
|
||||||
"reichard.io/antholume/search"
|
|
||||||
"reichard.io/antholume/web/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func convertDBDocToUI(r database.GetDocumentsWithStatsRow) models.Document {
|
|
||||||
return models.Document{
|
|
||||||
ID: r.ID,
|
|
||||||
Title: ptr.Deref(r.Title),
|
|
||||||
Author: ptr.Deref(r.Author),
|
|
||||||
ISBN10: ptr.Deref(r.Isbn10),
|
|
||||||
ISBN13: ptr.Deref(r.Isbn13),
|
|
||||||
Description: ptr.Deref(r.Description),
|
|
||||||
Percentage: r.Percentage,
|
|
||||||
WPM: r.Wpm,
|
|
||||||
Words: r.Words,
|
|
||||||
TotalTimeRead: time.Duration(r.TotalTimeSeconds) * time.Second,
|
|
||||||
TimePerPercent: time.Duration(r.SecondsPerPercent) * time.Second,
|
|
||||||
HasFile: ptr.Deref(r.Filepath) != "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertMetaToUI(m metadata.MetadataInfo) *models.DocumentMetadata {
|
|
||||||
return &models.DocumentMetadata{
|
|
||||||
SourceID: ptr.Deref(m.SourceID),
|
|
||||||
ISBN10: ptr.Deref(m.ISBN10),
|
|
||||||
ISBN13: ptr.Deref(m.ISBN13),
|
|
||||||
Title: ptr.Deref(m.Title),
|
|
||||||
Author: ptr.Deref(m.Author),
|
|
||||||
Description: ptr.Deref(m.Description),
|
|
||||||
Source: m.Source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertDBActivityToUI(r database.GetActivityRow) models.Activity {
|
|
||||||
return models.Activity{
|
|
||||||
ID: r.DocumentID,
|
|
||||||
Author: utils.FirstNonZero(ptr.Deref(r.Author), "N/A"),
|
|
||||||
Title: utils.FirstNonZero(ptr.Deref(r.Title), "N/A"),
|
|
||||||
StartTime: r.StartTime,
|
|
||||||
Duration: time.Duration(r.Duration) * time.Second,
|
|
||||||
Percentage: r.EndPercentage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertDBProgressToUI(r database.GetProgressRow) models.Progress {
|
|
||||||
return models.Progress{
|
|
||||||
ID: r.DocumentID,
|
|
||||||
Author: utils.FirstNonZero(ptr.Deref(r.Author), "N/A"),
|
|
||||||
Title: utils.FirstNonZero(ptr.Deref(r.Title), "N/A"),
|
|
||||||
DeviceName: r.DeviceName,
|
|
||||||
Percentage: r.Percentage,
|
|
||||||
CreatedAt: r.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertDBDeviceToUI(r database.GetDevicesRow) models.Device {
|
|
||||||
return models.Device{
|
|
||||||
DeviceName: r.DeviceName,
|
|
||||||
LastSynced: r.LastSynced,
|
|
||||||
CreatedAt: r.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertSearchToUI(r search.SearchItem) models.SearchResult {
|
|
||||||
return models.SearchResult{
|
|
||||||
ID: r.ID,
|
|
||||||
Title: r.Title,
|
|
||||||
Author: r.Author,
|
|
||||||
Series: r.Series,
|
|
||||||
FileType: r.FileType,
|
|
||||||
FileSize: r.FileSize,
|
|
||||||
UploadDate: r.UploadDate,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -62,19 +62,13 @@ func (api *API) opdsEntry(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) opdsDocuments(c *gin.Context) {
|
func (api *API) opdsDocuments(c *gin.Context) {
|
||||||
auth, err := getAuthData(c)
|
var auth authData
|
||||||
if err != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
log.WithError(err).Error("failed to acquire auth data")
|
auth = data.(authData)
|
||||||
c.AbortWithStatus(http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Potential URL Parameters (Default Pagination - 100)
|
// Potential URL Parameters (Default Pagination - 100)
|
||||||
qParams, err := bindQueryParams(c, 100)
|
qParams := bindQueryParams(c, 100)
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind query params")
|
|
||||||
c.AbortWithStatus(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Possible Query
|
// Possible Query
|
||||||
var query *string
|
var query *string
|
||||||
@@ -92,7 +86,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
Limit: *qParams.Limit,
|
Limit: *qParams.Limit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get documents with stats")
|
log.Error("GetDocumentsWithStats DB Error:", err)
|
||||||
c.AbortWithStatus(http.StatusBadRequest)
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
11
api/utils.go
11
api/utils.go
@@ -8,22 +8,11 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/graph"
|
"reichard.io/antholume/graph"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getAuthData(ctx *gin.Context) (*authData, error) {
|
|
||||||
if data, ok := ctx.Get("Authorization"); ok {
|
|
||||||
var auth *authData
|
|
||||||
if auth, ok = data.(*authData); ok {
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.New("could not acquire auth data")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTimeZones returns a string slice of IANA timezones.
|
// getTimeZones returns a string slice of IANA timezones.
|
||||||
func getTimeZones() []string {
|
func getTimeZones() []string {
|
||||||
return []string{
|
return []string{
|
||||||
|
|||||||
151
api/v1/activity.go
Normal file
151
api/v1/activity.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /activity
|
||||||
|
func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObject) (GetActivityResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetActivity401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
docFilter := false
|
||||||
|
if request.Params.DocFilter != nil {
|
||||||
|
docFilter = *request.Params.DocFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
documentID := ""
|
||||||
|
if request.Params.DocumentId != nil {
|
||||||
|
documentID = *request.Params.DocumentId
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := int64(0)
|
||||||
|
if request.Params.Offset != nil {
|
||||||
|
offset = *request.Params.Offset
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(100)
|
||||||
|
if request.Params.Limit != nil {
|
||||||
|
limit = *request.Params.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
activities, err := s.db.Queries.GetActivity(ctx, database.GetActivityParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
DocFilter: docFilter,
|
||||||
|
DocumentID: documentID,
|
||||||
|
Offset: offset,
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiActivities := make([]Activity, len(activities))
|
||||||
|
for i, a := range activities {
|
||||||
|
// Convert StartTime from interface{} to string
|
||||||
|
startTimeStr := ""
|
||||||
|
if a.StartTime != nil {
|
||||||
|
if str, ok := a.StartTime.(string); ok {
|
||||||
|
startTimeStr = str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apiActivities[i] = Activity{
|
||||||
|
DocumentId: a.DocumentID,
|
||||||
|
DeviceId: a.DeviceID,
|
||||||
|
StartTime: startTimeStr,
|
||||||
|
Title: a.Title,
|
||||||
|
Author: a.Author,
|
||||||
|
Duration: a.Duration,
|
||||||
|
StartPercentage: float32(a.StartPercentage),
|
||||||
|
EndPercentage: float32(a.EndPercentage),
|
||||||
|
ReadPercentage: float32(a.ReadPercentage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ActivityResponse{
|
||||||
|
Activities: apiActivities,
|
||||||
|
}
|
||||||
|
return GetActivity200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /activity
|
||||||
|
func (s *Server) CreateActivity(ctx context.Context, request CreateActivityRequestObject) (CreateActivityResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return CreateActivity401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return CreateActivity400JSONResponse{Code: 400, Message: "Request body is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Transaction Begin DB Error:", err)
|
||||||
|
return CreateActivity500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if committed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
log.Debug("Transaction Rollback DB Error:", rollbackErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
qtx := s.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
|
allDocumentsMap := make(map[string]struct{})
|
||||||
|
for _, item := range request.Body.Activity {
|
||||||
|
allDocumentsMap[item.DocumentId] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for documentID := range allDocumentsMap {
|
||||||
|
if _, err := qtx.UpsertDocument(ctx, database.UpsertDocumentParams{ID: documentID}); err != nil {
|
||||||
|
log.Error("UpsertDocument DB Error:", err)
|
||||||
|
return CreateActivity400JSONResponse{Code: 400, Message: "Invalid document"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := qtx.UpsertDevice(ctx, database.UpsertDeviceParams{
|
||||||
|
ID: request.Body.DeviceId,
|
||||||
|
UserID: auth.UserName,
|
||||||
|
DeviceName: request.Body.DeviceName,
|
||||||
|
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("UpsertDevice DB Error:", err)
|
||||||
|
return CreateActivity400JSONResponse{Code: 400, Message: "Invalid device"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range request.Body.Activity {
|
||||||
|
if _, err := qtx.AddActivity(ctx, database.AddActivityParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
DocumentID: item.DocumentId,
|
||||||
|
DeviceID: request.Body.DeviceId,
|
||||||
|
StartTime: time.Unix(item.StartTime, 0).UTC().Format(time.RFC3339),
|
||||||
|
Duration: item.Duration,
|
||||||
|
StartPercentage: float64(item.Page) / float64(item.Pages),
|
||||||
|
EndPercentage: float64(item.Page+1) / float64(item.Pages),
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("AddActivity DB Error:", err)
|
||||||
|
return CreateActivity400JSONResponse{Code: 400, Message: "Invalid activity"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.Error("Transaction Commit DB Error:", err)
|
||||||
|
return CreateActivity500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
|
||||||
|
response := CreateActivityResponse{Added: int64(len(request.Body.Activity))}
|
||||||
|
return CreateActivity200JSONResponse(response), nil
|
||||||
|
}
|
||||||
1070
api/v1/admin.go
Normal file
1070
api/v1/admin.go
Normal file
File diff suppressed because it is too large
Load Diff
152
api/v1/admin_test.go
Normal file
152
api/v1/admin_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
argon2 "github.com/alexedwards/argon2id"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"reichard.io/antholume/config"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createAdminTestUser(t *testing.T, db *database.DBManager, username, password string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
|
||||||
|
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
authHash := "test-auth-hash"
|
||||||
|
_, err = db.Queries.CreateUser(context.Background(), database.CreateUserParams{
|
||||||
|
ID: username,
|
||||||
|
Pass: &hashedPassword,
|
||||||
|
AuthHash: &authHash,
|
||||||
|
Admin: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginAdminTestUser(t *testing.T, srv *Server, username, password string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body, err := json.Marshal(LoginRequest{Username: username, Password: password})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
require.Len(t, cookies, 1)
|
||||||
|
|
||||||
|
return cookies[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLogsPagination(t *testing.T) {
|
||||||
|
configPath := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(configPath, "logs"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(configPath, "logs", "antholume.log"), []byte(
|
||||||
|
"{\"level\":\"info\",\"msg\":\"one\"}\n"+
|
||||||
|
"plain two\n"+
|
||||||
|
"{\"level\":\"error\",\"msg\":\"three\"}\n"+
|
||||||
|
"plain four\n",
|
||||||
|
), 0o644))
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
ListenPort: "8080",
|
||||||
|
DBType: "memory",
|
||||||
|
DBName: "test",
|
||||||
|
ConfigPath: configPath,
|
||||||
|
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||||
|
CookieEncKey: "0123456789abcdef",
|
||||||
|
CookieSecure: false,
|
||||||
|
CookieHTTPOnly: true,
|
||||||
|
Version: "test",
|
||||||
|
DemoMode: false,
|
||||||
|
RegistrationEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.NewMgr(cfg)
|
||||||
|
srv := NewServer(db, cfg, nil)
|
||||||
|
createAdminTestUser(t, db, "admin", "password")
|
||||||
|
cookie := loginAdminTestUser(t, srv, "admin", "password")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/logs?page=2&limit=2", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp LogsResponse
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
require.NotNil(t, resp.Logs)
|
||||||
|
require.Len(t, *resp.Logs, 2)
|
||||||
|
require.NotNil(t, resp.Page)
|
||||||
|
require.Equal(t, int64(2), *resp.Page)
|
||||||
|
require.NotNil(t, resp.Limit)
|
||||||
|
require.Equal(t, int64(2), *resp.Limit)
|
||||||
|
require.NotNil(t, resp.Total)
|
||||||
|
require.Equal(t, int64(4), *resp.Total)
|
||||||
|
require.Nil(t, resp.NextPage)
|
||||||
|
require.NotNil(t, resp.PreviousPage)
|
||||||
|
require.Equal(t, int64(1), *resp.PreviousPage)
|
||||||
|
require.Contains(t, (*resp.Logs)[0], "three")
|
||||||
|
require.Contains(t, (*resp.Logs)[1], "plain four")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLogsPaginationWithBasicFilter(t *testing.T) {
|
||||||
|
configPath := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(configPath, "logs"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(configPath, "logs", "antholume.log"), []byte(
|
||||||
|
"{\"level\":\"info\",\"msg\":\"match-1\"}\n"+
|
||||||
|
"{\"level\":\"info\",\"msg\":\"skip\"}\n"+
|
||||||
|
"plain match-2\n"+
|
||||||
|
"{\"level\":\"info\",\"msg\":\"match-3\"}\n",
|
||||||
|
), 0o644))
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
ListenPort: "8080",
|
||||||
|
DBType: "memory",
|
||||||
|
DBName: "test",
|
||||||
|
ConfigPath: configPath,
|
||||||
|
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||||
|
CookieEncKey: "0123456789abcdef",
|
||||||
|
CookieSecure: false,
|
||||||
|
CookieHTTPOnly: true,
|
||||||
|
Version: "test",
|
||||||
|
DemoMode: false,
|
||||||
|
RegistrationEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.NewMgr(cfg)
|
||||||
|
srv := NewServer(db, cfg, nil)
|
||||||
|
createAdminTestUser(t, db, "admin", "password")
|
||||||
|
cookie := loginAdminTestUser(t, srv, "admin", "password")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/logs?filter=%22match%22&page=1&limit=2", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp LogsResponse
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
require.NotNil(t, resp.Logs)
|
||||||
|
require.Len(t, *resp.Logs, 2)
|
||||||
|
require.NotNil(t, resp.Total)
|
||||||
|
require.Equal(t, int64(3), *resp.Total)
|
||||||
|
require.NotNil(t, resp.NextPage)
|
||||||
|
require.Equal(t, int64(2), *resp.NextPage)
|
||||||
|
}
|
||||||
4146
api/v1/api.gen.go
Normal file
4146
api/v1/api.gen.go
Normal file
File diff suppressed because it is too large
Load Diff
286
api/v1/auth.go
Normal file
286
api/v1/auth.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
argon2 "github.com/alexedwards/argon2id"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /auth/login
|
||||||
|
func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) {
|
||||||
|
if request.Body == nil {
|
||||||
|
return Login400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := *request.Body
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
return Login400JSONResponse{Code: 400, Message: "Invalid credentials"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MD5 - KOSync compatibility
|
||||||
|
password := fmt.Sprintf("%x", md5.Sum([]byte(req.Password)))
|
||||||
|
|
||||||
|
// Verify credentials
|
||||||
|
user, err := s.db.Queries.GetUser(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
|
||||||
|
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
|
||||||
|
return Login500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Login200JSONResponse{
|
||||||
|
Body: LoginResponse{
|
||||||
|
Username: user.ID,
|
||||||
|
IsAdmin: user.Admin,
|
||||||
|
},
|
||||||
|
Headers: Login200ResponseHeaders{
|
||||||
|
SetCookie: s.getSetCookieFromContext(ctx),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/register
|
||||||
|
func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (RegisterResponseObject, error) {
|
||||||
|
if !s.cfg.RegistrationEnabled {
|
||||||
|
return Register403JSONResponse{Code: 403, Message: "Registration is disabled"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return Register400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := *request.Body
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
return Register400JSONResponse{Code: 400, Message: "Invalid user or password"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUsers, err := s.db.Queries.GetUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Register500JSONResponse{Code: 500, Message: "Failed to create user"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin := len(currentUsers) == 0
|
||||||
|
if err := s.createUser(ctx, req.Username, &req.Password, &isAdmin); err != nil {
|
||||||
|
return Register400JSONResponse{Code: 400, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.db.Queries.GetUser(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
return Register500JSONResponse{Code: 500, Message: "Failed to load created user"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
|
||||||
|
return Register500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Register201JSONResponse{
|
||||||
|
Body: LoginResponse{
|
||||||
|
Username: user.ID,
|
||||||
|
IsAdmin: user.Admin,
|
||||||
|
},
|
||||||
|
Headers: Register201ResponseHeaders{
|
||||||
|
SetCookie: s.getSetCookieFromContext(ctx),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/logout
|
||||||
|
func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) {
|
||||||
|
_, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r := s.getRequestFromContext(ctx)
|
||||||
|
w := s.getResponseWriterFromContext(ctx)
|
||||||
|
|
||||||
|
if r == nil || w == nil {
|
||||||
|
return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := s.getCookieSession(r)
|
||||||
|
if err != nil {
|
||||||
|
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Values = make(map[any]any)
|
||||||
|
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
return Logout401JSONResponse{Code: 401, Message: "Failed to logout"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Logout200Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /auth/me
|
||||||
|
func (s *Server) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetMe401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetMe200JSONResponse{
|
||||||
|
Username: auth.UserName,
|
||||||
|
IsAdmin: auth.IsAdmin,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) saveUserSession(ctx context.Context, username string, isAdmin bool, authHash string) error {
|
||||||
|
r := s.getRequestFromContext(ctx)
|
||||||
|
w := s.getResponseWriterFromContext(ctx)
|
||||||
|
if r == nil || w == nil {
|
||||||
|
return fmt.Errorf("internal context error")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := s.getCookieSession(r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Values["authorizedUser"] = username
|
||||||
|
session.Values["isAdmin"] = isAdmin
|
||||||
|
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
|
||||||
|
session.Values["authHash"] = authHash
|
||||||
|
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
return fmt.Errorf("failed to create session")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getCookieSession(r *http.Request) (*sessions.Session, error) {
|
||||||
|
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
|
||||||
|
if s.cfg.CookieEncKey != "" {
|
||||||
|
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
|
||||||
|
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := store.Get(r, "token")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Options.SameSite = http.SameSiteLaxMode
|
||||||
|
session.Options.HttpOnly = true
|
||||||
|
session.Options.Secure = s.cfg.CookieSecure
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSessionFromContext extracts authData from context
|
||||||
|
func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
|
||||||
|
auth, ok := ctx.Value("auth").(authData)
|
||||||
|
if !ok {
|
||||||
|
return authData{}, false
|
||||||
|
}
|
||||||
|
return auth, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAdmin checks if a user has admin privileges
|
||||||
|
func (s *Server) isAdmin(ctx context.Context) bool {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return auth.IsAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRequestFromContext extracts the HTTP request from context
|
||||||
|
func (s *Server) getRequestFromContext(ctx context.Context) *http.Request {
|
||||||
|
r, ok := ctx.Value("request").(*http.Request)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// getResponseWriterFromContext extracts the response writer from context
|
||||||
|
func (s *Server) getResponseWriterFromContext(ctx context.Context) http.ResponseWriter {
|
||||||
|
w, ok := ctx.Value("response").(http.ResponseWriter)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getSetCookieFromContext(ctx context.Context) string {
|
||||||
|
w := s.getResponseWriterFromContext(ctx)
|
||||||
|
if w == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return w.Header().Get("Set-Cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSession retrieves auth data from the session cookie
|
||||||
|
func (s *Server) getSession(r *http.Request) (auth authData, ok bool) {
|
||||||
|
// Get session from cookie store
|
||||||
|
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
|
||||||
|
if s.cfg.CookieEncKey != "" {
|
||||||
|
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
|
||||||
|
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
|
||||||
|
} else {
|
||||||
|
log.Error("invalid cookie encryption key (must be 16 or 32 bytes)")
|
||||||
|
return authData{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := store.Get(r, "token")
|
||||||
|
if err != nil {
|
||||||
|
return authData{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session values
|
||||||
|
authorizedUser := session.Values["authorizedUser"]
|
||||||
|
isAdmin := session.Values["isAdmin"]
|
||||||
|
expiresAt := session.Values["expiresAt"]
|
||||||
|
authHash := session.Values["authHash"]
|
||||||
|
|
||||||
|
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
||||||
|
return authData{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
auth = authData{
|
||||||
|
UserName: authorizedUser.(string),
|
||||||
|
IsAdmin: isAdmin.(bool),
|
||||||
|
AuthHash: authHash.(string),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate auth hash
|
||||||
|
ctx := r.Context()
|
||||||
|
correctAuthHash, err := s.getUserAuthHash(ctx, auth.UserName)
|
||||||
|
if err != nil || correctAuthHash != auth.AuthHash {
|
||||||
|
return authData{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserAuthHash retrieves the user's auth hash from DB or cache
|
||||||
|
func (s *Server) getUserAuthHash(ctx context.Context, username string) (string, error) {
|
||||||
|
user, err := s.db.Queries.GetUser(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return *user.AuthHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authData represents authenticated user information
|
||||||
|
type authData struct {
|
||||||
|
UserName string
|
||||||
|
IsAdmin bool
|
||||||
|
AuthHash string
|
||||||
|
}
|
||||||
228
api/v1/auth_test.go
Normal file
228
api/v1/auth_test.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
|
||||||
|
argon2 "github.com/alexedwards/argon2id"
|
||||||
|
"reichard.io/antholume/config"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
db *database.DBManager
|
||||||
|
cfg *config.Config
|
||||||
|
srv *Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) setupConfig() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
ListenPort: "8080",
|
||||||
|
DBType: "memory",
|
||||||
|
DBName: "test",
|
||||||
|
ConfigPath: "/tmp",
|
||||||
|
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||||
|
CookieEncKey: "0123456789abcdef",
|
||||||
|
CookieSecure: false,
|
||||||
|
CookieHTTPOnly: true,
|
||||||
|
Version: "test",
|
||||||
|
DemoMode: false,
|
||||||
|
RegistrationEnabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth(t *testing.T) {
|
||||||
|
suite.Run(t, new(AuthTestSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) SetupTest() {
|
||||||
|
suite.cfg = suite.setupConfig()
|
||||||
|
suite.db = database.NewMgr(suite.cfg)
|
||||||
|
suite.srv = NewServer(suite.db, suite.cfg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) createTestUser(username, password string) {
|
||||||
|
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
|
||||||
|
|
||||||
|
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
authHash := "test-auth-hash"
|
||||||
|
|
||||||
|
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
|
||||||
|
ID: username,
|
||||||
|
Pass: &hashedPassword,
|
||||||
|
AuthHash: &authHash,
|
||||||
|
Admin: true,
|
||||||
|
})
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) assertSessionCookie(cookie *http.Cookie) {
|
||||||
|
suite.Require().NotNil(cookie)
|
||||||
|
suite.Equal("token", cookie.Name)
|
||||||
|
suite.NotEmpty(cookie.Value)
|
||||||
|
suite.True(cookie.HttpOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
|
||||||
|
reqBody := LoginRequest{
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusOK, w.Code, "login should return 200")
|
||||||
|
|
||||||
|
var resp LoginResponse
|
||||||
|
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
suite.Require().Len(cookies, 1, "should have session cookie")
|
||||||
|
suite.assertSessionCookie(cookies[0])
|
||||||
|
|
||||||
|
return cookies[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) TestAPILogin() {
|
||||||
|
suite.createTestUser("testuser", "testpass")
|
||||||
|
|
||||||
|
reqBody := LoginRequest{
|
||||||
|
Username: "testuser",
|
||||||
|
Password: "testpass",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp LoginResponse
|
||||||
|
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
suite.Equal("testuser", resp.Username)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
suite.Require().Len(cookies, 1)
|
||||||
|
suite.assertSessionCookie(cookies[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
|
||||||
|
reqBody := LoginRequest{
|
||||||
|
Username: "testuser",
|
||||||
|
Password: "wrongpass",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) TestAPIRegister() {
|
||||||
|
reqBody := LoginRequest{
|
||||||
|
Username: "newuser",
|
||||||
|
Password: "newpass",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusCreated, w.Code)
|
||||||
|
|
||||||
|
var resp LoginResponse
|
||||||
|
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
suite.Equal("newuser", resp.Username)
|
||||||
|
suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
suite.Require().Len(cookies, 1, "register should set a session cookie")
|
||||||
|
suite.assertSessionCookie(cookies[0])
|
||||||
|
|
||||||
|
user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.True(user.Admin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) TestAPIRegisterDisabled() {
|
||||||
|
suite.cfg.RegistrationEnabled = false
|
||||||
|
suite.srv = NewServer(suite.db, suite.cfg, nil)
|
||||||
|
|
||||||
|
reqBody := LoginRequest{
|
||||||
|
Username: "newuser",
|
||||||
|
Password: "newpass",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusForbidden, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) TestAPILogout() {
|
||||||
|
suite.createTestUser("testuser", "testpass")
|
||||||
|
cookie := suite.login("testuser", "testpass")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
suite.Require().Len(cookies, 1)
|
||||||
|
suite.Equal("token", cookies[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) TestAPIGetMe() {
|
||||||
|
suite.createTestUser("testuser", "testpass")
|
||||||
|
cookie := suite.login("testuser", "testpass")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp UserData
|
||||||
|
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
suite.Equal("testuser", resp.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) TestAPIGetMeUnauthenticated() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
827
api/v1/documents.go
Normal file
827
api/v1/documents.go
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
"reichard.io/antholume/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /documents
|
||||||
|
func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestObject) (GetDocumentsResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetDocuments401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
page := int64(1)
|
||||||
|
if request.Params.Page != nil {
|
||||||
|
page = *request.Params.Page
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(9)
|
||||||
|
if request.Params.Limit != nil {
|
||||||
|
limit = *request.Params.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
search := ""
|
||||||
|
if request.Params.Search != nil {
|
||||||
|
search = "%" + *request.Params.Search + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Queries.GetDocumentsWithStats(
|
||||||
|
ctx,
|
||||||
|
database.GetDocumentsWithStatsParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Query: &search,
|
||||||
|
Deleted: ptrOf(false),
|
||||||
|
Offset: (page - 1) * limit,
|
||||||
|
Limit: limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(len(rows))
|
||||||
|
var nextPage *int64
|
||||||
|
var previousPage *int64
|
||||||
|
if page*limit < total {
|
||||||
|
nextPage = ptrOf(page + 1)
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
previousPage = ptrOf(page - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDocuments := make([]Document, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
apiDocuments[i] = Document{
|
||||||
|
Id: row.ID,
|
||||||
|
Title: *row.Title,
|
||||||
|
Author: *row.Author,
|
||||||
|
Description: row.Description,
|
||||||
|
Isbn10: row.Isbn10,
|
||||||
|
Isbn13: row.Isbn13,
|
||||||
|
Words: row.Words,
|
||||||
|
Filepath: row.Filepath,
|
||||||
|
Percentage: ptrOf(float32(row.Percentage)),
|
||||||
|
TotalTimeSeconds: ptrOf(row.TotalTimeSeconds),
|
||||||
|
Wpm: ptrOf(float32(row.Wpm)),
|
||||||
|
SecondsPerPercent: ptrOf(row.SecondsPerPercent),
|
||||||
|
LastRead: parseInterfaceTime(row.LastRead),
|
||||||
|
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
|
||||||
|
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
|
||||||
|
Deleted: false, // Default, should be overridden if available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DocumentsResponse{
|
||||||
|
Documents: apiDocuments,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
NextPage: nextPage,
|
||||||
|
PreviousPage: previousPage,
|
||||||
|
Search: request.Params.Search,
|
||||||
|
}
|
||||||
|
return GetDocuments200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /documents/{id}
|
||||||
|
func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use GetDocumentsWithStats to get document with stats
|
||||||
|
docs, err := s.db.Queries.GetDocumentsWithStats(
|
||||||
|
ctx,
|
||||||
|
database.GetDocumentsWithStatsParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
ID: &request.Id,
|
||||||
|
Deleted: ptrOf(false),
|
||||||
|
Offset: 0,
|
||||||
|
Limit: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || len(docs) == 0 {
|
||||||
|
return GetDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := docs[0]
|
||||||
|
|
||||||
|
apiDoc := Document{
|
||||||
|
Id: doc.ID,
|
||||||
|
Title: *doc.Title,
|
||||||
|
Author: *doc.Author,
|
||||||
|
Description: doc.Description,
|
||||||
|
Isbn10: doc.Isbn10,
|
||||||
|
Isbn13: doc.Isbn13,
|
||||||
|
Words: doc.Words,
|
||||||
|
Filepath: doc.Filepath,
|
||||||
|
Percentage: ptrOf(float32(doc.Percentage)),
|
||||||
|
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
|
||||||
|
Wpm: ptrOf(float32(doc.Wpm)),
|
||||||
|
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
|
||||||
|
LastRead: parseInterfaceTime(doc.LastRead),
|
||||||
|
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
|
||||||
|
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
|
||||||
|
Deleted: false, // Default, should be overridden if available
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DocumentResponse{
|
||||||
|
Document: apiDoc,
|
||||||
|
}
|
||||||
|
return GetDocument200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /documents/{id}
|
||||||
|
func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestObject) (EditDocumentResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return EditDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return EditDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate document exists and get current state
|
||||||
|
currentDoc, err := s.db.Queries.GetDocument(ctx, request.Id)
|
||||||
|
if err != nil {
|
||||||
|
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate at least one editable field is provided
|
||||||
|
if request.Body.Title == nil &&
|
||||||
|
request.Body.Author == nil &&
|
||||||
|
request.Body.Description == nil &&
|
||||||
|
request.Body.Isbn10 == nil &&
|
||||||
|
request.Body.Isbn13 == nil &&
|
||||||
|
request.Body.CoverGbid == nil {
|
||||||
|
return EditDocument400JSONResponse{Code: 400, Message: "No editable fields provided"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cover via Google Books ID
|
||||||
|
var coverFileName *string
|
||||||
|
if request.Body.CoverGbid != nil {
|
||||||
|
coverDir := filepath.Join(s.cfg.DataPath, "covers")
|
||||||
|
fileName, err := metadata.CacheCoverWithContext(ctx, *request.Body.CoverGbid, coverDir, request.Id, true)
|
||||||
|
if err == nil {
|
||||||
|
coverFileName = fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update document with provided editable fields only
|
||||||
|
_, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
||||||
|
ID: request.Id,
|
||||||
|
Title: request.Body.Title,
|
||||||
|
Author: request.Body.Author,
|
||||||
|
Description: request.Body.Description,
|
||||||
|
Isbn10: request.Body.Isbn10,
|
||||||
|
Isbn13: request.Body.Isbn13,
|
||||||
|
Coverfile: coverFileName,
|
||||||
|
// Preserve existing values for non-editable fields
|
||||||
|
Md5: currentDoc.Md5,
|
||||||
|
Basepath: currentDoc.Basepath,
|
||||||
|
Filepath: currentDoc.Filepath,
|
||||||
|
Words: currentDoc.Words,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("UpsertDocument DB Error:", err)
|
||||||
|
return EditDocument500JSONResponse{Code: 500, Message: "Failed to update document"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use GetDocumentsWithStats to get document with stats for the response
|
||||||
|
docs, err := s.db.Queries.GetDocumentsWithStats(
|
||||||
|
ctx,
|
||||||
|
database.GetDocumentsWithStatsParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
ID: &request.Id,
|
||||||
|
Deleted: ptrOf(false),
|
||||||
|
Offset: 0,
|
||||||
|
Limit: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || len(docs) == 0 {
|
||||||
|
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := docs[0]
|
||||||
|
|
||||||
|
|
||||||
|
apiDoc := Document{
|
||||||
|
Id: doc.ID,
|
||||||
|
Title: *doc.Title,
|
||||||
|
Author: *doc.Author,
|
||||||
|
Description: doc.Description,
|
||||||
|
Isbn10: doc.Isbn10,
|
||||||
|
Isbn13: doc.Isbn13,
|
||||||
|
Words: doc.Words,
|
||||||
|
Filepath: doc.Filepath,
|
||||||
|
Percentage: ptrOf(float32(doc.Percentage)),
|
||||||
|
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
|
||||||
|
Wpm: ptrOf(float32(doc.Wpm)),
|
||||||
|
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
|
||||||
|
LastRead: parseInterfaceTime(doc.LastRead),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Deleted: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DocumentResponse{
|
||||||
|
Document: apiDoc,
|
||||||
|
}
|
||||||
|
return EditDocument200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
|
||||||
|
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
||||||
|
// Derive New FileName
|
||||||
|
var newFileName string
|
||||||
|
if metadataInfo.Author != nil && *metadataInfo.Author != "" {
|
||||||
|
newFileName = newFileName + *metadataInfo.Author
|
||||||
|
} else {
|
||||||
|
newFileName = newFileName + "Unknown"
|
||||||
|
}
|
||||||
|
if metadataInfo.Title != nil && *metadataInfo.Title != "" {
|
||||||
|
newFileName = newFileName + " - " + *metadataInfo.Title
|
||||||
|
} else {
|
||||||
|
newFileName = newFileName + " - Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Slashes
|
||||||
|
fileName := strings.ReplaceAll(newFileName, "/", "")
|
||||||
|
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInterfaceTime converts an interface{} to time.Time for SQLC queries
|
||||||
|
func parseInterfaceTime(t any) *time.Time {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := t.(type) {
|
||||||
|
case string:
|
||||||
|
parsed, err := time.Parse(time.RFC3339, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &parsed
|
||||||
|
case time.Time:
|
||||||
|
return &v
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveNoCover serves the default no-cover image from assets
|
||||||
|
func (s *Server) serveNoCover() (fs.File, string, int64, error) {
|
||||||
|
// Try to open the no-cover image from assets
|
||||||
|
file, err := s.assets.Open("assets/images/no-cover.jpg")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, "image/jpeg", info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFileReader opens a file and returns it as an io.ReaderCloser
|
||||||
|
func openFileReader(path string) (*os.File, error) {
|
||||||
|
return os.Open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /documents/{id}/cover
|
||||||
|
func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error) {
|
||||||
|
// Authentication is handled by middleware, which also adds auth data to context
|
||||||
|
// This endpoint just serves the cover image
|
||||||
|
|
||||||
|
// Validate Document Exists in DB
|
||||||
|
document, err := s.db.Queries.GetDocument(ctx, request.Id)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocument DB Error:", err)
|
||||||
|
return GetDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverFile fs.File
|
||||||
|
var contentType string
|
||||||
|
var contentLength int64
|
||||||
|
var needMetadataFetch bool
|
||||||
|
|
||||||
|
// Handle Identified Document
|
||||||
|
if document.Coverfile != nil {
|
||||||
|
if *document.Coverfile == "UNKNOWN" {
|
||||||
|
// Serve no-cover image
|
||||||
|
file, ct, size, err := s.serveNoCover()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to open no-cover image:", err)
|
||||||
|
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
|
||||||
|
}
|
||||||
|
coverFile = file
|
||||||
|
contentType = ct
|
||||||
|
contentLength = size
|
||||||
|
needMetadataFetch = true
|
||||||
|
} else {
|
||||||
|
// Derive Path
|
||||||
|
coverPath := filepath.Join(s.cfg.DataPath, "covers", *document.Coverfile)
|
||||||
|
|
||||||
|
// Validate File Exists
|
||||||
|
fileInfo, err := os.Stat(coverPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
log.Error("Cover file should but doesn't exist: ", err)
|
||||||
|
// Serve no-cover image
|
||||||
|
file, ct, size, err := s.serveNoCover()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to open no-cover image:", err)
|
||||||
|
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
|
||||||
|
}
|
||||||
|
coverFile = file
|
||||||
|
contentType = ct
|
||||||
|
contentLength = size
|
||||||
|
needMetadataFetch = true
|
||||||
|
} else {
|
||||||
|
// Open the cover file
|
||||||
|
file, err := openFileReader(coverPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to open cover file:", err)
|
||||||
|
return GetDocumentCover500JSONResponse{Code: 500, Message: "Failed to open cover"}, nil
|
||||||
|
}
|
||||||
|
coverFile = file
|
||||||
|
contentLength = fileInfo.Size()
|
||||||
|
|
||||||
|
// Determine content type based on file extension
|
||||||
|
contentType = "image/jpeg"
|
||||||
|
if strings.HasSuffix(coverPath, ".png") {
|
||||||
|
contentType = "image/png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
needMetadataFetch = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt Metadata fetch if needed
|
||||||
|
var cachedCoverFile string = "UNKNOWN"
|
||||||
|
var coverDir string = filepath.Join(s.cfg.DataPath, "covers")
|
||||||
|
|
||||||
|
if needMetadataFetch {
|
||||||
|
// Create context with timeout for metadata service calls
|
||||||
|
metadataCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Identify Documents & Save Covers
|
||||||
|
metadataResults, err := metadata.SearchMetadataWithContext(metadataCtx, metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
||||||
|
Title: document.Title,
|
||||||
|
Author: document.Author,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
|
||||||
|
firstResult := metadataResults[0]
|
||||||
|
|
||||||
|
// Save Cover
|
||||||
|
fileName, err := metadata.CacheCoverWithContext(metadataCtx, *firstResult.ID, coverDir, document.ID, false)
|
||||||
|
if err == nil {
|
||||||
|
cachedCoverFile = *fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store First Metadata Result
|
||||||
|
if _, err = s.db.Queries.AddMetadata(ctx, database.AddMetadataParams{
|
||||||
|
DocumentID: document.ID,
|
||||||
|
Title: firstResult.Title,
|
||||||
|
Author: firstResult.Author,
|
||||||
|
Description: firstResult.Description,
|
||||||
|
Gbid: firstResult.ID,
|
||||||
|
Olid: nil,
|
||||||
|
Isbn10: firstResult.ISBN10,
|
||||||
|
Isbn13: firstResult.ISBN13,
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("AddMetadata DB Error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert Document
|
||||||
|
if _, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
||||||
|
ID: document.ID,
|
||||||
|
Coverfile: &cachedCoverFile,
|
||||||
|
}); err != nil {
|
||||||
|
log.Warn("UpsertDocument DB Error:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cover file if we got a new cover
|
||||||
|
if cachedCoverFile != "UNKNOWN" {
|
||||||
|
coverPath := filepath.Join(coverDir, cachedCoverFile)
|
||||||
|
fileInfo, err := os.Stat(coverPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to stat cached cover:", err)
|
||||||
|
// Keep the no-cover image
|
||||||
|
} else {
|
||||||
|
file, err := openFileReader(coverPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to open cached cover:", err)
|
||||||
|
// Keep the no-cover image
|
||||||
|
} else {
|
||||||
|
_ = coverFile.Close() // Close the previous file
|
||||||
|
coverFile = file
|
||||||
|
contentLength = fileInfo.Size()
|
||||||
|
|
||||||
|
// Determine content type based on file extension
|
||||||
|
contentType = "image/jpeg"
|
||||||
|
if strings.HasSuffix(coverPath, ".png") {
|
||||||
|
contentType = "image/png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GetDocumentCover200Response{
|
||||||
|
Body: coverFile,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
ContentType: contentType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /documents/{id}/cover
|
||||||
|
func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocumentCoverRequestObject) (UploadDocumentCoverResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return UploadDocumentCover401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate document exists
|
||||||
|
_, err := s.db.Queries.GetDocument(ctx, request.Id)
|
||||||
|
if err != nil {
|
||||||
|
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read multipart form
|
||||||
|
form, err := request.Body.ReadForm(32 << 20) // 32MB max
|
||||||
|
if err != nil {
|
||||||
|
log.Error("ReadForm error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file from form
|
||||||
|
fileField := form.File["cover_file"]
|
||||||
|
if len(fileField) == 0 {
|
||||||
|
return UploadDocumentCover400JSONResponse{Code: 400, Message: "No file provided"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file := fileField[0]
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
if !strings.HasSuffix(strings.ToLower(file.Filename), ".jpg") && !strings.HasSuffix(strings.ToLower(file.Filename), ".png") {
|
||||||
|
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Only JPG and PNG files are allowed"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file
|
||||||
|
f, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Open file error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Read file error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate actual content type
|
||||||
|
contentType := http.DetectContentType(data)
|
||||||
|
allowedTypes := map[string]bool{
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
}
|
||||||
|
if !allowedTypes[contentType] {
|
||||||
|
return UploadDocumentCover400JSONResponse{
|
||||||
|
Code: 400,
|
||||||
|
Message: fmt.Sprintf("Invalid file type: %s. Only JPG and PNG files are allowed.", contentType),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive storage path
|
||||||
|
coverDir := filepath.Join(s.cfg.DataPath, "covers")
|
||||||
|
fileName := fmt.Sprintf("%s%s", request.Id, strings.ToLower(filepath.Ext(file.Filename)))
|
||||||
|
safePath := filepath.Join(coverDir, fileName)
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
err = os.WriteFile(safePath, data, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Save file error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Unable to save cover"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert document with new cover
|
||||||
|
_, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
||||||
|
ID: request.Id,
|
||||||
|
Coverfile: &fileName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("UpsertDocument DB error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to save cover"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use GetDocumentsWithStats to get document with stats for the response
|
||||||
|
docs, err := s.db.Queries.GetDocumentsWithStats(
|
||||||
|
ctx,
|
||||||
|
database.GetDocumentsWithStatsParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
ID: &request.Id,
|
||||||
|
Deleted: ptrOf(false),
|
||||||
|
Offset: 0,
|
||||||
|
Limit: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || len(docs) == 0 {
|
||||||
|
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := docs[0]
|
||||||
|
|
||||||
|
|
||||||
|
apiDoc := Document{
|
||||||
|
Id: doc.ID,
|
||||||
|
Title: *doc.Title,
|
||||||
|
Author: *doc.Author,
|
||||||
|
Description: doc.Description,
|
||||||
|
Isbn10: doc.Isbn10,
|
||||||
|
Isbn13: doc.Isbn13,
|
||||||
|
Words: doc.Words,
|
||||||
|
Filepath: doc.Filepath,
|
||||||
|
Percentage: ptrOf(float32(doc.Percentage)),
|
||||||
|
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
|
||||||
|
Wpm: ptrOf(float32(doc.Wpm)),
|
||||||
|
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
|
||||||
|
LastRead: parseInterfaceTime(doc.LastRead),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Deleted: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DocumentResponse{
|
||||||
|
Document: apiDoc,
|
||||||
|
}
|
||||||
|
return UploadDocumentCover200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /documents/{id}/file
|
||||||
|
func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error) {
|
||||||
|
// Authentication is handled by middleware, which also adds auth data to context
|
||||||
|
// This endpoint just serves the document file download
|
||||||
|
// Get Document
|
||||||
|
document, err := s.db.Queries.GetDocument(ctx, request.Id)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocument DB Error:", err)
|
||||||
|
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if document.Filepath == nil {
|
||||||
|
log.Error("Document Doesn't Have File:", request.Id)
|
||||||
|
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive Basepath
|
||||||
|
basepath := filepath.Join(s.cfg.DataPath, "documents")
|
||||||
|
if document.Basepath != nil && *document.Basepath != "" {
|
||||||
|
basepath = *document.Basepath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive Storage Location
|
||||||
|
filePath := filepath.Join(basepath, *document.Filepath)
|
||||||
|
|
||||||
|
// Validate File Exists
|
||||||
|
fileInfo, err := os.Stat(filePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
log.Error("File should but doesn't exist:", err)
|
||||||
|
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to open document file:", err)
|
||||||
|
return GetDocumentFile500JSONResponse{Code: 500, Message: "Failed to open document"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GetDocumentFile200Response{
|
||||||
|
Body: file,
|
||||||
|
ContentLength: fileInfo.Size(),
|
||||||
|
Filename: filepath.Base(*document.Filepath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /documents
|
||||||
|
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
|
||||||
|
_, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return CreateDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return CreateDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read multipart form
|
||||||
|
form, err := request.Body.ReadForm(32 << 20) // 32MB max memory
|
||||||
|
if err != nil {
|
||||||
|
log.Error("ReadForm error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file from form
|
||||||
|
fileField := form.File["document_file"]
|
||||||
|
if len(fileField) == 0 {
|
||||||
|
return CreateDocument400JSONResponse{Code: 400, Message: "No file provided"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file := fileField[0]
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
if !strings.HasSuffix(strings.ToLower(file.Filename), ".epub") {
|
||||||
|
return CreateDocument400JSONResponse{Code: 400, Message: "Only EPUB files are allowed"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file
|
||||||
|
f, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Open file error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Read file error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate actual content type
|
||||||
|
contentType := http.DetectContentType(data)
|
||||||
|
if contentType != "application/epub+zip" && contentType != "application/zip" {
|
||||||
|
return CreateDocument400JSONResponse{
|
||||||
|
Code: 400,
|
||||||
|
Message: fmt.Sprintf("Invalid file type: %s. Only EPUB files are allowed.", contentType),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp file to get metadata
|
||||||
|
tempFile, err := os.CreateTemp("", "book")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Temp file create error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to create temp file"}, nil
|
||||||
|
}
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
// Write data to temp file
|
||||||
|
if _, err := tempFile.Write(data); err != nil {
|
||||||
|
log.Error("Write temp file error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to write temp file"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata using metadata package
|
||||||
|
metadataInfo, err := metadata.GetMetadata(tempFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetMetadata error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to acquire metadata"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
_, err = s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
|
||||||
|
if err == nil {
|
||||||
|
// Document already exists
|
||||||
|
existingDoc, _ := s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
|
||||||
|
apiDoc := Document{
|
||||||
|
Id: existingDoc.ID,
|
||||||
|
Title: *existingDoc.Title,
|
||||||
|
Author: *existingDoc.Author,
|
||||||
|
Description: existingDoc.Description,
|
||||||
|
Isbn10: existingDoc.Isbn10,
|
||||||
|
Isbn13: existingDoc.Isbn13,
|
||||||
|
Words: existingDoc.Words,
|
||||||
|
Filepath: existingDoc.Filepath,
|
||||||
|
CreatedAt: parseTime(existingDoc.CreatedAt),
|
||||||
|
UpdatedAt: parseTime(existingDoc.UpdatedAt),
|
||||||
|
Deleted: existingDoc.Deleted,
|
||||||
|
}
|
||||||
|
response := DocumentResponse{
|
||||||
|
Document: apiDoc,
|
||||||
|
}
|
||||||
|
return CreateDocument200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive & sanitize file name
|
||||||
|
fileName := deriveBaseFileName(metadataInfo)
|
||||||
|
basePath := filepath.Join(s.cfg.DataPath, "documents")
|
||||||
|
safePath := filepath.Join(basePath, fileName)
|
||||||
|
|
||||||
|
// Save file to storage
|
||||||
|
err = os.WriteFile(safePath, data, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Save file error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to save file"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert document
|
||||||
|
doc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
||||||
|
ID: *metadataInfo.PartialMD5,
|
||||||
|
Title: metadataInfo.Title,
|
||||||
|
Author: metadataInfo.Author,
|
||||||
|
Description: metadataInfo.Description,
|
||||||
|
Md5: metadataInfo.MD5,
|
||||||
|
Words: metadataInfo.WordCount,
|
||||||
|
Filepath: &fileName,
|
||||||
|
Basepath: &basePath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("UpsertDocument DB error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to save document"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDoc := Document{
|
||||||
|
Id: doc.ID,
|
||||||
|
Title: *doc.Title,
|
||||||
|
Author: *doc.Author,
|
||||||
|
Description: doc.Description,
|
||||||
|
Isbn10: doc.Isbn10,
|
||||||
|
Isbn13: doc.Isbn13,
|
||||||
|
Words: doc.Words,
|
||||||
|
Filepath: doc.Filepath,
|
||||||
|
CreatedAt: parseTime(doc.CreatedAt),
|
||||||
|
UpdatedAt: parseTime(doc.UpdatedAt),
|
||||||
|
Deleted: doc.Deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DocumentResponse{
|
||||||
|
Document: apiDoc,
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateDocument200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDocumentCover200Response is a custom response type that allows setting content type
|
||||||
|
type GetDocumentCover200Response struct {
|
||||||
|
Body io.Reader
|
||||||
|
ContentLength int64
|
||||||
|
ContentType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response GetDocumentCover200Response) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", response.ContentType)
|
||||||
|
if response.ContentLength != 0 {
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
if closer, ok := response.Body.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
_, err := io.Copy(w, response.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDocumentFile200Response is a custom response type that allows setting filename for download
|
||||||
|
type GetDocumentFile200Response struct {
|
||||||
|
Body io.Reader
|
||||||
|
ContentLength int64
|
||||||
|
Filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response GetDocumentFile200Response) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
if response.ContentLength != 0 {
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", response.Filename))
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
if closer, ok := response.Body.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
_, err := io.Copy(w, response.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
178
api/v1/documents_test.go
Normal file
178
api/v1/documents_test.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
|
||||||
|
argon2 "github.com/alexedwards/argon2id"
|
||||||
|
"reichard.io/antholume/config"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
"reichard.io/antholume/pkg/ptr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DocumentsTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
db *database.DBManager
|
||||||
|
cfg *config.Config
|
||||||
|
srv *Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) setupConfig() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
ListenPort: "8080",
|
||||||
|
DBType: "memory",
|
||||||
|
DBName: "test",
|
||||||
|
ConfigPath: "/tmp",
|
||||||
|
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||||
|
CookieEncKey: "0123456789abcdef",
|
||||||
|
CookieSecure: false,
|
||||||
|
CookieHTTPOnly: true,
|
||||||
|
Version: "test",
|
||||||
|
DemoMode: false,
|
||||||
|
RegistrationEnabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocuments(t *testing.T) {
|
||||||
|
suite.Run(t, new(DocumentsTestSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) SetupTest() {
|
||||||
|
suite.cfg = suite.setupConfig()
|
||||||
|
suite.db = database.NewMgr(suite.cfg)
|
||||||
|
suite.srv = NewServer(suite.db, suite.cfg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) createTestUser(username, password string) {
|
||||||
|
suite.authTestSuiteHelper(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) login(username, password string) *http.Cookie {
|
||||||
|
return suite.authLoginHelper(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) authTestSuiteHelper(username, password string) {
|
||||||
|
// MD5 hash for KOSync compatibility (matches existing system)
|
||||||
|
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
|
||||||
|
|
||||||
|
// Then argon2 hash the MD5
|
||||||
|
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
|
||||||
|
ID: username,
|
||||||
|
Pass: &hashedPassword,
|
||||||
|
AuthHash: ptr.Of("test-auth-hash"),
|
||||||
|
Admin: true,
|
||||||
|
})
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) authLoginHelper(username, password string) *http.Cookie {
|
||||||
|
reqBody := LoginRequest{Username: username, Password: password}
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
suite.Require().Len(cookies, 1)
|
||||||
|
|
||||||
|
return cookies[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) TestAPIGetDocuments() {
|
||||||
|
suite.createTestUser("testuser", "testpass")
|
||||||
|
cookie := suite.login("testuser", "testpass")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents?page=1&limit=9", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp DocumentsResponse
|
||||||
|
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
suite.Equal(int64(1), resp.Page)
|
||||||
|
suite.Equal(int64(9), resp.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) TestAPIGetDocumentsUnauthenticated() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) TestAPIGetDocument() {
|
||||||
|
suite.createTestUser("testuser", "testpass")
|
||||||
|
|
||||||
|
docID := "test-doc-1"
|
||||||
|
_, err := suite.db.Queries.UpsertDocument(suite.T().Context(), database.UpsertDocumentParams{
|
||||||
|
ID: docID,
|
||||||
|
Title: ptr.Of("Test Document"),
|
||||||
|
Author: ptr.Of("Test Author"),
|
||||||
|
})
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
cookie := suite.login("testuser", "testpass")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+docID, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp DocumentResponse
|
||||||
|
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
suite.Equal(docID, resp.Document.Id)
|
||||||
|
suite.Equal("Test Document", resp.Document.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) TestAPIGetDocumentNotFound() {
|
||||||
|
suite.createTestUser("testuser", "testpass")
|
||||||
|
cookie := suite.login("testuser", "testpass")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/non-existent", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) TestAPIGetDocumentCoverUnauthenticated() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/cover", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *DocumentsTestSuite) TestAPIGetDocumentFileUnauthenticated() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/file", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
3
api/v1/generate.go
Normal file
3
api/v1/generate.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
//go:generate oapi-codegen -config oapi-codegen.yaml openapi.yaml
|
||||||
226
api/v1/home.go
Normal file
226
api/v1/home.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
"reichard.io/antholume/graph"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /home
|
||||||
|
func (s *Server) GetHome(ctx context.Context, request GetHomeRequestObject) (GetHomeResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetHome401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database info
|
||||||
|
dbInfo, err := s.db.Queries.GetDatabaseInfo(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDatabaseInfo DB Error:", err)
|
||||||
|
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get streaks
|
||||||
|
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStreaks DB Error:", err)
|
||||||
|
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get graph data
|
||||||
|
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDailyReadStats DB Error:", err)
|
||||||
|
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user statistics
|
||||||
|
userStats, err := s.db.Queries.GetUserStatistics(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStatistics DB Error:", err)
|
||||||
|
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
response := HomeResponse{
|
||||||
|
DatabaseInfo: DatabaseInfo{
|
||||||
|
DocumentsSize: dbInfo.DocumentsSize,
|
||||||
|
ActivitySize: dbInfo.ActivitySize,
|
||||||
|
ProgressSize: dbInfo.ProgressSize,
|
||||||
|
DevicesSize: dbInfo.DevicesSize,
|
||||||
|
},
|
||||||
|
Streaks: StreaksResponse{
|
||||||
|
Streaks: convertStreaks(streaks),
|
||||||
|
},
|
||||||
|
GraphData: GraphDataResponse{
|
||||||
|
GraphData: convertGraphData(graphData),
|
||||||
|
},
|
||||||
|
UserStatistics: arrangeUserStatistics(userStats),
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetHome200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /home/streaks
|
||||||
|
func (s *Server) GetStreaks(ctx context.Context, request GetStreaksRequestObject) (GetStreaksResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetStreaks401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStreaks DB Error:", err)
|
||||||
|
return GetStreaks500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := StreaksResponse{
|
||||||
|
Streaks: convertStreaks(streaks),
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetStreaks200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /home/graph
|
||||||
|
func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestObject) (GetGraphDataResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetGraphData401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDailyReadStats DB Error:", err)
|
||||||
|
return GetGraphData500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := GraphDataResponse{
|
||||||
|
GraphData: convertGraphData(graphData),
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetGraphData200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /home/statistics
|
||||||
|
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
|
||||||
|
_, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetUserStatistics401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userStats, err := s.db.Queries.GetUserStatistics(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStatistics DB Error:", err)
|
||||||
|
return GetUserStatistics500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := arrangeUserStatistics(userStats)
|
||||||
|
return GetUserStatistics200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertStreaks(streaks []database.UserStreak) []UserStreak {
|
||||||
|
result := make([]UserStreak, len(streaks))
|
||||||
|
for i, streak := range streaks {
|
||||||
|
result[i] = UserStreak{
|
||||||
|
Window: streak.Window,
|
||||||
|
MaxStreak: streak.MaxStreak,
|
||||||
|
MaxStreakStartDate: streak.MaxStreakStartDate,
|
||||||
|
MaxStreakEndDate: streak.MaxStreakEndDate,
|
||||||
|
CurrentStreak: streak.CurrentStreak,
|
||||||
|
CurrentStreakStartDate: streak.CurrentStreakStartDate,
|
||||||
|
CurrentStreakEndDate: streak.CurrentStreakEndDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertGraphData(graphData []database.GetDailyReadStatsRow) []GraphDataPoint {
|
||||||
|
result := make([]GraphDataPoint, len(graphData))
|
||||||
|
for i, data := range graphData {
|
||||||
|
result[i] = GraphDataPoint{
|
||||||
|
Date: data.Date,
|
||||||
|
MinutesRead: data.MinutesRead,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) UserStatisticsResponse {
|
||||||
|
// Sort by WPM for each period
|
||||||
|
sortByWPM := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) float64) []LeaderboardEntry {
|
||||||
|
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
return getter(sorted[i]) > getter(sorted[j])
|
||||||
|
})
|
||||||
|
|
||||||
|
result := make([]LeaderboardEntry, len(sorted))
|
||||||
|
for i, item := range sorted {
|
||||||
|
result[i] = LeaderboardEntry{UserId: item.UserID, Value: getter(item)}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by duration (seconds) for each period
|
||||||
|
sortByDuration := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
|
||||||
|
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
return getter(sorted[i]) > getter(sorted[j])
|
||||||
|
})
|
||||||
|
|
||||||
|
result := make([]LeaderboardEntry, len(sorted))
|
||||||
|
for i, item := range sorted {
|
||||||
|
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by words for each period
|
||||||
|
sortByWords := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
|
||||||
|
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
return getter(sorted[i]) > getter(sorted[j])
|
||||||
|
})
|
||||||
|
|
||||||
|
result := make([]LeaderboardEntry, len(sorted))
|
||||||
|
for i, item := range sorted {
|
||||||
|
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserStatisticsResponse{
|
||||||
|
Wpm: LeaderboardData{
|
||||||
|
All: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.TotalWpm }),
|
||||||
|
Year: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.YearlyWpm }),
|
||||||
|
Month: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.MonthlyWpm }),
|
||||||
|
Week: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.WeeklyWpm }),
|
||||||
|
},
|
||||||
|
Duration: LeaderboardData{
|
||||||
|
All: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalSeconds }),
|
||||||
|
Year: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlySeconds }),
|
||||||
|
Month: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlySeconds }),
|
||||||
|
Week: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklySeconds }),
|
||||||
|
},
|
||||||
|
Words: LeaderboardData{
|
||||||
|
All: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalWordsRead }),
|
||||||
|
Year: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlyWordsRead }),
|
||||||
|
Month: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlyWordsRead }),
|
||||||
|
Week: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklyWordsRead }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSVGGraphData generates SVG bezier path for graph visualization
|
||||||
|
func GetSVGGraphData(inputData []GraphDataPoint, svgWidth int, svgHeight int) graph.SVGGraphData {
|
||||||
|
// Convert to int64 slice expected by graph package
|
||||||
|
intData := make([]int64, len(inputData))
|
||||||
|
|
||||||
|
for i, data := range inputData {
|
||||||
|
intData[i] = int64(data.MinutesRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
|
||||||
|
}
|
||||||
6
api/v1/oapi-codegen.yaml
Normal file
6
api/v1/oapi-codegen.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package: v1
|
||||||
|
generate:
|
||||||
|
std-http-server: true
|
||||||
|
strict-server: true
|
||||||
|
models: true
|
||||||
|
output: api.gen.go
|
||||||
1977
api/v1/openapi.yaml
Normal file
1977
api/v1/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
163
api/v1/progress.go
Normal file
163
api/v1/progress.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /progress
|
||||||
|
func (s *Server) GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetProgressList401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
page := int64(1)
|
||||||
|
if request.Params.Page != nil {
|
||||||
|
page = *request.Params.Page
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(15)
|
||||||
|
if request.Params.Limit != nil {
|
||||||
|
limit = *request.Params.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := database.GetProgressParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Offset: (page - 1) * limit,
|
||||||
|
Limit: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Params.Document != nil && *request.Params.Document != "" {
|
||||||
|
filter.DocFilter = true
|
||||||
|
filter.DocumentID = *request.Params.Document
|
||||||
|
}
|
||||||
|
|
||||||
|
progress, err := s.db.Queries.GetProgress(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetProgress DB Error:", err)
|
||||||
|
return GetProgressList500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(len(progress))
|
||||||
|
var nextPage *int64
|
||||||
|
var previousPage *int64
|
||||||
|
|
||||||
|
// Calculate total pages
|
||||||
|
totalPages := int64(math.Ceil(float64(total) / float64(limit)))
|
||||||
|
if page < totalPages {
|
||||||
|
nextPage = ptrOf(page + 1)
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
previousPage = ptrOf(page - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiProgress := make([]Progress, len(progress))
|
||||||
|
for i, row := range progress {
|
||||||
|
apiProgress[i] = Progress{
|
||||||
|
Title: row.Title,
|
||||||
|
Author: row.Author,
|
||||||
|
DeviceName: &row.DeviceName,
|
||||||
|
Percentage: &row.Percentage,
|
||||||
|
DocumentId: &row.DocumentID,
|
||||||
|
UserId: &row.UserID,
|
||||||
|
CreatedAt: parseTimePtr(row.CreatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ProgressListResponse{
|
||||||
|
Progress: &apiProgress,
|
||||||
|
Page: &page,
|
||||||
|
Limit: &limit,
|
||||||
|
NextPage: nextPage,
|
||||||
|
PreviousPage: previousPage,
|
||||||
|
Total: &total,
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetProgressList200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /progress/{id}
|
||||||
|
func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObject) (GetProgressResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
DocumentID: request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocumentProgress DB Error:", err)
|
||||||
|
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiProgress := Progress{
|
||||||
|
DeviceName: &row.DeviceName,
|
||||||
|
DeviceId: &row.DeviceID,
|
||||||
|
Percentage: &row.Percentage,
|
||||||
|
Progress: &row.Progress,
|
||||||
|
DocumentId: &row.DocumentID,
|
||||||
|
UserId: &row.UserID,
|
||||||
|
CreatedAt: parseTimePtr(row.CreatedAt),
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ProgressResponse{
|
||||||
|
Progress: &apiProgress,
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetProgress200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /progress
|
||||||
|
func (s *Server) UpdateProgress(ctx context.Context, request UpdateProgressRequestObject) (UpdateProgressResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return UpdateProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return UpdateProgress400JSONResponse{Code: 400, Message: "Request body is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.db.Queries.UpsertDevice(ctx, database.UpsertDeviceParams{
|
||||||
|
ID: request.Body.DeviceId,
|
||||||
|
UserID: auth.UserName,
|
||||||
|
DeviceName: request.Body.DeviceName,
|
||||||
|
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("UpsertDevice DB Error:", err)
|
||||||
|
return UpdateProgress500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
||||||
|
ID: request.Body.DocumentId,
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("UpsertDocument DB Error:", err)
|
||||||
|
return UpdateProgress500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
progress, err := s.db.Queries.UpdateProgress(ctx, database.UpdateProgressParams{
|
||||||
|
Percentage: request.Body.Percentage,
|
||||||
|
DocumentID: request.Body.DocumentId,
|
||||||
|
DeviceID: request.Body.DeviceId,
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Progress: request.Body.Progress,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("UpdateProgress DB Error:", err)
|
||||||
|
return UpdateProgress400JSONResponse{Code: 400, Message: "Invalid request"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := UpdateProgressResponse{
|
||||||
|
DocumentId: progress.DocumentID,
|
||||||
|
Timestamp: parseTime(progress.CreatedAt),
|
||||||
|
}
|
||||||
|
|
||||||
|
return UpdateProgress200JSONResponse(response), nil
|
||||||
|
}
|
||||||
59
api/v1/search.go
Normal file
59
api/v1/search.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"reichard.io/antholume/search"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /search
|
||||||
|
func (s *Server) GetSearch(ctx context.Context, request GetSearchRequestObject) (GetSearchResponseObject, error) {
|
||||||
|
|
||||||
|
if request.Params.Query == "" {
|
||||||
|
return GetSearch400JSONResponse{Code: 400, Message: "Invalid query"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := request.Params.Query
|
||||||
|
source := string(request.Params.Source)
|
||||||
|
|
||||||
|
// Validate source
|
||||||
|
if source != "LibGen" && source != "Annas Archive" {
|
||||||
|
return GetSearch400JSONResponse{Code: 400, Message: "Invalid source"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResults, err := search.SearchBook(query, search.Source(source))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Search Error:", err)
|
||||||
|
return GetSearch500JSONResponse{Code: 500, Message: "Search error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiResults := make([]SearchItem, len(searchResults))
|
||||||
|
for i, item := range searchResults {
|
||||||
|
apiResults[i] = SearchItem{
|
||||||
|
Id: ptrOf(item.ID),
|
||||||
|
Title: ptrOf(item.Title),
|
||||||
|
Author: ptrOf(item.Author),
|
||||||
|
Language: ptrOf(item.Language),
|
||||||
|
Series: ptrOf(item.Series),
|
||||||
|
FileType: ptrOf(item.FileType),
|
||||||
|
FileSize: ptrOf(item.FileSize),
|
||||||
|
UploadDate: ptrOf(item.UploadDate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := SearchResponse{
|
||||||
|
Results: apiResults,
|
||||||
|
Source: source,
|
||||||
|
Query: query,
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetSearch200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /search
|
||||||
|
func (s *Server) PostSearch(ctx context.Context, request PostSearchRequestObject) (PostSearchResponseObject, error) {
|
||||||
|
// This endpoint is used by the SSR template to queue a download
|
||||||
|
// For the API, we just return success - the actual download happens via /documents POST
|
||||||
|
return PostSearch200Response{}, nil
|
||||||
|
}
|
||||||
99
api/v1/server.go
Normal file
99
api/v1/server.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"reichard.io/antholume/config"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ StrictServerInterface = (*Server)(nil)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
mux *http.ServeMux
|
||||||
|
db *database.DBManager
|
||||||
|
cfg *config.Config
|
||||||
|
assets fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new native HTTP server
|
||||||
|
func NewServer(db *database.DBManager, cfg *config.Config, assets fs.FS) *Server {
|
||||||
|
s := &Server{
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
db: db,
|
||||||
|
cfg: cfg,
|
||||||
|
assets: assets,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create strict handler with authentication middleware
|
||||||
|
strictHandler := NewStrictHandler(s, []StrictMiddlewareFunc{s.authMiddleware})
|
||||||
|
|
||||||
|
s.mux = HandlerFromMuxWithBaseURL(strictHandler, s.mux, "/api/v1").(*http.ServeMux)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mux.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authMiddleware adds authentication context to requests
|
||||||
|
func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) StrictHandlerFunc {
|
||||||
|
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) {
|
||||||
|
// Store request and response in context for all handlers
|
||||||
|
ctx = context.WithValue(ctx, "request", r)
|
||||||
|
ctx = context.WithValue(ctx, "response", w)
|
||||||
|
|
||||||
|
// Skip auth for public auth and info endpoints - cover and file require auth via cookies
|
||||||
|
if operationID == "Login" || operationID == "Register" || operationID == "GetInfo" {
|
||||||
|
return handler(ctx, w, r, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, ok := s.getSession(r)
|
||||||
|
if !ok {
|
||||||
|
// Write 401 response directly
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(401)
|
||||||
|
json.NewEncoder(w).Encode(ErrorResponse{Code: 401, Message: "Unauthorized"})
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin status for admin-only endpoints
|
||||||
|
adminEndpoints := []string{
|
||||||
|
"GetAdmin",
|
||||||
|
"PostAdminAction",
|
||||||
|
"GetUsers",
|
||||||
|
"UpdateUser",
|
||||||
|
"GetImportDirectory",
|
||||||
|
"PostImport",
|
||||||
|
"GetImportResults",
|
||||||
|
"GetLogs",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, adminEndpoint := range adminEndpoints {
|
||||||
|
if operationID == adminEndpoint && !auth.IsAdmin {
|
||||||
|
// Write 403 response directly
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(403)
|
||||||
|
json.NewEncoder(w).Encode(ErrorResponse{Code: 403, Message: "Admin privileges required"})
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store auth in context for handlers to access
|
||||||
|
ctx = context.WithValue(ctx, "auth", auth)
|
||||||
|
|
||||||
|
return handler(ctx, w, r, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInfo returns server information
|
||||||
|
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
|
||||||
|
return GetInfo200JSONResponse{
|
||||||
|
Version: s.cfg.Version,
|
||||||
|
SearchEnabled: s.cfg.SearchEnabled,
|
||||||
|
RegistrationEnabled: s.cfg.RegistrationEnabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
58
api/v1/server_test.go
Normal file
58
api/v1/server_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
|
||||||
|
"reichard.io/antholume/config"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
db *database.DBManager
|
||||||
|
cfg *config.Config
|
||||||
|
srv *Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer(t *testing.T) {
|
||||||
|
suite.Run(t, new(ServerTestSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ServerTestSuite) SetupTest() {
|
||||||
|
suite.cfg = &config.Config{
|
||||||
|
ListenPort: "8080",
|
||||||
|
DBType: "memory",
|
||||||
|
DBName: "test",
|
||||||
|
ConfigPath: "/tmp",
|
||||||
|
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||||
|
CookieEncKey: "0123456789abcdef",
|
||||||
|
CookieSecure: false,
|
||||||
|
CookieHTTPOnly: true,
|
||||||
|
Version: "test",
|
||||||
|
DemoMode: false,
|
||||||
|
RegistrationEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.db = database.NewMgr(suite.cfg)
|
||||||
|
suite.srv = NewServer(suite.db, suite.cfg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ServerTestSuite) TestNewServer() {
|
||||||
|
suite.NotNil(suite.srv)
|
||||||
|
suite.NotNil(suite.srv.mux)
|
||||||
|
suite.NotNil(suite.srv.db)
|
||||||
|
suite.NotNil(suite.srv.cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ServerTestSuite) TestServerServeHTTP() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
suite.Equal(http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
157
api/v1/settings.go
Normal file
157
api/v1/settings.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
argon2id "github.com/alexedwards/argon2id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /settings
|
||||||
|
func (s *Server) GetSettings(ctx context.Context, request GetSettingsRequestObject) (GetSettingsResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetSettings401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := s.db.Queries.GetDevices(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDevices := make([]Device, len(devices))
|
||||||
|
for i, device := range devices {
|
||||||
|
apiDevices[i] = Device{
|
||||||
|
Id: &device.ID,
|
||||||
|
DeviceName: &device.DeviceName,
|
||||||
|
CreatedAt: parseTimePtr(device.CreatedAt),
|
||||||
|
LastSynced: parseTimePtr(device.LastSynced),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := SettingsResponse{
|
||||||
|
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||||
|
Timezone: user.Timezone,
|
||||||
|
Devices: &apiDevices,
|
||||||
|
}
|
||||||
|
return GetSettings200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizeCredentials verifies if credentials are valid
|
||||||
|
func (s *Server) authorizeCredentials(ctx context.Context, username string, password string) bool {
|
||||||
|
user, err := s.db.Queries.GetUser(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try argon2 hash comparison
|
||||||
|
if match, err := argon2id.ComparePasswordAndHash(password, *user.Pass); err == nil && match {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /settings
|
||||||
|
func (s *Server) UpdateSettings(ctx context.Context, request UpdateSettingsRequestObject) (UpdateSettingsResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return UpdateSettings401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return UpdateSettings400JSONResponse{Code: 400, Message: "Request body is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams := database.UpdateUserParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Admin: auth.IsAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password if provided
|
||||||
|
if request.Body.NewPassword != nil {
|
||||||
|
if request.Body.Password == nil {
|
||||||
|
return UpdateSettings400JSONResponse{Code: 400, Message: "Current password is required to set new password"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password - first try bcrypt (new format), then argon2, then MD5 (legacy format)
|
||||||
|
currentPasswordMatched := false
|
||||||
|
|
||||||
|
// Try argon2 (current format)
|
||||||
|
if !currentPasswordMatched {
|
||||||
|
currentPassword := fmt.Sprintf("%x", md5.Sum([]byte(*request.Body.Password)))
|
||||||
|
if match, err := argon2id.ComparePasswordAndHash(currentPassword, *user.Pass); err == nil && match {
|
||||||
|
currentPasswordMatched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !currentPasswordMatched {
|
||||||
|
return UpdateSettings400JSONResponse{Code: 400, Message: "Invalid current password"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password with argon2
|
||||||
|
newPassword := fmt.Sprintf("%x", md5.Sum([]byte(*request.Body.NewPassword)))
|
||||||
|
hashedPassword, err := argon2id.CreateHash(newPassword, argon2id.DefaultParams)
|
||||||
|
if err != nil {
|
||||||
|
return UpdateSettings500JSONResponse{Code: 500, Message: "Failed to hash password"}, nil
|
||||||
|
}
|
||||||
|
updateParams.Password = &hashedPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timezone if provided
|
||||||
|
if request.Body.Timezone != nil {
|
||||||
|
updateParams.Timezone = request.Body.Timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing to update, return error
|
||||||
|
if request.Body.NewPassword == nil && request.Body.Timezone == nil {
|
||||||
|
return UpdateSettings400JSONResponse{Code: 400, Message: "At least one field must be provided"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
_, err = s.db.Queries.UpdateUser(ctx, updateParams)
|
||||||
|
if err != nil {
|
||||||
|
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated settings to return
|
||||||
|
user, err = s.db.Queries.GetUser(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := s.db.Queries.GetDevices(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDevices := make([]Device, len(devices))
|
||||||
|
for i, device := range devices {
|
||||||
|
apiDevices[i] = Device{
|
||||||
|
Id: &device.ID,
|
||||||
|
DeviceName: &device.DeviceName,
|
||||||
|
CreatedAt: parseTimePtr(device.CreatedAt),
|
||||||
|
LastSynced: parseTimePtr(device.LastSynced),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := SettingsResponse{
|
||||||
|
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||||
|
Timezone: user.Timezone,
|
||||||
|
Devices: &apiDevices,
|
||||||
|
}
|
||||||
|
return UpdateSettings200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
84
api/v1/utils.go
Normal file
84
api/v1/utils.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeJSON writes a JSON response (deprecated - used by tests only)
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "Failed to encode response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSONError writes a JSON error response (deprecated - used by tests only)
|
||||||
|
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, ErrorResponse{
|
||||||
|
Code: status,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryParams represents parsed query parameters (deprecated - used by tests only)
|
||||||
|
type QueryParams struct {
|
||||||
|
Page int64
|
||||||
|
Limit int64
|
||||||
|
Search *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseQueryParams parses URL query parameters (deprecated - used by tests only)
|
||||||
|
func parseQueryParams(query url.Values, defaultLimit int64) QueryParams {
|
||||||
|
page, _ := strconv.ParseInt(query.Get("page"), 10, 64)
|
||||||
|
if page == 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
limit, _ := strconv.ParseInt(query.Get("limit"), 10, 64)
|
||||||
|
if limit == 0 {
|
||||||
|
limit = defaultLimit
|
||||||
|
}
|
||||||
|
search := query.Get("search")
|
||||||
|
var searchPtr *string
|
||||||
|
if search != "" {
|
||||||
|
searchPtr = ptrOf("%" + search + "%")
|
||||||
|
}
|
||||||
|
return QueryParams{
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
Search: searchPtr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrOf returns a pointer to the given value
|
||||||
|
func ptrOf[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTime parses a string to time.Time
|
||||||
|
func parseTime(s string) time.Time {
|
||||||
|
t, _ := time.Parse(time.RFC3339, s)
|
||||||
|
if t.IsZero() {
|
||||||
|
t, _ = time.Parse("2006-01-02T15:04:05", s)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTimePtr parses an interface{} (from SQL) to *time.Time
|
||||||
|
func parseTimePtr(v interface{}) *time.Time {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
t := parseTime(s)
|
||||||
|
if t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
76
api/v1/utils_test.go
Normal file
76
api/v1/utils_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UtilsTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUtils(t *testing.T) {
|
||||||
|
suite.Run(t, new(UtilsTestSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *UtilsTestSuite) TestWriteJSON() {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
data := map[string]string{"test": "value"}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, data)
|
||||||
|
|
||||||
|
suite.Equal("application/json", w.Header().Get("Content-Type"))
|
||||||
|
suite.Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
suite.Equal("value", resp["test"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *UtilsTestSuite) TestWriteJSONError() {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "test error")
|
||||||
|
|
||||||
|
suite.Equal(http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
|
var resp ErrorResponse
|
||||||
|
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
suite.Equal(http.StatusBadRequest, resp.Code)
|
||||||
|
suite.Equal("test error", resp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *UtilsTestSuite) TestParseQueryParams() {
|
||||||
|
query := make(map[string][]string)
|
||||||
|
query["page"] = []string{"2"}
|
||||||
|
query["limit"] = []string{"15"}
|
||||||
|
query["search"] = []string{"test"}
|
||||||
|
|
||||||
|
params := parseQueryParams(query, 9)
|
||||||
|
|
||||||
|
suite.Equal(int64(2), params.Page)
|
||||||
|
suite.Equal(int64(15), params.Limit)
|
||||||
|
suite.NotNil(params.Search)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *UtilsTestSuite) TestParseQueryParamsDefaults() {
|
||||||
|
query := make(map[string][]string)
|
||||||
|
|
||||||
|
params := parseQueryParams(query, 9)
|
||||||
|
|
||||||
|
suite.Equal(int64(1), params.Page)
|
||||||
|
suite.Equal(int64(9), params.Limit)
|
||||||
|
suite.Nil(params.Search)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *UtilsTestSuite) TestPtrOf() {
|
||||||
|
value := "test"
|
||||||
|
ptr := ptrOf(value)
|
||||||
|
|
||||||
|
suite.NotNil(ptr)
|
||||||
|
suite.Equal("test", *ptr)
|
||||||
|
}
|
||||||
116
assets/index.css
116
assets/index.css
@@ -1,116 +0,0 @@
|
|||||||
/* ----------------------------- */
|
|
||||||
/* -------- PWA Styling -------- */
|
|
||||||
/* ----------------------------- */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
overscroll-behavior-y: none;
|
|
||||||
margin: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
height: calc(100% + env(safe-area-inset-bottom));
|
|
||||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
|
|
||||||
env(safe-area-inset-left);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
height: calc(100dvh - 4rem - env(safe-area-inset-top));
|
|
||||||
}
|
|
||||||
|
|
||||||
#container {
|
|
||||||
padding-bottom: calc(5em + env(safe-area-inset-bottom) * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No Scrollbar - IE, Edge, Firefox */
|
|
||||||
* {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No Scrollbar - WebKit */
|
|
||||||
*::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- */
|
|
||||||
/* -------- CSS Button -------- */
|
|
||||||
/* ----------------------------- */
|
|
||||||
.css-button:checked + div {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.css-button + div {
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- */
|
|
||||||
/* ------- User Dropdown ------- */
|
|
||||||
/* ----------------------------- */
|
|
||||||
#user-dropdown-button:checked + #user-dropdown {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#user-dropdown {
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- */
|
|
||||||
/* ----- Mobile Navigation ----- */
|
|
||||||
/* ----------------------------- */
|
|
||||||
#mobile-nav-button span {
|
|
||||||
transform-origin: 5px 0px;
|
|
||||||
transition:
|
|
||||||
transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
|
||||||
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
|
||||||
opacity 0.55s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button span:first-child {
|
|
||||||
transform-origin: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button span:nth-last-child(2) {
|
|
||||||
transform-origin: 0% 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button input:checked ~ span {
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(45deg) translate(2px, -2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button input:checked ~ span:nth-last-child(3) {
|
|
||||||
opacity: 0;
|
|
||||||
transform: rotate(0deg) scale(0.2, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button input:checked ~ span:nth-last-child(2) {
|
|
||||||
transform: rotate(-45deg) translate(0, 6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button input:checked ~ div {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
#mobile-nav-button input ~ div {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#menu {
|
|
||||||
top: 0;
|
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
transform-origin: 0% 0%;
|
|
||||||
transform: translate(-100%, 0);
|
|
||||||
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (orientation: landscape) {
|
|
||||||
#menu {
|
|
||||||
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<title>AnthoLume - Local</title>
|
<title>AnthoLume - Local</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
|
||||||
<!-- Libraries -->
|
<!-- Libraries -->
|
||||||
<script src="/assets/lib/jszip.min.js"></script>
|
<script src="/assets/lib/jszip.min.js"></script>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<title>AnthoLume - Reader</title>
|
<title>AnthoLume - Reader</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
|
||||||
<!-- Libraries -->
|
<!-- Libraries -->
|
||||||
<script src="/assets/lib/jszip.min.js"></script>
|
<script src="/assets/lib/jszip.min.js"></script>
|
||||||
@@ -82,13 +82,9 @@
|
|||||||
id="top-bar"
|
id="top-bar"
|
||||||
class="transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 w-full px-2"
|
class="transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 w-full px-2"
|
||||||
>
|
>
|
||||||
<div
|
<div class="max-h-[75vh] w-full flex flex-col items-center justify-around relative dark:text-white">
|
||||||
class="max-h-[75vh] w-full flex flex-col items-center justify-around relative dark:text-white"
|
<div class="h-32">
|
||||||
>
|
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
|
||||||
<div class="h-32">
|
|
||||||
<div
|
|
||||||
class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<a href="#">
|
<a href="#">
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
@@ -156,11 +152,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div id="toc" class="w-full text-center max-h-[50%] overflow-scroll no-scrollbar"></div>
|
||||||
id="toc"
|
|
||||||
class="w-full text-center max-h-[50%] overflow-scroll no-scrollbar"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1317,7 +1317,7 @@ class EBookReader {
|
|||||||
let spineWC = await Promise.all(
|
let spineWC = await Promise.all(
|
||||||
this.book.spine.spineItems.map(async (item) => {
|
this.book.spine.spineItems.map(async (item) => {
|
||||||
let newDoc = await item.load(this.book.load.bind(this.book));
|
let newDoc = await item.load(this.book.load.bind(this.book));
|
||||||
let spineWords = newDoc.innerText.trim().split(/\s+/).length;
|
let spineWords = (newDoc.innerText || "").trim().split(/\s+/).length;
|
||||||
item.wordCount = spineWords;
|
item.wordCount = spineWords;
|
||||||
return spineWords;
|
return spineWords;
|
||||||
}),
|
}),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -72,8 +72,7 @@ const PRECACHE_ASSETS = [
|
|||||||
// Main App Assets
|
// Main App Assets
|
||||||
"/manifest.json",
|
"/manifest.json",
|
||||||
"/assets/index.js",
|
"/assets/index.js",
|
||||||
"/assets/index.css",
|
"/assets/style.css",
|
||||||
"/assets/tailwind.css",
|
|
||||||
"/assets/common.js",
|
"/assets/common.js",
|
||||||
|
|
||||||
// Library Assets
|
// Library Assets
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -67,7 +67,7 @@ WITH filtered_activity AS (
|
|||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
device_id,
|
device_id,
|
||||||
CAST(LOCAL_TIME(activity.start_time, users.timezone) AS TEXT) AS start_time,
|
LOCAL_TIME(activity.start_time, users.timezone) AS start_time,
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
@@ -138,8 +138,8 @@ WHERE id = $device_id LIMIT 1;
|
|||||||
SELECT
|
SELECT
|
||||||
devices.id,
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
|
LOCAL_TIME(devices.created_at, users.timezone) AS created_at,
|
||||||
CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced
|
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced
|
||||||
FROM devices
|
FROM devices
|
||||||
JOIN users ON users.id = devices.user_id
|
JOIN users ON users.id = devices.user_id
|
||||||
WHERE users.id = $user_id
|
WHERE users.id = $user_id
|
||||||
@@ -246,7 +246,7 @@ SELECT
|
|||||||
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
||||||
progress.document_id,
|
progress.document_id,
|
||||||
progress.user_id,
|
progress.user_id,
|
||||||
CAST(LOCAL_TIME(progress.created_at, users.timezone) AS TEXT) AS created_at
|
LOCAL_TIME(progress.created_at, users.timezone) AS created_at
|
||||||
FROM document_progress AS progress
|
FROM document_progress AS progress
|
||||||
LEFT JOIN users ON progress.user_id = users.id
|
LEFT JOIN users ON progress.user_id = users.id
|
||||||
LEFT JOIN devices ON progress.device_id = devices.id
|
LEFT JOIN devices ON progress.device_id = devices.id
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ WITH filtered_activity AS (
|
|||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
device_id,
|
device_id,
|
||||||
CAST(LOCAL_TIME(activity.start_time, users.timezone) AS TEXT) AS start_time,
|
LOCAL_TIME(activity.start_time, users.timezone) AS start_time,
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
@@ -214,15 +214,15 @@ type GetActivityParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetActivityRow struct {
|
type GetActivityRow struct {
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime interface{} `json:"start_time"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Author *string `json:"author"`
|
Author *string `json:"author"`
|
||||||
Duration int64 `json:"duration"`
|
Duration int64 `json:"duration"`
|
||||||
StartPercentage float64 `json:"start_percentage"`
|
StartPercentage float64 `json:"start_percentage"`
|
||||||
EndPercentage float64 `json:"end_percentage"`
|
EndPercentage float64 `json:"end_percentage"`
|
||||||
ReadPercentage float64 `json:"read_percentage"`
|
ReadPercentage float64 `json:"read_percentage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
||||||
@@ -422,8 +422,8 @@ const getDevices = `-- name: GetDevices :many
|
|||||||
SELECT
|
SELECT
|
||||||
devices.id,
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
|
LOCAL_TIME(devices.created_at, users.timezone) AS created_at,
|
||||||
CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced
|
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced
|
||||||
FROM devices
|
FROM devices
|
||||||
JOIN users ON users.id = devices.user_id
|
JOIN users ON users.id = devices.user_id
|
||||||
WHERE users.id = ?1
|
WHERE users.id = ?1
|
||||||
@@ -431,10 +431,10 @@ ORDER BY devices.last_synced DESC
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetDevicesRow struct {
|
type GetDevicesRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt interface{} `json:"created_at"`
|
||||||
LastSynced string `json:"last_synced"`
|
LastSynced interface{} `json:"last_synced"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
|
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
|
||||||
@@ -824,7 +824,7 @@ SELECT
|
|||||||
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
||||||
progress.document_id,
|
progress.document_id,
|
||||||
progress.user_id,
|
progress.user_id,
|
||||||
CAST(LOCAL_TIME(progress.created_at, users.timezone) AS TEXT) AS created_at
|
LOCAL_TIME(progress.created_at, users.timezone) AS created_at
|
||||||
FROM document_progress AS progress
|
FROM document_progress AS progress
|
||||||
LEFT JOIN users ON progress.user_id = users.id
|
LEFT JOIN users ON progress.user_id = users.id
|
||||||
LEFT JOIN devices ON progress.device_id = devices.id
|
LEFT JOIN devices ON progress.device_id = devices.id
|
||||||
@@ -851,13 +851,13 @@ type GetProgressParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetProgressRow struct {
|
type GetProgressRow struct {
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Author *string `json:"author"`
|
Author *string `json:"author"`
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt interface{} `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]GetProgressRow, error) {
|
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]GetProgressRow, error) {
|
||||||
|
|||||||
6
flake.lock
generated
6
flake.lock
generated
@@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1764522689,
|
"lastModified": 1773524153,
|
||||||
"narHash": "sha256-SqUuBFjhl/kpDiVaKLQBoD8TLD+/cTUzzgVFoaHrkqY=",
|
"narHash": "sha256-Jms57zzlFf64ayKzzBWSE2SGvJmK+NGt8Gli71d9kmY=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8bb5646e0bed5dbd3ab08c7a7cc15b75ab4e1d0f",
|
"rev": "e9f278faa1d0c2fc835bd331d4666b59b505a410",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -21,11 +21,12 @@
|
|||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
go
|
go
|
||||||
gopls
|
|
||||||
golangci-lint
|
golangci-lint
|
||||||
|
gopls
|
||||||
|
|
||||||
|
bun
|
||||||
nodejs
|
nodejs
|
||||||
tailwindcss
|
tailwindcss
|
||||||
python311Packages.grip
|
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export PATH=$PATH:~/go/bin
|
export PATH=$PATH:~/go/bin
|
||||||
|
|||||||
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
2
frontend/.prettierignore
Normal file
2
frontend/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Generated API code
|
||||||
|
src/generated/**/*
|
||||||
11
frontend/.prettierrc
Normal file
11
frontend/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
76
frontend/AGENTS.md
Normal file
76
frontend/AGENTS.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# AnthoLume Frontend Agent Guide
|
||||||
|
|
||||||
|
Read this file for work in `frontend/`.
|
||||||
|
Also follow the repository root guide at `../AGENTS.md`.
|
||||||
|
|
||||||
|
## 1) Stack
|
||||||
|
|
||||||
|
- Package manager: `bun`
|
||||||
|
- Framework: React + Vite
|
||||||
|
- Data fetching: React Query
|
||||||
|
- API generation: Orval
|
||||||
|
- Linting: ESLint + Tailwind plugin
|
||||||
|
- Formatting: Prettier
|
||||||
|
|
||||||
|
## 2) Conventions
|
||||||
|
|
||||||
|
- Use local icon components from `src/icons/`.
|
||||||
|
- Do not add external icon libraries.
|
||||||
|
- Prefer generated types from `src/generated/model/` over `any`.
|
||||||
|
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
|
||||||
|
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
|
||||||
|
- Prefer `LoadingState` for result-area loading indicators; avoid early returns that unmount search/filter forms during fetches.
|
||||||
|
- Use theme tokens from `tailwind.config.js` / `src/index.css` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs.
|
||||||
|
- Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape.
|
||||||
|
|
||||||
|
## 3) Generated API client
|
||||||
|
|
||||||
|
- Do not edit `src/generated/**` directly.
|
||||||
|
- Edit `../api/v1/openapi.yaml` and regenerate instead.
|
||||||
|
- Regenerate with: `bun run generate:api`
|
||||||
|
|
||||||
|
### Important behavior
|
||||||
|
|
||||||
|
- The generated client returns `{ data, status, headers }` for both success and error responses.
|
||||||
|
- Do not assume non-2xx responses throw.
|
||||||
|
- Check `response.status` and response shape before treating a request as successful.
|
||||||
|
|
||||||
|
## 4) Auth / Query State
|
||||||
|
|
||||||
|
- When changing auth flows, account for React Query cache state.
|
||||||
|
- Pay special attention to `/api/v1/auth/me`.
|
||||||
|
- A local auth state update may not be enough if cached query data still reflects a previous auth state.
|
||||||
|
|
||||||
|
## 5) Commands
|
||||||
|
|
||||||
|
- Lint: `bun run lint`
|
||||||
|
- Typecheck: `bun run typecheck`
|
||||||
|
- Lint fix: `bun run lint:fix`
|
||||||
|
- Format check: `bun run format`
|
||||||
|
- Format fix: `bun run format:fix`
|
||||||
|
- Build: `bun run build`
|
||||||
|
- Generate API client: `bun run generate:api`
|
||||||
|
|
||||||
|
## 6) Validation Notes
|
||||||
|
|
||||||
|
- ESLint ignores `src/generated/**`.
|
||||||
|
- Frontend unit tests use Vitest and live alongside source as `src/**/*.test.ts(x)`.
|
||||||
|
- Read `TESTING_STRATEGY.md` before adding or expanding frontend tests.
|
||||||
|
- Prefer tests for meaningful app behavior, branching logic, side effects, and user-visible outcomes.
|
||||||
|
- Avoid low-value tests that mainly assert exact styling classes, duplicate existing coverage, or re-test framework/library behavior.
|
||||||
|
- `bun run lint` includes test files but does not typecheck.
|
||||||
|
- Use `bun run typecheck` to run TypeScript validation for app code and colocated tests without a full production build.
|
||||||
|
- Run frontend tests with `bun run test`.
|
||||||
|
- `bun run build` still runs `tsc && vite build`, so unrelated TypeScript issues elsewhere in `src/` can fail the build.
|
||||||
|
- When possible, validate changed files directly before escalating to full-project fixes.
|
||||||
|
|
||||||
|
## 7) Updating This File
|
||||||
|
|
||||||
|
After completing a frontend task, update this file if you learned something general that would help future frontend agents.
|
||||||
|
|
||||||
|
Rules for updates:
|
||||||
|
|
||||||
|
- Add only frontend-wide guidance.
|
||||||
|
- Do not record one-off task history.
|
||||||
|
- Keep updates concise and action-oriented.
|
||||||
|
- Prefer notes that prevent repeated mistakes.
|
||||||
111
frontend/README.md
Normal file
111
frontend/README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# AnthoLume Frontend
|
||||||
|
|
||||||
|
A React + TypeScript frontend for AnthoLume, replacing the server-side rendering (SSR) templates.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **React 19** - UI framework
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **React Query (TanStack Query)** - Server state management
|
||||||
|
- **Orval** - API client generation from OpenAPI spec
|
||||||
|
- **React Router** - Navigation
|
||||||
|
- **Tailwind CSS** - Styling
|
||||||
|
- **Vite** - Build tool
|
||||||
|
- **Axios** - HTTP client with auth interceptors
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The frontend includes a complete authentication system:
|
||||||
|
|
||||||
|
### Auth Context
|
||||||
|
- `AuthProvider` - Manages authentication state globally
|
||||||
|
- `useAuth()` - Hook to access auth state and methods
|
||||||
|
- Token stored in `localStorage`
|
||||||
|
- Axios interceptors automatically attach Bearer token to API requests
|
||||||
|
|
||||||
|
### Protected Routes
|
||||||
|
- All main routes are wrapped in `ProtectedRoute`
|
||||||
|
- Unauthenticated users are redirected to `/login`
|
||||||
|
- Layout redirects to login if not authenticated
|
||||||
|
|
||||||
|
### Login Flow
|
||||||
|
1. User enters credentials on `/login`
|
||||||
|
2. POST to `/api/v1/auth/login`
|
||||||
|
3. Token stored in localStorage
|
||||||
|
4. Redirect to home page
|
||||||
|
5. Axios interceptor includes token in subsequent requests
|
||||||
|
|
||||||
|
### Logout Flow
|
||||||
|
1. User clicks "Logout" in dropdown menu
|
||||||
|
2. POST to `/api/v1/auth/logout`
|
||||||
|
3. Token cleared from localStorage
|
||||||
|
4. Redirect to `/login`
|
||||||
|
|
||||||
|
### 401 Handling
|
||||||
|
- Axios response interceptor clears token on 401 errors
|
||||||
|
- Prevents stale auth state
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The frontend mirrors the existing SSR templates structure:
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
- `HomePage` - Landing page with recent documents
|
||||||
|
- `DocumentsPage` - Document listing with search and pagination
|
||||||
|
- `DocumentPage` - Single document view with details
|
||||||
|
- `ProgressPage` - Reading progress table
|
||||||
|
- `ActivityPage` - User activity log
|
||||||
|
- `SearchPage` - Search interface
|
||||||
|
- `SettingsPage` - User settings
|
||||||
|
- `LoginPage` - Authentication
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- `Layout` - Main layout with navigation sidebar and header
|
||||||
|
- Generated API hooks from `api/v1/openapi.yaml`
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
The frontend uses **Orval** to generate TypeScript types and React Query hooks from the OpenAPI spec:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates:
|
||||||
|
- Type definitions for all API schemas
|
||||||
|
- React Query hooks (`useGetDocuments`, `useGetDocument`, etc.)
|
||||||
|
- Mutation hooks (`useLogin`, `useLogout`)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Generate API types (if OpenAPI spec changes)
|
||||||
|
npm run generate:api
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The built output is in `dist/` and can be served by the Go backend or deployed separately.
|
||||||
|
|
||||||
|
## Migration from SSR
|
||||||
|
|
||||||
|
The frontend replicates the functionality of the following SSR templates:
|
||||||
|
- `templates/pages/home.tmpl` → `HomePage.tsx`
|
||||||
|
- `templates/pages/documents.tmpl` → `DocumentsPage.tsx`
|
||||||
|
- `templates/pages/document.tmpl` → `DocumentPage.tsx`
|
||||||
|
- `templates/pages/progress.tmpl` → `ProgressPage.tsx`
|
||||||
|
- `templates/pages/activity.tmpl` → `ActivityPage.tsx`
|
||||||
|
- `templates/pages/search.tmpl` → `SearchPage.tsx`
|
||||||
|
- `templates/pages/settings.tmpl` → `SettingsPage.tsx`
|
||||||
|
- `templates/pages/login.tmpl` → `LoginPage.tsx`
|
||||||
|
|
||||||
|
The styling follows the same Tailwind CSS classes as the original templates for consistency.
|
||||||
73
frontend/TESTING_STRATEGY.md
Normal file
73
frontend/TESTING_STRATEGY.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Frontend Testing Strategy
|
||||||
|
|
||||||
|
This project prefers meaningful frontend tests over high test counts.
|
||||||
|
|
||||||
|
## What we want to test
|
||||||
|
|
||||||
|
Prioritize tests for app-owned behavior such as:
|
||||||
|
|
||||||
|
- user-visible page and component behavior
|
||||||
|
- auth and routing behavior
|
||||||
|
- branching logic and business rules
|
||||||
|
- data normalization and error handling
|
||||||
|
- timing behavior with real app logic
|
||||||
|
- side effects that could regress, such as token handling or redirects
|
||||||
|
- algorithmic or formatting logic that defines product behavior
|
||||||
|
|
||||||
|
Good examples in this repo:
|
||||||
|
|
||||||
|
- login and registration flows
|
||||||
|
- protected-route behavior
|
||||||
|
- auth interceptor token injection and cleanup
|
||||||
|
- error message extraction
|
||||||
|
- debounce timing
|
||||||
|
- human-readable formatting logic
|
||||||
|
- graph/algorithm output where exact parity matters
|
||||||
|
|
||||||
|
## What we usually do not want to test
|
||||||
|
|
||||||
|
Avoid tests that mostly prove:
|
||||||
|
|
||||||
|
- the language/runtime works
|
||||||
|
- React forwards basic props correctly
|
||||||
|
- a third-party library behaves as documented
|
||||||
|
- exact Tailwind class strings with no product meaning
|
||||||
|
- implementation details not observable in behavior
|
||||||
|
- duplicated examples that re-assert the same logic
|
||||||
|
|
||||||
|
In other words, do not add tests equivalent to checking that JavaScript can compute `1 + 1`.
|
||||||
|
|
||||||
|
## Preferred test style
|
||||||
|
|
||||||
|
- Prefer behavior-focused assertions over implementation-detail assertions.
|
||||||
|
- Prefer user-visible outcomes over internal state inspection.
|
||||||
|
- Mock at module boundaries when needed.
|
||||||
|
- Keep test setup small and local.
|
||||||
|
- Use exact-output assertions only when the output itself is the contract.
|
||||||
|
|
||||||
|
## When exact assertions are appropriate
|
||||||
|
|
||||||
|
Exact assertions are appropriate when they protect a real contract, for example:
|
||||||
|
|
||||||
|
- a formatter's exact human-readable output
|
||||||
|
- auth decision outcomes for a given API response shape
|
||||||
|
- exact algorithm output that must remain stable
|
||||||
|
|
||||||
|
Exact assertions are usually not appropriate for:
|
||||||
|
|
||||||
|
- incidental class names
|
||||||
|
- framework internals
|
||||||
|
- non-observable React keys
|
||||||
|
|
||||||
|
## Cleanup rule of thumb
|
||||||
|
|
||||||
|
Keep tests that would catch meaningful regressions in product behavior.
|
||||||
|
Trim or remove tests that are brittle, duplicated, or mostly validate tooling rather than app logic.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
For frontend test work, validate with:
|
||||||
|
|
||||||
|
- `cd frontend && bun run lint`
|
||||||
|
- `cd frontend && bun run typecheck`
|
||||||
|
- `cd frontend && bun run test`
|
||||||
1350
frontend/bun.lock
Normal file
1350
frontend/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
82
frontend/eslint.config.js
Normal file
82
frontend/eslint.config.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import typescriptParser from "@typescript-eslint/parser";
|
||||||
|
import typescriptPlugin from "@typescript-eslint/eslint-plugin";
|
||||||
|
import reactPlugin from "eslint-plugin-react";
|
||||||
|
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||||
|
import tailwindcss from "eslint-plugin-tailwindcss";
|
||||||
|
import prettier from "eslint-plugin-prettier";
|
||||||
|
import eslintConfigPrettier from "eslint-config-prettier";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
|
ignores: ["**/generated/**"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: typescriptParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
projectService: true,
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
localStorage: "readonly",
|
||||||
|
sessionStorage: "readonly",
|
||||||
|
document: "readonly",
|
||||||
|
window: "readonly",
|
||||||
|
setTimeout: "readonly",
|
||||||
|
clearTimeout: "readonly",
|
||||||
|
setInterval: "readonly",
|
||||||
|
clearInterval: "readonly",
|
||||||
|
HTMLElement: "readonly",
|
||||||
|
HTMLDivElement: "readonly",
|
||||||
|
HTMLButtonElement: "readonly",
|
||||||
|
HTMLAnchorElement: "readonly",
|
||||||
|
MouseEvent: "readonly",
|
||||||
|
Node: "readonly",
|
||||||
|
File: "readonly",
|
||||||
|
Blob: "readonly",
|
||||||
|
FormData: "readonly",
|
||||||
|
alert: "readonly",
|
||||||
|
confirm: "readonly",
|
||||||
|
prompt: "readonly",
|
||||||
|
React: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": typescriptPlugin,
|
||||||
|
react: reactPlugin,
|
||||||
|
"react-hooks": reactHooksPlugin,
|
||||||
|
tailwindcss,
|
||||||
|
prettier,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...eslintConfigPrettier.rules,
|
||||||
|
...tailwindcss.configs.recommended.rules,
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||||
|
"no-undef": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"no-useless-catch": "off",
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
31
frontend/index.html
Normal file
31
frontend/index.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta
|
||||||
|
name="apple-mobile-web-app-status-bar-style"
|
||||||
|
content="black-translucent"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#F3F4F6"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#1F2937"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
<title>AnthoLume</title>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
frontend/orval.config.ts
Normal file
21
frontend/orval.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'orval';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
antholume: {
|
||||||
|
output: {
|
||||||
|
mode: 'split',
|
||||||
|
baseUrl: '/api/v1',
|
||||||
|
target: 'src/generated',
|
||||||
|
schemas: 'src/generated/model',
|
||||||
|
client: 'react-query',
|
||||||
|
mock: false,
|
||||||
|
override: {
|
||||||
|
useQuery: true,
|
||||||
|
mutations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
target: '../api/v1/openapi.yaml',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
56
frontend/package.json
Normal file
56
frontend/package.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "antholume-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"generate:api": "orval",
|
||||||
|
"lint": "eslint src --max-warnings=0",
|
||||||
|
"lint:fix": "eslint src --fix",
|
||||||
|
"format": "prettier --check src",
|
||||||
|
"format:fix": "prettier --write src",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.62.16",
|
||||||
|
"ajv": "^8.18.0",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"epubjs": "^0.3.93",
|
||||||
|
"nosleep.js": "^0.12.0",
|
||||||
|
"orval": "8.5.3",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.8",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||||
|
"@typescript-eslint/parser": "^8.13.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
12
frontend/src/App.tsx
Normal file
12
frontend/src/App.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { AuthProvider } from './auth/AuthContext';
|
||||||
|
import { Routes } from './Routes';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
134
frontend/src/Routes.tsx
Normal file
134
frontend/src/Routes.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Route, Routes as ReactRoutes } from 'react-router-dom';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import HomePage from './pages/HomePage';
|
||||||
|
import DocumentsPage from './pages/DocumentsPage';
|
||||||
|
import DocumentPage from './pages/DocumentPage';
|
||||||
|
import ProgressPage from './pages/ProgressPage';
|
||||||
|
import ActivityPage from './pages/ActivityPage';
|
||||||
|
import SearchPage from './pages/SearchPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import RegisterPage from './pages/RegisterPage';
|
||||||
|
import AdminPage from './pages/AdminPage';
|
||||||
|
import AdminImportPage from './pages/AdminImportPage';
|
||||||
|
import AdminImportResultsPage from './pages/AdminImportResultsPage';
|
||||||
|
import AdminUsersPage from './pages/AdminUsersPage';
|
||||||
|
import AdminLogsPage from './pages/AdminLogsPage';
|
||||||
|
import ReaderPage from './pages/ReaderPage';
|
||||||
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
|
|
||||||
|
export function Routes() {
|
||||||
|
return (
|
||||||
|
<ReactRoutes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<HomePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="documents"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DocumentsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="documents/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DocumentPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="progress"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProgressPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="activity"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ActivityPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="search"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SearchPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route
|
||||||
|
path="admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/import"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminImportPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/import-results"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminImportResultsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/users"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminUsersPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/logs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminLogsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path="/reader/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ReaderPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
</ReactRoutes>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
frontend/src/auth/AuthContext.tsx
Normal file
135
frontend/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
getGetMeQueryKey,
|
||||||
|
useLogin,
|
||||||
|
useLogout,
|
||||||
|
useGetMe,
|
||||||
|
useRegister,
|
||||||
|
} from '../generated/anthoLumeAPIV1';
|
||||||
|
import {
|
||||||
|
type AuthState,
|
||||||
|
getAuthenticatedAuthState,
|
||||||
|
getUnauthenticatedAuthState,
|
||||||
|
resolveAuthStateFromMe,
|
||||||
|
validateAuthMutationResponse,
|
||||||
|
} from './authHelpers';
|
||||||
|
|
||||||
|
interface AuthContextType extends AuthState {
|
||||||
|
login: (_username: string, _password: string) => Promise<void>;
|
||||||
|
register: (_username: string, _password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const initialAuthState: AuthState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
isCheckingAuth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
|
||||||
|
|
||||||
|
const loginMutation = useLogin();
|
||||||
|
const registerMutation = useRegister();
|
||||||
|
const logoutMutation = useLogout();
|
||||||
|
|
||||||
|
const { data: meData, error: meError, isLoading: meLoading } = useGetMe();
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAuthState(prev =>
|
||||||
|
resolveAuthStateFromMe({
|
||||||
|
meData,
|
||||||
|
meError,
|
||||||
|
meLoading,
|
||||||
|
previousState: prev,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [meData, meError, meLoading]);
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await loginMutation.mutateAsync({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = validateAuthMutationResponse(response, 200);
|
||||||
|
if (!user) {
|
||||||
|
setAuthState(getUnauthenticatedAuthState());
|
||||||
|
throw new Error('Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthState(getAuthenticatedAuthState(user));
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||||
|
navigate('/');
|
||||||
|
} catch (_error) {
|
||||||
|
setAuthState(getUnauthenticatedAuthState());
|
||||||
|
throw new Error('Login failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loginMutation, navigate, queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await registerMutation.mutateAsync({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = validateAuthMutationResponse(response, 201);
|
||||||
|
if (!user) {
|
||||||
|
setAuthState(getUnauthenticatedAuthState());
|
||||||
|
throw new Error('Registration failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthState(getAuthenticatedAuthState(user));
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||||
|
navigate('/');
|
||||||
|
} catch (_error) {
|
||||||
|
setAuthState(getUnauthenticatedAuthState());
|
||||||
|
throw new Error('Registration failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate, queryClient, registerMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
logoutMutation.mutate(undefined, {
|
||||||
|
onSuccess: async () => {
|
||||||
|
setAuthState(getUnauthenticatedAuthState());
|
||||||
|
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
||||||
|
navigate('/login');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [logoutMutation, navigate, queryClient]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ ...authState, login, register, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
90
frontend/src/auth/ProtectedRoute.test.tsx
Normal file
90
frontend/src/auth/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
vi.mock('./AuthContext', () => ({
|
||||||
|
useAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedUseAuth = vi.mocked(useAuth);
|
||||||
|
|
||||||
|
describe('ProtectedRoute', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a loading state while auth is being checked', () => {
|
||||||
|
mockedUseAuth.mockReturnValue({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isCheckingAuth: true,
|
||||||
|
user: null,
|
||||||
|
login: vi.fn(),
|
||||||
|
register: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/private']}>
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div>Secret</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Secret')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects unauthenticated users to the login page', () => {
|
||||||
|
mockedUseAuth.mockReturnValue({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isCheckingAuth: false,
|
||||||
|
user: null,
|
||||||
|
login: vi.fn(),
|
||||||
|
register: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/private']}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/private"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div>Secret</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/login" element={<div>Login Page</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Secret')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children for authenticated users', () => {
|
||||||
|
mockedUseAuth.mockReturnValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isCheckingAuth: false,
|
||||||
|
user: { username: 'evan', is_admin: false },
|
||||||
|
login: vi.fn(),
|
||||||
|
register: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div>Secret</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Secret')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
frontend/src/auth/ProtectedRoute.tsx
Normal file
21
frontend/src/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
|
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (isCheckingAuth) {
|
||||||
|
return <div className="text-content-muted">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
157
frontend/src/auth/authHelpers.test.ts
Normal file
157
frontend/src/auth/authHelpers.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
getCheckingAuthState,
|
||||||
|
getUnauthenticatedAuthState,
|
||||||
|
normalizeAuthenticatedUser,
|
||||||
|
resolveAuthStateFromMe,
|
||||||
|
validateAuthMutationResponse,
|
||||||
|
type AuthState,
|
||||||
|
} from './authHelpers';
|
||||||
|
|
||||||
|
const previousState: AuthState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
isCheckingAuth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('authHelpers', () => {
|
||||||
|
it('normalizes a valid authenticated user payload', () => {
|
||||||
|
expect(normalizeAuthenticatedUser({ username: 'evan', is_admin: true })).toEqual({
|
||||||
|
username: 'evan',
|
||||||
|
is_admin: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid authenticated user payloads', () => {
|
||||||
|
expect(normalizeAuthenticatedUser(null)).toBeNull();
|
||||||
|
expect(normalizeAuthenticatedUser({ username: 'evan' })).toBeNull();
|
||||||
|
expect(normalizeAuthenticatedUser({ username: 123, is_admin: true })).toBeNull();
|
||||||
|
expect(normalizeAuthenticatedUser({ username: 'evan', is_admin: 'yes' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a checking state while preserving previous auth information', () => {
|
||||||
|
expect(
|
||||||
|
getCheckingAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: { username: 'evan', is_admin: false },
|
||||||
|
isCheckingAuth: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: { username: 'evan', is_admin: false },
|
||||||
|
isCheckingAuth: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves auth state from a successful /auth/me response', () => {
|
||||||
|
expect(
|
||||||
|
resolveAuthStateFromMe({
|
||||||
|
meData: {
|
||||||
|
status: 200,
|
||||||
|
data: { username: 'evan', is_admin: false },
|
||||||
|
},
|
||||||
|
meError: undefined,
|
||||||
|
meLoading: false,
|
||||||
|
previousState,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: { username: 'evan', is_admin: false },
|
||||||
|
isCheckingAuth: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves auth state to unauthenticated on 401 or query error', () => {
|
||||||
|
expect(
|
||||||
|
resolveAuthStateFromMe({
|
||||||
|
meData: {
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
meError: undefined,
|
||||||
|
meLoading: false,
|
||||||
|
previousState,
|
||||||
|
})
|
||||||
|
).toEqual(getUnauthenticatedAuthState());
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveAuthStateFromMe({
|
||||||
|
meData: undefined,
|
||||||
|
meError: new Error('failed'),
|
||||||
|
meLoading: false,
|
||||||
|
previousState,
|
||||||
|
})
|
||||||
|
).toEqual(getUnauthenticatedAuthState());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps checking state while /auth/me is still loading', () => {
|
||||||
|
expect(
|
||||||
|
resolveAuthStateFromMe({
|
||||||
|
meData: undefined,
|
||||||
|
meError: undefined,
|
||||||
|
meLoading: true,
|
||||||
|
previousState: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: { username: 'evan', is_admin: true },
|
||||||
|
isCheckingAuth: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: { username: 'evan', is_admin: true },
|
||||||
|
isCheckingAuth: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the previous state with checking disabled when there is no decisive me result', () => {
|
||||||
|
expect(
|
||||||
|
resolveAuthStateFromMe({
|
||||||
|
meData: {
|
||||||
|
status: 204,
|
||||||
|
},
|
||||||
|
meError: undefined,
|
||||||
|
meLoading: false,
|
||||||
|
previousState: {
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
isCheckingAuth: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
isCheckingAuth: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates auth mutation responses by expected status and payload shape', () => {
|
||||||
|
expect(
|
||||||
|
validateAuthMutationResponse(
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
data: { username: 'evan', is_admin: false },
|
||||||
|
},
|
||||||
|
200
|
||||||
|
)
|
||||||
|
).toEqual({ username: 'evan', is_admin: false });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateAuthMutationResponse(
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
data: { username: 'evan', is_admin: false },
|
||||||
|
},
|
||||||
|
200
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateAuthMutationResponse(
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
data: { username: 'evan' },
|
||||||
|
},
|
||||||
|
200
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
98
frontend/src/auth/authHelpers.ts
Normal file
98
frontend/src/auth/authHelpers.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
export interface AuthUser {
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: AuthUser | null;
|
||||||
|
isCheckingAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseLike {
|
||||||
|
status?: number;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnauthenticatedAuthState(): AuthState {
|
||||||
|
return {
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
isCheckingAuth: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCheckingAuthState(previousState?: AuthState): AuthState {
|
||||||
|
return {
|
||||||
|
isAuthenticated: previousState?.isAuthenticated ?? false,
|
||||||
|
user: previousState?.user ?? null,
|
||||||
|
isCheckingAuth: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthenticatedAuthState(user: AuthUser): AuthState {
|
||||||
|
return {
|
||||||
|
isAuthenticated: true,
|
||||||
|
user,
|
||||||
|
isCheckingAuth: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthenticatedUser(value: unknown): AuthUser | null {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('username' in value) || typeof value.username !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('is_admin' in value) || typeof value.is_admin !== 'boolean') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: value.username,
|
||||||
|
is_admin: value.is_admin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthStateFromMe(params: {
|
||||||
|
meData?: ResponseLike;
|
||||||
|
meError?: unknown;
|
||||||
|
meLoading: boolean;
|
||||||
|
previousState: AuthState;
|
||||||
|
}): AuthState {
|
||||||
|
const { meData, meError, meLoading, previousState } = params;
|
||||||
|
|
||||||
|
if (meLoading) {
|
||||||
|
return getCheckingAuthState(previousState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meData?.status === 200) {
|
||||||
|
const user = normalizeAuthenticatedUser(meData.data);
|
||||||
|
if (user) {
|
||||||
|
return getAuthenticatedAuthState(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meError || meData?.status === 401) {
|
||||||
|
return getUnauthenticatedAuthState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previousState,
|
||||||
|
isCheckingAuth: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAuthMutationResponse(
|
||||||
|
response: ResponseLike,
|
||||||
|
expectedStatus: number
|
||||||
|
): AuthUser | null {
|
||||||
|
if (response.status !== expectedStatus) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeAuthenticatedUser(response.data);
|
||||||
|
}
|
||||||
11
frontend/src/auth/authInterceptor.test.ts
Normal file
11
frontend/src/auth/authInterceptor.test.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { setupAuthInterceptors } from './authInterceptor';
|
||||||
|
|
||||||
|
describe('setupAuthInterceptors', () => {
|
||||||
|
it('is a no-op when auth is handled by HttpOnly cookies', () => {
|
||||||
|
const cleanup = setupAuthInterceptors();
|
||||||
|
|
||||||
|
expect(typeof cleanup).toBe('function');
|
||||||
|
expect(() => cleanup()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
3
frontend/src/auth/authInterceptor.ts
Normal file
3
frontend/src/auth/authInterceptor.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function setupAuthInterceptors() {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
45
frontend/src/components/Button.tsx
Normal file
45
frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ButtonHTMLAttributes, AnchorHTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
|
interface BaseButtonProps {
|
||||||
|
variant?: 'default' | 'secondary';
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
|
||||||
|
|
||||||
|
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
||||||
|
const baseClass =
|
||||||
|
'h-full w-full px-2 py-1 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
if (variant === 'secondary') {
|
||||||
|
return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseClass} bg-primary-500 text-primary-foreground hover:bg-primary-700 disabled:hover:bg-primary-500`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ variant = 'default', children, className = '', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export const ButtonLink = forwardRef<HTMLAnchorElement, LinkProps>(
|
||||||
|
({ variant = 'default', children, className = '', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<a ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ButtonLink.displayName = 'ButtonLink';
|
||||||
41
frontend/src/components/Field.tsx
Normal file
41
frontend/src/components/Field.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface FieldProps {
|
||||||
|
label: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
isEditing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative rounded">
|
||||||
|
<div className="relative inline-flex gap-2 text-content-muted">{label}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldLabelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldLabel({ children }: FieldLabelProps) {
|
||||||
|
return <p>{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldValueProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldValue({ children, className = '' }: FieldValueProps) {
|
||||||
|
return <p className={`text-lg font-medium ${className}`}>{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldActionsProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldActions({ children }: FieldActionsProps) {
|
||||||
|
return <div className="inline-flex gap-2">{children}</div>;
|
||||||
|
}
|
||||||
181
frontend/src/components/HamburgerMenu.tsx
Normal file
181
frontend/src/components/HamburgerMenu.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon, GitIcon } from '../icons';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ path: '/', label: 'Home', icon: HomeIcon, title: 'Home' },
|
||||||
|
{ path: '/documents', label: 'Documents', icon: DocumentsIcon, title: 'Documents' },
|
||||||
|
{ path: '/progress', label: 'Progress', icon: ActivityIcon, title: 'Progress' },
|
||||||
|
{ path: '/activity', label: 'Activity', icon: ActivityIcon, title: 'Activity' },
|
||||||
|
{ path: '/search', label: 'Search', icon: SearchIcon, title: 'Search' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminSubItems: NavItem[] = [
|
||||||
|
{ path: '/admin', label: 'General', icon: SettingsIcon, title: 'General' },
|
||||||
|
{ path: '/admin/import', label: 'Import', icon: SettingsIcon, title: 'Import' },
|
||||||
|
{ path: '/admin/users', label: 'Users', icon: SettingsIcon, title: 'Users' },
|
||||||
|
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function hasPrefix(path: string, prefix: string): boolean {
|
||||||
|
return path.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HamburgerMenu() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const isAdmin = user?.is_admin ?? false;
|
||||||
|
|
||||||
|
const { data: infoData } = useGetInfo({
|
||||||
|
query: {
|
||||||
|
staleTime: Infinity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const version =
|
||||||
|
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data
|
||||||
|
? infoData.data.version
|
||||||
|
: 'v1.0.0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-40 ml-6 flex flex-col">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden"
|
||||||
|
id="mobile-nav-checkbox"
|
||||||
|
checked={isOpen}
|
||||||
|
onChange={e => setIsOpen(e.target.checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="z-40 mt-0.5 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||||
|
style={{
|
||||||
|
transformOrigin: '5px 0px',
|
||||||
|
transition:
|
||||||
|
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
||||||
|
transform: isOpen ? 'rotate(45deg) translate(2px, -2px)' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||||
|
style={{
|
||||||
|
transformOrigin: '0% 100%',
|
||||||
|
transition:
|
||||||
|
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
||||||
|
opacity: isOpen ? 0 : 1,
|
||||||
|
transform: isOpen ? 'rotate(0deg) scale(0.2, 0.2)' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||||
|
style={{
|
||||||
|
transformOrigin: '0% 0%',
|
||||||
|
transition:
|
||||||
|
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
||||||
|
transform: isOpen ? 'rotate(-45deg) translate(0, 6px)' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="menu"
|
||||||
|
className="fixed -ml-6 h-full w-56 bg-surface shadow-lg lg:w-48"
|
||||||
|
style={{
|
||||||
|
top: 0,
|
||||||
|
paddingTop: 'env(safe-area-inset-top)',
|
||||||
|
transformOrigin: '0% 0%',
|
||||||
|
transform: isOpen ? 'none' : 'translate(-100%, 0)',
|
||||||
|
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
#menu {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="flex h-16 justify-end lg:justify-around">
|
||||||
|
<p className="my-auto pr-8 text-right text-xl font-bold text-content lg:pr-0">AnthoLume</p>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
{navItems.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
||||||
|
location.pathname === item.path
|
||||||
|
? 'border-primary-500 text-content'
|
||||||
|
: 'border-transparent text-content-subtle hover:text-content'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<item.icon size={20} />
|
||||||
|
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div
|
||||||
|
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
||||||
|
hasPrefix(location.pathname, '/admin')
|
||||||
|
? 'border-primary-500 text-content'
|
||||||
|
: 'border-transparent text-content-subtle'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={`flex w-full justify-start ${
|
||||||
|
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
|
||||||
|
? 'text-content'
|
||||||
|
: 'text-content-subtle hover:text-content'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SettingsIcon size={20} />
|
||||||
|
<span className="mx-4 text-sm font-normal">Admin</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{hasPrefix(location.pathname, '/admin') && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{adminSubItems.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={`flex w-full justify-start ${
|
||||||
|
location.pathname === item.path
|
||||||
|
? 'text-content'
|
||||||
|
: 'text-content-subtle hover:text-content'
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: '1.75em' }}
|
||||||
|
>
|
||||||
|
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<a
|
||||||
|
className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-content"
|
||||||
|
target="_blank"
|
||||||
|
href="https://gitea.va.reichard.io/evan/AnthoLume"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<GitIcon size={20} />
|
||||||
|
<span className="text-xs">{version}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
frontend/src/components/Layout.tsx
Normal file
178
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
||||||
|
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { UserIcon, DropdownIcon } from '../icons';
|
||||||
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
|
import type { ThemeMode } from '../utils/localSettings';
|
||||||
|
import HamburgerMenu from './HamburgerMenu';
|
||||||
|
|
||||||
|
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
||||||
|
const { themeMode, setThemeMode } = useTheme();
|
||||||
|
const { data } = useGetMe(isAuthenticated ? {} : undefined);
|
||||||
|
const fetchedUser =
|
||||||
|
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
|
||||||
|
const userData = user ?? fetchedUser;
|
||||||
|
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
setIsUserDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsUserDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/admin/import-results', title: 'Admin - Import' },
|
||||||
|
{ path: '/admin/import', title: 'Admin - Import' },
|
||||||
|
{ path: '/admin/users', title: 'Admin - Users' },
|
||||||
|
{ path: '/admin/logs', title: 'Admin - Logs' },
|
||||||
|
{ path: '/admin', title: 'Admin - General' },
|
||||||
|
{ path: '/documents', title: 'Documents' },
|
||||||
|
{ path: '/progress', title: 'Progress' },
|
||||||
|
{ path: '/activity', title: 'Activity' },
|
||||||
|
{ path: '/search', title: 'Search' },
|
||||||
|
{ path: '/settings', title: 'Settings' },
|
||||||
|
{ path: '/', title: 'Home' },
|
||||||
|
];
|
||||||
|
const currentPageTitle =
|
||||||
|
navItems.find(item =>
|
||||||
|
item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path)
|
||||||
|
)?.title || 'Home';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `AnthoLume - ${currentPageTitle}`;
|
||||||
|
}, [currentPageTitle]);
|
||||||
|
|
||||||
|
if (isCheckingAuth) {
|
||||||
|
return <div className="text-content-muted">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-canvas">
|
||||||
|
<div className="flex h-16 w-full items-center justify-between">
|
||||||
|
<HamburgerMenu />
|
||||||
|
|
||||||
|
<h1 className="whitespace-nowrap px-6 text-xl font-bold text-content lg:ml-44">
|
||||||
|
{currentPageTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative flex w-full items-center justify-end space-x-4 p-4"
|
||||||
|
ref={dropdownRef}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||||
|
className="relative block text-content"
|
||||||
|
>
|
||||||
|
<UserIcon size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isUserDropdownOpen && (
|
||||||
|
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
||||||
|
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30">
|
||||||
|
<div
|
||||||
|
className="border-b border-border px-4 py-3"
|
||||||
|
role="group"
|
||||||
|
aria-label="Theme mode"
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
|
||||||
|
Theme
|
||||||
|
</p>
|
||||||
|
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
||||||
|
{themeModes.map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setThemeMode(mode)}
|
||||||
|
className={`flex-1 rounded px-2 py-1 text-xs font-medium capitalize transition-colors ${
|
||||||
|
themeMode === mode
|
||||||
|
? 'bg-content text-content-inverse'
|
||||||
|
: 'text-content-muted hover:bg-surface hover:text-content'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="py-1"
|
||||||
|
role="menu"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-labelledby="options-menu"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
onClick={() => setIsUserDropdownOpen(false)}
|
||||||
|
className="block px-4 py-2 text-content-muted hover:bg-surface-muted hover:text-content"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span>Settings</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="block w-full px-4 py-2 text-left text-content-muted hover:bg-surface-muted hover:text-content"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span>Logout</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||||
|
className="flex cursor-pointer items-center gap-2 py-4 text-content-muted"
|
||||||
|
>
|
||||||
|
<span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
|
||||||
|
<span
|
||||||
|
className="text-content transition-transform duration-200"
|
||||||
|
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||||
|
>
|
||||||
|
<DropdownIcon size={20} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="container"
|
||||||
|
className="h-dvh overflow-auto px-4 md:px-6 lg:ml-48"
|
||||||
|
style={{ paddingBottom: 'calc(5em + env(safe-area-inset-bottom) * 2)' }}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/components/LoadingState.tsx
Normal file
21
frontend/src/components/LoadingState.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { LoadingIcon } from '../icons';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
interface LoadingStateProps {
|
||||||
|
message?: string;
|
||||||
|
className?: string;
|
||||||
|
iconSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingState({
|
||||||
|
message = 'Loading...',
|
||||||
|
className = '',
|
||||||
|
iconSize = 24,
|
||||||
|
}: LoadingStateProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center gap-3 text-content-muted', className)}>
|
||||||
|
<LoadingIcon size={iconSize} className="text-primary-500" />
|
||||||
|
<span className="text-sm font-medium">{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
frontend/src/components/README.md
Normal file
203
frontend/src/components/README.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# UI Components
|
||||||
|
|
||||||
|
This directory contains reusable UI components for the AnthoLume application.
|
||||||
|
|
||||||
|
## Toast Notifications
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
The toast system provides info, warning, and error notifications that respect the current theme and dark/light mode.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useToasts } from './components/ToastContext';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { showInfo, showWarning, showError, showToast } = useToasts();
|
||||||
|
|
||||||
|
const handleAction = async () => {
|
||||||
|
try {
|
||||||
|
// Do something
|
||||||
|
showInfo('Operation completed successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
showError('An error occurred while processing your request.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={handleAction}>Click me</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- `showToast(message: string, type?: 'info' | 'warning' | 'error', duration?: number): string`
|
||||||
|
- Shows a toast notification
|
||||||
|
- Returns the toast ID for manual removal
|
||||||
|
- Default type: 'info'
|
||||||
|
- Default duration: 5000ms (0 = no auto-dismiss)
|
||||||
|
|
||||||
|
- `showInfo(message: string, duration?: number): string`
|
||||||
|
- Shortcut for showing an info toast
|
||||||
|
|
||||||
|
- `showWarning(message: string, duration?: number): string`
|
||||||
|
- Shortcut for showing a warning toast
|
||||||
|
|
||||||
|
- `showError(message: string, duration?: number): string`
|
||||||
|
- Shortcut for showing an error toast
|
||||||
|
|
||||||
|
- `removeToast(id: string): void`
|
||||||
|
- Manually remove a toast by ID
|
||||||
|
|
||||||
|
- `clearToasts(): void`
|
||||||
|
- Clear all active toasts
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Info toast (auto-dismisses after 5 seconds)
|
||||||
|
showInfo('Document saved successfully!');
|
||||||
|
|
||||||
|
// Warning toast (auto-dismisses after 10 seconds)
|
||||||
|
showWarning('Low disk space warning', 10000);
|
||||||
|
|
||||||
|
// Error toast (no auto-dismiss)
|
||||||
|
showError('Failed to load data', 0);
|
||||||
|
|
||||||
|
// Generic toast
|
||||||
|
showToast('Custom message', 'warning', 3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Skeleton Loading
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
Skeleton components provide placeholder content while data is loading. They automatically adapt to dark/light mode.
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
#### `Skeleton`
|
||||||
|
|
||||||
|
Basic skeleton element with various variants:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Skeleton } from './components/Skeleton';
|
||||||
|
|
||||||
|
// Default (rounded rectangle)
|
||||||
|
<Skeleton className="w-full h-8" />
|
||||||
|
|
||||||
|
// Text variant
|
||||||
|
<Skeleton variant="text" className="w-3/4" />
|
||||||
|
|
||||||
|
// Circular variant (for avatars)
|
||||||
|
<Skeleton variant="circular" width={40} height={40} />
|
||||||
|
|
||||||
|
// Rectangular variant
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={200} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `SkeletonText`
|
||||||
|
|
||||||
|
Multiple lines of text skeleton:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SkeletonText lines={3} />
|
||||||
|
<SkeletonText lines={5} className="max-w-md" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `SkeletonAvatar`
|
||||||
|
|
||||||
|
Avatar placeholder:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SkeletonAvatar size="md" />
|
||||||
|
<SkeletonAvatar size={56} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `SkeletonCard`
|
||||||
|
|
||||||
|
Card placeholder with optional elements:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Default card
|
||||||
|
<SkeletonCard />
|
||||||
|
|
||||||
|
// With avatar
|
||||||
|
<SkeletonCard showAvatar />
|
||||||
|
|
||||||
|
// Custom configuration
|
||||||
|
<SkeletonCard
|
||||||
|
showAvatar
|
||||||
|
showTitle
|
||||||
|
showText
|
||||||
|
textLines={4}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `SkeletonTable`
|
||||||
|
|
||||||
|
Table placeholder:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SkeletonTable rows={5} columns={4} />
|
||||||
|
<SkeletonTable rows={10} columns={6} showHeader={false} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `SkeletonButton`
|
||||||
|
|
||||||
|
Button placeholder:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SkeletonButton width={120} />
|
||||||
|
<SkeletonButton className="w-full" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `PageLoader`
|
||||||
|
|
||||||
|
Full-page loading indicator:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PageLoader message="Loading your documents..." />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `InlineLoader`
|
||||||
|
|
||||||
|
Small inline loading spinner:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<InlineLoader size="sm" />
|
||||||
|
<InlineLoader size="md" />
|
||||||
|
<InlineLoader size="lg" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Table Component
|
||||||
|
|
||||||
|
The Table component now supports skeleton loading:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Table, SkeletonTable } from './components/Table';
|
||||||
|
|
||||||
|
function DocumentList() {
|
||||||
|
const { data, isLoading } = useGetDocuments();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <SkeletonTable rows={10} columns={5} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Table columns={columns} data={data?.documents || []} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme Support
|
||||||
|
|
||||||
|
All components automatically adapt to the current theme:
|
||||||
|
|
||||||
|
- **Light mode**: Uses gray tones for skeletons, appropriate colors for toasts
|
||||||
|
- **Dark mode**: Uses darker gray tones for skeletons, adjusted colors for toasts
|
||||||
|
|
||||||
|
The theme is controlled via Tailwind's `dark:` classes, which respond to the system preference or manual theme toggles.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `clsx` - Utility for constructing className strings
|
||||||
|
- `tailwind-merge` - Merges Tailwind CSS classes intelligently
|
||||||
|
- `lucide-react` - Icon library used by Toast component
|
||||||
53
frontend/src/components/ReadingHistoryGraph.test.ts
Normal file
53
frontend/src/components/ReadingHistoryGraph.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getSVGGraphData } from './ReadingHistoryGraph';
|
||||||
|
|
||||||
|
// Intentionally exact fixture data for algorithm parity coverage
|
||||||
|
const testInput = [
|
||||||
|
{ date: '2024-01-01', minutes_read: 10 },
|
||||||
|
{ date: '2024-01-02', minutes_read: 90 },
|
||||||
|
{ date: '2024-01-03', minutes_read: 50 },
|
||||||
|
{ date: '2024-01-04', minutes_read: 5 },
|
||||||
|
{ date: '2024-01-05', minutes_read: 10 },
|
||||||
|
{ date: '2024-01-06', minutes_read: 5 },
|
||||||
|
{ date: '2024-01-07', minutes_read: 70 },
|
||||||
|
{ date: '2024-01-08', minutes_read: 60 },
|
||||||
|
{ date: '2024-01-09', minutes_read: 50 },
|
||||||
|
{ date: '2024-01-10', minutes_read: 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const svgWidth = 500;
|
||||||
|
const svgHeight = 100;
|
||||||
|
|
||||||
|
describe('ReadingHistoryGraph', () => {
|
||||||
|
describe('getSVGGraphData', () => {
|
||||||
|
it('should match exactly', () => {
|
||||||
|
const result = getSVGGraphData(testInput, svgWidth, svgHeight);
|
||||||
|
|
||||||
|
// Expected exact algorithm output
|
||||||
|
const expectedBezierPath =
|
||||||
|
'M 50,95 C63,95 80,50 100,50 C120,50 128,73 150,73 C172,73 180,98 200,98 C220,98 230,95 250,95 C270,95 279,98 300,98 C321,98 330,62 350,62 C370,62 380,67 400,67 C420,67 430,73 450,73 C470,73 489,50 500,50';
|
||||||
|
const expectedBezierFill = 'L 500,98 L 50,98 Z';
|
||||||
|
const expectedWidth = 500;
|
||||||
|
const expectedHeight = 100;
|
||||||
|
const expectedOffset = 50;
|
||||||
|
|
||||||
|
expect(result.BezierPath).toBe(expectedBezierPath);
|
||||||
|
expect(result.BezierFill).toBe(expectedBezierFill);
|
||||||
|
expect(svgWidth).toBe(expectedWidth);
|
||||||
|
expect(svgHeight).toBe(expectedHeight);
|
||||||
|
expect(result.Offset).toBe(expectedOffset);
|
||||||
|
|
||||||
|
// Verify line points are integer pixel values
|
||||||
|
result.LinePoints.forEach((p, _i) => {
|
||||||
|
expect(Number.isInteger(p.x)).toBe(true);
|
||||||
|
expect(Number.isInteger(p.y)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expected line points from the current algorithm:
|
||||||
|
// idx 0: itemSize=5, itemY=95, lineX=50
|
||||||
|
// idx 1: itemSize=45, itemY=55, lineX=100
|
||||||
|
// idx 2: itemSize=25, itemY=75, lineX=150
|
||||||
|
// ...and so on
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
210
frontend/src/components/ReadingHistoryGraph.tsx
Normal file
210
frontend/src/components/ReadingHistoryGraph.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type { GraphDataPoint } from '../generated/model';
|
||||||
|
|
||||||
|
interface ReadingHistoryGraphProps {
|
||||||
|
data: GraphDataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SVGPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSVGBezierOpposedLine(
|
||||||
|
pointA: SVGPoint,
|
||||||
|
pointB: SVGPoint
|
||||||
|
): { Length: number; Angle: number } {
|
||||||
|
const lengthX = pointB.x - pointA.x;
|
||||||
|
const lengthY = pointB.y - pointA.y;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
|
||||||
|
Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBezierControlPoint(
|
||||||
|
currentPoint: SVGPoint,
|
||||||
|
prevPoint: SVGPoint | null,
|
||||||
|
nextPoint: SVGPoint | null,
|
||||||
|
isReverse: boolean
|
||||||
|
): SVGPoint {
|
||||||
|
let pPrev = prevPoint;
|
||||||
|
let pNext = nextPoint;
|
||||||
|
if (!pPrev) {
|
||||||
|
pPrev = currentPoint;
|
||||||
|
}
|
||||||
|
if (!pNext) {
|
||||||
|
pNext = currentPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoothingRatio = 0.2;
|
||||||
|
const directionModifier = isReverse ? Math.PI : 0;
|
||||||
|
|
||||||
|
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
|
||||||
|
const lineAngle = opposingLine.Angle + directionModifier;
|
||||||
|
const lineLength = opposingLine.Length * smoothingRatio;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
|
||||||
|
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSVGBezierPath(points: SVGPoint[]): string {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let bezierSVGPath = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < points.length; index++) {
|
||||||
|
const point = points[index];
|
||||||
|
if (!point) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
bezierSVGPath += `M ${point.x},${point.y}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointMinusOne = points[index - 1];
|
||||||
|
if (!pointMinusOne) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointPlusOne = points[index + 1] ?? point;
|
||||||
|
const pointMinusTwo = index - 2 >= 0 ? (points[index - 2] ?? null) : null;
|
||||||
|
|
||||||
|
const startControlPoint = getBezierControlPoint(pointMinusOne, pointMinusTwo, point, false);
|
||||||
|
const endControlPoint = getBezierControlPoint(point, pointMinusOne, pointPlusOne, true);
|
||||||
|
|
||||||
|
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bezierSVGPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SVGGraphData {
|
||||||
|
LinePoints: SVGPoint[];
|
||||||
|
BezierPath: string;
|
||||||
|
BezierFill: string;
|
||||||
|
Offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSVGGraphData(
|
||||||
|
inputData: GraphDataPoint[],
|
||||||
|
svgWidth: number,
|
||||||
|
svgHeight: number
|
||||||
|
): SVGGraphData {
|
||||||
|
let maxHeight = 0;
|
||||||
|
for (const item of inputData) {
|
||||||
|
if (item.minutes_read > maxHeight) {
|
||||||
|
maxHeight = item.minutes_read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizePercentage = 0.5;
|
||||||
|
const sizeRatio = maxHeight > 0 ? (svgHeight * sizePercentage) / maxHeight : 0;
|
||||||
|
const blockOffset = inputData.length > 0 ? Math.floor(svgWidth / inputData.length) : 0;
|
||||||
|
|
||||||
|
const linePoints: SVGPoint[] = [];
|
||||||
|
|
||||||
|
let maxBX = 0;
|
||||||
|
let maxBY = 0;
|
||||||
|
let minBX = 0;
|
||||||
|
|
||||||
|
for (let idx = 0; idx < inputData.length; idx++) {
|
||||||
|
const item = inputData[idx];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemSize = Math.floor(item.minutes_read * sizeRatio);
|
||||||
|
const itemY = svgHeight - itemSize;
|
||||||
|
const lineX = (idx + 1) * blockOffset;
|
||||||
|
|
||||||
|
linePoints.push({ x: lineX, y: itemY });
|
||||||
|
|
||||||
|
if (lineX > maxBX) {
|
||||||
|
maxBX = lineX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineX < minBX) {
|
||||||
|
minBX = lineX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemY > maxBY) {
|
||||||
|
maxBY = itemY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
LinePoints: linePoints,
|
||||||
|
BezierPath: getSVGBezierPath(linePoints),
|
||||||
|
BezierFill: `L ${Math.floor(maxBX)},${Math.floor(maxBY)} L ${Math.floor(minBX + blockOffset)},${Math.floor(maxBY)} Z`,
|
||||||
|
Offset: blockOffset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
|
||||||
|
const svgWidth = 800;
|
||||||
|
const svgHeight = 70;
|
||||||
|
|
||||||
|
if (!data || data.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-24 items-center justify-center bg-surface-muted">
|
||||||
|
<p className="text-content-subtle">No data available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { BezierPath, BezierFill } = getSVGGraphData(data, svgWidth, svgHeight);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
|
||||||
|
<path fill="rgb(var(--secondary-600))" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
|
||||||
|
<path fill="none" stroke="rgb(var(--secondary-600))" d={BezierPath} />
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 flex size-full"
|
||||||
|
style={{
|
||||||
|
width: 'calc(100% * 31 / 30)',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
left: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.map((point, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-full opacity-0 hover:opacity-100"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-3 flex flex-col items-center rounded bg-surface/80 p-2 text-xs text-content"
|
||||||
|
style={{
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
left: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{formatDate(point.date)}</span>
|
||||||
|
<span>{point.minutes_read} minutes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
frontend/src/components/Skeleton.tsx
Normal file
215
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
variant?: 'default' | 'text' | 'circular' | 'rectangular';
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
animation?: 'pulse' | 'wave' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
className = '',
|
||||||
|
variant = 'default',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
animation = 'pulse',
|
||||||
|
}: SkeletonProps) {
|
||||||
|
const baseClasses = 'bg-surface-strong';
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'rounded',
|
||||||
|
text: 'h-4 rounded-md',
|
||||||
|
circular: 'rounded-full',
|
||||||
|
rectangular: 'rounded-none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const animationClasses = {
|
||||||
|
pulse: 'animate-pulse',
|
||||||
|
wave: 'animate-wave',
|
||||||
|
none: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
|
||||||
|
height:
|
||||||
|
height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(baseClasses, variantClasses[variant], animationClasses[animation], className)}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonTextProps {
|
||||||
|
lines?: number;
|
||||||
|
className?: string;
|
||||||
|
lineClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonText({ lines = 3, className = '', lineClassName = '' }: SkeletonTextProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
variant="text"
|
||||||
|
className={cn(lineClassName, i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonAvatarProps {
|
||||||
|
size?: number | 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarProps) {
|
||||||
|
const sizeMap = {
|
||||||
|
sm: 32,
|
||||||
|
md: 40,
|
||||||
|
lg: 56,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
|
||||||
|
|
||||||
|
return <Skeleton variant="circular" width={pixelSize} height={pixelSize} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonCardProps {
|
||||||
|
className?: string;
|
||||||
|
showAvatar?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
|
showText?: boolean;
|
||||||
|
textLines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCard({
|
||||||
|
className = '',
|
||||||
|
showAvatar = false,
|
||||||
|
showTitle = true,
|
||||||
|
showText = true,
|
||||||
|
textLines = 3,
|
||||||
|
}: SkeletonCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-lg border border-border bg-surface p-4', className)}>
|
||||||
|
{showAvatar && (
|
||||||
|
<div className="mb-4 flex items-start gap-4">
|
||||||
|
<SkeletonAvatar />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton variant="text" className="mb-2 w-3/4" />
|
||||||
|
<Skeleton variant="text" className="w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showTitle && <Skeleton variant="text" className="mb-4 h-6 w-1/2" />}
|
||||||
|
{showText && <SkeletonText lines={textLines} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonTableProps {
|
||||||
|
rows?: number;
|
||||||
|
columns?: number;
|
||||||
|
className?: string;
|
||||||
|
showHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonTable({
|
||||||
|
rows = 5,
|
||||||
|
columns = 4,
|
||||||
|
className = '',
|
||||||
|
showHeader = true,
|
||||||
|
}: SkeletonTableProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
|
||||||
|
<table className="min-w-full">
|
||||||
|
{showHeader && (
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
|
<th key={i} className="p-3">
|
||||||
|
<Skeleton variant="text" className="h-5 w-3/4" />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
)}
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||||
|
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||||
|
<td key={colIndex} className="p-3">
|
||||||
|
<Skeleton
|
||||||
|
variant="text"
|
||||||
|
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonButtonProps {
|
||||||
|
className?: string;
|
||||||
|
width?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonButton({ className = '', width }: SkeletonButtonProps) {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
variant="rectangular"
|
||||||
|
height={36}
|
||||||
|
width={width || '100%'}
|
||||||
|
className={cn('rounded', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageLoaderProps {
|
||||||
|
message?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex min-h-[400px] flex-col items-center justify-center gap-4', className)}>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="size-12 animate-spin rounded-full border-4 border-surface-strong border-t-secondary-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-content-muted">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineLoaderProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
|
||||||
|
const sizeMap = {
|
||||||
|
sm: 'h-4 w-4 border-2',
|
||||||
|
md: 'h-6 w-6 border-[3px]',
|
||||||
|
lg: 'h-8 w-8 border-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center', className)}>
|
||||||
|
<div
|
||||||
|
className={`${sizeMap[size]} animate-spin rounded-full border-surface-strong border-t-secondary-500`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SkeletonTable as SkeletonTableExport };
|
||||||
56
frontend/src/components/Table.test.tsx
Normal file
56
frontend/src/components/Table.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Table, type Column } from './Table';
|
||||||
|
|
||||||
|
interface TestRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<TestRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
header: 'Role',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const data: TestRow[] = [
|
||||||
|
{ id: 'user-1', name: 'Ada', role: 'Admin' },
|
||||||
|
{ id: 'user-2', name: 'Grace', role: 'Reader' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Table', () => {
|
||||||
|
it('renders a skeleton table while loading', () => {
|
||||||
|
const { container } = render(<Table columns={columns} data={[]} loading />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('No Results')).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll('tbody tr')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty state message when there is no data', () => {
|
||||||
|
render(<Table columns={columns} data={[]} emptyMessage="Nothing here" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a custom render function for column output', () => {
|
||||||
|
const customColumns: Column<TestRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (_value, row, index) => `${index + 1}. ${row.name.toUpperCase()}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<Table columns={customColumns} data={data} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('1. ADA')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2. GRACE')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
125
frontend/src/components/Table.tsx
Normal file
125
frontend/src/components/Table.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
export interface Column<T extends object> {
|
||||||
|
key: keyof T;
|
||||||
|
header: string;
|
||||||
|
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableProps<T extends object> {
|
||||||
|
columns: Column<T>[];
|
||||||
|
data: T[];
|
||||||
|
loading?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
rowKey?: keyof T | ((row: T) => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonTable({
|
||||||
|
rows = 5,
|
||||||
|
columns = 4,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
rows?: number;
|
||||||
|
columns?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
|
<th key={i} className="p-3">
|
||||||
|
<Skeleton variant="text" className="h-5 w-3/4" />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||||
|
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||||
|
<td key={colIndex} className="p-3">
|
||||||
|
<Skeleton
|
||||||
|
variant="text"
|
||||||
|
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Table<T extends object>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
loading = false,
|
||||||
|
emptyMessage = 'No Results',
|
||||||
|
rowKey,
|
||||||
|
}: TableProps<T>) {
|
||||||
|
const getRowKey = (row: T, index: number): string => {
|
||||||
|
if (typeof rowKey === 'function') {
|
||||||
|
return rowKey(row);
|
||||||
|
}
|
||||||
|
if (rowKey) {
|
||||||
|
return String(row[rowKey] ?? index);
|
||||||
|
}
|
||||||
|
return `row-${index}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <SkeletonTable rows={5} columns={columns.length} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<table className="min-w-full bg-surface">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
{columns.map(column => (
|
||||||
|
<th
|
||||||
|
key={String(column.key)}
|
||||||
|
className={`p-3 text-left text-content-muted ${column.className || ''}`}
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="p-3 text-center text-content-muted">
|
||||||
|
{emptyMessage}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.map((row, index) => (
|
||||||
|
<tr key={getRowKey(row, index)} className="border-b border-border">
|
||||||
|
{columns.map(column => (
|
||||||
|
<td
|
||||||
|
key={`${getRowKey(row, index)}-${String(column.key)}`}
|
||||||
|
className={`p-3 text-content ${column.className || ''}`}
|
||||||
|
>
|
||||||
|
{column.render
|
||||||
|
? column.render(row[column.key], row, index)
|
||||||
|
: (row[column.key] as React.ReactNode)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/components/Toast.tsx
Normal file
87
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { InfoIcon, WarningIcon, ErrorIcon, CloseIcon } from '../icons';
|
||||||
|
|
||||||
|
export type ToastType = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface ToastProps {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
onClose?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToastStyles = (_type: ToastType) => {
|
||||||
|
const baseStyles =
|
||||||
|
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
|
||||||
|
|
||||||
|
const typeStyles = {
|
||||||
|
info: 'border-secondary-500 bg-secondary-100',
|
||||||
|
warning: 'border-yellow-500 bg-yellow-100',
|
||||||
|
error: 'border-red-500 bg-red-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconStyles = {
|
||||||
|
info: 'text-secondary-700',
|
||||||
|
warning: 'text-yellow-700',
|
||||||
|
error: 'text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const textStyles = {
|
||||||
|
info: 'text-secondary-900',
|
||||||
|
warning: 'text-yellow-900',
|
||||||
|
error: 'text-red-900',
|
||||||
|
};
|
||||||
|
|
||||||
|
return { baseStyles, typeStyles, iconStyles, textStyles };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
||||||
|
|
||||||
|
const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsAnimatingOut(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
onClose?.(id);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration > 0) {
|
||||||
|
const timer = setTimeout(handleClose, duration);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
info: <InfoIcon size={20} className={iconStyles[type]} />,
|
||||||
|
warning: <WarningIcon size={20} className={iconStyles[type]} />,
|
||||||
|
error: <ErrorIcon size={20} className={iconStyles[type]} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${baseStyles} ${typeStyles[type]} ${
|
||||||
|
isAnimatingOut ? 'translate-x-full opacity-0' : 'animate-slideInRight opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icons[type]}
|
||||||
|
<p className={`flex-1 text-sm font-medium ${textStyles[type]}`}>{message}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className={`ml-2 opacity-70 transition-opacity hover:opacity-100 ${textStyles[type]}`}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<CloseIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
frontend/src/components/ToastContext.tsx
Normal file
95
frontend/src/components/ToastContext.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { Toast, ToastType, ToastProps } from './Toast';
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
showToast: (message: string, type?: ToastType, duration?: number) => string;
|
||||||
|
showInfo: (message: string, duration?: number) => string;
|
||||||
|
showWarning: (message: string, duration?: number) => string;
|
||||||
|
showError: (message: string, duration?: number) => string;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
clearToasts: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<(ToastProps & { id: string })[]>([]);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showToast = useCallback(
|
||||||
|
(message: string, _type: ToastType = 'info', _duration?: number): string => {
|
||||||
|
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
setToasts(prev => [
|
||||||
|
...prev,
|
||||||
|
{ id, type: _type, message, duration: _duration, onClose: removeToast },
|
||||||
|
]);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[removeToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showInfo = useCallback(
|
||||||
|
(message: string, _duration?: number) => {
|
||||||
|
return showToast(message, 'info', _duration);
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showWarning = useCallback(
|
||||||
|
(message: string, _duration?: number) => {
|
||||||
|
return showToast(message, 'warning', _duration);
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showError = useCallback(
|
||||||
|
(message: string, _duration?: number) => {
|
||||||
|
return showToast(message, 'error', _duration);
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearToasts = useCallback(() => {
|
||||||
|
setToasts([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider
|
||||||
|
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContainerProps {
|
||||||
|
toasts: (ToastProps & { id: string })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastContainer({ toasts }: ToastContainerProps) {
|
||||||
|
if (toasts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<div key={toast.id} className="pointer-events-auto">
|
||||||
|
<Toast {...toast} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToasts() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useToasts must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
23
frontend/src/components/index.ts
Normal file
23
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Reading History Graph
|
||||||
|
export { default as ReadingHistoryGraph } from './ReadingHistoryGraph';
|
||||||
|
|
||||||
|
// Toast components
|
||||||
|
export { Toast } from './Toast';
|
||||||
|
export { ToastProvider, useToasts } from './ToastContext';
|
||||||
|
export type { ToastType, ToastProps } from './Toast';
|
||||||
|
|
||||||
|
// Skeleton components
|
||||||
|
export {
|
||||||
|
Skeleton,
|
||||||
|
SkeletonText,
|
||||||
|
SkeletonAvatar,
|
||||||
|
SkeletonCard,
|
||||||
|
SkeletonTable,
|
||||||
|
SkeletonButton,
|
||||||
|
PageLoader,
|
||||||
|
InlineLoader,
|
||||||
|
} from './Skeleton';
|
||||||
|
export { LoadingState } from './LoadingState';
|
||||||
|
|
||||||
|
// Field components
|
||||||
|
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||||
4117
frontend/src/generated/anthoLumeAPIV1.ts
Normal file
4117
frontend/src/generated/anthoLumeAPIV1.ts
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/src/generated/model/activity.ts
Normal file
19
frontend/src/generated/model/activity.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
document_id: string;
|
||||||
|
device_id: string;
|
||||||
|
start_time: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
duration: number;
|
||||||
|
start_percentage: number;
|
||||||
|
end_percentage: number;
|
||||||
|
read_percentage: number;
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/activityResponse.ts
Normal file
12
frontend/src/generated/model/activityResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { Activity } from './activity';
|
||||||
|
|
||||||
|
export interface ActivityResponse {
|
||||||
|
activities: Activity[];
|
||||||
|
}
|
||||||
15
frontend/src/generated/model/backupType.ts
Normal file
15
frontend/src/generated/model/backupType.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BackupType = typeof BackupType[keyof typeof BackupType];
|
||||||
|
|
||||||
|
|
||||||
|
export const BackupType = {
|
||||||
|
COVERS: 'COVERS',
|
||||||
|
DOCUMENTS: 'DOCUMENTS',
|
||||||
|
} as const;
|
||||||
13
frontend/src/generated/model/configResponse.ts
Normal file
13
frontend/src/generated/model/configResponse.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ConfigResponse {
|
||||||
|
version: string;
|
||||||
|
search_enabled: boolean;
|
||||||
|
registration_enabled: boolean;
|
||||||
|
}
|
||||||
15
frontend/src/generated/model/createActivityItem.ts
Normal file
15
frontend/src/generated/model/createActivityItem.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CreateActivityItem {
|
||||||
|
document_id: string;
|
||||||
|
start_time: number;
|
||||||
|
duration: number;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/createActivityRequest.ts
Normal file
14
frontend/src/generated/model/createActivityRequest.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { CreateActivityItem } from './createActivityItem';
|
||||||
|
|
||||||
|
export interface CreateActivityRequest {
|
||||||
|
device_id: string;
|
||||||
|
device_name: string;
|
||||||
|
activity: CreateActivityItem[];
|
||||||
|
}
|
||||||
11
frontend/src/generated/model/createActivityResponse.ts
Normal file
11
frontend/src/generated/model/createActivityResponse.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CreateActivityResponse {
|
||||||
|
added: number;
|
||||||
|
}
|
||||||
11
frontend/src/generated/model/createDocumentBody.ts
Normal file
11
frontend/src/generated/model/createDocumentBody.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CreateDocumentBody = {
|
||||||
|
document_file: Blob;
|
||||||
|
};
|
||||||
14
frontend/src/generated/model/databaseInfo.ts
Normal file
14
frontend/src/generated/model/databaseInfo.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DatabaseInfo {
|
||||||
|
documents_size: number;
|
||||||
|
activity_size: number;
|
||||||
|
progress_size: number;
|
||||||
|
devices_size: number;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/device.ts
Normal file
14
frontend/src/generated/model/device.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id?: string;
|
||||||
|
device_name?: string;
|
||||||
|
created_at?: string;
|
||||||
|
last_synced?: string;
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/directoryItem.ts
Normal file
12
frontend/src/generated/model/directoryItem.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DirectoryItem {
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
13
frontend/src/generated/model/directoryListResponse.ts
Normal file
13
frontend/src/generated/model/directoryListResponse.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { DirectoryItem } from './directoryItem';
|
||||||
|
|
||||||
|
export interface DirectoryListResponse {
|
||||||
|
current_path?: string;
|
||||||
|
items?: DirectoryItem[];
|
||||||
|
}
|
||||||
26
frontend/src/generated/model/document.ts
Normal file
26
frontend/src/generated/model/document.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Document {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
description?: string;
|
||||||
|
isbn10?: string;
|
||||||
|
isbn13?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted: boolean;
|
||||||
|
words?: number;
|
||||||
|
filepath?: string;
|
||||||
|
percentage?: number;
|
||||||
|
total_time_seconds?: number;
|
||||||
|
wpm?: number;
|
||||||
|
seconds_per_percent?: number;
|
||||||
|
last_read?: string;
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/documentResponse.ts
Normal file
12
frontend/src/generated/model/documentResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { Document } from './document';
|
||||||
|
|
||||||
|
export interface DocumentResponse {
|
||||||
|
document: Document;
|
||||||
|
}
|
||||||
18
frontend/src/generated/model/documentsResponse.ts
Normal file
18
frontend/src/generated/model/documentsResponse.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { Document } from './document';
|
||||||
|
|
||||||
|
export interface DocumentsResponse {
|
||||||
|
documents: Document[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
next_page?: number;
|
||||||
|
previous_page?: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
16
frontend/src/generated/model/editDocumentBody.ts
Normal file
16
frontend/src/generated/model/editDocumentBody.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EditDocumentBody = {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
description?: string;
|
||||||
|
isbn10?: string;
|
||||||
|
isbn13?: string;
|
||||||
|
cover_gbid?: string;
|
||||||
|
};
|
||||||
12
frontend/src/generated/model/errorResponse.ts
Normal file
12
frontend/src/generated/model/errorResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/getActivityParams.ts
Normal file
14
frontend/src/generated/model/getActivityParams.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GetActivityParams = {
|
||||||
|
doc_filter?: boolean;
|
||||||
|
document_id?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
12
frontend/src/generated/model/getAdmin200.ts
Normal file
12
frontend/src/generated/model/getAdmin200.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { DatabaseInfo } from './databaseInfo';
|
||||||
|
|
||||||
|
export type GetAdmin200 = {
|
||||||
|
database_info?: DatabaseInfo;
|
||||||
|
};
|
||||||
13
frontend/src/generated/model/getDocumentsParams.ts
Normal file
13
frontend/src/generated/model/getDocumentsParams.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GetDocumentsParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
12
frontend/src/generated/model/getImportDirectoryParams.ts
Normal file
12
frontend/src/generated/model/getImportDirectoryParams.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GetImportDirectoryParams = {
|
||||||
|
directory?: string;
|
||||||
|
select?: string;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user