1073 lines
30 KiB
Go
1073 lines
30 KiB
Go
package v1
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"mime/multipart"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
argon2 "github.com/alexedwards/argon2id"
|
|
"github.com/itchyny/gojq"
|
|
log "github.com/sirupsen/logrus"
|
|
"reichard.io/antholume/database"
|
|
"reichard.io/antholume/metadata"
|
|
"reichard.io/antholume/utils"
|
|
)
|
|
|
|
// GET /admin
|
|
func (s *Server) GetAdmin(ctx context.Context, request GetAdminRequestObject) (GetAdminResponseObject, error) {
|
|
_, ok := s.getSessionFromContext(ctx)
|
|
if !ok {
|
|
return GetAdmin401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
}
|
|
|
|
// Get documents count using existing SQLC query
|
|
documentsSize, err := s.db.Queries.GetDocumentsSize(ctx, nil)
|
|
if err != nil {
|
|
return GetAdmin401JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
}
|
|
|
|
// For other counts, we need to aggregate across all users
|
|
// Get all users first
|
|
users, err := s.db.Queries.GetUsers(ctx)
|
|
if err != nil {
|
|
return GetAdmin401JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
}
|
|
|
|
var activitySize, progressSize, devicesSize int64
|
|
for _, user := range users {
|
|
// Get user's database info using existing SQLC query
|
|
dbInfo, err := s.db.Queries.GetDatabaseInfo(ctx, user.ID)
|
|
if err == nil {
|
|
activitySize += dbInfo.ActivitySize
|
|
progressSize += dbInfo.ProgressSize
|
|
devicesSize += dbInfo.DevicesSize
|
|
}
|
|
}
|
|
|
|
response := GetAdmin200JSONResponse{
|
|
DatabaseInfo: &DatabaseInfo{
|
|
DocumentsSize: documentsSize,
|
|
ActivitySize: activitySize,
|
|
ProgressSize: progressSize,
|
|
DevicesSize: devicesSize,
|
|
},
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
// POST /admin
|
|
func (s *Server) PostAdminAction(ctx context.Context, request PostAdminActionRequestObject) (PostAdminActionResponseObject, error) {
|
|
_, ok := s.getSessionFromContext(ctx)
|
|
if !ok {
|
|
return PostAdminAction401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
}
|
|
|
|
if request.Body == nil {
|
|
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
|
}
|
|
|
|
// Read the multipart form in a streaming way to support large files
|
|
reader := request.Body
|
|
form, err := reader.ReadForm(32 << 20) // 32MB for non-file fields (files are not stored in memory)
|
|
if err != nil {
|
|
return PostAdminAction400JSONResponse{Code: 400, Message: "Unable to parse form"}, nil
|
|
}
|
|
|
|
// Extract action from form
|
|
actionValues := form.Value["action"]
|
|
if len(actionValues) == 0 {
|
|
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing action"}, nil
|
|
}
|
|
action := actionValues[0]
|
|
|
|
// Handle different admin actions mirroring legacy appPerformAdminAction
|
|
switch action {
|
|
case "METADATA_MATCH":
|
|
// This is a TODO in the legacy code as well
|
|
go func() {
|
|
// TODO: Implement metadata matching logic
|
|
log.Info("Metadata match action triggered (not yet implemented)")
|
|
}()
|
|
return PostAdminAction200JSONResponse{
|
|
Message: "Metadata match started",
|
|
}, nil
|
|
|
|
case "CACHE_TABLES":
|
|
// Cache temp tables asynchronously, matching legacy implementation
|
|
go func() {
|
|
err := s.db.CacheTempTables(context.Background())
|
|
if err != nil {
|
|
log.Error("Unable to cache temp tables: ", err)
|
|
}
|
|
}()
|
|
return PostAdminAction200JSONResponse{
|
|
Message: "Cache tables operation started",
|
|
}, nil
|
|
|
|
case "BACKUP":
|
|
return s.handleBackupAction(ctx, request, form)
|
|
|
|
case "RESTORE":
|
|
return s.handleRestoreAction(ctx, request, form)
|
|
|
|
default:
|
|
return PostAdminAction400JSONResponse{Code: 400, Message: "Invalid action"}, nil
|
|
}
|
|
}
|
|
|
|
// handleBackupAction handles the backup action, mirroring legacy createBackup logic
|
|
func (s *Server) handleBackupAction(ctx context.Context, request PostAdminActionRequestObject, form *multipart.Form) (PostAdminActionResponseObject, error) {
|
|
// Extract backup_types from form
|
|
backupTypesValues := form.Value["backup_types"]
|
|
|
|
// Create a pipe for streaming the backup
|
|
pr, pw := io.Pipe()
|
|
|
|
go func() {
|
|
defer pw.Close()
|
|
var directories []string
|
|
for _, val := range backupTypesValues {
|
|
if val == "COVERS" {
|
|
directories = append(directories, "covers")
|
|
} else if val == "DOCUMENTS" {
|
|
directories = append(directories, "documents")
|
|
}
|
|
}
|
|
log.Info("Starting backup for directories: ", directories)
|
|
err := s.createBackup(ctx, pw, directories)
|
|
if err != nil {
|
|
log.Error("Backup failed: ", err)
|
|
} else {
|
|
log.Info("Backup completed successfully")
|
|
}
|
|
}()
|
|
|
|
// Set Content-Length to 0 to enable chunked transfer encoding
|
|
// This allows streaming with unknown file size
|
|
return PostAdminAction200ApplicationoctetStreamResponse{
|
|
Body: pr,
|
|
ContentLength: 0,
|
|
}, nil
|
|
}
|
|
|
|
// handleRestoreAction handles the restore action, mirroring legacy processRestoreFile logic
|
|
func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActionRequestObject, form *multipart.Form) (PostAdminActionResponseObject, error) {
|
|
// Get the uploaded file from form
|
|
fileHeaders := form.File["restore_file"]
|
|
if len(fileHeaders) == 0 {
|
|
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing restore file"}, nil
|
|
}
|
|
|
|
file, err := fileHeaders[0].Open()
|
|
if err != nil {
|
|
return PostAdminAction400JSONResponse{Code: 400, Message: "Unable to open restore file"}, nil
|
|
}
|
|
defer file.Close()
|
|
|
|
// Create temp file for the uploaded file
|
|
tempFile, err := os.CreateTemp("", "restore")
|
|
if err != nil {
|
|
log.Warn("Temp File Create Error: ", err)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to create temp file"}, nil
|
|
}
|
|
defer os.Remove(tempFile.Name())
|
|
defer tempFile.Close()
|
|
|
|
// Save uploaded file to temp
|
|
if _, err = io.Copy(tempFile, file); err != nil {
|
|
log.Error("Unable to save uploaded file: ", err)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to save file"}, nil
|
|
}
|
|
|
|
// Get file info and validate ZIP
|
|
fileInfo, err := tempFile.Stat()
|
|
if err != nil {
|
|
log.Error("Unable to read temp file: ", err)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to read file"}, nil
|
|
}
|
|
|
|
zipReader, err := zip.NewReader(tempFile, fileInfo.Size())
|
|
if err != nil {
|
|
log.Error("Unable to read zip: ", err)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to read zip"}, nil
|
|
}
|
|
|
|
// Validate ZIP contents (mirroring legacy logic)
|
|
hasDBFile := false
|
|
hasUnknownFile := false
|
|
for _, file := range zipReader.File {
|
|
fileName := strings.TrimPrefix(file.Name, "/")
|
|
if fileName == "antholume.db" {
|
|
hasDBFile = true
|
|
} else if !strings.HasPrefix(fileName, "covers/") && !strings.HasPrefix(fileName, "documents/") {
|
|
hasUnknownFile = true
|
|
}
|
|
}
|
|
|
|
if !hasDBFile {
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Invalid Restore ZIP - Missing DB"}, nil
|
|
} else if hasUnknownFile {
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Invalid Restore ZIP - Invalid File(s)"}, nil
|
|
}
|
|
|
|
// Create backup before restoring (mirroring legacy logic)
|
|
log.Info("Creating backup before restore...")
|
|
backupFilePath := filepath.Join(s.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)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to create backup file"}, nil
|
|
}
|
|
defer backupFile.Close()
|
|
|
|
w := bufio.NewWriter(backupFile)
|
|
err = s.createBackup(ctx, w, []string{"covers", "documents"})
|
|
if err != nil {
|
|
log.Error("Unable to save backup file: ", err)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to save backup file"}, nil
|
|
}
|
|
|
|
// Remove data (mirroring legacy removeData)
|
|
log.Info("Removing data...")
|
|
err = s.removeData()
|
|
if err != nil {
|
|
log.Error("Unable to delete data: ", err)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to delete data"}, nil
|
|
}
|
|
|
|
// Restore data (mirroring legacy restoreData)
|
|
log.Info("Restoring data...")
|
|
err = s.restoreData(zipReader)
|
|
if err != nil {
|
|
log.Error("Unable to restore data: ", err)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to restore data"}, nil
|
|
}
|
|
|
|
// Reload DB (mirroring legacy Reload)
|
|
log.Info("Reloading database...")
|
|
if err := s.db.Reload(ctx); err != nil {
|
|
log.Error("Unable to reload DB: ", err)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to reload DB"}, nil
|
|
}
|
|
|
|
// Rotate auth hashes (mirroring legacy rotateAllAuthHashes)
|
|
log.Info("Rotating auth hashes...")
|
|
if err := s.rotateAllAuthHashes(ctx); err != nil {
|
|
log.Error("Unable to rotate hashes: ", err)
|
|
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to rotate hashes"}, nil
|
|
}
|
|
|
|
log.Info("Restore completed successfully")
|
|
return PostAdminAction200JSONResponse{
|
|
Message: "Restore completed successfully",
|
|
}, nil
|
|
}
|
|
|
|
// createBackup creates a backup ZIP archive, mirroring legacy createBackup
|
|
func (s *Server) createBackup(ctx context.Context, w io.Writer, directories []string) error {
|
|
// Vacuum DB
|
|
_, err := s.db.DB.ExecContext(ctx, "VACUUM;")
|
|
if err != nil {
|
|
return fmt.Errorf("Unable to vacuum database: %w", err)
|
|
}
|
|
|
|
ar := zip.NewWriter(w)
|
|
|
|
// Helper function to walk and archive files
|
|
exportWalker := func(currentPath string, f fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
file, err := os.Open(currentPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
fileName := filepath.Base(currentPath)
|
|
folderName := filepath.Base(filepath.Dir(currentPath))
|
|
|
|
newF, err := ar.Create(filepath.Join(folderName, fileName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = io.Copy(newF, file)
|
|
return err
|
|
}
|
|
|
|
// Copy Database File (mirroring legacy logic)
|
|
fileName := fmt.Sprintf("%s.db", s.cfg.DBName)
|
|
dbLocation := filepath.Join(s.cfg.ConfigPath, fileName)
|
|
|
|
dbFile, err := os.Open(dbLocation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dbFile.Close()
|
|
|
|
newDbFile, err := ar.Create(fileName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = io.Copy(newDbFile, dbFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Backup Covers & Documents (mirroring legacy logic)
|
|
for _, dir := range directories {
|
|
err = filepath.WalkDir(filepath.Join(s.cfg.DataPath, dir), exportWalker)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Close writer to flush all data before returning
|
|
ar.Close()
|
|
return nil
|
|
}
|
|
|
|
// removeData removes all data files, mirroring legacy removeData
|
|
func (s *Server) removeData() error {
|
|
allPaths := []string{
|
|
"covers",
|
|
"documents",
|
|
"antholume.db",
|
|
"antholume.db-wal",
|
|
"antholume.db-shm",
|
|
}
|
|
|
|
for _, name := range allPaths {
|
|
fullPath := filepath.Join(s.cfg.DataPath, name)
|
|
err := os.RemoveAll(fullPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// restoreData restores data from ZIP archive, mirroring legacy restoreData
|
|
func (s *Server) restoreData(zipReader *zip.Reader) error {
|
|
// Ensure Directories
|
|
s.cfg.EnsureDirectories()
|
|
|
|
// Restore Data
|
|
for _, file := range zipReader.File {
|
|
rc, err := file.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
|
|
destPath := filepath.Join(s.cfg.DataPath, file.Name)
|
|
destFile, err := os.Create(destPath)
|
|
if err != nil {
|
|
log.Errorf("error creating destination file: %v", 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 {
|
|
log.Errorf("Error copying file contents: %v", err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// rotateAllAuthHashes rotates all user auth hashes, mirroring legacy rotateAllAuthHashes
|
|
func (s *Server) rotateAllAuthHashes(ctx context.Context) error {
|
|
users, err := s.db.Queries.GetUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, user := range users {
|
|
rawAuthHash, err := utils.GenerateToken(64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
authHash := fmt.Sprintf("%x", rawAuthHash)
|
|
|
|
_, err = s.db.Queries.UpdateUser(ctx, database.UpdateUserParams{
|
|
UserID: user.ID,
|
|
AuthHash: &authHash,
|
|
Admin: user.Admin,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GET /admin/users
|
|
func (s *Server) GetUsers(ctx context.Context, request GetUsersRequestObject) (GetUsersResponseObject, error) {
|
|
_, ok := s.getSessionFromContext(ctx)
|
|
if !ok {
|
|
return GetUsers401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
}
|
|
|
|
// Get users from database
|
|
users, err := s.db.Queries.GetUsers(ctx)
|
|
if err != nil {
|
|
return GetUsers500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
}
|
|
|
|
apiUsers := make([]User, len(users))
|
|
for i, user := range users {
|
|
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
|
|
apiUsers[i] = User{
|
|
Id: user.ID,
|
|
Admin: user.Admin,
|
|
CreatedAt: createdAt,
|
|
}
|
|
}
|
|
|
|
response := GetUsers200JSONResponse{
|
|
Users: &apiUsers,
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
// POST /admin/users
|
|
func (s *Server) UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) {
|
|
_, ok := s.getSessionFromContext(ctx)
|
|
if !ok {
|
|
return UpdateUser401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
}
|
|
|
|
if request.Body == nil {
|
|
return UpdateUser400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
|
}
|
|
|
|
// Ensure Username (mirroring legacy validation)
|
|
if request.Body.User == "" {
|
|
return UpdateUser400JSONResponse{Code: 400, Message: "User cannot be empty"}, nil
|
|
}
|
|
|
|
var err error
|
|
// Handle different operations mirroring legacy appUpdateAdminUsers
|
|
switch request.Body.Operation {
|
|
case "CREATE":
|
|
err = s.createUser(ctx, request.Body.User, request.Body.Password, request.Body.IsAdmin)
|
|
case "UPDATE":
|
|
err = s.updateUser(ctx, request.Body.User, request.Body.Password, request.Body.IsAdmin)
|
|
case "DELETE":
|
|
err = s.deleteUser(ctx, request.Body.User)
|
|
default:
|
|
return UpdateUser400JSONResponse{Code: 400, Message: "Unknown user operation"}, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return UpdateUser500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
}
|
|
|
|
// Get updated users list (mirroring legacy appGetAdminUsers)
|
|
users, err := s.db.Queries.GetUsers(ctx)
|
|
if err != nil {
|
|
return UpdateUser500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
}
|
|
|
|
apiUsers := make([]User, len(users))
|
|
for i, user := range users {
|
|
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
|
|
apiUsers[i] = User{
|
|
Id: user.ID,
|
|
Admin: user.Admin,
|
|
CreatedAt: createdAt,
|
|
}
|
|
}
|
|
|
|
return UpdateUser200JSONResponse{
|
|
Users: &apiUsers,
|
|
}, nil
|
|
}
|
|
|
|
// createUser creates a new user, mirroring legacy createUser
|
|
func (s *Server) createUser(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
|
|
// Validate Necessary Parameters (mirroring legacy)
|
|
if rawPassword == nil || *rawPassword == "" {
|
|
return fmt.Errorf("password can't be empty")
|
|
}
|
|
|
|
// Base Params
|
|
createParams := database.CreateUserParams{
|
|
ID: user,
|
|
}
|
|
|
|
// Handle Admin (Explicit or False)
|
|
if isAdmin != nil {
|
|
createParams.Admin = *isAdmin
|
|
} else {
|
|
createParams.Admin = false
|
|
}
|
|
|
|
// Parse Password (mirroring legacy)
|
|
password := fmt.Sprintf("%x", md5.Sum([]byte(*rawPassword)))
|
|
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create hashed password")
|
|
}
|
|
createParams.Pass = &hashedPassword
|
|
|
|
// Generate Auth Hash (mirroring legacy)
|
|
rawAuthHash, err := utils.GenerateToken(64)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create token for user")
|
|
}
|
|
authHash := fmt.Sprintf("%x", rawAuthHash)
|
|
createParams.AuthHash = &authHash
|
|
|
|
// Create user in DB (mirroring legacy)
|
|
if rows, err := s.db.Queries.CreateUser(ctx, createParams); err != nil {
|
|
return fmt.Errorf("unable to create user")
|
|
} else if rows == 0 {
|
|
return fmt.Errorf("user already exists")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// updateUser updates an existing user, mirroring legacy updateUser
|
|
func (s *Server) updateUser(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
|
|
// Validate Necessary Parameters (mirroring legacy)
|
|
if rawPassword == nil && isAdmin == nil {
|
|
return fmt.Errorf("nothing to update")
|
|
}
|
|
|
|
// Base Params
|
|
updateParams := database.UpdateUserParams{
|
|
UserID: user,
|
|
}
|
|
|
|
// Handle Admin (Update or Existing)
|
|
if isAdmin != nil {
|
|
updateParams.Admin = *isAdmin
|
|
} else {
|
|
userData, err := s.db.Queries.GetUser(ctx, user)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to get user")
|
|
}
|
|
updateParams.Admin = userData.Admin
|
|
}
|
|
|
|
// Check Admins - Disallow Demotion (mirroring legacy isLastAdmin)
|
|
if isLast, err := s.isLastAdmin(ctx, user); err != nil {
|
|
return err
|
|
} else if isLast && !updateParams.Admin {
|
|
return fmt.Errorf("unable to demote %s - last admin", user)
|
|
}
|
|
|
|
// Handle Password (mirroring legacy)
|
|
if rawPassword != nil {
|
|
if *rawPassword == "" {
|
|
return fmt.Errorf("password can't be empty")
|
|
}
|
|
|
|
// Parse Password
|
|
password := fmt.Sprintf("%x", md5.Sum([]byte(*rawPassword)))
|
|
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create hashed password")
|
|
}
|
|
updateParams.Password = &hashedPassword
|
|
|
|
// Generate Auth Hash
|
|
rawAuthHash, err := utils.GenerateToken(64)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create token for user")
|
|
}
|
|
authHash := fmt.Sprintf("%x", rawAuthHash)
|
|
updateParams.AuthHash = &authHash
|
|
}
|
|
|
|
// Update User (mirroring legacy)
|
|
_, err := s.db.Queries.UpdateUser(ctx, updateParams)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to update user")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// deleteUser deletes a user, mirroring legacy deleteUser
|
|
func (s *Server) deleteUser(ctx context.Context, user string) error {
|
|
// Check Admins (mirroring legacy isLastAdmin)
|
|
if isLast, err := s.isLastAdmin(ctx, user); err != nil {
|
|
return err
|
|
} else if isLast {
|
|
return fmt.Errorf("unable to delete %s - last admin", user)
|
|
}
|
|
|
|
// Create Backup File (mirroring legacy)
|
|
backupFilePath := filepath.Join(s.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
|
|
backupFile, err := os.Create(backupFilePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer backupFile.Close()
|
|
|
|
// Save Backup File (DB Only) (mirroring legacy)
|
|
w := bufio.NewWriter(backupFile)
|
|
err = s.createBackup(ctx, w, []string{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Delete User (mirroring legacy)
|
|
_, err = s.db.Queries.DeleteUser(ctx, user)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to delete user")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isLastAdmin checks if the user is the last admin, mirroring legacy isLastAdmin
|
|
func (s *Server) isLastAdmin(ctx context.Context, userID string) (bool, error) {
|
|
allUsers, err := s.db.Queries.GetUsers(ctx)
|
|
if err != nil {
|
|
return false, fmt.Errorf("unable to get users")
|
|
}
|
|
|
|
hasAdmin := false
|
|
for _, user := range allUsers {
|
|
if user.Admin && user.ID != userID {
|
|
hasAdmin = true
|
|
break
|
|
}
|
|
}
|
|
|
|
return !hasAdmin, nil
|
|
}
|
|
|
|
// GET /admin/import
|
|
func (s *Server) GetImportDirectory(ctx context.Context, request GetImportDirectoryRequestObject) (GetImportDirectoryResponseObject, error) {
|
|
_, ok := s.getSessionFromContext(ctx)
|
|
if !ok {
|
|
return GetImportDirectory401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
}
|
|
|
|
// Handle select parameter - mirroring legacy appGetAdminImport
|
|
if request.Params.Select != nil && *request.Params.Select != "" {
|
|
return GetImportDirectory200JSONResponse{
|
|
CurrentPath: request.Params.Select,
|
|
Items: &[]DirectoryItem{},
|
|
}, nil
|
|
}
|
|
|
|
// Default Path (mirroring legacy logic)
|
|
directory := ""
|
|
if request.Params.Directory != nil && *request.Params.Directory != "" {
|
|
directory = *request.Params.Directory
|
|
} else {
|
|
dPath, err := filepath.Abs(s.cfg.DataPath)
|
|
if err != nil {
|
|
return GetImportDirectory500JSONResponse{Code: 500, Message: "Unable to get data directory absolute path"}, nil
|
|
}
|
|
directory = dPath
|
|
}
|
|
|
|
// Read directory entries (mirroring legacy)
|
|
entries, err := os.ReadDir(directory)
|
|
if err != nil {
|
|
return GetImportDirectory500JSONResponse{Code: 500, Message: "Invalid directory"}, nil
|
|
}
|
|
|
|
allDirectories := []DirectoryItem{}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
|
|
name := e.Name()
|
|
path := filepath.Join(directory, name)
|
|
allDirectories = append(allDirectories, DirectoryItem{
|
|
Name: &name,
|
|
Path: &path,
|
|
})
|
|
}
|
|
|
|
cleanPath := filepath.Clean(directory)
|
|
|
|
return GetImportDirectory200JSONResponse{
|
|
CurrentPath: &cleanPath,
|
|
Items: &allDirectories,
|
|
}, nil
|
|
}
|
|
|
|
// POST /admin/import
|
|
func (s *Server) PostImport(ctx context.Context, request PostImportRequestObject) (PostImportResponseObject, error) {
|
|
_, ok := s.getSessionFromContext(ctx)
|
|
if !ok {
|
|
return PostImport401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
}
|
|
|
|
if request.Body == nil {
|
|
return PostImport400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
|
}
|
|
|
|
// Get import directory (mirroring legacy)
|
|
importDirectory := filepath.Clean(request.Body.Directory)
|
|
|
|
// Get data directory (mirroring legacy)
|
|
absoluteDataPath, _ := filepath.Abs(filepath.Join(s.cfg.DataPath, "documents"))
|
|
|
|
// Validate different path (mirroring legacy)
|
|
if absoluteDataPath == importDirectory {
|
|
return PostImport400JSONResponse{Code: 400, Message: "Directory is the same as data path"}, nil
|
|
}
|
|
|
|
// Do Transaction (mirroring legacy)
|
|
tx, err := s.db.DB.Begin()
|
|
if err != nil {
|
|
return PostImport500JSONResponse{Code: 500, Message: "Unknown error"}, nil
|
|
}
|
|
|
|
// Defer & Start Transaction (mirroring legacy)
|
|
defer func() {
|
|
if err := tx.Rollback(); err != nil {
|
|
log.Error("DB Rollback Error:", err)
|
|
}
|
|
}()
|
|
qtx := s.db.Queries.WithTx(tx)
|
|
|
|
// Track imports (mirroring legacy)
|
|
importResults := make([]ImportResult, 0)
|
|
|
|
// Walk Directory & Import (mirroring legacy)
|
|
err = filepath.WalkDir(importDirectory, func(importPath string, f fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if f.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
// Get relative path (mirroring legacy)
|
|
basePath := importDirectory
|
|
relFilePath, err := filepath.Rel(importDirectory, importPath)
|
|
if err != nil {
|
|
log.Warnf("path error: %v", err)
|
|
return nil
|
|
}
|
|
|
|
// Track imports (mirroring legacy)
|
|
iResult := ImportResult{
|
|
Path: &relFilePath,
|
|
}
|
|
defer func() {
|
|
importResults = append(importResults, iResult)
|
|
}()
|
|
|
|
// Get metadata (mirroring legacy)
|
|
fileMeta, err := metadata.GetMetadata(importPath)
|
|
if err != nil {
|
|
log.Errorf("metadata error: %v", err)
|
|
errMsg := err.Error()
|
|
iResult.Error = &errMsg
|
|
status := ImportResultStatus("FAILED")
|
|
iResult.Status = &status
|
|
return nil
|
|
}
|
|
iResult.Id = fileMeta.PartialMD5
|
|
name := fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title)
|
|
iResult.Name = &name
|
|
|
|
// Check already exists (mirroring legacy)
|
|
_, err = qtx.GetDocument(ctx, *fileMeta.PartialMD5)
|
|
if err == nil {
|
|
log.Warnf("document already exists: %s", *fileMeta.PartialMD5)
|
|
status := ImportResultStatus("EXISTS")
|
|
iResult.Status = &status
|
|
return nil
|
|
}
|
|
|
|
// Import Copy (mirroring legacy)
|
|
if request.Body.Type == "COPY" {
|
|
// Derive & Sanitize File Name (mirroring legacy deriveBaseFileName)
|
|
relFilePath = s.deriveBaseFileName(fileMeta)
|
|
safePath := filepath.Join(s.cfg.DataPath, "documents", relFilePath)
|
|
|
|
// Open Source File
|
|
srcFile, err := os.Open(importPath)
|
|
if err != nil {
|
|
log.Errorf("unable to open current file: %v", err)
|
|
errMsg := err.Error()
|
|
iResult.Error = &errMsg
|
|
return nil
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
// Open Destination File
|
|
destFile, err := os.Create(safePath)
|
|
if err != nil {
|
|
log.Errorf("unable to open destination file: %v", err)
|
|
errMsg := err.Error()
|
|
iResult.Error = &errMsg
|
|
return nil
|
|
}
|
|
defer destFile.Close()
|
|
|
|
// Copy File
|
|
if _, err = io.Copy(destFile, srcFile); err != nil {
|
|
log.Errorf("unable to save file: %v", err)
|
|
errMsg := err.Error()
|
|
iResult.Error = &errMsg
|
|
return nil
|
|
}
|
|
|
|
// Update Base & Path
|
|
basePath = filepath.Join(s.cfg.DataPath, "documents")
|
|
iResult.Path = &relFilePath
|
|
}
|
|
|
|
// Upsert document (mirroring legacy)
|
|
if _, err = qtx.UpsertDocument(ctx, database.UpsertDocumentParams{
|
|
ID: *fileMeta.PartialMD5,
|
|
Title: fileMeta.Title,
|
|
Author: fileMeta.Author,
|
|
Description: fileMeta.Description,
|
|
Md5: fileMeta.MD5,
|
|
Words: fileMeta.WordCount,
|
|
Filepath: &relFilePath,
|
|
Basepath: &basePath,
|
|
}); err != nil {
|
|
log.Errorf("UpsertDocument DB Error: %v", err)
|
|
errMsg := err.Error()
|
|
iResult.Error = &errMsg
|
|
return nil
|
|
}
|
|
|
|
status := ImportResultStatus("SUCCESS")
|
|
iResult.Status = &status
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return PostImport500JSONResponse{Code: 500, Message: fmt.Sprintf("Import Failed: %v", err)}, nil
|
|
}
|
|
|
|
// Commit transaction (mirroring legacy)
|
|
if err := tx.Commit(); err != nil {
|
|
log.Error("Transaction Commit DB Error: ", err)
|
|
return PostImport500JSONResponse{Code: 500, Message: fmt.Sprintf("Import DB Error: %v", err)}, nil
|
|
}
|
|
|
|
// Sort import results (mirroring legacy importStatusPriority)
|
|
sort.Slice(importResults, func(i int, j int) bool {
|
|
return s.importStatusPriority(*importResults[i].Status) <
|
|
s.importStatusPriority(*importResults[j].Status)
|
|
})
|
|
|
|
return PostImport200JSONResponse{
|
|
Results: &importResults,
|
|
}, nil
|
|
}
|
|
|
|
// importStatusPriority returns the order priority for import status, mirroring legacy
|
|
func (s *Server) importStatusPriority(status ImportResultStatus) int {
|
|
switch status {
|
|
case "FAILED":
|
|
return 1
|
|
case "EXISTS":
|
|
return 2
|
|
default:
|
|
return 3
|
|
}
|
|
}
|
|
|
|
// deriveBaseFileName builds the base filename for a given MetadataInfo object, mirroring legacy deriveBaseFileName
|
|
func (s *Server) deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
|
var newFileName string
|
|
if *metadataInfo.Author != "" {
|
|
newFileName = newFileName + *metadataInfo.Author
|
|
} else {
|
|
newFileName = newFileName + "Unknown"
|
|
}
|
|
if *metadataInfo.Title != "" {
|
|
newFileName = newFileName + " - " + *metadataInfo.Title
|
|
} else {
|
|
newFileName = newFileName + " - Unknown"
|
|
}
|
|
|
|
// Remove Slashes (mirroring legacy)
|
|
fileName := strings.ReplaceAll(newFileName, "/", "")
|
|
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
|
|
}
|
|
|
|
// GET /admin/import-results
|
|
func (s *Server) GetImportResults(ctx context.Context, request GetImportResultsRequestObject) (GetImportResultsResponseObject, error) {
|
|
_, ok := s.getSessionFromContext(ctx)
|
|
if !ok {
|
|
return GetImportResults401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
}
|
|
|
|
// Note: In the legacy implementation, import results are returned directly
|
|
// after import. This endpoint could be enhanced to store results in
|
|
// session or memory for later retrieval. For now, return empty results.
|
|
return GetImportResults200JSONResponse{
|
|
Results: &[]ImportResult{},
|
|
}, nil
|
|
}
|
|
|
|
// GET /admin/logs
|
|
func (s *Server) GetLogs(ctx context.Context, request GetLogsRequestObject) (GetLogsResponseObject, error) {
|
|
_, ok := s.getSessionFromContext(ctx)
|
|
if !ok {
|
|
return GetLogs401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
}
|
|
|
|
page := int64(1)
|
|
if request.Params.Page != nil && *request.Params.Page > 0 {
|
|
page = *request.Params.Page
|
|
}
|
|
|
|
limit := int64(100)
|
|
if request.Params.Limit != nil && *request.Params.Limit > 0 {
|
|
limit = *request.Params.Limit
|
|
}
|
|
|
|
filter := ""
|
|
if request.Params.Filter != nil {
|
|
filter = strings.TrimSpace(*request.Params.Filter)
|
|
}
|
|
|
|
var jqFilter *gojq.Code
|
|
var basicFilter string
|
|
|
|
// Parse JQ or basic filter (mirroring legacy)
|
|
if strings.HasPrefix(filter, "\"") && strings.HasSuffix(filter, "\"") {
|
|
basicFilter = filter[1 : len(filter)-1]
|
|
} else if filter != "" {
|
|
parsed, err := gojq.Parse(filter)
|
|
if err != nil {
|
|
log.Error("Unable to parse JQ filter")
|
|
return GetLogs500JSONResponse{Code: 500, Message: "Unable to parse JQ filter"}, nil
|
|
}
|
|
|
|
jqFilter, err = gojq.Compile(parsed)
|
|
if err != nil {
|
|
log.Error("Unable to compile JQ filter")
|
|
return GetLogs500JSONResponse{Code: 500, Message: "Unable to compile JQ filter"}, nil
|
|
}
|
|
}
|
|
|
|
logPath := filepath.Join(s.cfg.ConfigPath, "logs/antholume.log")
|
|
logFile, err := os.Open(logPath)
|
|
if err != nil {
|
|
return GetLogs500JSONResponse{Code: 500, Message: "Missing AnthoLume log file"}, nil
|
|
}
|
|
defer logFile.Close()
|
|
|
|
offset := (page - 1) * limit
|
|
logLines := make([]string, 0, limit)
|
|
matchedCount := int64(0)
|
|
|
|
scanner := bufio.NewScanner(logFile)
|
|
for scanner.Scan() {
|
|
formattedLog, matched := formatLogLine(scanner.Text(), basicFilter, jqFilter)
|
|
if !matched {
|
|
continue
|
|
}
|
|
|
|
if matchedCount >= offset && int64(len(logLines)) < limit {
|
|
logLines = append(logLines, formattedLog)
|
|
}
|
|
matchedCount++
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return GetLogs500JSONResponse{Code: 500, Message: "Unable to read AnthoLume log file"}, nil
|
|
}
|
|
|
|
var nextPage *int64
|
|
var previousPage *int64
|
|
if page > 1 {
|
|
previousPage = ptrOf(page - 1)
|
|
}
|
|
if offset+int64(len(logLines)) < matchedCount {
|
|
nextPage = ptrOf(page + 1)
|
|
}
|
|
|
|
return GetLogs200JSONResponse{
|
|
Logs: &logLines,
|
|
Filter: &filter,
|
|
Page: &page,
|
|
Limit: &limit,
|
|
NextPage: nextPage,
|
|
PreviousPage: previousPage,
|
|
Total: &matchedCount,
|
|
}, nil
|
|
}
|
|
|
|
func formatLogLine(rawLog string, basicFilter string, jqFilter *gojq.Code) (string, bool) {
|
|
var jsonMap map[string]any
|
|
if err := json.Unmarshal([]byte(rawLog), &jsonMap); err != nil {
|
|
if basicFilter == "" && jqFilter == nil {
|
|
return rawLog, true
|
|
}
|
|
if basicFilter != "" && strings.Contains(rawLog, basicFilter) {
|
|
return rawLog, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
rawData, err := json.MarshalIndent(jsonMap, "", " ")
|
|
if err != nil {
|
|
if basicFilter == "" && jqFilter == nil {
|
|
return rawLog, true
|
|
}
|
|
if basicFilter != "" && strings.Contains(rawLog, basicFilter) {
|
|
return rawLog, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
formattedLog := string(rawData)
|
|
if basicFilter != "" {
|
|
return formattedLog, strings.Contains(formattedLog, basicFilter)
|
|
}
|
|
if jqFilter == nil {
|
|
return formattedLog, true
|
|
}
|
|
|
|
result, _ := jqFilter.Run(jsonMap).Next()
|
|
if _, ok := result.(error); ok {
|
|
return formattedLog, true
|
|
}
|
|
if result == nil {
|
|
return "", false
|
|
}
|
|
|
|
filteredData, err := json.MarshalIndent(result, "", " ")
|
|
if err == nil {
|
|
formattedLog = string(filteredData)
|
|
}
|
|
|
|
return formattedLog, true
|
|
}
|