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)
|
||||
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())
|
||||
}
|
||||
|
||||
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"
|
||||
"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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Reference in a new issue