Sharing fixes, live preview
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m19s

This commit is contained in:
Elijah 2026-05-22 19:19:15 -07:00
parent 27fd6fa4f5
commit a52f1d63f7
4 changed files with 350 additions and 92 deletions

View file

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

View file

@ -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<string | null>(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<FileInfo | null>(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 <Folder className="w-5 h-5 text-blue-500" />;
if (mimeType?.startsWith('image/')) return <ImageIcon className="w-5 h-5 text-green-500" />;
if (mimeType?.startsWith('video/')) return <Film className="w-5 h-5 text-purple-500" />;
if (mimeType?.startsWith('audio/')) return <Music className="w-5 h-5 text-yellow-500" />;
if (mimeType === 'application/pdf') return <FileText className="w-5 h-5 text-red-500" />;
if (mimeType === 'application/zip' || mimeType === 'application/x-gzip') return <Archive className="w-5 h-5 text-orange-500" />;
return <File className="w-5 h-5 text-gray-400" />;
}
if (loading && !fileInfo) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#0f172a] to-[#1e1b4b]">
<Loader2 className="w-8 h-8 animate-spin text-white/50" />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
<div className="max-w-md w-full p-8 rounded-2xl text-center shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
<Shield className="w-12 h-12 mx-auto mb-4" style={{ color: 'var(--color-danger)' }} />
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Access Denied</h1>
<p style={{ color: 'var(--color-text-secondary)' }}>{error}</p>
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#0f172a] to-[#1e1b4b]">
<div className="max-w-md w-full p-8 rounded-3xl text-center bg-white/10 backdrop-blur-xl border border-white/20 shadow-2xl">
<Shield className="w-16 h-16 mx-auto mb-6 text-red-400" />
<h1 className="text-2xl font-bold mb-3 text-white">Access Denied</h1>
<p className="text-white/70">{error}</p>
</div>
</div>
);
@ -78,85 +98,189 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
if (requirePassword) {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
<div className="max-w-md w-full p-8 rounded-2xl shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#0f172a] to-[#1e1b4b]">
<div className="max-w-md w-full p-8 rounded-3xl bg-white/10 backdrop-blur-xl border border-white/20 shadow-2xl">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
<Lock className="w-8 h-8" style={{ color: 'var(--color-accent)' }} />
<div className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 bg-white/5 border border-white/10">
<Lock className="w-10 h-10 text-white/80" />
</div>
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Protected File</h1>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>This shared file is password protected.</p>
<h1 className="text-2xl font-bold mb-2 text-white">Protected File</h1>
<p className="text-white/60">This shared link is password protected.</p>
</div>
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<form onSubmit={handlePasswordSubmit} className="space-y-6">
<div>
<input
type="password"
value={password}
onChange={(e) => 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
/>
</div>
<button
type="submit"
className="w-full py-3 rounded-xl text-white font-medium transition-opacity hover:opacity-90 shadow-lg"
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
className="w-full py-4 rounded-xl text-white font-semibold transition-all hover:scale-[1.02] active:scale-[0.98] shadow-lg"
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
>
Unlock
Unlock Access
</button>
</form>
</div>
</div>
);
}
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 (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
<div className="max-w-md w-full p-8 rounded-2xl text-center shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
<div className="mb-6 flex justify-center">
{fileInfo?.is_dir ? (
<div className="w-24 h-24 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
<Folder className="w-12 h-12" style={{ color: 'var(--color-accent)' }} />
<div className="min-h-screen p-4 md:p-8 bg-gradient-to-br from-[#0f172a] to-[#1e1b4b] text-white flex flex-col items-center">
<div className={`w-full ${fileInfo?.is_dir ? 'max-w-4xl' : 'max-w-md'} transition-all duration-500`}>
{/* Header/Card */}
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl overflow-hidden">
<div className="p-8">
{/* Top Bar for Directories */}
{fileInfo?.is_dir && (
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
<div className="flex items-center gap-2 text-white/80 overflow-x-auto whitespace-nowrap scrollbar-hide">
<button onClick={() => setCurrentPath('')} className="hover:text-white transition-colors flex items-center gap-2">
<Home className="w-5 h-5" />
<span className="font-medium">Shared Folder</span>
</button>
{pathParts.map((part, idx) => {
const pathToHere = pathParts.slice(0, idx + 1).join('/');
return (
<div key={pathToHere} className="flex items-center gap-2">
<ChevronRight className="w-4 h-4 text-white/40" />
<button
onClick={() => setCurrentPath(pathToHere)}
className="hover:text-white transition-colors font-medium truncate max-w-[150px]"
>
{part}
</button>
</div>
) : isImage ? (
<img src={downloadUrl} alt={fileInfo?.name} className="w-full max-h-48 object-contain rounded-xl" />
);
})}
</div>
<button
onClick={() => handleDownload()}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-medium transition-all hover:bg-white/10 bg-white/5 border border-white/10 text-white shadow-lg"
>
<Download className="w-4 h-4" />
Download ZIP
</button>
</div>
)}
{!fileInfo?.is_dir ? (
// Single File View
<div className="text-center">
<div className="mb-8 flex justify-center">
{isImage ? (
<img src={downloadUrl} alt={fileInfo?.name} className="w-full max-h-64 object-contain rounded-2xl shadow-lg border border-white/10 bg-black/20" />
) : isVideo ? (
<video src={downloadUrl} controls className="w-full max-h-48 rounded-xl" />
<video src={downloadUrl} controls className="w-full max-h-64 rounded-2xl shadow-lg border border-white/10 bg-black/20" />
) : isAudio ? (
<audio src={downloadUrl} controls className="w-full mt-4" />
<div className="w-full bg-black/20 p-6 rounded-2xl border border-white/10 shadow-lg">
<Music className="w-16 h-16 mx-auto mb-6 text-yellow-400 opacity-80" />
<audio src={downloadUrl} controls className="w-full" />
</div>
) : isPdf ? (
<iframe src={downloadUrl} className="w-full h-64 rounded-xl border-none" />
<iframe src={downloadUrl} className="w-full h-80 rounded-2xl border border-white/10 shadow-lg bg-white" />
) : (
<div className="w-24 h-24 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
<File className="w-12 h-12" style={{ color: 'var(--color-accent)' }} />
<div className="w-32 h-32 rounded-3xl flex items-center justify-center bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-white/10 shadow-inner">
<File className="w-16 h-16 text-indigo-400" />
</div>
)}
</div>
<h1 className="text-xl font-bold mb-1 truncate px-4" style={{ color: 'var(--color-text-primary)' }} title={fileInfo?.name}>
<h1 className="text-2xl font-bold mb-2 truncate px-4 text-white" title={fileInfo?.name}>
{fileInfo?.name}
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--color-text-secondary)' }}>
{fileInfo?.is_dir ? 'Folder' : formatSize(fileInfo?.size || 0)}
<p className="text-white/60 mb-8 font-medium">
{formatSize(fileInfo?.size || 0)}
</p>
<button
onClick={handleDownload}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-white font-medium transition-opacity hover:opacity-90 shadow-lg"
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
onClick={() => handleDownload()}
className="w-full flex items-center justify-center gap-2 py-4 rounded-xl text-white font-semibold transition-all hover:scale-[1.02] active:scale-[0.98] shadow-[0_0_20px_rgba(99,102,241,0.4)]"
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
>
<Download className="w-5 h-5" />
Download
Download File
</button>
</div>
) : (
// Directory Listing View
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/10 text-white/50 text-sm">
<th className="pb-4 font-medium pl-4">Name</th>
<th className="pb-4 font-medium">Size</th>
<th className="pb-4 font-medium text-right pr-4">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{fileInfo.files?.length === 0 ? (
<tr>
<td colSpan={3} className="py-12 text-center text-white/40">
This folder is empty.
</td>
</tr>
) : (
fileInfo.files?.map((f) => (
<tr
key={f.name}
className={`group transition-colors ${f.is_dir ? 'cursor-pointer hover:bg-white/5' : 'hover:bg-white/5'}`}
onClick={() => {
if (f.is_dir) {
setCurrentPath(currentPath ? `${currentPath}/${f.name}` : f.name);
}
}}
>
<td className="py-4 pl-4 flex items-center gap-4">
{getFileIcon(f.is_dir, f.mime_type)}
<span className="font-medium text-white/90 group-hover:text-white truncate max-w-[200px] sm:max-w-[400px]">
{f.name}
</span>
</td>
<td className="py-4 text-white/50 text-sm">
{f.is_dir ? '--' : formatSize(f.size)}
</td>
<td className="py-4 pr-4 text-right">
<button
onClick={(e) => {
e.stopPropagation();
handleDownload(f.name);
}}
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors ml-auto flex items-center justify-center"
title={`Download ${f.is_dir ? 'folder as ZIP' : 'file'}`}
>
<Download className="w-4 h-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -9,6 +9,7 @@ export default function SharedFilesPage() {
const [shares, setShares] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [shareToRevoke, setShareToRevoke] = useState<string | null>(null);
useEffect(() => {
loadShares();
@ -26,11 +27,16 @@ export default function SharedFilesPage() {
}
}
async function handleRevoke(id: string) {
if (!confirm('Are you sure you want to revoke this share link?')) return;
function handleRevoke(id: string) {
setShareToRevoke(id);
}
async function confirmRevoke() {
if (!shareToRevoke) return;
try {
await api.revokeShare(id);
setShares(prev => prev.filter(s => s.id !== id));
await api.revokeShare(shareToRevoke);
setShares(prev => prev.filter(s => s.id !== shareToRevoke));
setShareToRevoke(null);
} catch (err) {
console.error(err);
}
@ -120,6 +126,36 @@ export default function SharedFilesPage() {
</div>
)}
</div>
{shareToRevoke && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-md p-6 rounded-2xl shadow-xl"
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--color-text-primary)' }}>Revoke Share Link</h3>
<p className="text-sm mb-6" style={{ color: 'var(--color-text-secondary)' }}>Are you sure you want to revoke this share link? Anyone with the link will lose access immediately.</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShareToRevoke(null)}
className="px-4 py-2 rounded-xl text-sm font-medium transition-colors"
style={{ backgroundColor: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}
>
Cancel
</button>
<button
onClick={confirmRevoke}
className="px-4 py-2 rounded-xl text-sm font-medium transition-colors hover:bg-red-500/20"
style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: '#ef4444' }}
>
Revoke Link
</button>
</div>
</motion.div>
</div>
)}
</div>
);
}

View file

@ -351,9 +351,13 @@ class ApiClient {
return res.json();
}
async getPublicShare(id: string, password?: string) {
async getPublicShare(id: string, password?: string, path?: string) {
// Note: No auth headers here, so we use fetch directly to avoid interceptors
const res = await fetch(`${API_BASE}/api/public/share/${id}`, {
let url = `${API_BASE}/api/public/share/${id}`;
if (path) {
url += `?path=${encodeURIComponent(path)}`;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
@ -365,10 +369,14 @@ class ApiClient {
return res.json();
}
getPublicDownloadUrl(id: string, password?: string) {
getPublicDownloadUrl(id: string, password?: string, path?: string) {
let url = `${API_BASE}/api/public/download/${id}`;
if (password) {
url += `?pwd=${encodeURIComponent(password)}`;
const params = new URLSearchParams();
if (password) params.set('pwd', password);
if (path) params.set('path', path);
const qs = params.toString();
if (qs) {
url += `?${qs}`;
}
return url;
}