diff --git a/backend/handlers/sharing.go b/backend/handlers/sharing.go index 2e84cf3..8df9572 100644 --- a/backend/handlers/sharing.go +++ b/backend/handlers/sharing.go @@ -19,6 +19,7 @@ import ( type ShareHandler struct { DB *database.DB Config *config.Config + Guard *middleware.BruteForceGuard } type ShareRequest struct { @@ -197,8 +198,14 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"require_password": true}) } if !verifyPassword(req.Password, *hash) { + if h.Guard != nil { + h.Guard.RecordFailure(c.IP()) + } return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"}) } + if h.Guard != nil { + h.Guard.RecordSuccess(c.IP()) + } } // Send file info @@ -273,8 +280,14 @@ func (h *ShareHandler) CreateShareToken(c *fiber.Ctx) error { if hash != nil { if req.Password == "" || !verifyPassword(req.Password, *hash) { + if h.Guard != nil { + h.Guard.RecordFailure(c.IP()) + } return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"}) } + if h.Guard != nil { + h.Guard.RecordSuccess(c.IP()) + } } // Generate a short-lived download token with the share ID diff --git a/backend/main.go b/backend/main.go index f8cac22..8578135 100644 --- a/backend/main.go +++ b/backend/main.go @@ -67,7 +67,8 @@ func main() { tusHandler := handlers.NewTusHandler(db, cfg) webdavHandler := handlers.NewWebDAVHandler(db, cfg) archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg} - shareHandler := &handlers.ShareHandler{DB: db, Config: cfg} + shareGuard := middleware.NewBruteForceGuard(3, 60) + shareHandler := &handlers.ShareHandler{DB: db, Config: cfg, Guard: shareGuard} // Initialize background workers workers.InitTaskManager(cfg) @@ -130,8 +131,8 @@ func main() { Max: 500, Expiration: 1 * time.Minute, })) - public.Post("/share/:id/token", shareHandler.CreateShareToken) - public.Post("/share/:id", shareHandler.GetPublicShare) + public.Post("/share/:id/token", shareGuard.Check(), shareHandler.CreateShareToken) + public.Post("/share/:id", shareGuard.Check(), shareHandler.GetPublicShare) public.Get("/download/:id", shareHandler.DownloadPublicShare) // Auth routes (with brute force protection) diff --git a/frontend/src/app/share/[id]/page.tsx b/frontend/src/app/share/[id]/page.tsx index 6af3e74..ea5763f 100644 --- a/frontend/src/app/share/[id]/page.tsx +++ b/frontend/src/app/share/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { use, useState, useEffect } from 'react'; -import { Shield, Download, Lock, File, Folder, Loader2, ChevronRight, Home, Image as ImageIcon, Film, Music, FileText, Archive } from 'lucide-react'; +import { Shield, Download, Lock, File, Folder, Loader2, ChevronRight, Home, Image as ImageIcon, Film, Music, FileText, Archive, Eye, EyeOff } from 'lucide-react'; import api from '@/lib/api'; interface FileInfo { @@ -19,17 +19,30 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri const [error, setError] = useState(null); const [requirePassword, setRequirePassword] = useState(false); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [lockoutTime, setLockoutTime] = useState(0); + const [passwordError, setPasswordError] = useState(null); const [token, setToken] = useState(''); const [fileInfo, setFileInfo] = useState(null); const [currentPath, setCurrentPath] = useState(''); + useEffect(() => { + if (lockoutTime <= 0) return; + const timer = setInterval(() => { + setLockoutTime(prev => Math.max(0, prev - 1)); + }, 1000); + return () => clearInterval(timer); + }, [lockoutTime]); + useEffect(() => { fetchShareInfo(password, currentPath); }, [currentPath]); async function fetchShareInfo(pwd?: string, path?: string) { + if (lockoutTime > 0) return; setLoading(true); setError(null); + setPasswordError(null); try { const data = await api.getPublicShare(id, pwd, path); if (data.require_password) { @@ -47,6 +60,13 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri } catch (err: any) { if (err.require_password) { setRequirePassword(true); + } else if (err.retry_after) { + setRequirePassword(true); + setLockoutTime(err.retry_after); + setPasswordError(err.error || 'Too many attempts. Locked out.'); + } else if (requirePassword || pwd) { + setRequirePassword(true); + setPasswordError(err.error || 'Invalid password'); } else { setError(err.error || 'Failed to load share'); } @@ -117,21 +137,38 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
- setPassword(e.target.value)} - placeholder="Enter password" - 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 - /> +
+ setPassword(e.target.value)} + placeholder="Enter password" + disabled={lockoutTime > 0} + className="w-full px-5 py-4 pr-12 rounded-xl outline-none bg-black/20 border border-white/10 text-white placeholder-white/40 focus:border-white/30 transition-colors disabled:opacity-50" + autoFocus + /> + +
+ {passwordError && ( +

{passwordError}

+ )} + {lockoutTime > 0 && ( +

Locked out. Try again in {lockoutTime}s.

+ )}