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"` } // 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), }) } 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) } // 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 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) }