feat(logs): jq filtering, feat(import): directory picker, refactor(admin): move routes to seperate file
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
b1cfd16627
commit
a86e2520ef
@ -149,11 +149,13 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
|||||||
router.GET("/register", api.appGetRegister)
|
router.GET("/register", api.appGetRegister)
|
||||||
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
|
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
|
||||||
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.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.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
|
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
|
||||||
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
|
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
|
||||||
router.POST("/login", api.appAuthFormLogin)
|
router.POST("/login", api.appAuthLogin)
|
||||||
router.POST("/register", api.appAuthFormRegister)
|
router.POST("/register", api.appAuthRegister)
|
||||||
|
|
||||||
// Demo Mode Enabled Configuration
|
// Demo Mode Enabled Configuration
|
||||||
if api.cfg.DemoMode {
|
if api.cfg.DemoMode {
|
||||||
@ -186,7 +188,7 @@ func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
|
|||||||
koGroup.GET("/users/auth", api.authKOMiddleware, api.koAuthorizeUser)
|
koGroup.GET("/users/auth", api.authKOMiddleware, api.koAuthorizeUser)
|
||||||
koGroup.POST("/activity", api.authKOMiddleware, api.koAddActivities)
|
koGroup.POST("/activity", api.authKOMiddleware, api.koAddActivities)
|
||||||
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.koCheckActivitySync)
|
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.koCheckActivitySync)
|
||||||
koGroup.POST("/users/create", api.koCreateUser)
|
koGroup.POST("/users/create", api.koAuthRegister)
|
||||||
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.koSetProgress)
|
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.koSetProgress)
|
||||||
|
|
||||||
// Demo Mode Enabled Configuration
|
// Demo Mode Enabled Configuration
|
||||||
|
549
api/app-admin-routes.go
Normal file
549
api/app-admin-routes.go
Normal file
@ -0,0 +1,549 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/itchyny/gojq"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
adminBackup adminAction = "BACKUP"
|
||||||
|
adminRestore adminAction = "RESTORE"
|
||||||
|
adminMetadataMatch adminAction = "METADATA_MATCH"
|
||||||
|
adminCacheTables adminAction = "CACHE_TABLES"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestAdminAction struct {
|
||||||
|
Action adminAction `form:"action"`
|
||||||
|
|
||||||
|
// Backup Action
|
||||||
|
BackupTypes []backupType `form:"backup_types"`
|
||||||
|
|
||||||
|
// Restore Action
|
||||||
|
RestoreFile *multipart.FileHeader `form:"restore_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
importDirect importType = "DIRECT"
|
||||||
|
importCopy importType = "COPY"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestAdminImport struct {
|
||||||
|
Directory string `form:"directory"`
|
||||||
|
Select string `form:"select"`
|
||||||
|
Type importType `form:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestAdminLogs struct {
|
||||||
|
Filter string `form:"filter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appPerformAdminAction(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
||||||
|
|
||||||
|
var rAdminAction requestAdminAction
|
||||||
|
if err := c.ShouldBind(&rAdminAction); err != nil {
|
||||||
|
log.Error("Invalid Form Bind: ", err)
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO - Messages
|
||||||
|
switch rAdminAction.Action {
|
||||||
|
case adminMetadataMatch:
|
||||||
|
// TODO
|
||||||
|
// 1. Documents xref most recent metadata table?
|
||||||
|
// 2. Select all / deselect?
|
||||||
|
case adminCacheTables:
|
||||||
|
go api.db.CacheTempTables()
|
||||||
|
case adminRestore:
|
||||||
|
api.processRestoreFile(rAdminAction, c)
|
||||||
|
case adminBackup:
|
||||||
|
// Vacuum
|
||||||
|
_, err := api.db.DB.ExecContext(api.db.Ctx, "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 rAdminAction.BackupTypes {
|
||||||
|
if item == backupCovers {
|
||||||
|
directories = append(directories, "covers")
|
||||||
|
} else if item == backupDocuments {
|
||||||
|
directories = append(directories, "documents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := api.createBackup(w, directories)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Backup Error: ", err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin-logs", c)
|
||||||
|
|
||||||
|
var rAdminLogs requestAdminLogs
|
||||||
|
if err := c.ShouldBindQuery(&rAdminLogs); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid URI parameters.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rAdminLogs.Filter = strings.TrimSpace(rAdminLogs.Filter)
|
||||||
|
|
||||||
|
var jqFilter *gojq.Code
|
||||||
|
if rAdminLogs.Filter != "" {
|
||||||
|
parsed, err := gojq.Parse(rAdminLogs.Filter)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to parse JQ filter")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Unable to parse JQ filter.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jqFilter, err = gojq.Compile(parsed)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to compile JQ filter")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Unable to compile JQ filter.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Log File
|
||||||
|
logPath := filepath.Join(api.cfg.ConfigPath, "logs/antholume.log")
|
||||||
|
logFile, err := os.Open(logPath)
|
||||||
|
if err != nil {
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Missing AnthoLume log file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer logFile.Close()
|
||||||
|
|
||||||
|
// Log Lines
|
||||||
|
var logLines []string
|
||||||
|
scanner := bufio.NewScanner(logFile)
|
||||||
|
for scanner.Scan() {
|
||||||
|
rawLog := scanner.Text()
|
||||||
|
|
||||||
|
// Attempt JSON Pretty
|
||||||
|
var jsonMap map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(rawLog), &jsonMap)
|
||||||
|
if err != nil {
|
||||||
|
logLines = append(logLines, scanner.Text())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
rawData, err := json.MarshalIndent(jsonMap, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
logLines = append(logLines, scanner.Text())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Filter
|
||||||
|
if jqFilter == nil {
|
||||||
|
logLines = append(logLines, string(rawData))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error or nil
|
||||||
|
result, _ := jqFilter.Run(jsonMap).Next()
|
||||||
|
if _, ok := result.(error); ok {
|
||||||
|
logLines = append(logLines, string(rawData))
|
||||||
|
continue
|
||||||
|
} else if result == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt filtered json
|
||||||
|
filteredData, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err == nil {
|
||||||
|
rawData = filteredData
|
||||||
|
}
|
||||||
|
|
||||||
|
logLines = append(logLines, string(rawData))
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = logLines
|
||||||
|
templateVars["Filter"] = strings.TrimSpace(rAdminLogs.Filter)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/admin-logs", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetAdminUsers(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin-users", c)
|
||||||
|
|
||||||
|
users, err := api.db.Queries.GetUsers(api.db.Ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUsers DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUsers DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = users
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/admin-users", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetAdminImport(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin-import", c)
|
||||||
|
|
||||||
|
var rImportFolder requestAdminImport
|
||||||
|
if err := c.ShouldBindQuery(&rImportFolder); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid directory.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rImportFolder.Select != "" {
|
||||||
|
templateVars["SelectedDirectory"] = rImportFolder.Select
|
||||||
|
c.HTML(http.StatusOK, "page/admin-import", templateVars)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Path
|
||||||
|
if rImportFolder.Directory == "" {
|
||||||
|
dPath, err := filepath.Abs(api.cfg.DataPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Absolute filepath error: ", rImportFolder.Directory)
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Unable to get data directory absolute path.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rImportFolder.Directory = dPath
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(rImportFolder.Directory)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Invalid directory: ", rImportFolder.Directory)
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid directory.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allDirectories := []string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
allDirectories = append(allDirectories, e.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["CurrentPath"] = filepath.Clean(rImportFolder.Directory)
|
||||||
|
templateVars["Data"] = allDirectories
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/admin-import", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appPerformAdminImport(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin-import", c)
|
||||||
|
|
||||||
|
var rAdminImport requestAdminImport
|
||||||
|
if err := c.ShouldBind(&rAdminImport); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid directory.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
|
||||||
|
fmt.Println(rAdminImport)
|
||||||
|
|
||||||
|
templateVars["CurrentPath"] = filepath.Clean(rAdminImport.Directory)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/admin-import", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
|
||||||
|
// Validate Type & Derive Extension on MIME
|
||||||
|
uploadedFile, err := rAdminAction.RestoreFile.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("File Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to open file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileMime, err := mimetype.DetectReader(uploadedFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("MIME Error")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to detect filetype.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileExtension := fileMime.Extension()
|
||||||
|
|
||||||
|
// Validate Extension
|
||||||
|
if !slices.Contains([]string{".zip"}, fileExtension) {
|
||||||
|
log.Error("Invalid FileType: ", fileExtension)
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid filetype.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Temp File
|
||||||
|
tempFile, err := os.CreateTemp("", "restore")
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Temp File Create Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to create temp file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
// Save Temp
|
||||||
|
err = c.SaveUploadedFile(rAdminAction.RestoreFile, tempFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("File Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to save file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP Info
|
||||||
|
fileInfo, err := tempFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("File Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to read file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ZIP Reader
|
||||||
|
zipReader, err := zip.NewReader(tempFile, fileInfo.Size())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("ZIP Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to read zip.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ZIP Contents
|
||||||
|
hasDBFile := false
|
||||||
|
hasUnknownFile := false
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
fileName := strings.TrimPrefix(file.Name, "/")
|
||||||
|
if fileName == "antholume.db" {
|
||||||
|
hasDBFile = true
|
||||||
|
break
|
||||||
|
} else if !strings.HasPrefix(fileName, "covers/") && !strings.HasPrefix(fileName, "documents/") {
|
||||||
|
hasUnknownFile = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid ZIP
|
||||||
|
if !hasDBFile {
|
||||||
|
log.Error("Invalid ZIP File - Missing DB")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Invalid Restore ZIP - Missing DB")
|
||||||
|
return
|
||||||
|
} else if hasUnknownFile {
|
||||||
|
log.Error("Invalid ZIP File - Invalid File(s)")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Invalid Restore ZIP - Invalid File(s)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Backup File
|
||||||
|
backupFilePath := filepath.Join(api.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
|
||||||
|
backupFile, err := os.Create(backupFilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to create backup file: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to create backup file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer backupFile.Close()
|
||||||
|
|
||||||
|
// Vacuum DB
|
||||||
|
_, err = api.db.DB.ExecContext(api.db.Ctx, "VACUUM;")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to vacuum DB: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Backup File
|
||||||
|
w := bufio.NewWriter(backupFile)
|
||||||
|
err = api.createBackup(w, []string{"covers", "documents"})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to save backup file: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to save backup file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Data
|
||||||
|
err = api.removeData()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to delete data: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to delete data.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore Data
|
||||||
|
err = api.restoreData(zipReader)
|
||||||
|
if err != nil {
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to restore data.")
|
||||||
|
log.Panic("Unable to restore data: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinit DB
|
||||||
|
if err := api.db.Reload(); err != nil {
|
||||||
|
log.Panicf("Unable to reload DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate Auth Hashes
|
||||||
|
if err := api.rotateAllAuthHashes(); err != nil {
|
||||||
|
log.Panicf("Unable to rotate auth hashes: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) restoreData(zipReader *zip.Reader) error {
|
||||||
|
// Ensure Directories
|
||||||
|
api.cfg.EnsureDirectories()
|
||||||
|
|
||||||
|
// Restore Data
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
destPath := filepath.Join(api.cfg.DataPath, file.Name)
|
||||||
|
destFile, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error creating destination file:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
// Copy the contents from the zip file to the destination file.
|
||||||
|
if _, err := io.Copy(destFile, rc); err != nil {
|
||||||
|
fmt.Println("Error copying file contents:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Extracted: %s\n", destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) removeData() error {
|
||||||
|
allPaths := []string{
|
||||||
|
"covers",
|
||||||
|
"documents",
|
||||||
|
"antholume.db",
|
||||||
|
"antholume.db-wal",
|
||||||
|
"antholume.db-shm",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range allPaths {
|
||||||
|
fullPath := filepath.Join(api.cfg.DataPath, name)
|
||||||
|
err := os.RemoveAll(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to delete %s: %v", name, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) createBackup(w io.Writer, directories []string) error {
|
||||||
|
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(filepath.Join(folderName, fileName))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy File in Export
|
||||||
|
_, err = io.Copy(newF, file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get DB Path
|
||||||
|
fileName := fmt.Sprintf("%s.db", api.cfg.DBName)
|
||||||
|
dbLocation := filepath.Join(api.cfg.ConfigPath, fileName)
|
||||||
|
|
||||||
|
// Copy Database File
|
||||||
|
dbFile, err := os.Open(dbLocation)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dbFile.Close()
|
||||||
|
|
||||||
|
newDbFile, err := ar.Create(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
io.Copy(newDbFile, dbFile)
|
||||||
|
|
||||||
|
// Backup Covers & Documents
|
||||||
|
for _, dir := range directories {
|
||||||
|
err = filepath.WalkDir(filepath.Join(api.cfg.DataPath, dir), exportWalker)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ar.Close()
|
||||||
|
return nil
|
||||||
|
}
|
@ -1,14 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
|
||||||
"bufio"
|
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"math"
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -30,23 +26,6 @@ import (
|
|||||||
"reichard.io/antholume/utils"
|
"reichard.io/antholume/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminAction string
|
|
||||||
|
|
||||||
const (
|
|
||||||
adminImport adminAction = "IMPORT"
|
|
||||||
adminBackup adminAction = "BACKUP"
|
|
||||||
adminRestore adminAction = "RESTORE"
|
|
||||||
adminMetadataMatch adminAction = "METADATA_MATCH"
|
|
||||||
adminCacheTables adminAction = "CACHE_TABLES"
|
|
||||||
)
|
|
||||||
|
|
||||||
type importType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
importDirect importType = "DIRECT"
|
|
||||||
importCopy importType = "COPY"
|
|
||||||
)
|
|
||||||
|
|
||||||
type backupType string
|
type backupType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -81,20 +60,6 @@ type requestDocumentEdit struct {
|
|||||||
CoverFile *multipart.FileHeader `form:"cover_file"`
|
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 {
|
type requestDocumentIdentify struct {
|
||||||
Title *string `form:"title"`
|
Title *string `form:"title"`
|
||||||
Author *string `form:"author"`
|
Author *string `form:"author"`
|
||||||
@ -341,127 +306,10 @@ func (api *API) appGetSettings(c *gin.Context) {
|
|||||||
c.HTML(http.StatusOK, "page/settings", templateVars)
|
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) {
|
|
||||||
templateVars, _ := api.getBaseTemplateVars("admin-logs", c)
|
|
||||||
|
|
||||||
// Open Log File
|
|
||||||
logPath := filepath.Join(api.cfg.ConfigPath, "logs/antholume.log")
|
|
||||||
logFile, err := os.Open(logPath)
|
|
||||||
if err != nil {
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Missing AnthoLume log file.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer logFile.Close()
|
|
||||||
|
|
||||||
// Log Lines
|
|
||||||
var logLines []string
|
|
||||||
scanner := bufio.NewScanner(logFile)
|
|
||||||
for scanner.Scan() {
|
|
||||||
rawLog := scanner.Text()
|
|
||||||
|
|
||||||
// Attempt JSON Pretty
|
|
||||||
var jsonMap map[string]interface{}
|
|
||||||
err := json.Unmarshal([]byte(rawLog), &jsonMap)
|
|
||||||
if err != nil {
|
|
||||||
logLines = append(logLines, scanner.Text())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
prettyJSON, err := json.MarshalIndent(jsonMap, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
logLines = append(logLines, scanner.Text())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
logLines = append(logLines, string(prettyJSON))
|
|
||||||
}
|
|
||||||
templateVars["Data"] = logLines
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/admin-logs", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetAdminUsers(c *gin.Context) {
|
|
||||||
templateVars, _ := api.getBaseTemplateVars("admin-users", c)
|
|
||||||
|
|
||||||
users, err := api.db.Queries.GetUsers(api.db.Ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetUsers DB Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUsers DB Error: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Data"] = users
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/admin-users", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tabs:
|
// Tabs:
|
||||||
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
||||||
// - Users
|
// - Users
|
||||||
// - Metadata
|
// - Metadata
|
||||||
func (api *API) appPerformAdminAction(c *gin.Context) {
|
|
||||||
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
|
||||||
|
|
||||||
var rAdminAction requestAdminAction
|
|
||||||
if err := c.ShouldBind(&rAdminAction); err != nil {
|
|
||||||
log.Error("Invalid Form Bind: ", err)
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch rAdminAction.Action {
|
|
||||||
case adminImport:
|
|
||||||
// TODO
|
|
||||||
case adminMetadataMatch:
|
|
||||||
// TODO
|
|
||||||
// 1. Documents xref most recent metadata table?
|
|
||||||
// 2. Select all / deselect?
|
|
||||||
case adminCacheTables:
|
|
||||||
go api.db.CacheTempTables()
|
|
||||||
case adminRestore:
|
|
||||||
api.processRestoreFile(rAdminAction, c)
|
|
||||||
case adminBackup:
|
|
||||||
// Vacuum
|
|
||||||
_, err := api.db.DB.ExecContext(api.db.Ctx, "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 rAdminAction.BackupTypes {
|
|
||||||
if item == backupCovers {
|
|
||||||
directories = append(directories, "covers")
|
|
||||||
} else if item == backupDocuments {
|
|
||||||
directories = append(directories, "documents")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := api.createBackup(w, directories)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Backup Error: ", err)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/admin", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetSearch(c *gin.Context) {
|
func (api *API) appGetSearch(c *gin.Context) {
|
||||||
templateVars, _ := api.getBaseTemplateVars("search", c)
|
templateVars, _ := api.getBaseTemplateVars("search", c)
|
||||||
|
|
||||||
@ -1326,260 +1174,3 @@ func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
|
|
||||||
// Validate Type & Derive Extension on MIME
|
|
||||||
uploadedFile, err := rAdminAction.RestoreFile.Open()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("File Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to open file.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileMime, err := mimetype.DetectReader(uploadedFile)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("MIME Error")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to detect filetype.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileExtension := fileMime.Extension()
|
|
||||||
|
|
||||||
// Validate Extension
|
|
||||||
if !slices.Contains([]string{".zip"}, fileExtension) {
|
|
||||||
log.Error("Invalid FileType: ", fileExtension)
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid filetype.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Temp File
|
|
||||||
tempFile, err := os.CreateTemp("", "restore")
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Temp File Create Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to create temp file.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer os.Remove(tempFile.Name())
|
|
||||||
defer tempFile.Close()
|
|
||||||
|
|
||||||
// Save Temp
|
|
||||||
err = c.SaveUploadedFile(rAdminAction.RestoreFile, tempFile.Name())
|
|
||||||
if err != nil {
|
|
||||||
log.Error("File Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to save file.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZIP Info
|
|
||||||
fileInfo, err := tempFile.Stat()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("File Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to read file.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create ZIP Reader
|
|
||||||
zipReader, err := zip.NewReader(tempFile, fileInfo.Size())
|
|
||||||
if err != nil {
|
|
||||||
log.Error("ZIP Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to read zip.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate ZIP Contents
|
|
||||||
hasDBFile := false
|
|
||||||
hasUnknownFile := false
|
|
||||||
for _, file := range zipReader.File {
|
|
||||||
fileName := strings.TrimPrefix(file.Name, "/")
|
|
||||||
if fileName == "antholume.db" {
|
|
||||||
hasDBFile = true
|
|
||||||
break
|
|
||||||
} else if !strings.HasPrefix(fileName, "covers/") && !strings.HasPrefix(fileName, "documents/") {
|
|
||||||
hasUnknownFile = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid ZIP
|
|
||||||
if !hasDBFile {
|
|
||||||
log.Error("Invalid ZIP File - Missing DB")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Invalid Restore ZIP - Missing DB")
|
|
||||||
return
|
|
||||||
} else if hasUnknownFile {
|
|
||||||
log.Error("Invalid ZIP File - Invalid File(s)")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Invalid Restore ZIP - Invalid File(s)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Backup File
|
|
||||||
backupFilePath := filepath.Join(api.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
|
|
||||||
backupFile, err := os.Create(backupFilePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Unable to create backup file: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to create backup file.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer backupFile.Close()
|
|
||||||
|
|
||||||
// Vacuum DB
|
|
||||||
_, err = api.db.DB.ExecContext(api.db.Ctx, "VACUUM;")
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Unable to vacuum DB: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save Backup File
|
|
||||||
w := bufio.NewWriter(backupFile)
|
|
||||||
err = api.createBackup(w, []string{"covers", "documents"})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Unable to save backup file: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to save backup file.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove Data
|
|
||||||
err = api.removeData()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Unable to delete data: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to delete data.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore Data
|
|
||||||
err = api.restoreData(zipReader)
|
|
||||||
if err != nil {
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to restore data.")
|
|
||||||
log.Panic("Unable to restore data: ", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reinit DB
|
|
||||||
if err := api.db.Reload(); err != nil {
|
|
||||||
log.Panicf("Unable to reload DB: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate Auth Hashes
|
|
||||||
if err := api.rotateAllAuthHashes(); err != nil {
|
|
||||||
log.Panicf("Unable to rotate auth hashes: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) restoreData(zipReader *zip.Reader) error {
|
|
||||||
// Ensure Directories
|
|
||||||
api.cfg.EnsureDirectories()
|
|
||||||
|
|
||||||
// Restore Data
|
|
||||||
for _, file := range zipReader.File {
|
|
||||||
rc, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
|
|
||||||
destPath := filepath.Join(api.cfg.DataPath, file.Name)
|
|
||||||
destFile, err := os.Create(destPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error creating destination file:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer destFile.Close()
|
|
||||||
|
|
||||||
// Copy the contents from the zip file to the destination file.
|
|
||||||
if _, err := io.Copy(destFile, rc); err != nil {
|
|
||||||
fmt.Println("Error copying file contents:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Extracted: %s\n", destPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) removeData() error {
|
|
||||||
allPaths := []string{
|
|
||||||
"covers",
|
|
||||||
"documents",
|
|
||||||
"antholume.db",
|
|
||||||
"antholume.db-wal",
|
|
||||||
"antholume.db-shm",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range allPaths {
|
|
||||||
fullPath := filepath.Join(api.cfg.DataPath, name)
|
|
||||||
err := os.RemoveAll(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Unable to delete %s: %v", name, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) createBackup(w io.Writer, directories []string) error {
|
|
||||||
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(filepath.Join(folderName, fileName))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy File in Export
|
|
||||||
_, err = io.Copy(newF, file)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get DB Path
|
|
||||||
fileName := fmt.Sprintf("%s.db", api.cfg.DBName)
|
|
||||||
dbLocation := filepath.Join(api.cfg.ConfigPath, fileName)
|
|
||||||
|
|
||||||
// Copy Database File
|
|
||||||
dbFile, err := os.Open(dbLocation)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer dbFile.Close()
|
|
||||||
|
|
||||||
newDbFile, err := ar.Create(fileName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
io.Copy(newDbFile, dbFile)
|
|
||||||
|
|
||||||
// Backup Covers & Documents
|
|
||||||
for _, dir := range directories {
|
|
||||||
err = filepath.WalkDir(filepath.Join(api.cfg.DataPath, dir), exportWalker)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ar.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
61
api/auth.go
61
api/auth.go
@ -146,7 +146,7 @@ func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appAuthFormLogin(c *gin.Context) {
|
func (api *API) appAuthLogin(c *gin.Context) {
|
||||||
templateVars, _ := api.getBaseTemplateVars("login", c)
|
templateVars, _ := api.getBaseTemplateVars("login", c)
|
||||||
|
|
||||||
username := strings.TrimSpace(c.PostForm("username"))
|
username := strings.TrimSpace(c.PostForm("username"))
|
||||||
@ -179,7 +179,7 @@ func (api *API) appAuthFormLogin(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appAuthFormRegister(c *gin.Context) {
|
func (api *API) appAuthRegister(c *gin.Context) {
|
||||||
if !api.cfg.RegistrationEnabled {
|
if !api.cfg.RegistrationEnabled {
|
||||||
appErrorPage(c, http.StatusUnauthorized, "Nice try. Registration is disabled.")
|
appErrorPage(c, http.StatusUnauthorized, "Nice try. Registration is disabled.")
|
||||||
return
|
return
|
||||||
@ -269,6 +269,63 @@ func (api *API) appAuthLogout(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/login")
|
c.Redirect(http.StatusFound, "/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) koAuthRegister(c *gin.Context) {
|
||||||
|
if !api.cfg.RegistrationEnabled {
|
||||||
|
c.AbortWithStatus(http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rUser requestUser
|
||||||
|
if err := c.ShouldBindJSON(&rUser); err != nil {
|
||||||
|
log.Error("Invalid JSON Bind")
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rUser.Username == "" || rUser.Password == "" {
|
||||||
|
log.Error("Invalid User - Empty Username or Password")
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Argon2 Hash Failure:", err)
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Auth Hash
|
||||||
|
rawAuthHash, err := utils.GenerateToken(64)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to generate user token: ", err)
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
|
||||||
|
ID: rUser.Username,
|
||||||
|
Pass: &hashedPassword,
|
||||||
|
AuthHash: fmt.Sprintf("%x", rawAuthHash),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("CreateUser DB Error:", err)
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Exists
|
||||||
|
if rows == 0 {
|
||||||
|
log.Error("User Already Exists:", rUser.Username)
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "User Already Exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"username": rUser.Username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
|
func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
|
||||||
// Get Session
|
// Get Session
|
||||||
authorizedUser := session.Get("authorizedUser")
|
authorizedUser := session.Get("authorizedUser")
|
||||||
|
@ -13,14 +13,12 @@ import (
|
|||||||
"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/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type activityItem struct {
|
type activityItem struct {
|
||||||
@ -82,63 +80,6 @@ func (api *API) koAuthorizeUser(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) koCreateUser(c *gin.Context) {
|
|
||||||
if !api.cfg.RegistrationEnabled {
|
|
||||||
c.AbortWithStatus(http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var rUser requestUser
|
|
||||||
if err := c.ShouldBindJSON(&rUser); err != nil {
|
|
||||||
log.Error("Invalid JSON Bind")
|
|
||||||
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if rUser.Username == "" || rUser.Password == "" {
|
|
||||||
log.Error("Invalid User - Empty Username or Password")
|
|
||||||
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Argon2 Hash Failure:", err)
|
|
||||||
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate Auth Hash
|
|
||||||
rawAuthHash, err := utils.GenerateToken(64)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to generate user token: ", err)
|
|
||||||
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
|
|
||||||
ID: rUser.Username,
|
|
||||||
Pass: &hashedPassword,
|
|
||||||
AuthHash: fmt.Sprintf("%x", rawAuthHash),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("CreateUser DB Error:", err)
|
|
||||||
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// User Exists
|
|
||||||
if rows == 0 {
|
|
||||||
log.Error("User Already Exists:", rUser.Username)
|
|
||||||
apiErrorPage(c, http.StatusBadRequest, "User Already Exists")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"username": rUser.Username,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) koSetProgress(c *gin.Context) {
|
func (api *API) koSetProgress(c *gin.Context) {
|
||||||
var auth authData
|
var auth authData
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
|
File diff suppressed because one or more lines are too long
2
go.mod
2
go.mod
@ -9,6 +9,7 @@ require (
|
|||||||
github.com/gin-contrib/multitemplate v0.0.0-20231230012943-32b233489a81
|
github.com/gin-contrib/multitemplate v0.0.0-20231230012943-32b233489a81
|
||||||
github.com/gin-contrib/sessions v0.0.5
|
github.com/gin-contrib/sessions v0.0.5
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/itchyny/gojq v0.12.14
|
||||||
github.com/microcosm-cc/bluemonday v1.0.26
|
github.com/microcosm-cc/bluemonday v1.0.26
|
||||||
github.com/pressly/goose/v3 v3.17.0
|
github.com/pressly/goose/v3 v3.17.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
@ -37,6 +38,7 @@ require (
|
|||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.2.2 // indirect
|
github.com/gorilla/sessions v1.2.2 // indirect
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||||
|
4
go.sum
4
go.sum
@ -115,6 +115,10 @@ github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTj
|
|||||||
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||||
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
||||||
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||||
|
github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc=
|
||||||
|
github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s=
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
1
main.go
1
main.go
@ -18,6 +18,7 @@ func main() {
|
|||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
Name: "AnthoLume",
|
Name: "AnthoLume",
|
||||||
Usage: "A self hosted e-book progress tracker.",
|
Usage: "A self hosted e-book progress tracker.",
|
||||||
|
EnableBashCompletion: true,
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "serve",
|
Name: "serve",
|
||||||
|
@ -181,6 +181,11 @@
|
|||||||
class="flex justify-start w-full {{ if not (eq .RouteName "admin") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
class="flex justify-start w-full {{ if not (eq .RouteName "admin") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
||||||
<span class="mx-4 text-sm font-normal">General</span>
|
<span class="mx-4 text-sm font-normal">General</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/import"
|
||||||
|
style="padding-left: 1.75em"
|
||||||
|
class="flex justify-start w-full {{ if not (eq .RouteName "admin-import") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
||||||
|
<span class="mx-4 text-sm font-normal">Import</span>
|
||||||
|
</a>
|
||||||
<a href="/admin/users"
|
<a href="/admin/users"
|
||||||
style="padding-left: 1.75em"
|
style="padding-left: 1.75em"
|
||||||
class="flex justify-start w-full {{ if not (eq .RouteName "admin-users") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
class="flex justify-start w-full {{ if not (eq .RouteName "admin-users") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
||||||
|
76
templates/pages/admin-import.tmpl
Normal file
76
templates/pages/admin-import.tmpl
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Admin - Import{{ end }}
|
||||||
|
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Import</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
{{ if .SelectedDirectory }}
|
||||||
|
<div class="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<p class="text-lg font-semibold text-gray-500">Selected Import Directory</p>
|
||||||
|
<form class="flex gap-4 flex-col" action="./import" method="POST">
|
||||||
|
<input type="text" name="directory" value="{{ .SelectedDirectory }}" class="hidden" />
|
||||||
|
<div class="flex justify-between gap-4 w-full">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<span>{{ template "svg/import" }}</span>
|
||||||
|
<p class="font-medium text-lg break-all">{{ .SelectedDirectory }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-around gap-2 mr-4">
|
||||||
|
<div class="inline-flex gap-2 items-center">
|
||||||
|
<input checked type="radio" id="copy" name="type" value="COPY" />
|
||||||
|
<label for="copy">Copy</label>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex gap-2 items-center">
|
||||||
|
<input type="radio" id="direct" name="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>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ if not .SelectedDirectory }}
|
||||||
|
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 font-normal text-left border-b border-gray-200 dark:border-gray-800 w-12"></th>
|
||||||
|
<th class="p-3 font-normal text-left border-b border-gray-200 dark:border-gray-800 break-all">{{ .CurrentPath }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not (eq .CurrentPath "/") }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400"></td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<a href="./import?directory={{$.CurrentPath}}/../">
|
||||||
|
<p>../</p>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ if not .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="2">No Folder</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ range $item := .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400">
|
||||||
|
<a href="./import?select={{ $.CurrentPath }}/{{ $item }}">{{ template "svg/import" }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<a href="./import?directory={{ $.CurrentPath }}/{{ $item }}">
|
||||||
|
<p>{{ $item }}</p>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
@ -2,6 +2,27 @@
|
|||||||
{{ define "title" }}Admin - Logs{{ end }}
|
{{ define "title" }}Admin - Logs{{ end }}
|
||||||
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Logs</a>{{ end }}
|
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Logs</a>{{ end }}
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
<div class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<form class="flex gap-4 flex-col lg:flex-row" action="./logs" method="GET">
|
||||||
|
<div class="flex flex-col w-full grow">
|
||||||
|
<div class="flex 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/search2" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input type="text"
|
||||||
|
id="filter"
|
||||||
|
name="filter"
|
||||||
|
value="{{ .Filter }}"
|
||||||
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 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="JQ Filter" />
|
||||||
|
</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">Filter</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<div class="flex flex-col-reverse text-black dark:text-white"
|
<div class="flex flex-col-reverse text-black dark:text-white"
|
||||||
style="font-family: monospace">
|
style="font-family: monospace">
|
||||||
{{ range $log := .Data }}
|
{{ range $log := .Data }}
|
||||||
|
@ -2,50 +2,7 @@
|
|||||||
{{ define "title" }}Admin - General{{ end }}
|
{{ define "title" }}Admin - General{{ end }}
|
||||||
{{ define "header" }}<a class="whitespace-pre" href="./admin">Admin - General</a>{{ end }}
|
{{ define "header" }}<a class="whitespace-pre" href="./admin">Admin - General</a>{{ end }}
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="w-full flex flex-col md:flex-row gap-4">
|
<div class="w-full flex flex-col gap-4 grow">
|
||||||
<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">
|
<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>
|
<p class="text-lg font-semibold mb-2">Backup & Restore</p>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
@ -125,5 +82,4 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
Loading…
Reference in New Issue
Block a user