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) }