All checks were successful
Automated Container Build / build-and-push (push) Successful in 59s
1040 lines
29 KiB
Go
1040 lines
29 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"os"
|
|
"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"`
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
// 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 := h.resolveSafe(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 := h.resolveSafe(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 := h.resolveSafe(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)})
|
|
}
|
|
|
|
// Move moves a file or folder to a new location.
|
|
// POST /api/files/move
|
|
func (h *FSHandler) Move(c *fiber.Ctx) error {
|
|
var body struct {
|
|
SourcePath string `json:"source_path"`
|
|
DestPath string `json:"dest_path"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
srcFull, err := h.resolveSafe(body.SourcePath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
dstFull, err := h.resolveSafe(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 destination is not a directory but the file exists (rename), we don't overwrite if they want `(1)`
|
|
// Wait, if they intentionally rename to an existing file, should it append (1)?
|
|
// Yes, to prevent accidental overwrites.
|
|
parentDir := filepath.Dir(dstFull)
|
|
uniqueName := GetUniquePath(parentDir, filepath.Base(dstFull))
|
|
dstFull = filepath.Join(parentDir, uniqueName)
|
|
body.DestPath = filepath.Join(filepath.Dir(body.DestPath), uniqueName)
|
|
}
|
|
|
|
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"})
|
|
}
|
|
|
|
// Copy copies a file or folder.
|
|
// POST /api/files/copy
|
|
func (h *FSHandler) Copy(c *fiber.Ctx) error {
|
|
var body struct {
|
|
SourcePath string `json:"source_path"`
|
|
DestPath string `json:"dest_path"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
srcFull, err := h.resolveSafe(body.SourcePath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
dstFull, err := h.resolveSafe(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"})
|
|
}
|
|
|
|
if info.IsDir() {
|
|
if err := copyDir(srcFull, dstFull); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy directory"})
|
|
}
|
|
} else {
|
|
if err := copyFile(srcFull, dstFull); 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)
|
|
|
|
// Check if file exists
|
|
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
|
}
|
|
|
|
// Soft-delete: move to .trash directory
|
|
trashPath := filepath.Join(h.Config.TrashDir, relativePath)
|
|
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"})
|
|
}
|
|
|
|
if err := os.Rename(resolvedPath, trashPath); err != nil {
|
|
fmt.Printf("Rename error %s -> %s: %v. Attempting copy & delete fallback...\n", resolvedPath, trashPath, err)
|
|
if err := copyFile(resolvedPath, trashPath); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move to trash"})
|
|
}
|
|
os.RemoveAll(resolvedPath)
|
|
}
|
|
|
|
// Update DB
|
|
cleanPath := filepath.ToSlash(relativePath)
|
|
h.DB.Exec(
|
|
`UPDATE files SET is_trashed = 1, trashed_at = CURRENT_TIMESTAMP, original_path = ? WHERE path = ?`,
|
|
cleanPath, cleanPath,
|
|
)
|
|
|
|
// Also insert if not tracked
|
|
h.DB.Exec(
|
|
`INSERT OR IGNORE INTO files (path, name, is_trashed, trashed_at, original_path) VALUES (?, ?, 1, CURRENT_TIMESTAMP, ?)`,
|
|
cleanPath, filepath.Base(cleanPath), cleanPath,
|
|
)
|
|
|
|
h.DB.AddAuditLog("deleted", cleanPath, 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))
|
|
restorePath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(originalPath))
|
|
|
|
// Ensure parent directory exists
|
|
if err := os.MkdirAll(filepath.Dir(restorePath), 0755); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare restore location"})
|
|
}
|
|
|
|
if err := os.Rename(trashPath, restorePath); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to restore file"})
|
|
}
|
|
|
|
h.DB.Exec(
|
|
"UPDATE files SET is_trashed = 0, trashed_at = NULL WHERE path = ?",
|
|
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 := os.RemoveAll(h.Config.TrashDir); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to empty trash"})
|
|
}
|
|
os.MkdirAll(h.Config.TrashDir, 0755)
|
|
|
|
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 := h.resolveSafe(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
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
// Append wildcard for prefix matching
|
|
ftsQuery := query + "*"
|
|
|
|
rows, err := h.DB.Query(`
|
|
SELECT f.path, f.name, f.is_dir, f.size, f.mime_type, f.is_pinned
|
|
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
|
|
if err := rows.Scan(&fi.Path, &fi.Name, &isDir, &fi.Size, &fi.MimeType, &isPinned); err != nil {
|
|
continue
|
|
}
|
|
fi.IsDir = isDir == 1
|
|
fi.IsPinned = isPinned == 1
|
|
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"})
|
|
}
|
|
|
|
// Verify checksum on download if stored
|
|
relativePath := c.Locals("relativePath").(string)
|
|
var storedChecksum string
|
|
h.DB.QueryRow("SELECT checksum FROM files WHERE path = ?", filepath.ToSlash(relativePath)).Scan(&storedChecksum)
|
|
|
|
// Set appropriate headers for range requests
|
|
c.Set("Accept-Ranges", "bytes")
|
|
c.Set("Content-Type", mime.TypeByExtension(filepath.Ext(resolvedPath)))
|
|
|
|
// 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 := h.resolveSafe(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 (h *FSHandler) resolveSafe(relativePath string) (string, error) {
|
|
cleaned := filepath.Clean(relativePath)
|
|
if strings.Contains(cleaned, "..") {
|
|
return "", fmt.Errorf("path traversal detected")
|
|
}
|
|
|
|
full := filepath.Join(h.Config.StorageDir, cleaned)
|
|
abs, err := filepath.Abs(full)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid path")
|
|
}
|
|
|
|
absRoot, _ := filepath.Abs(h.Config.StorageDir)
|
|
if abs != absRoot && !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("access denied")
|
|
}
|
|
|
|
return abs, nil
|
|
}
|
|
|
|
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"})
|
|
}
|
|
|
|
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)
|
|
}
|