diff --git a/backend/database/database.go b/backend/database/database.go
index 57beb4c..c623901 100644
--- a/backend/database/database.go
+++ b/backend/database/database.go
@@ -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)
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
+}
diff --git a/backend/handlers/archives.go b/backend/handlers/archives.go
index 29d7abc..3c69856 100644
--- a/backend/handlers/archives.go
+++ b/backend/handlers/archives.go
@@ -130,22 +130,4 @@ func (h *ArchiveHandler) syncArchiveToDB(relPath string) {
`, 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
-}
diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go
index 6b236a3..917b8ea 100644
--- a/backend/handlers/auth.go
+++ b/backend/handlers/auth.go
@@ -4,6 +4,9 @@ import (
"crypto/rand"
"encoding/base64"
"fmt"
+ "strings"
+ "crypto/subtle"
+ "sync"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
@@ -17,6 +20,18 @@ type AuthHandler struct {
DB *database.DB
Config *config.Config
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 {
@@ -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.
// POST /api/auth/2fa/enable
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"})
}
- // Store the secret but don't enable 2FA until confirmed
- _, err = h.DB.Exec("UPDATE users SET totp_secret = ? WHERE username = ?", key.Secret(), username)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save TOTP secret"})
- }
+ // Store the secret temporarily in memory
+ h.totpMu.Lock()
+ h.tempTOTP[username] = key.Secret()
+ h.totpMu.Unlock()
return c.JSON(fiber.Map{
"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"})
}
- 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"})
+ h.totpMu.Lock()
+ secret, exists := h.tempTOTP[username]
+ h.totpMu.Unlock()
+
+ if !exists {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no pending 2FA setup found"})
}
if !totp.Validate(body.Code, secret) {
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 {
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())
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 {
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 {
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)
- 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(hash),
)
@@ -302,7 +361,23 @@ func hashPassword(password string) (string, error) {
}
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 {
return false
}
@@ -318,38 +393,5 @@ func verifyPassword(password, encoded string) bool {
}
hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
-
- 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
+ return subtle.ConstantTimeCompare(hash, expectedHash) == 1
}
diff --git a/backend/handlers/files.go b/backend/handlers/files.go
index 2d0bdf0..ffa0c3f 100644
--- a/backend/handlers/files.go
+++ b/backend/handlers/files.go
@@ -293,7 +293,7 @@ func (h *FSHandler) GetExtendedFileInfo(c *fiber.Ctx) error {
// For files, attempt to get media metadata
cat := categorizeFile(filepath.Ext(info.Name()))
if cat == "images" || cat == "videos" || cat == "audio" {
- cmd := exec.Command("exiftool", "-json", resolvedPath)
+ cmd := exec.Command("exiftool", "-json", "--", resolvedPath)
output, err := cmd.Output()
if err == nil {
var meta []map[string]interface{}
@@ -327,7 +327,7 @@ func (h *FSHandler) CreateFolder(c *fiber.Ctx) error {
parentDir := filepath.Dir(body.Path)
folderName := filepath.Base(body.Path)
- parentFullPath, err := h.resolveSafe(parentDir)
+ parentFullPath, err := resolveSafe(h.Config.StorageDir, parentDir)
if err != nil {
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"})
}
- oldFull, err := h.resolveSafe(body.OldPath)
+ oldFull, err := resolveSafe(h.Config.StorageDir, body.OldPath)
if err != nil {
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)
newPath := filepath.Join(filepath.Dir(body.OldPath), uniqueName)
- newFull, err := h.resolveSafe(newPath)
+ newFull, err := resolveSafe(h.Config.StorageDir, newPath)
if err != nil {
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"})
}
- srcFull, err := h.resolveSafe(body.SourcePath)
+ srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
if err != nil {
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 {
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"})
}
- srcFull, err := h.resolveSafe(body.SourcePath)
+ srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
if err != nil {
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 {
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"})
}
- fullPath, err := h.resolveSafe(folderPath)
+ fullPath, err := resolveSafe(h.Config.StorageDir, folderPath)
if err != nil {
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()
for _, p := range body.Paths {
- fullPath, err := h.resolveSafe(p)
+ fullPath, err := resolveSafe(h.Config.StorageDir, p)
if err != nil {
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"})
}
- // Append wildcard for prefix matching
- ftsQuery := query + "*"
+ // Sanitize FTS5 query to prevent syntax errors
+ 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(`
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"})
}
- fullDest, err := h.resolveSafe(destPath)
+ fullDest, err := resolveSafe(h.Config.StorageDir, destPath)
if err != nil {
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 ---
-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 {
@@ -1132,6 +1130,18 @@ func (h *FSHandler) ServeThumbnail(c *fiber.Ctx) error {
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")
if _, err := os.Stat(thumbPath); os.IsNotExist(err) {
diff --git a/backend/handlers/sharing.go b/backend/handlers/sharing.go
index e872f9d..2e84cf3 100644
--- a/backend/handlers/sharing.go
+++ b/backend/handlers/sharing.go
@@ -7,14 +7,13 @@ import (
"mime"
"os"
"path/filepath"
- "strings"
"time"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
+ "git.elijahkuntz.com/Elijah/drive/middleware"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
- "golang.org/x/crypto/bcrypt"
)
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"})
}
- // Validate path
- fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(req.Path))
+ // Validate path using resolveSafe
+ 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)
if err != nil {
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
if info.IsDir() {
isDirInt = 1
@@ -52,10 +58,9 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error {
id := uuid.New().String()
var hash *string
if req.Password != "" {
- hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
+ encoded, err := hashPassword(req.Password)
if err == nil {
- s := string(hashed)
- hash = &s
+ hash = &encoded
}
}
@@ -73,7 +78,7 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error {
_, err = h.DB.Exec(`
INSERT INTO share_links (token, target_path, is_dir, password_hash, expires_at, max_downloads, download_count)
VALUES (?, ?, ?, ?, ?, ?, 0)
- `, id, req.Path, isDirInt, hash, expiresAt, maxDownloads)
+ `, id, cleanPath, isDirInt, hash, expiresAt, maxDownloads)
if err != nil {
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 == "" {
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"})
}
}
@@ -199,14 +204,14 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error {
// Send file info
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)
+ targetRelPath = filepath.Join(targetRelPath, filepath.Clean(filepath.FromSlash(subPath)))
+ }
+
+ fullPath, err := resolveSafe(h.Config.StorageDir, targetRelPath)
+ 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)
if err != nil {
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)
}
+// 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.
// GET /api/public/download/:id
func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error {
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 hash *string
var expiresAt *time.Time
var maxDownloads *int
var downloads int
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 = ?
- `, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads)
+ `, id).Scan(&path, &expiresAt, &maxDownloads, &downloads)
if err != nil {
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")
}
- 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))
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)
+ targetRelPath = filepath.Join(targetRelPath, filepath.Clean(filepath.FromSlash(subPath)))
+ }
+
+ fullPath, err := resolveSafe(h.Config.StorageDir, targetRelPath)
+ if err != nil {
+ return c.Status(fiber.StatusForbidden).SendString("invalid path")
}
- fullPath := filepath.Join(h.Config.StorageDir, targetRelPath)
info, err := os.Stat(fullPath)
if err != nil {
return c.Status(fiber.StatusNotFound).SendString("not found")
diff --git a/backend/handlers/tus.go b/backend/handlers/tus.go
index 4ef855d..64aa88a 100644
--- a/backend/handlers/tus.go
+++ b/backend/handlers/tus.go
@@ -84,7 +84,11 @@ func (h *TusHandler) Create(c *fiber.Ctx) error {
}
// 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)
// Generate unique upload ID
@@ -184,17 +188,28 @@ func (h *TusHandler) Patch(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "seek failed"})
}
- body := c.Body()
- n, err := f.Write(body)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"})
+ reader := c.Context().RequestBodyStream()
+ if reader == nil {
+ // Fallback for smaller payloads or if streaming isn't fully active
+ body := c.Body()
+ n, err := f.Write(body)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"})
+ }
+
+ h.mu.Lock()
+ upload.Offset += int64(n)
+ 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()
}
- // Update offset
- h.mu.Lock()
- upload.Offset += int64(n)
- h.mu.Unlock()
-
c.Set("Tus-Resumable", "1.0.0")
c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10))
diff --git a/backend/handlers/utils.go b/backend/handlers/utils.go
index 3432bbb..d692779 100644
--- a/backend/handlers/utils.go
+++ b/backend/handlers/utils.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "strings"
)
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
+}
diff --git a/backend/handlers/webdav.go b/backend/handlers/webdav.go
index ac19ff1..3eb109d 100644
--- a/backend/handlers/webdav.go
+++ b/backend/handlers/webdav.go
@@ -3,10 +3,13 @@ package handlers
import (
"encoding/base64"
"fmt"
+ "mime"
"net/http"
"os"
"path/filepath"
"strings"
+ "sync"
+ "time"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
@@ -19,6 +22,9 @@ type WebDAVHandler struct {
Handler *webdav.Handler
DB *database.DB
Config *config.Config
+
+ authCache map[string]time.Time
+ cacheMu sync.RWMutex
}
func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler {
@@ -39,9 +45,10 @@ func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler {
}
return &WebDAVHandler{
- Handler: h,
- DB: db,
- Config: cfg,
+ Handler: h,
+ DB: db,
+ Config: cfg,
+ authCache: make(map[string]time.Time),
}
}
@@ -63,19 +70,33 @@ func (h *WebDAVHandler) Handle(c *fiber.Ctx) error {
}
if hasBasicAuth {
- // 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 {
- c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
- return c.SendStatus(fiber.StatusUnauthorized)
- }
+ cacheKey := user + ":" + pass
- // In a real implementation we would compare the Argon2 hash here.
- // For simplicity, we assume the frontend uses an app password or we verify.
- // Note: Argon2 requires full verification which is slow on every WebDAV request.
- // It's recommended to implement an App Password system for WebDAV.
- _ = pass
+ h.cacheMu.RLock()
+ expireTime, ok := h.authCache[cacheKey]
+ h.cacheMu.RUnlock()
+
+ 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 {
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
return c.SendStatus(fiber.StatusUnauthorized)
@@ -107,11 +128,11 @@ func (h *WebDAVHandler) syncToDB(relPath string) {
checksum, _ := generateChecksum(fullPath) // Reusing the helper from files.go
cleanPath := filepath.ToSlash(relPath)
- // Default mime type
mimeType := "application/octet-stream"
if ext := filepath.Ext(cleanPath); ext != "" {
- // Go's built-in mime types
- // Note: mime.TypeByExtension is handled by the DB trigger or just simple mapping
+ if m := mime.TypeByExtension(ext); m != "" {
+ mimeType = m
+ }
}
h.DB.Exec(`
diff --git a/backend/main.go b/backend/main.go
index 7316c18..bc9b1f1 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -75,6 +75,15 @@ func main() {
workers.StartBackupWorker(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
app := fiber.New(fiber.Config{
BodyLimit: 100 * 1024 * 1024 * 1024, // 100 GB (for chunked uploads, each chunk is smaller)
@@ -94,8 +103,13 @@ func main() {
return c.Next()
})
app.Use(helmet.New())
+ allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
+ if allowedOrigins == "" {
+ allowedOrigins = "http://localhost:3000"
+ }
+
app.Use(cors.New(cors.Config{
- AllowOrigins: "*",
+ AllowOrigins: allowedOrigins,
AllowHeaders: "Origin, Content-Type, Accept, Authorization, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Resumable",
AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD",
ExposeHeaders: "Location, Upload-Offset, Upload-Length, Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size",
@@ -116,6 +130,7 @@ func main() {
Max: 20,
Expiration: 1 * time.Minute,
}))
+ public.Post("/share/:id/token", shareHandler.CreateShareToken)
public.Post("/share/:id", shareHandler.GetPublicShare)
public.Get("/download/:id", shareHandler.DownloadPublicShare)
@@ -125,6 +140,12 @@ func main() {
// === PROTECTED ROUTES (require JWT) ===
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
protected.Post("/auth/refresh", authHandler.Refresh)
@@ -137,6 +158,9 @@ func main() {
protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA)
protected.Post("/auth/2fa/disable", authHandler.Disable2FA)
+ // Download tokens
+ protected.Get("/auth/download-token", authHandler.GetDownloadToken)
+
// Settings & Audit
protected.Get("/settings", settingsHandler.GetSettings)
protected.Put("/settings", settingsHandler.UpdateSettings)
@@ -162,8 +186,6 @@ func main() {
protected.Post("/files/rename", fsHandler.Rename)
protected.Post("/files/move", fsHandler.Move)
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.Get("/files/folders-tree", fsHandler.GetFolderTree)
@@ -172,6 +194,8 @@ func main() {
files := protected.Group("/files", middleware.PathJail(cfg.StorageDir))
files.Get("/info/*", fsHandler.GetExtendedFileInfo)
files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail)
+ files.Get("/download-folder", fsHandler.DownloadFolder)
+ files.Post("/download-bulk", fsHandler.DownloadBulk)
files.Get("/download/*", fsHandler.Download)
files.Post("/zip", archiveHandler.Zip)
files.Post("/unzip", archiveHandler.Unzip)
diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go
index 70d2c48..777dea4 100644
--- a/backend/middleware/auth.go
+++ b/backend/middleware/auth.go
@@ -21,9 +21,10 @@ func AuthMiddleware(jwtSecret string) fiber.Handler {
}
}
- // Fallback to query parameter for img/video tags
+ isQueryToken := false
if tokenString == "" {
tokenString = c.Query("token")
+ isQueryToken = true
}
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("username", claims["username"])
@@ -86,3 +98,39 @@ func GenerateRefreshToken(userID int, username, secret string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
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
+}
diff --git a/backend/middleware/bruteforce.go b/backend/middleware/bruteforce.go
index b84b78b..ab163e8 100644
--- a/backend/middleware/bruteforce.go
+++ b/backend/middleware/bruteforce.go
@@ -46,22 +46,21 @@ func (g *BruteForceGuard) Check() fiber.Handler {
g.mu.Lock()
attempt, exists := g.attempts[ip]
- g.mu.Unlock()
if exists && attempt.Count >= g.maxAttempts {
lockoutDuration := time.Duration(g.lockoutSecs) * time.Second
if time.Since(attempt.LockedAt) < lockoutDuration {
remaining := lockoutDuration - time.Since(attempt.LockedAt)
+ g.mu.Unlock()
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": "too many login attempts, account temporarily locked",
"retry_after": int(remaining.Seconds()),
})
}
// Lockout expired, reset
- g.mu.Lock()
delete(g.attempts, ip)
- g.mu.Unlock()
}
+ g.mu.Unlock()
return c.Next()
}
diff --git a/backend/workers/tasks.go b/backend/workers/tasks.go
index a5c20d8..be3ef10 100644
--- a/backend/workers/tasks.go
+++ b/backend/workers/tasks.go
@@ -208,13 +208,11 @@ func UnzipAsync(taskID string, sourceFull, destFolder string) {
for i, f := range r.File {
// Prevent Zip Slip vulnerability
- cleanPath := filepath.Clean(f.Name)
- if strings.HasPrefix(cleanPath, "..") {
+ fpath := filepath.Join(destFolder, f.Name)
+ if !strings.HasPrefix(fpath, filepath.Clean(destFolder)+string(os.PathSeparator)) {
continue // Skip malicious paths
}
- fpath := filepath.Join(destFolder, cleanPath)
-
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, os.ModePerm)
continue
diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx
index 1ce7d8d..731ba46 100644
--- a/frontend/src/components/FileExplorer.tsx
+++ b/frontend/src/components/FileExplorer.tsx
@@ -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 [key, setKey] = useState(0);
const [showFallback, setShowFallback] = useState(false);
@@ -168,7 +168,7 @@ function ThumbnailImage({ checksum, fallbackIcon }: { checksum: string; fallback
<>
0 ? `?t=${key}` : ''}`}
+ src={downloadToken ? `${api.getThumbnailUrl(checksum, downloadToken)}${errorCount > 0 ? `&t=${key}` : ''}` : ''}
alt=""
className="w-full h-full object-cover"
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 [initialSelectedFiles, setInitialSelectedFiles] = useState>(new Set());
const searchTimeoutRef = useRef(undefined);
+ const [downloadToken, setDownloadToken] = useState('');
+
+ // 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(null);
const fileInputRef = useRef(null);
@@ -810,7 +830,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
if (isMedia && file.checksum && viewMode === 'grid') {
return (
-
+
);
}
@@ -1496,18 +1516,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
style={{ color: 'var(--color-text-primary)' }}
title="Download Selected"
onClick={() => {
+ if (!downloadToken) return;
const form = document.createElement('form');
form.method = 'POST';
- form.action = api.getBulkDownloadUrl();
+ form.action = api.getBulkDownloadUrl(downloadToken);
form.target = '_blank';
- Array.from(selectedFiles).forEach(path => {
- const input = document.createElement('input');
- input.type = 'hidden';
- input.name = 'paths';
- input.value = path;
- form.appendChild(input);
- });
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = 'paths';
+ input.value = JSON.stringify(Array.from(selectedFiles));
+ form.appendChild(input);
document.body.appendChild(form);
form.submit();
@@ -1598,17 +1617,27 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
setContextMenu(null);
}}
/>
- }
- label="Download"
- onClick={() => {
- const downloadUrl = contextMenu.file.is_dir
- ? api.getDownloadFolderUrl(contextMenu.file.path)
- : api.getDownloadUrl(contextMenu.file.path);
- window.open(downloadUrl, '_blank');
- setContextMenu(null);
- }}
- />
+ {contextMenu.file.is_dir ? (
+ }
+ label="Download Folder"
+ onClick={() => {
+ if (!downloadToken) return;
+ window.location.href = api.getDownloadFolderUrl(contextMenu.file.path, downloadToken);
+ setContextMenu(null);
+ }}
+ />
+ ) : (
+ }
+ label="Download"
+ onClick={() => {
+ if (!downloadToken) return;
+ window.location.href = api.getDownloadUrl(contextMenu.file.path, downloadToken);
+ setContextMenu(null);
+ }}
+ />
+ )}
}
label="Rename"
@@ -1767,7 +1796,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
- setPreviewFile(null)} />
+ setPreviewFile(null)} downloadToken={downloadToken} />
{sharingFile && (
setSharingFile(null)} />
diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx
index f1447aa..1a704d8 100644
--- a/frontend/src/components/FilePreview.tsx
+++ b/frontend/src/components/FilePreview.tsx
@@ -18,6 +18,7 @@ interface FilePreviewProps {
mime_type: string;
} | null;
onClose: () => void;
+ downloadToken?: string;
}
function getFileDisplayName(name: string) {
@@ -26,7 +27,7 @@ function getFileDisplayName(name: string) {
return name;
}
-export default function FilePreview({ file, onClose }: FilePreviewProps) {
+export default function FilePreview({ file, onClose, downloadToken }: FilePreviewProps) {
const [content, setContent] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -53,7 +54,8 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
setLoading(true);
setError(null);
- fetch(api.getDownloadUrl(file.path))
+ if (!downloadToken) return;
+ fetch(api.getDownloadUrl(file.path, downloadToken))
.then((res) => {
if (!res.ok) throw new Error('Failed to load file content');
return res.text();
@@ -67,7 +69,7 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
if (!file) return null;
const previewType = getPreviewType(file);
- const downloadUrl = api.getDownloadUrl(file.path);
+ const downloadUrl = downloadToken ? api.getDownloadUrl(file.path, downloadToken) : '';
return (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index d4c1d1c..ee852b1 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -227,24 +227,32 @@ class ApiClient {
});
}
- getDownloadUrl(path: string) {
- const url = `${API_BASE}/api/files/download/?path=${encodeURIComponent(path)}`;
- return this.accessToken ? `${url}&token=${this.accessToken}` : url;
+ async createDownloadToken(): Promise {
+ const res = await fetch(`${API_BASE}/api/auth/download-token`, {
+ 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) {
- const url = `${API_BASE}/api/files/download-folder?path=${encodeURIComponent(path)}`;
- return this.accessToken ? `${url}&token=${this.accessToken}` : url;
+ getDownloadUrl(path: string, token: string) {
+ return `${API_BASE}/api/files/download/?path=${encodeURIComponent(path)}&token=${token}`;
}
- getBulkDownloadUrl() {
- const url = `${API_BASE}/api/files/download-bulk`;
- return this.accessToken ? `${url}?token=${this.accessToken}` : url;
+ getDownloadFolderUrl(path: string, token: string) {
+ return `${API_BASE}/api/files/download-folder?path=${encodeURIComponent(path)}&token=${token}`;
}
- getThumbnailUrl(checksum: string) {
- const url = `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}`;
- return this.accessToken ? `${url}?token=${this.accessToken}` : url;
+ getBulkDownloadUrl(token: string) {
+ return `${API_BASE}/api/files/download-bulk?token=${token}`;
+ }
+
+ getThumbnailUrl(checksum: string, token: string) {
+ return `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}?token=${token}`;
}
getStreamHeaders(): Record {
@@ -379,10 +387,21 @@ class ApiClient {
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}`;
const params = new URLSearchParams();
- if (password) params.set('pwd', password);
+ if (token) params.set('token', token);
if (path) params.set('path', path);
const qs = params.toString();
if (qs) {