All checks were successful
Automated Container Build / build-and-push (push) Successful in 3m25s
1307 lines
37 KiB
Go
1307 lines
37 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.elijahkuntz.com/Elijah/drive/config"
|
|
"git.elijahkuntz.com/Elijah/drive/database"
|
|
"git.elijahkuntz.com/Elijah/drive/workers"
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/cespare/xxhash/v2"
|
|
)
|
|
|
|
type FSHandler struct {
|
|
DB *database.DB
|
|
Config *config.Config
|
|
}
|
|
|
|
type FileInfo struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
IsDir bool `json:"is_dir"`
|
|
Size int64 `json:"size"`
|
|
MimeType string `json:"mime_type"`
|
|
IsPinned bool `json:"is_pinned"`
|
|
IsTrashed bool `json:"is_trashed"`
|
|
ModTime time.Time `json:"mod_time"`
|
|
Checksum string `json:"checksum,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type FolderNode struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Children []*FolderNode `json:"children"`
|
|
}
|
|
|
|
// ListDirectory returns the contents of a directory.
|
|
// GET /api/files/*
|
|
func (h *FSHandler) ListDirectory(c *fiber.Ctx) error {
|
|
resolvedPath := c.Locals("resolvedPath").(string)
|
|
|
|
info, err := os.Stat(resolvedPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "path not found"})
|
|
}
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to stat path"})
|
|
}
|
|
|
|
// If it's a file, return its info directly
|
|
if !info.IsDir() {
|
|
return h.getFileInfo(c, resolvedPath)
|
|
}
|
|
|
|
entries, err := os.ReadDir(resolvedPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to read directory"})
|
|
}
|
|
|
|
relativePath := c.Locals("relativePath").(string)
|
|
files := make([]FileInfo, 0, len(entries))
|
|
|
|
for _, entry := range entries {
|
|
// Skip hidden system directories
|
|
if entry.Name() == ".versions" || entry.Name() == ".trash" || entry.Name() == ".backups" {
|
|
continue
|
|
}
|
|
|
|
entryInfo, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
entryPath := filepath.Join(relativePath, entry.Name())
|
|
mimeType := ""
|
|
if !entry.IsDir() {
|
|
mimeType = mime.TypeByExtension(filepath.Ext(entry.Name()))
|
|
}
|
|
|
|
fi := FileInfo{
|
|
Name: entry.Name(),
|
|
Path: filepath.ToSlash(entryPath),
|
|
IsDir: entry.IsDir(),
|
|
Size: entryInfo.Size(),
|
|
MimeType: mimeType,
|
|
ModTime: entryInfo.ModTime(),
|
|
}
|
|
|
|
// Enrich with DB metadata (pinned status, etc.)
|
|
var isPinned, isTrashed int
|
|
var checksum sql.NullString
|
|
err = h.DB.QueryRow(
|
|
"SELECT is_pinned, is_trashed, checksum FROM files WHERE path = ?",
|
|
filepath.ToSlash(entryPath),
|
|
).Scan(&isPinned, &isTrashed, &checksum)
|
|
if err == nil {
|
|
fi.IsPinned = isPinned == 1
|
|
fi.IsTrashed = isTrashed == 1
|
|
if checksum.Valid {
|
|
fi.Checksum = checksum.String
|
|
}
|
|
}
|
|
|
|
// Don't show trashed items in normal listings
|
|
if fi.IsTrashed {
|
|
continue
|
|
}
|
|
|
|
files = append(files, fi)
|
|
}
|
|
|
|
// Sort: directories first, then alphabetically
|
|
sort.Slice(files, func(i, j int) bool {
|
|
if files[i].IsDir != files[j].IsDir {
|
|
return files[i].IsDir
|
|
}
|
|
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
|
|
})
|
|
|
|
return c.JSON(fiber.Map{
|
|
"path": filepath.ToSlash(relativePath),
|
|
"files": files,
|
|
"count": len(files),
|
|
})
|
|
}
|
|
|
|
// GetFolderTree returns a hierarchical tree of all folders.
|
|
// GET /api/files/folders-tree
|
|
func (h *FSHandler) GetFolderTree(c *fiber.Ctx) error {
|
|
rootPath := h.Config.StorageDir
|
|
|
|
root := &FolderNode{
|
|
Name: "Home",
|
|
Path: ".",
|
|
Children: make([]*FolderNode, 0),
|
|
}
|
|
|
|
nodeMap := make(map[string]*FolderNode)
|
|
nodeMap["."] = root
|
|
|
|
filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if !d.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
relPath, err := filepath.Rel(rootPath, path)
|
|
if err != nil || relPath == "." || relPath == "" {
|
|
return nil
|
|
}
|
|
|
|
base := filepath.Base(relPath)
|
|
if strings.HasPrefix(base, ".") {
|
|
return filepath.SkipDir
|
|
}
|
|
|
|
cleanPath := filepath.ToSlash(relPath)
|
|
parentPath := filepath.ToSlash(filepath.Dir(relPath))
|
|
|
|
node := &FolderNode{
|
|
Name: d.Name(),
|
|
Path: cleanPath,
|
|
Children: make([]*FolderNode, 0),
|
|
}
|
|
nodeMap[cleanPath] = node
|
|
|
|
if parentNode, exists := nodeMap[parentPath]; exists {
|
|
parentNode.Children = append(parentNode.Children, node)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
var sortNode func(node *FolderNode)
|
|
sortNode = func(node *FolderNode) {
|
|
sort.Slice(node.Children, func(i, j int) bool {
|
|
return strings.ToLower(node.Children[i].Name) < strings.ToLower(node.Children[j].Name)
|
|
})
|
|
for _, child := range node.Children {
|
|
sortNode(child)
|
|
}
|
|
}
|
|
sortNode(root)
|
|
|
|
return c.JSON(root)
|
|
}
|
|
|
|
func (h *FSHandler) getFileInfo(c *fiber.Ctx, absPath string) error {
|
|
info, err := os.Stat(absPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
|
}
|
|
|
|
relativePath := c.Locals("relativePath").(string)
|
|
|
|
fi := FileInfo{
|
|
Name: info.Name(),
|
|
Path: filepath.ToSlash(relativePath),
|
|
IsDir: info.IsDir(),
|
|
Size: info.Size(),
|
|
MimeType: mime.TypeByExtension(filepath.Ext(info.Name())),
|
|
ModTime: info.ModTime(),
|
|
}
|
|
|
|
var isPinned int
|
|
var checksum sql.NullString
|
|
err = h.DB.QueryRow(
|
|
"SELECT is_pinned, checksum FROM files WHERE path = ?",
|
|
filepath.ToSlash(relativePath),
|
|
).Scan(&isPinned, &checksum)
|
|
if err == nil {
|
|
fi.IsPinned = isPinned == 1
|
|
if checksum.Valid {
|
|
fi.Checksum = checksum.String
|
|
}
|
|
}
|
|
|
|
return c.JSON(fi)
|
|
}
|
|
|
|
// GetExtendedFileInfo gets recursive size and file count for info modal
|
|
// GET /api/files/info/*
|
|
func (h *FSHandler) GetExtendedFileInfo(c *fiber.Ctx) error {
|
|
resolvedPath := c.Locals("resolvedPath").(string)
|
|
relativePath := c.Locals("relativePath").(string)
|
|
|
|
info, err := os.Stat(resolvedPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
|
}
|
|
|
|
fi := FileInfo{
|
|
Name: info.Name(),
|
|
Path: filepath.ToSlash(relativePath),
|
|
IsDir: info.IsDir(),
|
|
Size: info.Size(),
|
|
MimeType: mime.TypeByExtension(filepath.Ext(info.Name())),
|
|
ModTime: info.ModTime(),
|
|
}
|
|
|
|
var isPinned int
|
|
var isTrashed int
|
|
var checksum sql.NullString
|
|
var createdAt string
|
|
err = h.DB.QueryRow(
|
|
"SELECT is_pinned, is_trashed, checksum, created_at FROM files WHERE path = ?",
|
|
filepath.ToSlash(relativePath),
|
|
).Scan(&isPinned, &isTrashed, &checksum, &createdAt)
|
|
|
|
if err == nil {
|
|
fi.IsPinned = isPinned == 1
|
|
fi.IsTrashed = isTrashed == 1
|
|
if checksum.Valid {
|
|
fi.Checksum = checksum.String
|
|
}
|
|
}
|
|
|
|
fileCount := 0
|
|
if fi.IsDir {
|
|
var totalSize int64 = 0
|
|
filepath.Walk(resolvedPath, func(p string, i os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if !i.IsDir() {
|
|
totalSize += i.Size()
|
|
fileCount++
|
|
}
|
|
return nil
|
|
})
|
|
fi.Size = totalSize
|
|
return c.JSON(fiber.Map{
|
|
"info": fi,
|
|
"file_count": fileCount,
|
|
"created_at": createdAt,
|
|
})
|
|
}
|
|
|
|
// For files, attempt to get media metadata
|
|
cat := categorizeFile(filepath.Ext(info.Name()))
|
|
if cat == "images" || cat == "videos" || cat == "audio" {
|
|
ext := strings.ToLower(filepath.Ext(info.Name()))
|
|
validExts := map[string]bool{
|
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
|
".mp4": true, ".mov": true, ".webm": true, ".mkv": true,
|
|
".mp3": true, ".wav": true, ".flac": true, ".m4a": true,
|
|
}
|
|
if validExts[ext] {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "exiftool", "-json", "--", resolvedPath)
|
|
output, err := cmd.Output()
|
|
if err == nil {
|
|
var meta []map[string]interface{}
|
|
if err := json.Unmarshal(output, &meta); err == nil && len(meta) > 0 {
|
|
fi.Metadata = meta[0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"info": fi,
|
|
"file_count": fileCount,
|
|
"created_at": createdAt,
|
|
})
|
|
}
|
|
|
|
// CreateFolder creates a new directory.
|
|
// POST /api/files/mkdir
|
|
func (h *FSHandler) CreateFolder(c *fiber.Ctx) error {
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
if body.Path == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "path is required"})
|
|
}
|
|
|
|
parentDir := filepath.Dir(body.Path)
|
|
folderName := filepath.Base(body.Path)
|
|
|
|
parentFullPath, err := resolveSafe(h.Config.StorageDir, parentDir)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
uniqueName := GetUniquePath(parentFullPath, folderName)
|
|
fullPath := filepath.Join(parentFullPath, uniqueName)
|
|
cleanPath := filepath.ToSlash(filepath.Join(parentDir, uniqueName))
|
|
|
|
// Remove any prefix like "." if cleanPath was "." and it got joined.
|
|
// filepath.ToSlash cleans the path.
|
|
if cleanPath == "." {
|
|
// Just for safety, but base shouldn't be empty
|
|
}
|
|
|
|
if err := os.MkdirAll(fullPath, 0755); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create folder"})
|
|
}
|
|
|
|
// Record in DB
|
|
h.DB.Exec(
|
|
"INSERT OR IGNORE INTO files (path, name, is_dir) VALUES (?, ?, 1)",
|
|
cleanPath, uniqueName,
|
|
)
|
|
|
|
h.DB.AddAuditLog("folder_created", cleanPath, c.IP())
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
|
"message": "folder created",
|
|
"path": cleanPath,
|
|
})
|
|
}
|
|
|
|
// Rename renames a file or folder.
|
|
// POST /api/files/rename
|
|
func (h *FSHandler) Rename(c *fiber.Ctx) error {
|
|
var body struct {
|
|
OldPath string `json:"old_path"`
|
|
NewName string `json:"new_name"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
oldFull, err := resolveSafe(h.Config.StorageDir, body.OldPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
parentDir := filepath.Dir(oldFull)
|
|
uniqueName := GetUniquePath(parentDir, body.NewName)
|
|
newPath := filepath.Join(filepath.Dir(body.OldPath), uniqueName)
|
|
|
|
newFull, err := resolveSafe(h.Config.StorageDir, newPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
if err := os.Rename(oldFull, newFull); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rename"})
|
|
}
|
|
|
|
// Update body.NewName to reflect the actual unique name used
|
|
body.NewName = uniqueName
|
|
|
|
// Update DB
|
|
h.DB.Exec(
|
|
"UPDATE files SET path = ?, name = ?, updated_at = CURRENT_TIMESTAMP WHERE path = ?",
|
|
filepath.ToSlash(newPath), body.NewName, filepath.ToSlash(body.OldPath),
|
|
)
|
|
|
|
h.DB.AddAuditLog("renamed", fmt.Sprintf("%s -> %s", body.OldPath, newPath), c.IP())
|
|
|
|
return c.JSON(fiber.Map{"message": "renamed successfully", "new_path": filepath.ToSlash(newPath)})
|
|
}
|
|
|
|
type MoveRequest struct {
|
|
SourcePath string `json:"source_path"`
|
|
DestPath string `json:"dest_path"`
|
|
Resolution string `json:"resolution"` // "overwrite", "rename", "skip"
|
|
}
|
|
|
|
// Move moves a file or folder to a new location.
|
|
// POST /api/files/move
|
|
func (h *FSHandler) Move(c *fiber.Ctx) error {
|
|
var body MoveRequest
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
if srcFull == dstFull || filepath.Dir(srcFull) == dstFull {
|
|
return c.JSON(fiber.Map{"message": "moved successfully"})
|
|
}
|
|
|
|
// If destination is a directory, move into it
|
|
if info, err := os.Stat(dstFull); err == nil && info.IsDir() {
|
|
uniqueName := GetUniquePath(dstFull, filepath.Base(srcFull))
|
|
dstFull = filepath.Join(dstFull, uniqueName)
|
|
body.DestPath = filepath.Join(body.DestPath, uniqueName)
|
|
} else if err == nil {
|
|
if body.Resolution == "skip" {
|
|
return c.JSON(fiber.Map{"message": "skipped"})
|
|
}
|
|
if body.Resolution != "overwrite" {
|
|
parentDir := filepath.Dir(dstFull)
|
|
uniqueName := GetUniquePath(parentDir, filepath.Base(dstFull))
|
|
dstFull = filepath.Join(parentDir, uniqueName)
|
|
body.DestPath = filepath.Join(filepath.Dir(body.DestPath), uniqueName)
|
|
} else {
|
|
safeRemovePath(h.Config.StorageDir, dstFull)
|
|
}
|
|
}
|
|
|
|
if err := os.Rename(srcFull, dstFull); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move"})
|
|
}
|
|
|
|
h.DB.Exec(
|
|
"UPDATE files SET path = ?, updated_at = CURRENT_TIMESTAMP WHERE path = ?",
|
|
filepath.ToSlash(body.DestPath), filepath.ToSlash(body.SourcePath),
|
|
)
|
|
|
|
h.DB.AddAuditLog("moved", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
|
|
|
|
return c.JSON(fiber.Map{"message": "moved successfully"})
|
|
}
|
|
|
|
type CopyRequest struct {
|
|
SourcePath string `json:"source_path"`
|
|
DestPath string `json:"dest_path"`
|
|
Resolution string `json:"resolution"` // "overwrite", "rename", "skip"
|
|
}
|
|
|
|
// Copy copies a file or folder.
|
|
// POST /api/files/copy
|
|
func (h *FSHandler) Copy(c *fiber.Ctx) error {
|
|
var body CopyRequest
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
info, err := os.Stat(srcFull)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "source not found"})
|
|
}
|
|
|
|
// Handle conflict
|
|
finalDst := dstFull
|
|
if _, err := os.Stat(dstFull); err == nil {
|
|
if body.Resolution == "skip" {
|
|
return c.JSON(fiber.Map{"message": "skipped"})
|
|
}
|
|
if body.Resolution != "overwrite" {
|
|
parentDir := filepath.Dir(dstFull)
|
|
uniqueName := GetUniquePath(parentDir, filepath.Base(dstFull))
|
|
finalDst = filepath.Join(parentDir, uniqueName)
|
|
} else {
|
|
safeRemovePath(h.Config.StorageDir, dstFull)
|
|
}
|
|
}
|
|
|
|
if info.IsDir() {
|
|
if err := copyDir(srcFull, finalDst); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy directory"})
|
|
}
|
|
} else {
|
|
if err := copyFile(srcFull, finalDst); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy file"})
|
|
}
|
|
}
|
|
|
|
h.DB.AddAuditLog("copied", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
|
|
|
|
return c.JSON(fiber.Map{"message": "copied successfully"})
|
|
}
|
|
|
|
// Delete soft-deletes a file (moves to trash).
|
|
// DELETE /api/files/*
|
|
func (h *FSHandler) Delete(c *fiber.Ctx) error {
|
|
resolvedPath := c.Locals("resolvedPath").(string)
|
|
relativePath := c.Locals("relativePath").(string)
|
|
|
|
// Generate unique suffix for the trash path to prevent collisions
|
|
trashSuffix := fmt.Sprintf("_%d", time.Now().UnixNano())
|
|
trashName := filepath.Base(relativePath) + trashSuffix
|
|
trashDir := filepath.Dir(relativePath)
|
|
if trashDir == "." {
|
|
trashDir = ""
|
|
}
|
|
trashedRelativePath := filepath.Join(trashDir, trashName)
|
|
trashPath := filepath.Join(h.Config.TrashDir, trashedRelativePath)
|
|
|
|
// Check if permanent
|
|
isPermanent := c.Query("permanent") == "true"
|
|
if isPermanent {
|
|
// Ensure we don't delete the root
|
|
if resolvedPath == h.Config.StorageDir && relativePath == "." {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "cannot permanently delete root"})
|
|
}
|
|
|
|
// Remove both potential locations to be safe
|
|
safeRemovePath(h.Config.StorageDir, resolvedPath)
|
|
if relativePath != "." {
|
|
safeRemovePath(h.Config.TrashDir, trashPath)
|
|
}
|
|
|
|
cleanPath := filepath.ToSlash(relativePath)
|
|
h.DB.Exec(`DELETE FROM files WHERE path = ? OR path LIKE ?`, cleanPath, cleanPath+"/%")
|
|
h.DB.AddAuditLog("permanently_deleted", cleanPath, c.IP())
|
|
return c.JSON(fiber.Map{"message": "permanently deleted"})
|
|
}
|
|
|
|
// For soft-delete, file must exist in regular storage
|
|
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(trashPath), 0755); err != nil {
|
|
fmt.Printf("MkdirAll error: %v\n", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare trash"})
|
|
}
|
|
|
|
info, err := os.Stat(resolvedPath)
|
|
isDir := 0
|
|
size := int64(0)
|
|
if err == nil {
|
|
if info.IsDir() {
|
|
isDir = 1
|
|
}
|
|
size = info.Size()
|
|
}
|
|
|
|
if err := os.Rename(resolvedPath, trashPath); err != nil {
|
|
fmt.Printf("Rename error %s -> %s: %v. Attempting copy & delete fallback...\n", resolvedPath, trashPath, err)
|
|
if isDir == 1 {
|
|
if err := copyDir(resolvedPath, trashPath); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move directory to trash"})
|
|
}
|
|
} else {
|
|
if err := copyFile(resolvedPath, trashPath); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move file to trash"})
|
|
}
|
|
}
|
|
safeRemovePath(h.Config.StorageDir, resolvedPath)
|
|
}
|
|
|
|
// Update DB
|
|
cleanPath := filepath.ToSlash(relativePath)
|
|
cleanTrashPath := filepath.ToSlash(trashedRelativePath)
|
|
|
|
h.DB.Exec(
|
|
`UPDATE files SET is_trashed = 1, trashed_at = CURRENT_TIMESTAMP, original_path = ?, is_dir = ?, size = ?, path = ? WHERE path = ?`,
|
|
cleanPath, isDir, size, cleanTrashPath, cleanPath,
|
|
)
|
|
|
|
if isDir == 1 {
|
|
h.DB.Exec(
|
|
`UPDATE files SET path = REPLACE(path, ?, ?) WHERE path LIKE ?`,
|
|
cleanPath+"/", cleanTrashPath+"/", cleanPath+"/%",
|
|
)
|
|
}
|
|
|
|
// Also insert if not tracked
|
|
h.DB.Exec(
|
|
`INSERT OR IGNORE INTO files (path, name, is_trashed, trashed_at, original_path, is_dir, size) VALUES (?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?)`,
|
|
cleanTrashPath, filepath.Base(cleanTrashPath), cleanPath, isDir, size,
|
|
)
|
|
|
|
h.DB.AddAuditLog("deleted", cleanTrashPath, c.IP())
|
|
|
|
return c.JSON(fiber.Map{"message": "moved to trash"})
|
|
}
|
|
|
|
// ListTrash lists all trashed items.
|
|
// GET /api/trash
|
|
func (h *FSHandler) ListTrash(c *fiber.Ctx) error {
|
|
rows, err := h.DB.Query(
|
|
"SELECT path, name, is_dir, size, original_path, trashed_at FROM files WHERE is_trashed = 1 ORDER BY trashed_at DESC",
|
|
)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
defer rows.Close()
|
|
|
|
type TrashItem struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
IsDir bool `json:"is_dir"`
|
|
Size int64 `json:"size"`
|
|
OriginalPath string `json:"original_path"`
|
|
TrashedAt time.Time `json:"trashed_at"`
|
|
}
|
|
|
|
items := []TrashItem{}
|
|
for rows.Next() {
|
|
var item TrashItem
|
|
var isDir int
|
|
var trashedAt sql.NullTime
|
|
if err := rows.Scan(&item.Path, &item.Name, &isDir, &item.Size, &item.OriginalPath, &trashedAt); err != nil {
|
|
continue
|
|
}
|
|
item.IsDir = isDir == 1
|
|
if trashedAt.Valid {
|
|
item.TrashedAt = trashedAt.Time
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"items": items, "count": len(items)})
|
|
}
|
|
|
|
// RestoreFromTrash restores a file from the trash.
|
|
// POST /api/trash/restore
|
|
func (h *FSHandler) RestoreFromTrash(c *fiber.Ctx) error {
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
// Get original path from DB
|
|
var originalPath string
|
|
err := h.DB.QueryRow(
|
|
"SELECT original_path FROM files WHERE path = ? AND is_trashed = 1",
|
|
body.Path,
|
|
).Scan(&originalPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "item not found in trash"})
|
|
}
|
|
|
|
trashPath := filepath.Join(h.Config.TrashDir, filepath.FromSlash(body.Path))
|
|
destFull := filepath.Join(h.Config.StorageDir, filepath.FromSlash(originalPath))
|
|
|
|
// Check if originalPath already exists, assign new name if so
|
|
if _, err := os.Stat(destFull); err == nil {
|
|
uniqueName := GetUniquePath(filepath.Dir(destFull), filepath.Base(destFull))
|
|
originalPath = filepath.ToSlash(filepath.Join(filepath.Dir(originalPath), uniqueName))
|
|
destFull = filepath.Join(filepath.Dir(destFull), uniqueName)
|
|
}
|
|
|
|
// Ensure parent directory exists
|
|
if err := os.MkdirAll(filepath.Dir(destFull), 0755); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare restore location"})
|
|
}
|
|
|
|
if err := os.Rename(trashPath, destFull); err != nil {
|
|
if err := copyFile(trashPath, destFull); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to restore file"})
|
|
}
|
|
safeRemovePath(h.Config.TrashDir, trashPath)
|
|
}
|
|
|
|
h.DB.Exec("UPDATE files SET path = ?, name = ?, is_trashed = 0, trashed_at = NULL WHERE path = ?", originalPath, filepath.Base(originalPath), body.Path)
|
|
|
|
var isDir int
|
|
h.DB.QueryRow("SELECT is_dir FROM files WHERE path = ?", originalPath).Scan(&isDir)
|
|
if isDir == 1 {
|
|
h.DB.Exec(
|
|
`UPDATE files SET path = REPLACE(path, ?, ?) WHERE path LIKE ?`,
|
|
body.Path+"/", originalPath+"/", body.Path+"/%",
|
|
)
|
|
}
|
|
|
|
h.DB.AddAuditLog("restored", fmt.Sprintf("Restored '%s' from trash", originalPath), c.IP())
|
|
|
|
return c.JSON(fiber.Map{"message": "restored successfully", "path": originalPath})
|
|
}
|
|
|
|
// EmptyTrash permanently deletes all trashed items.
|
|
// DELETE /api/trash
|
|
func (h *FSHandler) EmptyTrash(c *fiber.Ctx) error {
|
|
if err := safeRemovePath(h.Config.TrashDir, h.Config.TrashDir); err != nil {
|
|
// safeRemovePath blocks deleting the root directory, so we just clear its contents instead
|
|
entries, err := os.ReadDir(h.Config.TrashDir)
|
|
if err == nil {
|
|
for _, e := range entries {
|
|
os.RemoveAll(filepath.Join(h.Config.TrashDir, e.Name()))
|
|
}
|
|
}
|
|
}
|
|
|
|
h.DB.Exec("DELETE FROM files WHERE is_trashed = 1")
|
|
h.DB.AddAuditLog("emptied_trash", "User emptied trash", c.IP())
|
|
return c.JSON(fiber.Map{"message": "trash emptied"})
|
|
}
|
|
|
|
// DownloadFolder creates and streams a ZIP archive of a folder.
|
|
// GET /api/files/download-folder?path=...
|
|
func (h *FSHandler) DownloadFolder(c *fiber.Ctx) error {
|
|
folderPath := c.Query("path")
|
|
if folderPath == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "path is required"})
|
|
}
|
|
|
|
fullPath, err := resolveSafe(h.Config.StorageDir, folderPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
info, err := os.Stat(fullPath)
|
|
if err != nil || !info.IsDir() {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "folder not found"})
|
|
}
|
|
|
|
c.Set("Content-Type", "application/zip")
|
|
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", filepath.Base(fullPath)))
|
|
|
|
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
|
zipWriter := zip.NewWriter(w)
|
|
defer zipWriter.Close()
|
|
|
|
filepath.Walk(fullPath, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if path == fullPath {
|
|
return nil
|
|
}
|
|
|
|
relPath, err := filepath.Rel(fullPath, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.IsDir() {
|
|
// add directory entry
|
|
relPath += "/"
|
|
_, err = zipWriter.Create(relPath)
|
|
return err
|
|
}
|
|
|
|
// add file entry
|
|
zipFile, err := zipWriter.Create(relPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fsFile, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer fsFile.Close()
|
|
|
|
_, err = io.Copy(zipFile, fsFile)
|
|
return err
|
|
})
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// DownloadBulk creates and streams a ZIP archive of multiple selected items.
|
|
// POST /api/files/download-bulk
|
|
func (h *FSHandler) DownloadBulk(c *fiber.Ctx) error {
|
|
var body struct {
|
|
Paths []string `json:"paths" form:"paths"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload"})
|
|
}
|
|
|
|
if len(body.Paths) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no paths provided"})
|
|
}
|
|
|
|
c.Set("Content-Type", "application/zip")
|
|
c.Set("Content-Disposition", "attachment; filename=\"download.zip\"")
|
|
|
|
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
|
zipWriter := zip.NewWriter(w)
|
|
defer zipWriter.Close()
|
|
|
|
for _, p := range body.Paths {
|
|
fullPath, err := resolveSafe(h.Config.StorageDir, p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
info, err := os.Stat(fullPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if info.IsDir() {
|
|
filepath.Walk(fullPath, func(path string, walkInfo os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Make it relative to the parent of the chosen directory
|
|
relPath, err := filepath.Rel(filepath.Dir(fullPath), path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if walkInfo.IsDir() {
|
|
relPath += "/"
|
|
_, _ = zipWriter.Create(relPath)
|
|
return nil
|
|
}
|
|
|
|
zipFile, err := zipWriter.Create(relPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fsFile, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer fsFile.Close()
|
|
|
|
_, _ = io.Copy(zipFile, fsFile)
|
|
return nil
|
|
})
|
|
} else {
|
|
zipFile, err := zipWriter.Create(info.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
fsFile, err := os.Open(fullPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
_, _ = io.Copy(zipFile, fsFile)
|
|
fsFile.Close()
|
|
}
|
|
}
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// TogglePin pins or unpins a file/folder.
|
|
// POST /api/files/pin
|
|
func (h *FSHandler) TogglePin(c *fiber.Ctx) error {
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
cleanPath := filepath.ToSlash(body.Path)
|
|
|
|
// Ensure the file record exists
|
|
h.DB.Exec(
|
|
"INSERT OR IGNORE INTO files (path, name) VALUES (?, ?)",
|
|
cleanPath, filepath.Base(cleanPath),
|
|
)
|
|
|
|
// Toggle pin
|
|
_, err := h.DB.Exec(
|
|
"UPDATE files SET is_pinned = CASE WHEN is_pinned = 1 THEN 0 ELSE 1 END WHERE path = ?",
|
|
cleanPath,
|
|
)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to toggle pin"})
|
|
}
|
|
|
|
var isPinned int
|
|
h.DB.QueryRow("SELECT is_pinned FROM files WHERE path = ?", cleanPath).Scan(&isPinned)
|
|
|
|
return c.JSON(fiber.Map{"pinned": isPinned == 1, "path": cleanPath})
|
|
}
|
|
|
|
// ListPinned returns all pinned items.
|
|
// GET /api/files/pinned
|
|
func (h *FSHandler) ListPinned(c *fiber.Ctx) error {
|
|
rows, err := h.DB.Query(
|
|
"SELECT path, name, is_dir, size, mime_type FROM files WHERE is_pinned = 1 AND is_trashed = 0",
|
|
)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []FileInfo{}
|
|
for rows.Next() {
|
|
var fi FileInfo
|
|
var isDir int
|
|
if err := rows.Scan(&fi.Path, &fi.Name, &isDir, &fi.Size, &fi.MimeType); err != nil {
|
|
continue
|
|
}
|
|
fi.IsDir = isDir == 1
|
|
fi.IsPinned = true
|
|
items = append(items, fi)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"items": items, "count": len(items)})
|
|
}
|
|
|
|
// Search performs an FTS5 full-text search on file names and paths.
|
|
// GET /api/search?q=query
|
|
func (h *FSHandler) Search(c *fiber.Ctx) error {
|
|
query := c.Query("q", "")
|
|
if query == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "query parameter 'q' is required"})
|
|
}
|
|
|
|
// Sanitize FTS5 query to prevent syntax errors
|
|
words := strings.Fields(query)
|
|
var safeWords []string
|
|
for _, w := range words {
|
|
// Keep only alphanumeric characters
|
|
var clean strings.Builder
|
|
for _, r := range w {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
clean.WriteRune(r)
|
|
}
|
|
}
|
|
if clean.Len() > 0 {
|
|
safeWords = append(safeWords, clean.String()+"*")
|
|
}
|
|
}
|
|
|
|
ftsQuery := strings.Join(safeWords, " AND ")
|
|
if ftsQuery == "" {
|
|
return c.JSON(fiber.Map{"items": []FileInfo{}, "count": 0})
|
|
}
|
|
|
|
rows, err := h.DB.Query(`
|
|
SELECT f.path, f.name, f.is_dir, f.size, f.mime_type, f.is_pinned, f.checksum
|
|
FROM files f
|
|
JOIN files_fts fts ON f.id = fts.rowid
|
|
WHERE files_fts MATCH ? AND f.is_trashed = 0
|
|
ORDER BY rank
|
|
LIMIT 50
|
|
`, ftsQuery)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "search failed"})
|
|
}
|
|
defer rows.Close()
|
|
|
|
results := []FileInfo{}
|
|
for rows.Next() {
|
|
var fi FileInfo
|
|
var isDir, isPinned int
|
|
var checksum sql.NullString
|
|
if err := rows.Scan(&fi.Path, &fi.Name, &isDir, &fi.Size, &fi.MimeType, &isPinned, &checksum); err != nil {
|
|
continue
|
|
}
|
|
fi.IsDir = isDir == 1
|
|
fi.IsPinned = isPinned == 1
|
|
fi.Checksum = checksum.String
|
|
results = append(results, fi)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"results": results, "count": len(results)})
|
|
}
|
|
|
|
// Download serves a file with full HTTP Range Request support.
|
|
// GET /api/files/download/*
|
|
func (h *FSHandler) Download(c *fiber.Ctx) error {
|
|
resolvedPath := c.Locals("resolvedPath").(string)
|
|
|
|
info, err := os.Stat(resolvedPath)
|
|
if err != nil || info.IsDir() {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
|
}
|
|
|
|
|
|
// Set appropriate headers for range requests
|
|
c.Set("Accept-Ranges", "bytes")
|
|
c.Set("Content-Type", mime.TypeByExtension(filepath.Ext(resolvedPath)))
|
|
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", info.Name()))
|
|
|
|
// Use Fiber's built-in SendFile which supports Range requests
|
|
return c.SendFile(resolvedPath)
|
|
}
|
|
|
|
// Upload handles file upload and generates an XXHash checksum.
|
|
// POST /api/files/upload
|
|
func (h *FSHandler) Upload(c *fiber.Ctx) error {
|
|
destPath := c.FormValue("path", "")
|
|
if destPath == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "destination path is required"})
|
|
}
|
|
|
|
file, err := c.FormFile("file")
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "file is required"})
|
|
}
|
|
|
|
fullDest, err := resolveSafe(h.Config.StorageDir, destPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
// If destination is a directory, save into it
|
|
if info, err := os.Stat(fullDest); err == nil && info.IsDir() {
|
|
uniqueName := GetUniquePath(fullDest, file.Filename)
|
|
fullDest = filepath.Join(fullDest, uniqueName)
|
|
destPath = filepath.Join(destPath, uniqueName)
|
|
}
|
|
|
|
// Ensure parent directory exists
|
|
if err := os.MkdirAll(filepath.Dir(fullDest), 0755); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create directory"})
|
|
}
|
|
|
|
// Save the file
|
|
if err := c.SaveFile(file, fullDest); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save file"})
|
|
}
|
|
|
|
// Generate XXHash checksum
|
|
checksum, err := generateChecksum(fullDest)
|
|
if err != nil {
|
|
// File saved but checksum failed — log but don't fail the upload
|
|
fmt.Printf("Warning: checksum generation failed for %s: %v\n", destPath, err)
|
|
}
|
|
|
|
cleanPath := filepath.ToSlash(destPath)
|
|
mimeType := mime.TypeByExtension(filepath.Ext(file.Filename))
|
|
|
|
// Upsert file record
|
|
h.DB.Exec(`
|
|
INSERT INTO files (path, name, size, mime_type, checksum)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(path) DO UPDATE SET
|
|
size = excluded.size,
|
|
mime_type = excluded.mime_type,
|
|
checksum = excluded.checksum,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
`, cleanPath, file.Filename, file.Size, mimeType, checksum)
|
|
|
|
h.DB.AddAuditLog("uploaded", fmt.Sprintf("%s (%d bytes)", cleanPath, file.Size), c.IP())
|
|
|
|
// Queue thumbnail generation
|
|
if workers.Instance != nil {
|
|
workers.Instance.Enqueue(workers.ThumbnailJob{
|
|
FilePath: fullDest,
|
|
Checksum: checksum,
|
|
MimeType: mimeType,
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
|
"message": "uploaded successfully",
|
|
"path": cleanPath,
|
|
"checksum": checksum,
|
|
"size": file.Size,
|
|
})
|
|
}
|
|
|
|
// StorageDashboard returns storage usage broken down by file type.
|
|
// GET /api/storage/dashboard
|
|
func (h *FSHandler) StorageDashboard(c *fiber.Ctx) error {
|
|
type CategoryUsage struct {
|
|
Category string `json:"category"`
|
|
Size int64 `json:"size"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// Walk the storage directory to compute real usage
|
|
categories := map[string]*CategoryUsage{
|
|
"images": {Category: "images"},
|
|
"videos": {Category: "videos"},
|
|
"audio": {Category: "audio"},
|
|
"documents": {Category: "documents"},
|
|
"archives": {Category: "archives"},
|
|
"other": {Category: "other"},
|
|
}
|
|
|
|
var totalSize int64
|
|
|
|
filepath.Walk(h.Config.StorageDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
// Skip system dirs
|
|
rel, _ := filepath.Rel(h.Config.StorageDir, path)
|
|
if strings.HasPrefix(rel, ".trash") || strings.HasPrefix(rel, ".versions") || strings.HasPrefix(rel, ".backups") {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(info.Name()))
|
|
cat := categorizeFile(ext)
|
|
categories[cat].Size += info.Size()
|
|
categories[cat].Count++
|
|
totalSize += info.Size()
|
|
return nil
|
|
})
|
|
|
|
result := make([]CategoryUsage, 0, len(categories))
|
|
for _, cat := range categories {
|
|
if cat.Count > 0 {
|
|
result = append(result, *cat)
|
|
}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"total_size": totalSize,
|
|
"categories": result,
|
|
})
|
|
}
|
|
|
|
// --- Helper functions ---
|
|
|
|
func generateChecksum(filePath string) (string, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
h := xxhash.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return fmt.Sprintf("%016x", h.Sum64()), nil
|
|
}
|
|
|
|
func copyFile(src, dst string) error {
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
sourceFile, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer sourceFile.Close()
|
|
|
|
destFile, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer destFile.Close()
|
|
|
|
_, err = io.Copy(destFile, sourceFile)
|
|
return err
|
|
}
|
|
|
|
func copyDir(src, dst string) error {
|
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rel, _ := filepath.Rel(src, path)
|
|
target := filepath.Join(dst, rel)
|
|
|
|
if info.IsDir() {
|
|
return os.MkdirAll(target, info.Mode())
|
|
}
|
|
|
|
return copyFile(path, target)
|
|
})
|
|
}
|
|
|
|
func categorizeFile(ext string) string {
|
|
switch ext {
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp", ".ico", ".tiff":
|
|
return "images"
|
|
case ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v":
|
|
return "videos"
|
|
case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a":
|
|
return "audio"
|
|
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".md", ".csv":
|
|
return "documents"
|
|
case ".zip", ".tar", ".gz", ".rar", ".7z", ".bz2":
|
|
return "archives"
|
|
default:
|
|
return "other"
|
|
}
|
|
}
|
|
|
|
// ServeThumbnail serves a generated thumbnail image.
|
|
// GET /api/files/thumbnail/:checksum
|
|
func (h *FSHandler) ServeThumbnail(c *fiber.Ctx) error {
|
|
checksum := c.Params("checksum")
|
|
if checksum == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing checksum"})
|
|
}
|
|
|
|
// Sanitize checksum: should only contain hex characters to prevent path traversal
|
|
isHex := true
|
|
for _, char := range checksum {
|
|
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
|
|
isHex = false
|
|
break
|
|
}
|
|
}
|
|
if !isHex {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid checksum format"})
|
|
}
|
|
|
|
thumbPath := filepath.Join(h.Config.ThumbnailDir, checksum+".jpg")
|
|
|
|
if _, err := os.Stat(thumbPath); os.IsNotExist(err) {
|
|
return c.SendStatus(fiber.StatusNotFound)
|
|
}
|
|
|
|
// Set caching headers for thumbnails (they don't change because checksums are immutable)
|
|
c.Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
|
|
return c.SendFile(thumbPath)
|
|
}
|
|
|
|
type CheckConflictsRequest struct {
|
|
DestPath string `json:"dest_path"`
|
|
Files []string `json:"files"`
|
|
}
|
|
|
|
// CheckConflicts checks if files exist in the destination.
|
|
// POST /api/files/check-conflicts
|
|
func (h *FSHandler) CheckConflicts(c *fiber.Ctx) error {
|
|
var req CheckConflictsRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
|
|
}
|
|
|
|
destFull, err := resolveSafe(h.Config.StorageDir, req.DestPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid path"})
|
|
}
|
|
|
|
conflicts := []string{}
|
|
for _, file := range req.Files {
|
|
filePath := filepath.Join(destFull, file)
|
|
if _, err := os.Stat(filePath); err == nil {
|
|
conflicts = append(conflicts, file)
|
|
}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"conflicts": conflicts})
|
|
}
|
|
|
|
// safeRemovePath ensures we never delete a base directory.
|
|
func safeRemovePath(baseDir, targetAbsPath string) error {
|
|
cleanBase := filepath.Clean(baseDir)
|
|
cleanTarget := filepath.Clean(targetAbsPath)
|
|
if cleanTarget == cleanBase || cleanTarget == "." || cleanTarget == "/" {
|
|
return fmt.Errorf("cannot remove base directory %s", baseDir)
|
|
}
|
|
// Ensure it's inside the baseDir
|
|
if !strings.HasPrefix(cleanTarget, cleanBase+string(os.PathSeparator)) {
|
|
return fmt.Errorf("path escapes base directory")
|
|
}
|
|
return os.RemoveAll(cleanTarget)
|
|
}
|