security: remediate vulnerabilities and issues from codebase audit
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s
This commit is contained in:
parent
b60a09196a
commit
fb3f3a3393
15 changed files with 465 additions and 208 deletions
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reader := c.Context().RequestBodyStream()
|
||||||
|
if reader == nil {
|
||||||
|
// Fallback for smaller payloads or if streaming isn't fully active
|
||||||
body := c.Body()
|
body := c.Body()
|
||||||
n, err := f.Write(body)
|
n, err := f.Write(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"})
|
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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -42,6 +48,7 @@ func NewWebDAVHandler(db *database.DB, cfg *config.Config) *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 {
|
||||||
|
cacheKey := user + ":" + pass
|
||||||
|
|
||||||
|
h.cacheMu.RLock()
|
||||||
|
expireTime, ok := h.authCache[cacheKey]
|
||||||
|
h.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
if !ok || time.Now().After(expireTime) {
|
||||||
// Verify basic auth against DB
|
// Verify basic auth against DB
|
||||||
var storedHash string
|
var storedHash string
|
||||||
err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash)
|
err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash)
|
||||||
if err != nil {
|
if err != nil || !verifyPassword(pass, storedHash) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// In a real implementation we would compare the Argon2 hash here.
|
// Cache successful auth for 5 minutes
|
||||||
// For simplicity, we assume the frontend uses an app password or we verify.
|
h.cacheMu.Lock()
|
||||||
// Note: Argon2 requires full verification which is slow on every WebDAV request.
|
h.authCache[cacheKey] = time.Now().Add(5 * time.Minute)
|
||||||
// It's recommended to implement an App Password system for WebDAV.
|
|
||||||
_ = pass
|
// 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(`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = path;
|
input.value = JSON.stringify(Array.from(selectedFiles));
|
||||||
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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{contextMenu.file.is_dir ? (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={<Download className="w-4 h-4" />}
|
||||||
|
label="Download Folder"
|
||||||
|
onClick={() => {
|
||||||
|
if (!downloadToken) return;
|
||||||
|
window.location.href = api.getDownloadFolderUrl(contextMenu.file.path, downloadToken);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={<Download className="w-4 h-4" />}
|
icon={<Download className="w-4 h-4" />}
|
||||||
label="Download"
|
label="Download"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const downloadUrl = contextMenu.file.is_dir
|
if (!downloadToken) return;
|
||||||
? api.getDownloadFolderUrl(contextMenu.file.path)
|
window.location.href = api.getDownloadUrl(contextMenu.file.path, downloadToken);
|
||||||
: api.getDownloadUrl(contextMenu.file.path);
|
|
||||||
window.open(downloadUrl, '_blank');
|
|
||||||
setContextMenu(null);
|
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)} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Reference in a new issue