security: remediate vulnerabilities and issues from codebase audit
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s

This commit is contained in:
Elijah 2026-05-23 10:04:08 -07:00
parent b60a09196a
commit fb3f3a3393
15 changed files with 465 additions and 208 deletions

View file

@ -182,3 +182,9 @@ func (db *DB) AddAuditLog(action, details, ip string) error {
_, err := db.Exec("INSERT INTO audit_log (action, details, ip_address) VALUES (?, ?, ?)", action, details, ip) _, err := db.Exec("INSERT INTO audit_log (action, details, ip_address) VALUES (?, ?, ?)", action, details, ip)
return err return err
} }
// CleanOldAuditLogs deletes audit logs older than 30 days.
func (db *DB) CleanOldAuditLogs() error {
_, err := db.Exec("DELETE FROM audit_log WHERE timestamp < datetime('now', '-30 days')")
return err
}

View file

@ -130,22 +130,4 @@ func (h *ArchiveHandler) syncArchiveToDB(relPath string) {
`, cleanPath, filepath.Base(cleanPath), info.Size(), info.IsDir()) `, cleanPath, filepath.Base(cleanPath), info.Size(), info.IsDir())
} }
func resolveSafe(root, rel string) (string, error) {
cleaned := filepath.Clean(rel)
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("path traversal detected")
}
full := filepath.Join(root, cleaned)
abs, err := filepath.Abs(full)
if err != nil {
return "", fmt.Errorf("invalid path")
}
absRoot, _ := filepath.Abs(root)
if abs != absRoot && !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) {
return "", fmt.Errorf("access denied")
}
return abs, nil
}

View file

@ -4,6 +4,9 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"strings"
"crypto/subtle"
"sync"
"git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database" "git.elijahkuntz.com/Elijah/drive/database"
@ -17,6 +20,18 @@ type AuthHandler struct {
DB *database.DB DB *database.DB
Config *config.Config Config *config.Config
Guard *middleware.BruteForceGuard Guard *middleware.BruteForceGuard
tempTOTP map[string]string
totpMu sync.Mutex
}
func NewAuthHandler(db *database.DB, cfg *config.Config, guard *middleware.BruteForceGuard) *AuthHandler {
return &AuthHandler{
DB: db,
Config: cfg,
Guard: guard,
tempTOTP: make(map[string]string),
}
} }
type SetupRequest struct { type SetupRequest struct {
@ -173,6 +188,26 @@ func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
}) })
} }
// GetDownloadToken issues a short-lived token for file downloads
// GET /api/auth/download-token
func (h *AuthHandler) GetDownloadToken(c *fiber.Ctx) error {
userID := c.Locals("userID")
username := c.Locals("username")
token, err := middleware.GenerateDownloadToken(
int(userID.(float64)),
username.(string),
h.Config.JWTSecret,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate download token"})
}
return c.JSON(fiber.Map{
"token": token,
})
}
// Enable2FA generates a TOTP secret and returns the provisioning URI. // Enable2FA generates a TOTP secret and returns the provisioning URI.
// POST /api/auth/2fa/enable // POST /api/auth/2fa/enable
func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error { func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error {
@ -186,11 +221,10 @@ func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate TOTP secret"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate TOTP secret"})
} }
// Store the secret but don't enable 2FA until confirmed // Store the secret temporarily in memory
_, err = h.DB.Exec("UPDATE users SET totp_secret = ? WHERE username = ?", key.Secret(), username) h.totpMu.Lock()
if err != nil { h.tempTOTP[username] = key.Secret()
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save TOTP secret"}) h.totpMu.Unlock()
}
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"secret": key.Secret(), "secret": key.Secret(),
@ -210,21 +244,27 @@ func (h *AuthHandler) Confirm2FA(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
} }
var secret string h.totpMu.Lock()
err := h.DB.QueryRow("SELECT totp_secret FROM users WHERE username = ?", username).Scan(&secret) secret, exists := h.tempTOTP[username]
if err != nil { h.totpMu.Unlock()
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
if !exists {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no pending 2FA setup found"})
} }
if !totp.Validate(body.Code, secret) { if !totp.Validate(body.Code, secret) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid TOTP code"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid TOTP code"})
} }
_, err = h.DB.Exec("UPDATE users SET totp_enabled = 1 WHERE username = ?", username) _, err := h.DB.Exec("UPDATE users SET totp_enabled = 1, totp_secret = ? WHERE username = ?", secret, username)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to enable 2FA"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to enable 2FA"})
} }
h.totpMu.Lock()
delete(h.tempTOTP, username)
h.totpMu.Unlock()
h.DB.AddAuditLog("2fa_enabled", fmt.Sprintf("User '%s' enabled 2FA", username), c.IP()) h.DB.AddAuditLog("2fa_enabled", fmt.Sprintf("User '%s' enabled 2FA", username), c.IP())
return c.JSON(fiber.Map{"message": "2FA enabled successfully"}) return c.JSON(fiber.Map{"message": "2FA enabled successfully"})
@ -235,7 +275,25 @@ func (h *AuthHandler) Confirm2FA(c *fiber.Ctx) error {
func (h *AuthHandler) Disable2FA(c *fiber.Ctx) error { func (h *AuthHandler) Disable2FA(c *fiber.Ctx) error {
username := c.Locals("username").(string) username := c.Locals("username").(string)
_, err := h.DB.Exec("UPDATE users SET totp_enabled = 0, totp_secret = '' WHERE username = ?", username) var body struct {
Code string `json:"code"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
var secret string
err := h.DB.QueryRow("SELECT totp_secret FROM users WHERE username = ?", username).Scan(&secret)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
if !totp.Validate(body.Code, secret) {
h.Guard.RecordFailure(c.IP())
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid TOTP code"})
}
_, err = h.DB.Exec("UPDATE users SET totp_enabled = 0, totp_secret = '' WHERE username = ?", username)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to disable 2FA"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to disable 2FA"})
} }
@ -294,7 +352,8 @@ func hashPassword(password string) (string, error) {
} }
hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32) hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
encoded := fmt.Sprintf("%s$%s", // Output PHC format
encoded := fmt.Sprintf("$argon2id$v=19$m=65536,t=2,p=4$%s$%s",
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash), base64.RawStdEncoding.EncodeToString(hash),
) )
@ -302,7 +361,23 @@ func hashPassword(password string) (string, error) {
} }
func verifyPassword(password, encoded string) bool { func verifyPassword(password, encoded string) bool {
parts := splitN(encoded, "$", 2) if strings.HasPrefix(encoded, "$argon2id$") {
// PHC Format
parts := strings.Split(encoded, "$")
if len(parts) != 6 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil { return false }
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil { return false }
hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(hash, expectedHash) == 1
}
// Legacy format: base64(salt)$base64(hash)
parts := strings.SplitN(encoded, "$", 2)
if len(parts) != 2 { if len(parts) != 2 {
return false return false
} }
@ -318,38 +393,5 @@ func verifyPassword(password, encoded string) bool {
} }
hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32) hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(hash, expectedHash) == 1
if len(hash) != len(expectedHash) {
return false
}
// Constant-time comparison to prevent timing attacks
var diff byte
for i := range hash {
diff |= hash[i] ^ expectedHash[i]
}
return diff == 0
}
func splitN(s, sep string, n int) []string {
result := make([]string, 0, n)
for i := 0; i < n-1; i++ {
idx := indexOf(s, sep)
if idx < 0 {
break
}
result = append(result, s[:idx])
s = s[idx+len(sep):]
}
result = append(result, s)
return result
}
func indexOf(s, sub string) int {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
} }

View file

@ -293,7 +293,7 @@ func (h *FSHandler) GetExtendedFileInfo(c *fiber.Ctx) error {
// For files, attempt to get media metadata // For files, attempt to get media metadata
cat := categorizeFile(filepath.Ext(info.Name())) cat := categorizeFile(filepath.Ext(info.Name()))
if cat == "images" || cat == "videos" || cat == "audio" { if cat == "images" || cat == "videos" || cat == "audio" {
cmd := exec.Command("exiftool", "-json", resolvedPath) cmd := exec.Command("exiftool", "-json", "--", resolvedPath)
output, err := cmd.Output() output, err := cmd.Output()
if err == nil { if err == nil {
var meta []map[string]interface{} var meta []map[string]interface{}
@ -327,7 +327,7 @@ func (h *FSHandler) CreateFolder(c *fiber.Ctx) error {
parentDir := filepath.Dir(body.Path) parentDir := filepath.Dir(body.Path)
folderName := filepath.Base(body.Path) folderName := filepath.Base(body.Path)
parentFullPath, err := h.resolveSafe(parentDir) parentFullPath, err := resolveSafe(h.Config.StorageDir, parentDir)
if err != nil { if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
} }
@ -371,7 +371,7 @@ func (h *FSHandler) Rename(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
} }
oldFull, err := h.resolveSafe(body.OldPath) oldFull, err := resolveSafe(h.Config.StorageDir, body.OldPath)
if err != nil { if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
} }
@ -380,7 +380,7 @@ func (h *FSHandler) Rename(c *fiber.Ctx) error {
uniqueName := GetUniquePath(parentDir, body.NewName) uniqueName := GetUniquePath(parentDir, body.NewName)
newPath := filepath.Join(filepath.Dir(body.OldPath), uniqueName) newPath := filepath.Join(filepath.Dir(body.OldPath), uniqueName)
newFull, err := h.resolveSafe(newPath) newFull, err := resolveSafe(h.Config.StorageDir, newPath)
if err != nil { if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
} }
@ -414,12 +414,12 @@ func (h *FSHandler) Move(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
} }
srcFull, err := h.resolveSafe(body.SourcePath) srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
if err != nil { if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
} }
dstFull, err := h.resolveSafe(body.DestPath) dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath)
if err != nil { if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
} }
@ -468,12 +468,12 @@ func (h *FSHandler) Copy(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
} }
srcFull, err := h.resolveSafe(body.SourcePath) srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
if err != nil { if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
} }
dstFull, err := h.resolveSafe(body.DestPath) dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath)
if err != nil { if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
} }
@ -643,7 +643,7 @@ func (h *FSHandler) DownloadFolder(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "path is required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "path is required"})
} }
fullPath, err := h.resolveSafe(folderPath) fullPath, err := resolveSafe(h.Config.StorageDir, folderPath)
if err != nil { if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
} }
@ -722,7 +722,7 @@ func (h *FSHandler) DownloadBulk(c *fiber.Ctx) error {
defer zipWriter.Close() defer zipWriter.Close()
for _, p := range body.Paths { for _, p := range body.Paths {
fullPath, err := h.resolveSafe(p) fullPath, err := resolveSafe(h.Config.StorageDir, p)
if err != nil { if err != nil {
continue continue
} }
@ -851,8 +851,26 @@ func (h *FSHandler) Search(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "query parameter 'q' is required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "query parameter 'q' is required"})
} }
// Append wildcard for prefix matching // Sanitize FTS5 query to prevent syntax errors
ftsQuery := query + "*" words := strings.Fields(query)
var safeWords []string
for _, w := range words {
// Keep only alphanumeric characters
var clean strings.Builder
for _, r := range w {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
clean.WriteRune(r)
}
}
if clean.Len() > 0 {
safeWords = append(safeWords, clean.String()+"*")
}
}
ftsQuery := strings.Join(safeWords, " AND ")
if ftsQuery == "" {
return c.JSON(fiber.Map{"items": []FileInfo{}, "count": 0})
}
rows, err := h.DB.Query(` rows, err := h.DB.Query(`
SELECT f.path, f.name, f.is_dir, f.size, f.mime_type, f.is_pinned SELECT f.path, f.name, f.is_dir, f.size, f.mime_type, f.is_pinned
@ -918,7 +936,7 @@ func (h *FSHandler) Upload(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "file is required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "file is required"})
} }
fullDest, err := h.resolveSafe(destPath) fullDest, err := resolveSafe(h.Config.StorageDir, destPath)
if err != nil { if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
} }
@ -1034,26 +1052,6 @@ func (h *FSHandler) StorageDashboard(c *fiber.Ctx) error {
// --- Helper functions --- // --- 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) { func generateChecksum(filePath string) (string, error) {
f, err := os.Open(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
@ -1132,6 +1130,18 @@ func (h *FSHandler) ServeThumbnail(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing checksum"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing checksum"})
} }
// Sanitize checksum: should only contain hex characters to prevent path traversal
isHex := true
for _, char := range checksum {
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
isHex = false
break
}
}
if !isHex {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid checksum format"})
}
thumbPath := filepath.Join(h.Config.ThumbnailDir, checksum+".jpg") thumbPath := filepath.Join(h.Config.ThumbnailDir, checksum+".jpg")
if _, err := os.Stat(thumbPath); os.IsNotExist(err) { if _, err := os.Stat(thumbPath); os.IsNotExist(err) {

View file

@ -7,14 +7,13 @@ import (
"mime" "mime"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database" "git.elijahkuntz.com/Elijah/drive/database"
"git.elijahkuntz.com/Elijah/drive/middleware"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
) )
type ShareHandler struct { type ShareHandler struct {
@ -37,13 +36,20 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
} }
// Validate path // Validate path using resolveSafe
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(req.Path)) fullPath, err := resolveSafe(h.Config.StorageDir, req.Path)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid path"})
}
info, err := os.Stat(fullPath) info, err := os.Stat(fullPath)
if err != nil { if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
} }
// Clean path for DB
cleanPath := filepath.ToSlash(filepath.Clean(req.Path))
isDirInt := 0 isDirInt := 0
if info.IsDir() { if info.IsDir() {
isDirInt = 1 isDirInt = 1
@ -52,10 +58,9 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error {
id := uuid.New().String() id := uuid.New().String()
var hash *string var hash *string
if req.Password != "" { if req.Password != "" {
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10) encoded, err := hashPassword(req.Password)
if err == nil { if err == nil {
s := string(hashed) hash = &encoded
hash = &s
} }
} }
@ -73,7 +78,7 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error {
_, err = h.DB.Exec(` _, err = h.DB.Exec(`
INSERT INTO share_links (token, target_path, is_dir, password_hash, expires_at, max_downloads, download_count) INSERT INTO share_links (token, target_path, is_dir, password_hash, expires_at, max_downloads, download_count)
VALUES (?, ?, ?, ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, ?, 0)
`, id, req.Path, isDirInt, hash, expiresAt, maxDownloads) `, id, cleanPath, isDirInt, hash, expiresAt, maxDownloads)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create share"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create share"})
@ -191,7 +196,7 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error {
if req.Password == "" { if req.Password == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"require_password": true}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"require_password": true})
} }
if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(req.Password)); err != nil { if !verifyPassword(req.Password, *hash) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"})
} }
} }
@ -199,14 +204,14 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error {
// Send file info // Send file info
targetRelPath := filepath.Clean(filepath.FromSlash(path)) targetRelPath := filepath.Clean(filepath.FromSlash(path))
if subPath := c.Query("path"); subPath != "" { if subPath := c.Query("path"); subPath != "" {
cleanSub := filepath.Clean(filepath.FromSlash(subPath)) targetRelPath = filepath.Join(targetRelPath, 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"})
} fullPath, err := resolveSafe(h.Config.StorageDir, targetRelPath)
targetRelPath = filepath.Join(targetRelPath, cleanSub) if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid path"})
} }
fullPath := filepath.Join(h.Config.StorageDir, targetRelPath)
info, err := os.Stat(fullPath) info, err := os.Stat(fullPath)
if err != nil { if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"})
@ -251,22 +256,61 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error {
return c.JSON(response) return c.JSON(response)
} }
// CreateShareToken generates a short-lived HMAC token for public share downloads
// POST /api/public/share/:id/token
func (h *ShareHandler) CreateShareToken(c *fiber.Ctx) error {
id := c.Params("id")
var req struct {
Password string `json:"password"`
}
c.BodyParser(&req)
var hash *string
err := h.DB.QueryRow(`SELECT password_hash FROM share_links WHERE token = ?`, id).Scan(&hash)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "share not found"})
}
if hash != nil {
if req.Password == "" || !verifyPassword(req.Password, *hash) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"})
}
}
// Generate a short-lived download token with the share ID
token, err := middleware.GenerateDownloadToken(0, "public_share_"+id, h.Config.JWTSecret)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
}
return c.JSON(fiber.Map{"token": token})
}
// DownloadPublicShare serves the file. // DownloadPublicShare serves the file.
// GET /api/public/download/:id // GET /api/public/download/:id
func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error { func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error {
id := c.Params("id") id := c.Params("id")
pwd := c.Query("pwd")
// Instead of ?pwd=..., we require the ?token=...
tokenString := c.Query("token")
if tokenString == "" {
return c.Status(fiber.StatusUnauthorized).SendString("missing download token")
}
// Validate the JWT download token
if err := middleware.ValidateDownloadToken(tokenString, h.Config.JWTSecret, "public_share_"+id); err != nil {
return c.Status(fiber.StatusUnauthorized).SendString("invalid or expired download token")
}
var path string var path string
var hash *string
var expiresAt *time.Time var expiresAt *time.Time
var maxDownloads *int var maxDownloads *int
var downloads int var downloads int
err := h.DB.QueryRow(` err := h.DB.QueryRow(`
SELECT target_path, password_hash, expires_at, max_downloads, download_count SELECT target_path, expires_at, max_downloads, download_count
FROM share_links WHERE token = ? FROM share_links WHERE token = ?
`, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads) `, id).Scan(&path, &expiresAt, &maxDownloads, &downloads)
if err != nil { if err != nil {
return c.Status(fiber.StatusNotFound).SendString("not found") return c.Status(fiber.StatusNotFound).SendString("not found")
@ -280,22 +324,16 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error {
return c.Status(fiber.StatusForbidden).SendString("limit reached") return c.Status(fiber.StatusForbidden).SendString("limit reached")
} }
if hash != nil {
if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(pwd)); err != nil {
return c.Status(fiber.StatusUnauthorized).SendString("unauthorized")
}
}
targetRelPath := filepath.Clean(filepath.FromSlash(path)) targetRelPath := filepath.Clean(filepath.FromSlash(path))
if subPath := c.Query("path"); subPath != "" { if subPath := c.Query("path"); subPath != "" {
cleanSub := filepath.Clean(filepath.FromSlash(subPath)) targetRelPath = filepath.Join(targetRelPath, filepath.Clean(filepath.FromSlash(subPath)))
if strings.HasPrefix(cleanSub, "..") || strings.HasPrefix(cleanSub, "/") || strings.HasPrefix(cleanSub, "\\") { }
return c.Status(fiber.StatusForbidden).SendString("invalid path")
} fullPath, err := resolveSafe(h.Config.StorageDir, targetRelPath)
targetRelPath = filepath.Join(targetRelPath, cleanSub) if err != nil {
return c.Status(fiber.StatusForbidden).SendString("invalid path")
} }
fullPath := filepath.Join(h.Config.StorageDir, targetRelPath)
info, err := os.Stat(fullPath) info, err := os.Stat(fullPath)
if err != nil { if err != nil {
return c.Status(fiber.StatusNotFound).SendString("not found") return c.Status(fiber.StatusNotFound).SendString("not found")

View file

@ -84,7 +84,11 @@ func (h *TusHandler) Create(c *fiber.Ctx) error {
} }
// Resolve destination directory and ensure unique filename // Resolve destination directory and ensure unique filename
destFull := filepath.Join(h.Config.StorageDir, filepath.FromSlash(destPath)) destFull, err := resolveSafe(h.Config.StorageDir, destPath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid destination path"})
}
uniqueFileName := GetUniquePath(destFull, fileName) uniqueFileName := GetUniquePath(destFull, fileName)
// Generate unique upload ID // Generate unique upload ID
@ -184,16 +188,27 @@ func (h *TusHandler) Patch(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "seek failed"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "seek failed"})
} }
body := c.Body() reader := c.Context().RequestBodyStream()
n, err := f.Write(body) if reader == nil {
if err != nil { // Fallback for smaller payloads or if streaming isn't fully active
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"}) body := c.Body()
} n, err := f.Write(body)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"})
}
// Update offset h.mu.Lock()
h.mu.Lock() upload.Offset += int64(n)
upload.Offset += int64(n) h.mu.Unlock()
h.mu.Unlock() } else {
written, err := io.Copy(f, reader)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "stream read/write failed"})
}
h.mu.Lock()
upload.Offset += written
h.mu.Unlock()
}
c.Set("Tus-Resumable", "1.0.0") c.Set("Tus-Resumable", "1.0.0")
c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10)) c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10))

View file

@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
) )
var numberSuffixRegex = regexp.MustCompile(` \(\d+\)$`) var numberSuffixRegex = regexp.MustCompile(` \(\d+\)$`)
@ -31,3 +32,26 @@ func GetUniquePath(baseDir, name string) string {
} }
} }
} }
// resolveSafe safely resolves a requested path against a root directory,
// ensuring the result is completely contained within the root.
// Returns the absolute clean path or an error if traversal is detected.
func resolveSafe(rootDir, reqPath string) (string, error) {
cleanRoot := filepath.Clean(rootDir)
// Pre-check for typical traversal patterns to be safe
cleanReq := filepath.Clean(filepath.FromSlash(reqPath))
if strings.HasPrefix(cleanReq, "..") || strings.HasPrefix(cleanReq, "/") || strings.HasPrefix(cleanReq, "\\") {
return "", fmt.Errorf("invalid path: contains traversal patterns")
}
absPath := filepath.Join(cleanRoot, cleanReq)
// Ensure the resulting path starts with the root directory + separator
// (or is exactly the root directory)
if !strings.HasPrefix(absPath, cleanRoot+string(filepath.Separator)) && absPath != cleanRoot {
return "", fmt.Errorf("path traversal attempt detected")
}
return absPath, nil
}

View file

@ -3,10 +3,13 @@ package handlers
import ( import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"mime"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time"
"git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database" "git.elijahkuntz.com/Elijah/drive/database"
@ -19,6 +22,9 @@ type WebDAVHandler struct {
Handler *webdav.Handler Handler *webdav.Handler
DB *database.DB DB *database.DB
Config *config.Config Config *config.Config
authCache map[string]time.Time
cacheMu sync.RWMutex
} }
func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler { func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler {
@ -39,9 +45,10 @@ func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler {
} }
return &WebDAVHandler{ return &WebDAVHandler{
Handler: h, Handler: h,
DB: db, DB: db,
Config: cfg, Config: cfg,
authCache: make(map[string]time.Time),
} }
} }
@ -63,19 +70,33 @@ func (h *WebDAVHandler) Handle(c *fiber.Ctx) error {
} }
if hasBasicAuth { if hasBasicAuth {
// Verify basic auth against DB cacheKey := user + ":" + pass
var storedHash string
err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash)
if err != nil {
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
return c.SendStatus(fiber.StatusUnauthorized)
}
// In a real implementation we would compare the Argon2 hash here. h.cacheMu.RLock()
// For simplicity, we assume the frontend uses an app password or we verify. expireTime, ok := h.authCache[cacheKey]
// Note: Argon2 requires full verification which is slow on every WebDAV request. h.cacheMu.RUnlock()
// It's recommended to implement an App Password system for WebDAV.
_ = pass if !ok || time.Now().After(expireTime) {
// Verify basic auth against DB
var storedHash string
err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash)
if err != nil || !verifyPassword(pass, storedHash) {
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
return c.SendStatus(fiber.StatusUnauthorized)
}
// Cache successful auth for 5 minutes
h.cacheMu.Lock()
h.authCache[cacheKey] = time.Now().Add(5 * time.Minute)
// Simple cache cleanup
for k, v := range h.authCache {
if time.Now().After(v) {
delete(h.authCache, k)
}
}
h.cacheMu.Unlock()
}
} else if c.Locals("userID") == nil { } else if c.Locals("userID") == nil {
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`) c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
@ -107,11 +128,11 @@ func (h *WebDAVHandler) syncToDB(relPath string) {
checksum, _ := generateChecksum(fullPath) // Reusing the helper from files.go checksum, _ := generateChecksum(fullPath) // Reusing the helper from files.go
cleanPath := filepath.ToSlash(relPath) cleanPath := filepath.ToSlash(relPath)
// Default mime type
mimeType := "application/octet-stream" mimeType := "application/octet-stream"
if ext := filepath.Ext(cleanPath); ext != "" { if ext := filepath.Ext(cleanPath); ext != "" {
// Go's built-in mime types if m := mime.TypeByExtension(ext); m != "" {
// Note: mime.TypeByExtension is handled by the DB trigger or just simple mapping mimeType = m
}
} }
h.DB.Exec(` h.DB.Exec(`

View file

@ -75,6 +75,15 @@ func main() {
workers.StartBackupWorker(cfg, db) workers.StartBackupWorker(cfg, db)
workers.StartTrashWorker(cfg, db) workers.StartTrashWorker(cfg, db)
// Start prune worker
go func() {
ticker := time.NewTicker(12 * time.Hour)
for range ticker.C {
workers.Tasks.CleanOldTasks()
db.CleanOldAuditLogs()
}
}()
// Create Fiber app // Create Fiber app
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
BodyLimit: 100 * 1024 * 1024 * 1024, // 100 GB (for chunked uploads, each chunk is smaller) BodyLimit: 100 * 1024 * 1024 * 1024, // 100 GB (for chunked uploads, each chunk is smaller)
@ -94,8 +103,13 @@ func main() {
return c.Next() return c.Next()
}) })
app.Use(helmet.New()) app.Use(helmet.New())
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
if allowedOrigins == "" {
allowedOrigins = "http://localhost:3000"
}
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{
AllowOrigins: "*", AllowOrigins: allowedOrigins,
AllowHeaders: "Origin, Content-Type, Accept, Authorization, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Resumable", AllowHeaders: "Origin, Content-Type, Accept, Authorization, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Resumable",
AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD", AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD",
ExposeHeaders: "Location, Upload-Offset, Upload-Length, Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size", ExposeHeaders: "Location, Upload-Offset, Upload-Length, Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size",
@ -116,6 +130,7 @@ func main() {
Max: 20, Max: 20,
Expiration: 1 * time.Minute, Expiration: 1 * time.Minute,
})) }))
public.Post("/share/:id/token", shareHandler.CreateShareToken)
public.Post("/share/:id", shareHandler.GetPublicShare) public.Post("/share/:id", shareHandler.GetPublicShare)
public.Get("/download/:id", shareHandler.DownloadPublicShare) public.Get("/download/:id", shareHandler.DownloadPublicShare)
@ -126,6 +141,12 @@ func main() {
// === PROTECTED ROUTES (require JWT) === // === PROTECTED ROUTES (require JWT) ===
protected := app.Group("/api", middleware.AuthMiddleware(cfg.JWTSecret)) protected := app.Group("/api", middleware.AuthMiddleware(cfg.JWTSecret))
// Apply rate limiting to all authenticated endpoints (e.g. 100 req/min)
protected.Use(limiter.New(limiter.Config{
Max: 100,
Expiration: 1 * time.Minute,
}))
// Token refresh // Token refresh
protected.Post("/auth/refresh", authHandler.Refresh) protected.Post("/auth/refresh", authHandler.Refresh)
@ -137,6 +158,9 @@ func main() {
protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA) protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA)
protected.Post("/auth/2fa/disable", authHandler.Disable2FA) protected.Post("/auth/2fa/disable", authHandler.Disable2FA)
// Download tokens
protected.Get("/auth/download-token", authHandler.GetDownloadToken)
// Settings & Audit // Settings & Audit
protected.Get("/settings", settingsHandler.GetSettings) protected.Get("/settings", settingsHandler.GetSettings)
protected.Put("/settings", settingsHandler.UpdateSettings) protected.Put("/settings", settingsHandler.UpdateSettings)
@ -162,8 +186,6 @@ func main() {
protected.Post("/files/rename", fsHandler.Rename) protected.Post("/files/rename", fsHandler.Rename)
protected.Post("/files/move", fsHandler.Move) protected.Post("/files/move", fsHandler.Move)
protected.Post("/files/copy", fsHandler.Copy) 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.Post("/files/upload", fsHandler.Upload)
protected.Get("/files/folders-tree", fsHandler.GetFolderTree) protected.Get("/files/folders-tree", fsHandler.GetFolderTree)
@ -172,6 +194,8 @@ func main() {
files := protected.Group("/files", middleware.PathJail(cfg.StorageDir)) files := protected.Group("/files", middleware.PathJail(cfg.StorageDir))
files.Get("/info/*", fsHandler.GetExtendedFileInfo) files.Get("/info/*", fsHandler.GetExtendedFileInfo)
files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail) files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail)
files.Get("/download-folder", fsHandler.DownloadFolder)
files.Post("/download-bulk", fsHandler.DownloadBulk)
files.Get("/download/*", fsHandler.Download) files.Get("/download/*", fsHandler.Download)
files.Post("/zip", archiveHandler.Zip) files.Post("/zip", archiveHandler.Zip)
files.Post("/unzip", archiveHandler.Unzip) files.Post("/unzip", archiveHandler.Unzip)

View file

@ -21,9 +21,10 @@ func AuthMiddleware(jwtSecret string) fiber.Handler {
} }
} }
// Fallback to query parameter for img/video tags isQueryToken := false
if tokenString == "" { if tokenString == "" {
tokenString = c.Query("token") tokenString = c.Query("token")
isQueryToken = true
} }
if tokenString == "" { if tokenString == "" {
@ -52,6 +53,17 @@ func AuthMiddleware(jwtSecret string) fiber.Handler {
}) })
} }
tokenType, _ := claims["type"].(string)
if isQueryToken {
if tokenType != "download" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token type for query parameter"})
}
} else {
if tokenType != "access" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token type for authorization header"})
}
}
c.Locals("userID", claims["sub"]) c.Locals("userID", claims["sub"])
c.Locals("username", claims["username"]) c.Locals("username", claims["username"])
@ -86,3 +98,39 @@ func GenerateRefreshToken(userID int, username, secret string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret)) return token.SignedString([]byte(secret))
} }
// GenerateDownloadToken creates a short-lived download token.
func GenerateDownloadToken(userID int, username, secret string) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"username": username,
"exp": time.Now().Add(5 * time.Minute).Unix(),
"iat": time.Now().Unix(),
"type": "download",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// ValidateDownloadToken validates a download token
func ValidateDownloadToken(tokenString, secret, expectedUsername string) error {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
return fiber.NewError(fiber.StatusUnauthorized, "invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || claims["type"] != "download" {
return fiber.NewError(fiber.StatusUnauthorized, "invalid token claims")
}
if expectedUsername != "" && claims["username"] != expectedUsername {
return fiber.NewError(fiber.StatusUnauthorized, "token belongs to different context")
}
return nil
}

View file

@ -46,22 +46,21 @@ func (g *BruteForceGuard) Check() fiber.Handler {
g.mu.Lock() g.mu.Lock()
attempt, exists := g.attempts[ip] attempt, exists := g.attempts[ip]
g.mu.Unlock()
if exists && attempt.Count >= g.maxAttempts { if exists && attempt.Count >= g.maxAttempts {
lockoutDuration := time.Duration(g.lockoutSecs) * time.Second lockoutDuration := time.Duration(g.lockoutSecs) * time.Second
if time.Since(attempt.LockedAt) < lockoutDuration { if time.Since(attempt.LockedAt) < lockoutDuration {
remaining := lockoutDuration - time.Since(attempt.LockedAt) remaining := lockoutDuration - time.Since(attempt.LockedAt)
g.mu.Unlock()
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": "too many login attempts, account temporarily locked", "error": "too many login attempts, account temporarily locked",
"retry_after": int(remaining.Seconds()), "retry_after": int(remaining.Seconds()),
}) })
} }
// Lockout expired, reset // Lockout expired, reset
g.mu.Lock()
delete(g.attempts, ip) delete(g.attempts, ip)
g.mu.Unlock()
} }
g.mu.Unlock()
return c.Next() return c.Next()
} }

View file

@ -208,13 +208,11 @@ func UnzipAsync(taskID string, sourceFull, destFolder string) {
for i, f := range r.File { for i, f := range r.File {
// Prevent Zip Slip vulnerability // Prevent Zip Slip vulnerability
cleanPath := filepath.Clean(f.Name) fpath := filepath.Join(destFolder, f.Name)
if strings.HasPrefix(cleanPath, "..") { if !strings.HasPrefix(fpath, filepath.Clean(destFolder)+string(os.PathSeparator)) {
continue // Skip malicious paths continue // Skip malicious paths
} }
fpath := filepath.Join(destFolder, cleanPath)
if f.FileInfo().IsDir() { if f.FileInfo().IsDir() {
os.MkdirAll(fpath, os.ModePerm) os.MkdirAll(fpath, os.ModePerm)
continue continue

View file

@ -159,7 +159,7 @@ function Tooltip({ children, text }: { children: React.ReactNode; text: string }
); );
} }
function ThumbnailImage({ checksum, fallbackIcon }: { checksum: string; fallbackIcon: React.ReactNode }) { function ThumbnailImage({ checksum, fallbackIcon, downloadToken }: { checksum: string; fallbackIcon: React.ReactNode; downloadToken: string }) {
const [errorCount, setErrorCount] = useState(0); const [errorCount, setErrorCount] = useState(0);
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const [showFallback, setShowFallback] = useState(false); const [showFallback, setShowFallback] = useState(false);
@ -168,7 +168,7 @@ function ThumbnailImage({ checksum, fallbackIcon }: { checksum: string; fallback
<> <>
<img <img
key={key} key={key}
src={`${api.getThumbnailUrl(checksum)}${errorCount > 0 ? `?t=${key}` : ''}`} src={downloadToken ? `${api.getThumbnailUrl(checksum, downloadToken)}${errorCount > 0 ? `&t=${key}` : ''}` : ''}
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
style={{ display: showFallback ? 'none' : 'block' }} style={{ display: showFallback ? 'none' : 'block' }}
@ -248,6 +248,26 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [selectionBox, setSelectionBox] = useState<{ startX: number; startY: number; currentX: number; currentY: number } | null>(null); const [selectionBox, setSelectionBox] = useState<{ startX: number; startY: number; currentX: number; currentY: number } | null>(null);
const [initialSelectedFiles, setInitialSelectedFiles] = useState<Set<string>>(new Set()); const [initialSelectedFiles, setInitialSelectedFiles] = useState<Set<string>>(new Set());
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined); const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const [downloadToken, setDownloadToken] = useState<string>('');
// Fetch download token loop
useEffect(() => {
let mounted = true;
const fetchToken = async () => {
try {
const token = await api.createDownloadToken();
if (mounted) setDownloadToken(token);
} catch (e) {
console.error('Failed to refresh download token', e);
}
};
fetchToken();
const interval = setInterval(fetchToken, 4 * 60 * 1000); // 4 minutes
return () => {
mounted = false;
clearInterval(interval);
};
}, []);
const mainContainerRef = useRef<HTMLDivElement>(null); const mainContainerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -810,7 +830,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
if (isMedia && file.checksum && viewMode === 'grid') { if (isMedia && file.checksum && viewMode === 'grid') {
return ( return (
<div className="relative w-full h-full flex items-center justify-center overflow-hidden rounded-md"> <div className="relative w-full h-full flex items-center justify-center overflow-hidden rounded-md">
<ThumbnailImage checksum={file.checksum} fallbackIcon={fallbackIcon} /> <ThumbnailImage checksum={file.checksum} fallbackIcon={fallbackIcon} downloadToken={downloadToken} />
</div> </div>
); );
} }
@ -1496,18 +1516,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
style={{ color: 'var(--color-text-primary)' }} style={{ color: 'var(--color-text-primary)' }}
title="Download Selected" title="Download Selected"
onClick={() => { onClick={() => {
if (!downloadToken) return;
const form = document.createElement('form'); const form = document.createElement('form');
form.method = 'POST'; form.method = 'POST';
form.action = api.getBulkDownloadUrl(); form.action = api.getBulkDownloadUrl(downloadToken);
form.target = '_blank'; form.target = '_blank';
Array.from(selectedFiles).forEach(path => { const input = document.createElement('input');
const input = document.createElement('input'); input.type = 'hidden';
input.type = 'hidden'; input.name = 'paths';
input.name = 'paths'; input.value = JSON.stringify(Array.from(selectedFiles));
input.value = path; form.appendChild(input);
form.appendChild(input);
});
document.body.appendChild(form); document.body.appendChild(form);
form.submit(); form.submit();
@ -1598,17 +1617,27 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
setContextMenu(null); setContextMenu(null);
}} }}
/> />
<ContextMenuItem {contextMenu.file.is_dir ? (
icon={<Download className="w-4 h-4" />} <ContextMenuItem
label="Download" icon={<Download className="w-4 h-4" />}
onClick={() => { label="Download Folder"
const downloadUrl = contextMenu.file.is_dir onClick={() => {
? api.getDownloadFolderUrl(contextMenu.file.path) if (!downloadToken) return;
: api.getDownloadUrl(contextMenu.file.path); window.location.href = api.getDownloadFolderUrl(contextMenu.file.path, downloadToken);
window.open(downloadUrl, '_blank'); setContextMenu(null);
setContextMenu(null); }}
}} />
/> ) : (
<ContextMenuItem
icon={<Download className="w-4 h-4" />}
label="Download"
onClick={() => {
if (!downloadToken) return;
window.location.href = api.getDownloadUrl(contextMenu.file.path, downloadToken);
setContextMenu(null);
}}
/>
)}
<ContextMenuItem <ContextMenuItem
icon={<Pencil className="w-4 h-4" />} icon={<Pencil className="w-4 h-4" />}
label="Rename" label="Rename"
@ -1767,7 +1796,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</AnimatePresence> </AnimatePresence>
<TaskManager /> <TaskManager />
<FilePreview file={previewFile} onClose={() => setPreviewFile(null)} /> <FilePreview file={previewFile} onClose={() => setPreviewFile(null)} downloadToken={downloadToken} />
<AnimatePresence> <AnimatePresence>
{sharingFile && ( {sharingFile && (
<ShareModal filePath={sharingFile} onClose={() => setSharingFile(null)} /> <ShareModal filePath={sharingFile} onClose={() => setSharingFile(null)} />

View file

@ -18,6 +18,7 @@ interface FilePreviewProps {
mime_type: string; mime_type: string;
} | null; } | null;
onClose: () => void; onClose: () => void;
downloadToken?: string;
} }
function getFileDisplayName(name: string) { function getFileDisplayName(name: string) {
@ -26,7 +27,7 @@ function getFileDisplayName(name: string) {
return name; return name;
} }
export default function FilePreview({ file, onClose }: FilePreviewProps) { export default function FilePreview({ file, onClose, downloadToken }: FilePreviewProps) {
const [content, setContent] = useState<string | null>(null); const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -53,7 +54,8 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
setLoading(true); setLoading(true);
setError(null); setError(null);
fetch(api.getDownloadUrl(file.path)) if (!downloadToken) return;
fetch(api.getDownloadUrl(file.path, downloadToken))
.then((res) => { .then((res) => {
if (!res.ok) throw new Error('Failed to load file content'); if (!res.ok) throw new Error('Failed to load file content');
return res.text(); return res.text();
@ -67,7 +69,7 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
if (!file) return null; if (!file) return null;
const previewType = getPreviewType(file); const previewType = getPreviewType(file);
const downloadUrl = api.getDownloadUrl(file.path); const downloadUrl = downloadToken ? api.getDownloadUrl(file.path, downloadToken) : '';
return ( return (
<AnimatePresence> <AnimatePresence>

View file

@ -227,24 +227,32 @@ class ApiClient {
}); });
} }
getDownloadUrl(path: string) { async createDownloadToken(): Promise<string> {
const url = `${API_BASE}/api/files/download/?path=${encodeURIComponent(path)}`; const res = await fetch(`${API_BASE}/api/auth/download-token`, {
return this.accessToken ? `${url}&token=${this.accessToken}` : url; method: 'GET',
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
if (!res.ok) throw new Error('Failed to fetch download token');
const data = await res.json();
return data.token;
} }
getDownloadFolderUrl(path: string) { getDownloadUrl(path: string, token: string) {
const url = `${API_BASE}/api/files/download-folder?path=${encodeURIComponent(path)}`; return `${API_BASE}/api/files/download/?path=${encodeURIComponent(path)}&token=${token}`;
return this.accessToken ? `${url}&token=${this.accessToken}` : url;
} }
getBulkDownloadUrl() { getDownloadFolderUrl(path: string, token: string) {
const url = `${API_BASE}/api/files/download-bulk`; return `${API_BASE}/api/files/download-folder?path=${encodeURIComponent(path)}&token=${token}`;
return this.accessToken ? `${url}?token=${this.accessToken}` : url;
} }
getThumbnailUrl(checksum: string) { getBulkDownloadUrl(token: string) {
const url = `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}`; return `${API_BASE}/api/files/download-bulk?token=${token}`;
return this.accessToken ? `${url}?token=${this.accessToken}` : url; }
getThumbnailUrl(checksum: string, token: string) {
return `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}?token=${token}`;
} }
getStreamHeaders(): Record<string, string> { getStreamHeaders(): Record<string, string> {
@ -379,10 +387,21 @@ class ApiClient {
return res.json(); return res.json();
} }
getPublicDownloadUrl(id: string, password?: string, path?: string) { async createPublicShareToken(id: string, password?: string) {
const res = await fetch(`${API_BASE}/api/public/share/${id}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: password || '' }),
});
if (!res.ok) throw await res.json();
const data = await res.json();
return data.token;
}
getPublicDownloadUrl(id: string, token: string, path?: string) {
let url = `${API_BASE}/api/public/download/${id}`; let url = `${API_BASE}/api/public/download/${id}`;
const params = new URLSearchParams(); const params = new URLSearchParams();
if (password) params.set('pwd', password); if (token) params.set('token', token);
if (path) params.set('path', path); if (path) params.set('path', path);
const qs = params.toString(); const qs = params.toString();
if (qs) { if (qs) {