This commit is contained in:
parent
fa2be029a2
commit
724d70e58b
3339 changed files with 1075535 additions and 0 deletions
807
backend/handlers/files.go
Normal file
807
backend/handlers/files.go
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"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
|
||||
err = h.DB.QueryRow(
|
||||
"SELECT is_pinned, is_trashed FROM files WHERE path = ?",
|
||||
filepath.ToSlash(entryPath),
|
||||
).Scan(&isPinned, &isTrashed)
|
||||
if err == nil {
|
||||
fi.IsPinned = isPinned == 1
|
||||
fi.IsTrashed = isTrashed == 1
|
||||
}
|
||||
|
||||
// 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 string
|
||||
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
|
||||
fi.Checksum = checksum
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
// Validate path within jail
|
||||
fullPath, err := h.resolveSafe(body.Path)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(fullPath, 0755); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create folder"})
|
||||
}
|
||||
|
||||
// Record in DB
|
||||
cleanPath := filepath.ToSlash(body.Path)
|
||||
h.DB.Exec(
|
||||
"INSERT OR IGNORE INTO files (path, name, is_dir) VALUES (?, ?, 1)",
|
||||
cleanPath, filepath.Base(cleanPath),
|
||||
)
|
||||
|
||||
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()})
|
||||
}
|
||||
|
||||
newPath := filepath.Join(filepath.Dir(body.OldPath), body.NewName)
|
||||
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 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() {
|
||||
dstFull = filepath.Join(dstFull, filepath.Base(srcFull))
|
||||
body.DestPath = filepath.Join(body.DestPath, filepath.Base(body.SourcePath))
|
||||
}
|
||||
|
||||
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 {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare trash"})
|
||||
}
|
||||
|
||||
if err := os.Rename(resolvedPath, trashPath); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move to trash"})
|
||||
}
|
||||
|
||||
// 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("trash_emptied", "All trashed items permanently deleted", c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "trash emptied"})
|
||||
}
|
||||
|
||||
// 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() {
|
||||
fullDest = filepath.Join(fullDest, file.Filename)
|
||||
destPath = filepath.Join(destPath, file.Filename)
|
||||
}
|
||||
|
||||
// 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"})
|
||||
}
|
||||
|
||||
// Version existing file if it exists
|
||||
CreateVersion(h.DB, h.Config, fullDest, filepath.ToSlash(destPath))
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in a new issue