From a52f1d63f7aca0ed8e4e688db7fbf8edaf4326c1 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 22 May 2026 19:19:15 -0700 Subject: [PATCH] Sharing fixes, live preview --- backend/handlers/sharing.go | 116 ++++++++- frontend/src/app/share/[id]/page.tsx | 264 ++++++++++++++------ frontend/src/components/SharedFilesPage.tsx | 44 +++- frontend/src/lib/api.ts | 18 +- 4 files changed, 350 insertions(+), 92 deletions(-) diff --git a/backend/handlers/sharing.go b/backend/handlers/sharing.go index 1a72244..e872f9d 100644 --- a/backend/handlers/sharing.go +++ b/backend/handlers/sharing.go @@ -1,10 +1,13 @@ package handlers import ( + "archive/zip" + "bufio" "fmt" "mime" "os" "path/filepath" + "strings" "time" "git.elijahkuntz.com/Elijah/drive/config" @@ -194,27 +197,58 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error { } // Send file info - fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path)) + targetRelPath := filepath.Clean(filepath.FromSlash(path)) + if subPath := c.Query("path"); subPath != "" { + cleanSub := filepath.Clean(filepath.FromSlash(subPath)) + if strings.HasPrefix(cleanSub, "..") || strings.HasPrefix(cleanSub, "/") || strings.HasPrefix(cleanSub, "\\") { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid path"}) + } + targetRelPath = filepath.Join(targetRelPath, cleanSub) + } + + fullPath := filepath.Join(h.Config.StorageDir, targetRelPath) info, err := os.Stat(fullPath) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"}) } - // We return a signed download token or just allow the immediate request. - // For simplicity, we'll return a short-lived download token JWT. - // Actually, easier: the frontend calls GET /api/public/download/:id?pwd=xxx - mimeType := mime.TypeByExtension(filepath.Ext(info.Name())) if mimeType == "" { mimeType = "application/octet-stream" } - return c.JSON(fiber.Map{ - "name": filepath.Base(path), - "size": info.Size(), - "is_dir": info.IsDir(), + response := fiber.Map{ + "name": info.Name(), // Use info.Name() instead of base of original path + "size": info.Size(), + "is_dir": info.IsDir(), "mime_type": mimeType, - }) + } + + if info.IsDir() { + var files []fiber.Map + entries, err := os.ReadDir(fullPath) + if err == nil { + for _, entry := range entries { + entryInfo, err := entry.Info() + if err != nil { + continue + } + eMime := mime.TypeByExtension(filepath.Ext(entry.Name())) + if eMime == "" { + eMime = "application/octet-stream" + } + files = append(files, fiber.Map{ + "name": entry.Name(), + "size": entryInfo.Size(), + "is_dir": entry.IsDir(), + "mime_type": eMime, + }) + } + } + response["files"] = files + } + + return c.JSON(response) } // DownloadPublicShare serves the file. @@ -252,11 +286,67 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error { } } - fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path)) - + targetRelPath := filepath.Clean(filepath.FromSlash(path)) + if subPath := c.Query("path"); subPath != "" { + cleanSub := filepath.Clean(filepath.FromSlash(subPath)) + if strings.HasPrefix(cleanSub, "..") || strings.HasPrefix(cleanSub, "/") || strings.HasPrefix(cleanSub, "\\") { + return c.Status(fiber.StatusForbidden).SendString("invalid path") + } + targetRelPath = filepath.Join(targetRelPath, cleanSub) + } + + fullPath := filepath.Join(h.Config.StorageDir, targetRelPath) + info, err := os.Stat(fullPath) + if err != nil { + return c.Status(fiber.StatusNotFound).SendString("not found") + } + // Increment download counter h.DB.Exec(`UPDATE share_links SET download_count = download_count + 1 WHERE token = ?`, id) - h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s", id), c.IP()) + h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s path %s", id, targetRelPath), c.IP()) + + if info.IsDir() { + 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(p string, i os.FileInfo, e error) error { + if e != nil || p == fullPath { + return e + } + + relPath, e := filepath.Rel(fullPath, p) + if e != nil { + return e + } + + if i.IsDir() { + relPath += "/" + _, e = zipWriter.Create(relPath) + return e + } + + zipFile, e := zipWriter.Create(relPath) + if e != nil { + return e + } + + fsFile, e := os.Open(p) + if e != nil { + return e + } + defer fsFile.Close() + + // Write file to zip + _, e = bufio.NewReader(fsFile).WriteTo(zipFile) + return e + }) + }) + return nil + } return c.SendFile(fullPath) } diff --git a/frontend/src/app/share/[id]/page.tsx b/frontend/src/app/share/[id]/page.tsx index 8de2cdd..74965e4 100644 --- a/frontend/src/app/share/[id]/page.tsx +++ b/frontend/src/app/share/[id]/page.tsx @@ -1,9 +1,17 @@ 'use client'; import { use, useState, useEffect } from 'react'; -import { Shield, Download, Lock, File, Folder, Loader2 } from 'lucide-react'; +import { Shield, Download, Lock, File, Folder, Loader2, ChevronRight, Home, Image as ImageIcon, Film, Music, FileText, Archive } from 'lucide-react'; import api from '@/lib/api'; +interface FileInfo { + name: string; + size: number; + is_dir: boolean; + mime_type?: string; + files?: FileInfo[]; +} + export default function PublicSharePage({ params }: { params: Promise<{ id: string }> }) { const resolvedParams = use(params); const id = resolvedParams.id; @@ -11,17 +19,18 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri const [error, setError] = useState(null); const [requirePassword, setRequirePassword] = useState(false); const [password, setPassword] = useState(''); - const [fileInfo, setFileInfo] = useState<{ name: string; size: number; is_dir: boolean; mime_type?: string } | null>(null); + const [fileInfo, setFileInfo] = useState(null); + const [currentPath, setCurrentPath] = useState(''); useEffect(() => { - fetchShareInfo(); - }, []); + fetchShareInfo(password, currentPath); + }, [currentPath]); - async function fetchShareInfo(pwd?: string) { + async function fetchShareInfo(pwd?: string, path?: string) { setLoading(true); setError(null); try { - const data = await api.getPublicShare(id, pwd); + const data = await api.getPublicShare(id, pwd, path); if (data.require_password) { setRequirePassword(true); } else { @@ -41,11 +50,12 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri function handlePasswordSubmit(e: React.FormEvent) { e.preventDefault(); - fetchShareInfo(password); + fetchShareInfo(password, currentPath); } - function handleDownload() { - const url = api.getPublicDownloadUrl(id, password); + function handleDownload(specificPath?: string) { + const downloadPath = specificPath ? (currentPath ? `${currentPath}/${specificPath}` : specificPath) : currentPath; + const url = api.getPublicDownloadUrl(id, password, downloadPath); window.location.href = url; } @@ -56,21 +66,31 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; } - if (loading) { + function getFileIcon(isDir: boolean, mimeType?: string) { + if (isDir) return ; + if (mimeType?.startsWith('image/')) return ; + if (mimeType?.startsWith('video/')) return ; + if (mimeType?.startsWith('audio/')) return ; + if (mimeType === 'application/pdf') return ; + if (mimeType === 'application/zip' || mimeType === 'application/x-gzip') return ; + return ; + } + + if (loading && !fileInfo) { return ( -
- +
+
); } if (error) { return ( -
-
- -

Access Denied

-

{error}

+
+
+ +

Access Denied

+

{error}

); @@ -78,84 +98,188 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri if (requirePassword) { return ( -
-
+
+
-
- +
+
-

Protected File

-

This shared file is password protected.

+

Protected File

+

This shared link is password protected.

-
+
setPassword(e.target.value)} placeholder="Enter password" - className="w-full px-4 py-3 rounded-xl outline-none" - style={{ backgroundColor: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }} + className="w-full px-5 py-4 rounded-xl outline-none bg-black/20 border border-white/10 text-white placeholder-white/40 focus:border-white/30 transition-colors" autoFocus />
); } - const isImage = fileInfo?.mime_type?.startsWith('image/'); - const isVideo = fileInfo?.mime_type?.startsWith('video/'); - const isAudio = fileInfo?.mime_type?.startsWith('audio/'); - const isPdf = fileInfo?.mime_type === 'application/pdf'; - const downloadUrl = fileInfo ? api.getPublicDownloadUrl(id, password) : ''; + + const isImage = !fileInfo?.is_dir && fileInfo?.mime_type?.startsWith('image/'); + const isVideo = !fileInfo?.is_dir && fileInfo?.mime_type?.startsWith('video/'); + const isAudio = !fileInfo?.is_dir && fileInfo?.mime_type?.startsWith('audio/'); + const isPdf = !fileInfo?.is_dir && fileInfo?.mime_type === 'application/pdf'; + const downloadUrl = fileInfo ? api.getPublicDownloadUrl(id, password, currentPath) : ''; + + // Breadcrumbs + const pathParts = currentPath.split('/').filter(Boolean); return ( -
-
-
- {fileInfo?.is_dir ? ( -
- -
- ) : isImage ? ( - {fileInfo?.name} - ) : isVideo ? ( -