This repository has been archived on 2026-07-15. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
drive/backend/handlers/files.go
Elijah 6772ba8c43
Some checks failed
Automated Container Build / build-and-push (push) Failing after 15s
feat: complete remediation phase 3
2026-05-26 14:23:50 -07:00

1312 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", originalPath, c.IP())
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"})
}
// 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 := 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)
}