diff --git a/backend/Dockerfile b/backend/Dockerfile index df9b0f8..af7fef5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,6 +19,7 @@ FROM alpine:3.21 RUN apk add --no-cache \ ffmpeg \ + exiftool \ sqlite \ ca-certificates \ mailcap \ diff --git a/backend/handlers/files.go b/backend/handlers/files.go index deee6e3..2c44450 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -32,9 +32,10 @@ type FileInfo struct { 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"` + 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 { @@ -280,10 +281,28 @@ func (h *FSHandler) GetExtendedFileInfo(c *fiber.Ctx) error { 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" { + cmd := exec.Command("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, + "info": fi, "file_count": fileCount, "created_at": createdAt, }) @@ -679,6 +698,90 @@ func (h *FSHandler) DownloadFolder(c *fiber.Ctx) error { 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 := h.resolveSafe(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 { diff --git a/backend/main.go b/backend/main.go index 50c63fd..7316c18 100644 --- a/backend/main.go +++ b/backend/main.go @@ -163,6 +163,7 @@ func main() { protected.Post("/files/move", fsHandler.Move) protected.Post("/files/copy", fsHandler.Copy) protected.Get("/files/download-folder", fsHandler.DownloadFolder) + protected.Post("/files/download-bulk", fsHandler.DownloadBulk) protected.Post("/files/upload", fsHandler.Upload) protected.Get("/files/folders-tree", fsHandler.GetFolderTree) diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index 047779c..dbb727b 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -788,7 +788,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { if (ext === 'pdf') { return ( -