[add] admin panel, [add] better logging
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Evan Reichard 2024-01-09 21:08:40 -05:00
parent d3d89b36f6
commit c5b181dda4
14 changed files with 588 additions and 99 deletions

View File

@ -9,6 +9,7 @@ import (
"net/http"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/multitemplate"
"github.com/gin-contrib/sessions"
@ -32,12 +33,15 @@ type API struct {
func NewApi(db *database.DBManager, c *config.Config, assets *embed.FS) *API {
api := &API{
HTMLPolicy: bluemonday.StrictPolicy(),
Router: gin.Default(),
Router: gin.New(),
Config: c,
DB: db,
Assets: assets,
}
// Add Logger
api.Router.Use(apiLogger())
// Assets & Web App Templates
assetsDir, _ := fs.Sub(assets, "assets")
api.Router.StaticFS("/assets", http.FS(assetsDir))
@ -107,6 +111,9 @@ func (api *API) registerWebAppRoutes() {
api.Router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout)
api.Router.GET("/register", api.appGetRegister)
api.Router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
api.Router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
api.Router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
api.Router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
api.Router.POST("/login", api.appAuthFormLogin)
api.Router.POST("/register", api.appAuthFormRegister)
@ -228,6 +235,23 @@ func (api *API) generateTemplates() *multitemplate.Renderer {
return &render
}
func apiLogger() gin.HandlerFunc {
return func(c *gin.Context) {
// Start Timer
startTime := time.Now()
// Process Request
c.Next()
// End Timer
endTime := time.Now()
latency := endTime.Sub(startTime).Round(time.Microsecond)
// Log Result
log.Infof("[HTTPRouter] %-15s (%10s) %d %7s %s", c.ClientIP(), latency, c.Writer.Status(), c.Request.Method, c.Request.URL.Path)
}
}
func generateToken(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)

View File

@ -1,14 +1,17 @@
package api
import (
"archive/zip"
"crypto/md5"
"database/sql"
"fmt"
"io"
"io/fs"
"math"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
@ -24,6 +27,29 @@ import (
"reichard.io/bbank/utils"
)
type AdminAction string
const (
AA_IMPORT AdminAction = "IMPORT"
AA_BACKUP AdminAction = "BACKUP"
AA_RESTORE AdminAction = "RESTORE"
AA_METADATA_MATCH AdminAction = "METADATA_MATCH"
)
type ImportType string
const (
IMPORT_TYPE_DIRECT ImportType = "DIRECT"
IMPORT_TYPE_COPY ImportType = "COPY"
)
type BackupType string
const (
BACKUP_TYPE_COVERS BackupType = "COVERS"
BACKUP_TYPE_DOCUMENTS BackupType = "DOCUMENTS"
)
type queryParams struct {
Page *int64 `form:"page"`
Limit *int64 `form:"limit"`
@ -51,6 +77,20 @@ type requestDocumentEdit struct {
CoverFile *multipart.FileHeader `form:"cover_file"`
}
type requestAdminAction struct {
Action AdminAction `form:"action"`
// Import Action
ImportDirectory *string `form:"import_directory"`
ImportType *ImportType `form:"import_type"`
// Backup Action
BackupTypes []BackupType `form:"backup_types"`
// Restore Action
RestoreFile *multipart.FileHeader `form:"restore_file"`
}
type requestDocumentIdentify struct {
Title *string `form:"title"`
Author *string `form:"author"`
@ -93,7 +133,7 @@ func (api *API) appDocumentReader(c *gin.Context) {
func (api *API) appGetDocuments(c *gin.Context) {
templateVars := api.getBaseTemplateVars("documents", c)
userID := templateVars["User"].(string)
auth := templateVars["Authorization"].(authData)
qParams := bindQueryParams(c, 9)
var query *string
@ -103,7 +143,7 @@ func (api *API) appGetDocuments(c *gin.Context) {
}
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
UserID: userID,
UserID: auth.UserName,
Query: query,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
@ -145,7 +185,7 @@ func (api *API) appGetDocuments(c *gin.Context) {
func (api *API) appGetDocument(c *gin.Context) {
templateVars := api.getBaseTemplateVars("document", c)
userID := templateVars["User"].(string)
auth := templateVars["Authorization"].(authData)
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
@ -155,7 +195,7 @@ func (api *API) appGetDocument(c *gin.Context) {
}
document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{
UserID: userID,
UserID: auth.UserName,
DocumentID: rDocID.DocumentID,
})
if err != nil {
@ -172,11 +212,12 @@ func (api *API) appGetDocument(c *gin.Context) {
func (api *API) appGetProgress(c *gin.Context) {
templateVars := api.getBaseTemplateVars("progress", c)
userID := templateVars["User"].(string)
auth := templateVars["Authorization"].(authData)
qParams := bindQueryParams(c, 15)
progressFilter := database.GetProgressParams{
UserID: userID,
UserID: auth.UserName,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
}
@ -200,11 +241,11 @@ func (api *API) appGetProgress(c *gin.Context) {
func (api *API) appGetActivity(c *gin.Context) {
templateVars := api.getBaseTemplateVars("activity", c)
userID := templateVars["User"].(string)
auth := templateVars["Authorization"].(authData)
qParams := bindQueryParams(c, 15)
activityFilter := database.GetActivityParams{
UserID: userID,
UserID: auth.UserName,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
}
@ -228,17 +269,17 @@ func (api *API) appGetActivity(c *gin.Context) {
func (api *API) appGetHome(c *gin.Context) {
templateVars := api.getBaseTemplateVars("home", c)
userID := templateVars["User"].(string)
auth := templateVars["Authorization"].(authData)
start := time.Now()
graphData, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, userID)
log.Info("GetDailyReadStats Performance: ", time.Since(start))
graphData, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, auth.UserName)
log.Debug("[appGetHome] GetDailyReadStats Performance: ", time.Since(start))
start = time.Now()
databaseInfo, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, userID)
log.Info("GetDatabaseInfo Performance: ", time.Since(start))
databaseInfo, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, auth.UserName)
log.Debug("[appGetHome] GetDatabaseInfo Performance: ", time.Since(start))
streaks, _ := api.DB.Queries.GetUserStreaks(api.DB.Ctx, userID)
streaks, _ := api.DB.Queries.GetUserStreaks(api.DB.Ctx, auth.UserName)
WPMLeaderboard, _ := api.DB.Queries.GetWPMLeaderboard(api.DB.Ctx)
templateVars["Data"] = gin.H{
@ -253,16 +294,16 @@ func (api *API) appGetHome(c *gin.Context) {
func (api *API) appGetSettings(c *gin.Context) {
templateVars := api.getBaseTemplateVars("settings", c)
userID := templateVars["User"].(string)
auth := templateVars["Authorization"].(authData)
user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)
user, err := api.DB.Queries.GetUser(api.DB.Ctx, auth.UserName)
if err != nil {
log.Error("[appGetSettings] GetUser DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
return
}
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, userID)
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, auth.UserName)
if err != nil {
log.Error("[appGetSettings] GetDevices DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
@ -277,6 +318,126 @@ func (api *API) appGetSettings(c *gin.Context) {
c.HTML(http.StatusOK, "page/settings", 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) {
// Open Log File
logPath := path.Join(api.Config.ConfigPath, "logs/antholume.log")
logFile, err := os.Open(logPath)
if err != nil {
errorPage(c, http.StatusBadRequest, "Missing AnthoLume log file.")
return
}
defer logFile.Close()
// Write Log File
c.Stream(func(w io.Writer) bool {
_, err = io.Copy(w, logFile)
if err != nil {
return true
}
return false
})
}
func (api *API) appPerformAdminAction(c *gin.Context) {
templateVars := api.getBaseTemplateVars("admin", c)
var rAdminAction requestAdminAction
if err := c.ShouldBind(&rAdminAction); err != nil {
log.Error("[appPerformAdminAction] Invalid Form Bind")
errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return
}
switch rAdminAction.Action {
case AA_IMPORT:
// TODO
case AA_METADATA_MATCH:
// TODO
// 1. Documents xref most recent metadata table?
// 2. Select all / deselect?
case AA_RESTORE:
// TODO
// 1. Consume backup ZIP
// 2. Move existing to "backup" folder (db, wal, shm, covers, documents)
// 3. Extract backup zip
// 4. Restart server?
case AA_BACKUP:
// Get File Paths
fileName := fmt.Sprintf("%s.db", api.Config.DBName)
dbLocation := path.Join(api.Config.ConfigPath, fileName)
c.Header("Content-type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeExport_%s.zip\"", time.Now().Format("20060102")))
// Stream Backup ZIP Archive
c.Stream(func(w io.Writer) bool {
ar := zip.NewWriter(w)
exportWalker := func(currentPath string, f fs.DirEntry, err error) error {
if err != nil {
return err
}
if f.IsDir() {
return nil
}
// Open File on Disk
file, err := os.Open(currentPath)
if err != nil {
return err
}
defer file.Close()
// Derive Export Structure
fileName := filepath.Base(currentPath)
folderName := filepath.Base(filepath.Dir(currentPath))
// Create File in Export
newF, err := ar.Create(path.Join(folderName, fileName))
if err != nil {
return err
}
// Copy File in Export
_, err = io.Copy(newF, file)
if err != nil {
return err
}
return nil
}
// Copy Database File
dbFile, _ := os.Open(dbLocation)
newDbFile, _ := ar.Create(fileName)
io.Copy(newDbFile, dbFile)
// Backup Covers & Documents
for _, item := range rAdminAction.BackupTypes {
if item == BACKUP_TYPE_COVERS {
filepath.WalkDir(path.Join(api.Config.ConfigPath, "covers"), exportWalker)
} else if item == BACKUP_TYPE_DOCUMENTS {
filepath.WalkDir(path.Join(api.Config.ConfigPath, "documents"), exportWalker)
}
}
ar.Close()
return false
})
return
}
c.HTML(http.StatusOK, "page/admin", templateVars)
}
func (api *API) appGetSearch(c *gin.Context) {
templateVars := api.getBaseTemplateVars("search", c)
@ -320,7 +481,10 @@ func (api *API) appGetRegister(c *gin.Context) {
}
func (api *API) appGetDocumentProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
@ -331,7 +495,7 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
progress, err := api.DB.Queries.GetDocumentProgress(api.DB.Ctx, database.GetDocumentProgressParams{
DocumentID: rDoc.DocumentID,
UserID: rUser.(string),
UserID: auth.UserName,
})
if err != nil && err != sql.ErrNoRows {
@ -341,7 +505,7 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
}
document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{
UserID: rUser.(string),
UserID: auth.UserName,
DocumentID: rDoc.DocumentID,
})
if err != nil {
@ -361,9 +525,12 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
}
func (api *API) appGetDevices(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string))
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, auth.UserName)
if err != nil && err != sql.ErrNoRows {
log.Error("[appGetDevices] GetDevices DB Error:", err)
@ -677,7 +844,7 @@ func (api *API) appIdentifyDocument(c *gin.Context) {
// Get Template Variables
templateVars := api.getBaseTemplateVars("document", c)
userID := templateVars["User"].(string)
auth := templateVars["Authorization"].(authData)
// Get Metadata
metadataResults, err := metadata.SearchMetadata(metadata.GBOOK, metadata.MetadataInfo{
@ -710,7 +877,7 @@ func (api *API) appIdentifyDocument(c *gin.Context) {
}
document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{
UserID: userID,
UserID: auth.UserName,
DocumentID: rDocID.DocumentID,
})
if err != nil {
@ -896,17 +1063,19 @@ func (api *API) appEditSettings(c *gin.Context) {
}
templateVars := api.getBaseTemplateVars("settings", c)
userID := templateVars["User"].(string)
auth := templateVars["Authorization"].(authData)
newUserSettings := database.UpdateUserParams{
UserID: userID,
UserID: auth.UserName,
}
// Set New Password
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
authorized := api.authorizeCredentials(userID, password)
if authorized == true {
data := api.authorizeCredentials(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 {
@ -915,8 +1084,6 @@ func (api *API) appEditSettings(c *gin.Context) {
templateVars["PasswordMessage"] = "Password Updated"
newUserSettings.Password = &hashedPassword
}
} else {
templateVars["PasswordErrorMessage"] = "Invalid Password"
}
}
@ -935,7 +1102,7 @@ func (api *API) appEditSettings(c *gin.Context) {
}
// Get User
user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)
user, err := api.DB.Queries.GetUser(api.DB.Ctx, auth.UserName)
if err != nil {
log.Error("[appEditSettings] GetUser DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
@ -943,7 +1110,7 @@ func (api *API) appEditSettings(c *gin.Context) {
}
// Get Devices
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, userID)
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, auth.UserName)
if err != nil {
log.Error("[appEditSettings] GetDevices DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
@ -1002,14 +1169,14 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
}
func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) gin.H {
var userID string
if rUser, _ := c.Get("AuthorizedUser"); rUser != nil {
userID = rUser.(string)
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
return gin.H{
"User": userID,
"RouteName": routeName,
"Authorization": auth,
"RouteName": routeName,
"Config": gin.H{
"Version": api.Config.Version,
"SearchEnabled": api.Config.SearchEnabled,

View File

@ -14,6 +14,12 @@ import (
"reichard.io/bbank/database"
)
// Authorization Data
type authData struct {
UserName string
IsAdmin bool
}
// KOSync API Auth Headers
type authKOHeader struct {
AuthUser string `header:"x-auth-user"`
@ -25,25 +31,28 @@ type authOPDSHeader struct {
Authorization string `header:"authorization"`
}
func (api *API) authorizeCredentials(username string, password string) (authorized bool) {
func (api *API) authorizeCredentials(username string, password string) (auth *authData) {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, username)
if err != nil {
return false
return
}
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true {
return false
return
}
return true
return &authData{
UserName: user.ID,
IsAdmin: user.Admin,
}
}
func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session First
if user, ok := getSession(session); ok == true {
c.Set("AuthorizedUser", user)
if auth, ok := getSession(session); ok == true {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
return
@ -61,17 +70,18 @@ func (api *API) authKOMiddleware(c *gin.Context) {
return
}
if authorized := api.authorizeCredentials(rHeader.AuthUser, rHeader.AuthKey); authorized != true {
authData := api.authorizeCredentials(rHeader.AuthUser, rHeader.AuthKey)
if authData == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if err := setSession(session, rHeader.AuthUser); err != nil {
if err := setSession(session, *authData); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
c.Set("AuthorizedUser", rHeader.AuthUser)
c.Set("Authorization", *authData)
c.Header("Cache-Control", "private")
c.Next()
}
@ -89,12 +99,13 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
// Validate Auth
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if authorized := api.authorizeCredentials(user, password); authorized != true {
authData := api.authorizeCredentials(user, password)
if authData == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
c.Set("AuthorizedUser", user)
c.Set("Authorization", *authData)
c.Header("Cache-Control", "private")
c.Next()
}
@ -103,8 +114,8 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session
if user, ok := getSession(session); ok == true {
c.Set("AuthorizedUser", user)
if auth, ok := getSession(session); ok == true {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
return
@ -115,6 +126,20 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
return
}
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
if data, _ := c.Get("Authorization"); data != nil {
auth := data.(authData)
if auth.IsAdmin == true {
c.Next()
return
}
}
errorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
c.Abort()
return
}
func (api *API) appAuthFormLogin(c *gin.Context) {
templateVars := api.getBaseTemplateVars("login", c)
@ -129,7 +154,8 @@ func (api *API) appAuthFormLogin(c *gin.Context) {
// MD5 - KOSync Compatiblity
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if authorized := api.authorizeCredentials(username, password); authorized != true {
authData := api.authorizeCredentials(username, password)
if authData == nil {
templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return
@ -137,7 +163,7 @@ func (api *API) appAuthFormLogin(c *gin.Context) {
// Set Session
session := sessions.Default(c)
if err := setSession(session, username); err != nil {
if err := setSession(session, *authData); err != nil {
templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return
@ -194,9 +220,22 @@ func (api *API) appAuthFormRegister(c *gin.Context) {
return
}
// Get User
user, err := api.DB.Queries.GetUser(api.DB.Ctx, username)
if err != nil {
log.Error("[appAuthFormRegister] GetUser DB Error:", err)
templateVars["Error"] = "Registration Disabled or User Already Exists"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
// Set Session
auth := authData{
UserName: user.ID,
IsAdmin: user.Admin,
}
session := sessions.Default(c)
if err := setSession(session, username); err != nil {
if err := setSession(session, auth); err != nil {
errorPage(c, http.StatusUnauthorized, "Unauthorized.")
return
}
@ -212,26 +251,35 @@ func (api *API) appAuthLogout(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
}
func getSession(session sessions.Session) (user string, ok bool) {
func getSession(session sessions.Session) (auth authData, ok bool) {
// Check Session
authorizedUser := session.Get("authorizedUser")
if authorizedUser == nil {
return "", false
isAdmin := session.Get("isAdmin")
expiresAt := session.Get("expiresAt")
if authorizedUser == nil || isAdmin == nil || expiresAt == nil {
return
}
// Create Auth Object
auth = authData{
UserName: authorizedUser.(string),
IsAdmin: isAdmin.(bool),
}
// Refresh
expiresAt := session.Get("expiresAt")
if expiresAt != nil && expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("[getSession] Refreshing Session")
setSession(session, authorizedUser.(string))
setSession(session, auth)
}
return authorizedUser.(string), true
// Authorized
return auth, true
}
func setSession(session sessions.Session, user string) error {
func setSession(session sessions.Session, auth authData) error {
// Set Session Cookie
session.Set("authorizedUser", user)
session.Set("authorizedUser", auth.UserName)
session.Set("isAdmin", auth.IsAdmin)
session.Set("expiresAt", time.Now().Unix()+(60*60*24*7))
return session.Save()
}

View File

@ -129,7 +129,10 @@ func (api *API) koCreateUser(c *gin.Context) {
}
func (api *API) koSetProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rPosition requestPosition
if err := c.ShouldBindJSON(&rPosition); err != nil {
@ -141,7 +144,7 @@ func (api *API) koSetProgress(c *gin.Context) {
// Upsert Device
if _, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
ID: rPosition.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
DeviceName: rPosition.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
@ -160,7 +163,7 @@ func (api *API) koSetProgress(c *gin.Context) {
Percentage: rPosition.Percentage,
DocumentID: rPosition.DocumentID,
DeviceID: rPosition.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
Progress: rPosition.Progress,
})
if err != nil {
@ -171,7 +174,7 @@ func (api *API) koSetProgress(c *gin.Context) {
// Update Statistic
log.Info("[koSetProgress] UpdateDocumentUserStatistic Running...")
if err := api.DB.UpdateDocumentUserStatistic(rPosition.DocumentID, rUser.(string)); err != nil {
if err := api.DB.UpdateDocumentUserStatistic(rPosition.DocumentID, auth.UserName); err != nil {
log.Error("[koSetProgress] UpdateDocumentUserStatistic Error:", err)
}
log.Info("[koSetProgress] UpdateDocumentUserStatistic Complete")
@ -183,7 +186,10 @@ func (api *API) koSetProgress(c *gin.Context) {
}
func (api *API) koGetProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
@ -194,7 +200,7 @@ func (api *API) koGetProgress(c *gin.Context) {
progress, err := api.DB.Queries.GetDocumentProgress(api.DB.Ctx, database.GetDocumentProgressParams{
DocumentID: rDocID.DocumentID,
UserID: rUser.(string),
UserID: auth.UserName,
})
if err == sql.ErrNoRows {
@ -217,7 +223,10 @@ func (api *API) koGetProgress(c *gin.Context) {
}
func (api *API) koAddActivities(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rActivity requestActivity
if err := c.ShouldBindJSON(&rActivity); err != nil {
@ -259,7 +268,7 @@ func (api *API) koAddActivities(c *gin.Context) {
// Upsert Device
if _, err = qtx.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
ID: rActivity.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
DeviceName: rActivity.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
@ -271,7 +280,7 @@ func (api *API) koAddActivities(c *gin.Context) {
// Add All Activity
for _, item := range rActivity.Activity {
if _, err := qtx.AddActivity(api.DB.Ctx, database.AddActivityParams{
UserID: rUser.(string),
UserID: auth.UserName,
DocumentID: item.DocumentID,
DeviceID: rActivity.DeviceID,
StartTime: time.Unix(int64(item.StartTime), 0).UTC().Format(time.RFC3339),
@ -295,7 +304,7 @@ func (api *API) koAddActivities(c *gin.Context) {
// Update Statistic
for _, doc := range allDocuments {
log.Info("[koAddActivities] UpdateDocumentUserStatistic Running...")
if err := api.DB.UpdateDocumentUserStatistic(doc, rUser.(string)); err != nil {
if err := api.DB.UpdateDocumentUserStatistic(doc, auth.UserName); err != nil {
log.Error("[koAddActivities] UpdateDocumentUserStatistic Error:", err)
}
log.Info("[koAddActivities] UpdateDocumentUserStatistic Complete")
@ -307,7 +316,10 @@ func (api *API) koAddActivities(c *gin.Context) {
}
func (api *API) koCheckActivitySync(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rCheckActivity requestCheckActivitySync
if err := c.ShouldBindJSON(&rCheckActivity); err != nil {
@ -319,7 +331,7 @@ func (api *API) koCheckActivitySync(c *gin.Context) {
// Upsert Device
if _, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
ID: rCheckActivity.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
DeviceName: rCheckActivity.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
@ -330,7 +342,7 @@ func (api *API) koCheckActivitySync(c *gin.Context) {
// Get Last Device Activity
lastActivity, err := api.DB.Queries.GetLastActivity(api.DB.Ctx, database.GetLastActivityParams{
UserID: rUser.(string),
UserID: auth.UserName,
DeviceID: rCheckActivity.DeviceID,
})
if err == sql.ErrNoRows {
@ -405,7 +417,10 @@ func (api *API) koAddDocuments(c *gin.Context) {
}
func (api *API) koCheckDocumentsSync(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rCheckDocs requestCheckDocumentSync
if err := c.ShouldBindJSON(&rCheckDocs); err != nil {
@ -417,7 +432,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
// Upsert Device
_, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
ID: rCheckDocs.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
DeviceName: rCheckDocs.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
})

View File

@ -61,9 +61,9 @@ func (api *API) opdsEntry(c *gin.Context) {
}
func (api *API) opdsDocuments(c *gin.Context) {
var userID string
if rUser, _ := c.Get("AuthorizedUser"); rUser != nil {
userID = rUser.(string)
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
// Potential URL Parameters (Default Pagination - 100)
@ -78,7 +78,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
// Get Documents
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
UserID: userID,
UserID: auth.UserName,
Query: query,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,

File diff suppressed because one or more lines are too long

View File

@ -2,9 +2,11 @@ package config
import (
"os"
"path"
"strings"
log "github.com/sirupsen/logrus"
"github.com/snowzach/rotatefilehook"
)
type Config struct {
@ -32,6 +34,15 @@ type Config struct {
CookieHTTPOnly bool
}
type UTCFormatter struct {
log.Formatter
}
func (u UTCFormatter) Format(e *log.Entry) ([]byte, error) {
e.Time = e.Time.UTC()
return u.Formatter.Format(e)
}
func Load() *Config {
c := &Config{
Version: "0.0.1",
@ -50,11 +61,31 @@ func Load() *Config {
}
// Log Level
ll, err := log.ParseLevel(c.LogLevel)
logLevel, err := log.ParseLevel(c.LogLevel)
if err != nil {
ll = log.InfoLevel
logLevel = log.InfoLevel
}
log.SetLevel(ll)
// Log Formatter
ttyLogFormatter := &UTCFormatter{&log.TextFormatter{FullTimestamp: true}}
fileLogFormatter := &UTCFormatter{&log.TextFormatter{FullTimestamp: true, DisableColors: true}}
// Log Rotater
rotateFileHook, err := rotatefilehook.NewRotateFileHook(rotatefilehook.RotateFileConfig{
Filename: path.Join(c.ConfigPath, "logs/antholume.log"),
MaxSize: 50,
MaxBackups: 3,
MaxAge: 30,
Level: logLevel,
Formatter: fileLogFormatter,
})
if err != nil {
log.Fatal("[config.Load] Unable to initialize file rotate hook")
}
log.SetLevel(logLevel)
log.SetFormatter(ttyLogFormatter)
log.AddHook(rotateFileHook)
return c
}

2
go.mod
View File

@ -47,6 +47,7 @@ require (
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/snowzach/rotatefilehook v0.0.0-20220211133110-53752135082d // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
@ -57,6 +58,7 @@ require (
golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.13.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect

4
go.sum
View File

@ -133,6 +133,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/snowzach/rotatefilehook v0.0.0-20220211133110-53752135082d h1:4660u5vJtsyrn3QwJNfESwCws+TM1CMhRn123xjVyQ8=
github.com/snowzach/rotatefilehook v0.0.0-20220211133110-53752135082d/go.mod h1:ZLVe3VfhAuMYLYWliGEydMBoRnfib8EFSqkBYu1ck9E=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -236,6 +238,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

11
main.go
View File

@ -15,18 +15,7 @@ import (
//go:embed templates/* assets/*
var assets embed.FS
type UTCFormatter struct {
log.Formatter
}
func (u UTCFormatter) Format(e *log.Entry) ([]byte, error) {
e.Time = e.Time.UTC()
return u.Formatter.Format(e)
}
func main() {
log.SetFormatter(UTCFormatter{&log.TextFormatter{FullTimestamp: true}})
app := &cli.App{
Name: "AnthoLume",
Usage: "A self hosted e-book progress tracker.",

View File

@ -231,6 +231,19 @@
aria-orientation="vertical"
aria-labelledby="options-menu"
>
{{ if .Authorization.IsAdmin }}
<a
href="/admin"
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"
>
<span class="flex flex-col">
<span>Administration</span>
</span>
</a>
{{ end }}
<a
href="/settings"
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
@ -265,7 +278,7 @@
<div
class="flex items-center gap-2 text-gray-500 dark:text-white text-md py-4 cursor-pointer"
>
<span>{{ .User }}</span>
<span>{{ .Authorization.UserName }}</span>
<span class="text-gray-800 dark:text-gray-200">{{ template "svg/dropdown" (dict "Size" 20) }}</span>
</div>
</label>

183
templates/pages/admin.html Normal file
View File

@ -0,0 +1,183 @@
{{template "base" .}} {{define "title"}}Administration{{end}} {{define
"header"}}
<a href="./admin">Administration</a>
{{end}} {{define "content"}}
<div class="w-full flex flex-col md:flex-row gap-4">
<div>
<div
class="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
{{ template "svg/user" (dict "Size" 60) }}
<p class="text-lg">{{ .Authorization.UserName }}</p>
</div>
</div>
<div class="flex flex-col gap-4 grow">
<div
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p class="text-lg font-semibold mb-2">Import Documents</p>
<form class="flex gap-4 flex-col" action="./admin" method="POST">
<input type="text" name="action" value="IMPORT" class="hidden" />
<div class="flex gap-4">
<div class="flex grow relative">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
{{ template "svg/import" (dict "Size" 15) }}
</span>
<input
type="text"
name="import_directory"
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Directory"
/>
</div>
<div class="flex flex-col mr-4">
<div class="inline-flex gap-2">
<input
checked
type="radio"
id="copy"
name="import_type"
value="COPY"
/>
<label for="copy"> Copy</label>
</div>
<div class="inline-flex gap-2">
<input
type="radio"
id="direct"
name="import_type"
value="DIRECT"
/>
<label for="direct"> Direct</label>
</div>
</div>
</div>
<button
type="submit"
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">Import Directory</span>
</button>
</form>
{{ if .PasswordErrorMessage }}
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
{{ else if .PasswordMessage }}
<span class="text-green-400 text-xs">{{ .PasswordMessage }}</span>
{{ end }}
</div>
<div
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p class="text-lg font-semibold mb-2">Backup & Restore</p>
<div class="flex flex-col gap-4">
<form class="flex justify-between" action="./admin" method="POST">
<input type="text" name="action" value="BACKUP" class="hidden" />
<div class="flex gap-8 items-center">
<div>
<input
type="checkbox"
id="backup_covers"
name="backup_types"
value="COVERS"
/>
<label for="backup_covers"> Covers</label>
</div>
<div>
<input
type="checkbox"
id="backup_documents"
name="backup_types"
value="DOCUMENTS"
/>
<label for="backup_documents"> Documents</label>
</div>
</div>
<button
type="submit"
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">Backup</span>
</button>
</form>
<form
method="POST"
enctype="multipart/form-data"
action="./admin"
class="flex justify-between grow"
>
<input type="text" name="action" value="RESTORE" class="hidden" />
<div class="flex items-center w-1/2">
<input
type="file"
accept=".zip"
name="restore_file"
class="w-full"
/>
</div>
<button
type="submit"
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">Restore</span>
</button>
</form>
</div>
{{ if .PasswordErrorMessage }}
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
{{ else if .PasswordMessage }}
<span class="text-green-400 text-xs">{{ .PasswordMessage }}</span>
{{ end }}
</div>
<div
class="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p class="text-lg font-semibold">Tasks</p>
<table class="min-w-full bg-white dark:bg-gray-700 text-sm">
<tbody class="text-black dark:text-white">
<tr>
<td class="pl-0">
<p>Metadata Matching</p>
</td>
<td class="py-2 float-right">
<form action="./admin" method="POST">
<input
type="text"
name="action"
value="METADATA_MATCH"
class="hidden"
/>
<button
type="submit"
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">Run</span>
</button>
</form>
</td>
</tr>
<tr>
<td>
<p>Logs</p>
</td>
<td class="py-2 float-right">
<a
href="./admin/logs"
target="_blank"
class="inline-block w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">View</span>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{{end}}

View File

@ -7,7 +7,7 @@
class="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
{{ template "svg/user" (dict "Size" 60) }}
<p class="text-lg">{{ .User }}</p>
<p class="text-lg">{{ .Authorization.UserName }}</p>
</div>
</div>

13
templates/svgs/import.svg Normal file
View File

@ -0,0 +1,13 @@
<svg
width="{{ or .Size 24 }}"
height="{{ or .Size 24 }}"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.06935 5.00839C2 5.37595 2 5.81722 2 6.69975V13.75C2 17.5212 2 19.4069 3.17157 20.5784C4.34315 21.75 6.22876 21.75 10 21.75H14C17.7712 21.75 19.6569 21.75 20.8284 20.5784C22 19.4069 22 17.5212 22 13.75V11.5479C22 8.91554 22 7.59935 21.2305 6.74383C21.1598 6.66514 21.0849 6.59024 21.0062 6.51946C20.1506 5.75 18.8345 5.75 16.2021 5.75H15.8284C14.6747 5.75 14.0979 5.75 13.5604 5.59678C13.2651 5.5126 12.9804 5.39471 12.7121 5.24543C12.2237 4.97367 11.8158 4.56578 11 3.75L10.4497 3.19975C10.1763 2.92633 10.0396 2.78961 9.89594 2.67051C9.27652 2.15704 8.51665 1.84229 7.71557 1.76738C7.52976 1.75 7.33642 1.75 6.94975 1.75C6.06722 1.75 5.62595 1.75 5.25839 1.81935C3.64031 2.12464 2.37464 3.39031 2.06935 5.00839ZM12 11C12.4142 11 12.75 11.3358 12.75 11.75V13H14C14.4142 13 14.75 13.3358 14.75 13.75C14.75 14.1642 14.4142 14.5 14 14.5H12.75V15.75C12.75 16.1642 12.4142 16.5 12 16.5C11.5858 16.5 11.25 16.1642 11.25 15.75V14.5H10C9.58579 14.5 9.25 14.1642 9.25 13.75C9.25 13.3358 9.58579 13 10 13H11.25V11.75C11.25 11.3358 11.5858 11 12 11Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB