package handlers import ( "archive/zip" "bufio" "fmt" "mime" "os" "path/filepath" "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" ) type ShareHandler struct { DB *database.DB Config *config.Config Guard *middleware.BruteForceGuard } type ShareRequest struct { Path string `json:"path"` Password string `json:"password,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` // Hours MaxDownloads int `json:"max_downloads,omitempty"` } // CreateShare creates a new public share link. // POST /api/share func (h *ShareHandler) CreateShare(c *fiber.Ctx) error { var req ShareRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) } // 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 } id := uuid.New().String() var hash *string if req.Password != "" { encoded, err := hashPassword(req.Password) if err == nil { hash = &encoded } } var expiresAt *time.Time if req.ExpiresIn > 0 { t := time.Now().Add(time.Duration(req.ExpiresIn) * time.Hour) expiresAt = &t } var maxDownloads *int if req.MaxDownloads > 0 { maxDownloads = &req.MaxDownloads } _, err = h.DB.Exec(` INSERT INTO share_links (token, target_path, is_dir, password_hash, expires_at, max_downloads, download_count) VALUES (?, ?, ?, ?, ?, ?, 0) `, id, cleanPath, isDirInt, hash, expiresAt, maxDownloads) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create share"}) } h.DB.AddAuditLog("share_created", fmt.Sprintf("Shared %s (ID: %s)", req.Path, id), c.IP()) return c.JSON(fiber.Map{ "id": id, "link": fmt.Sprintf("/share/%s", id), // Frontend route }) } // ListShares returns all active shares. // GET /api/share func (h *ShareHandler) ListShares(c *fiber.Ctx) error { rows, err := h.DB.Query(` SELECT token, target_path, expires_at, download_count, max_downloads, created_at, (password_hash IS NOT NULL AND password_hash != '') as has_password FROM share_links ORDER BY created_at DESC `) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"}) } defer rows.Close() var shares []map[string]interface{} for rows.Next() { var token, path, createdAt string var expiresAt *string var downloads int var maxDownloads *int var hasPassword bool rows.Scan(&token, &path, &expiresAt, &downloads, &maxDownloads, &createdAt, &hasPassword) // Skip expired shares if expiresAt != nil { t, err := time.Parse(time.RFC3339, *expiresAt) if err != nil { t, err = time.Parse("2006-01-02 15:04:05", *expiresAt) } if err == nil && time.Now().After(t) { continue } } shares = append(shares, map[string]interface{}{ "id": token, "path": path, "expires_at": expiresAt, "downloads": downloads, "max_downloads": maxDownloads, "created_at": createdAt, "has_password": hasPassword, }) } return c.JSON(fiber.Map{"shares": shares}) } // RevokeShare removes a share. // DELETE /api/share/:id func (h *ShareHandler) RevokeShare(c *fiber.Ctx) error { id := c.Params("id") _, err := h.DB.Exec(`DELETE FROM share_links WHERE token = ?`, id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to revoke"}) } h.DB.AddAuditLog("share_revoked", fmt.Sprintf("Revoked share %s", id), c.IP()) return c.JSON(fiber.Map{"message": "revoked"}) } // GetPublicShare returns share info without auth (used by the public page). // POST /api/public/share/:id // Body might contain {"password": "..."} func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error { id := c.Params("id") var req struct { Password string `json:"password"` } c.BodyParser(&req) 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 FROM share_links WHERE token = ? `, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "share not found"}) } // Check expiry if expiresAt != nil && time.Now().After(*expiresAt) { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "link expired"}) } // Check download limits (we only count actual downloads, not views) if maxDownloads != nil && downloads >= *maxDownloads { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "download limit reached"}) } // Check password if hash != nil { if req.Password == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"require_password": true}) } if !verifyPassword(req.Password, *hash) { if h.Guard != nil { h.Guard.RecordFailure(c.IP()) } return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"}) } if h.Guard != nil { h.Guard.RecordSuccess(c.IP()) } } // Send file info targetRelPath := filepath.Clean(filepath.FromSlash(path)) if subPath := c.Query("path"); subPath != "" { 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"}) } info, err := os.Stat(fullPath) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"}) } mimeType := mime.TypeByExtension(filepath.Ext(info.Name())) if mimeType == "" { mimeType = "application/octet-stream" } getDirSize := func(p string) int64 { var s int64 filepath.Walk(p, func(_ string, i os.FileInfo, err error) error { if err == nil && !i.IsDir() { s += i.Size() } return nil }) return s } size := info.Size() if info.IsDir() { size = getDirSize(fullPath) } response := fiber.Map{ "name": info.Name(), // Use info.Name() instead of base of original path "size": size, "is_dir": info.IsDir(), "mime_type": mimeType, } if info.IsDir() { var files []fiber.Map entries, err := os.ReadDir(fullPath) if err == nil { for _, entry := range entries { entryInfo, err := entry.Info() if err != nil { continue } eMime := mime.TypeByExtension(filepath.Ext(entry.Name())) if eMime == "" { eMime = "application/octet-stream" } entrySize := entryInfo.Size() if entry.IsDir() { entrySize = getDirSize(filepath.Join(fullPath, entry.Name())) } files = append(files, fiber.Map{ "name": entry.Name(), "size": entrySize, "is_dir": entry.IsDir(), "mime_type": eMime, }) } } response["files"] = files } 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) { if h.Guard != nil { h.Guard.RecordFailure(c.IP()) } return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"}) } if h.Guard != nil { h.Guard.RecordSuccess(c.IP()) } } // Generate a short-lived download token with the share ID 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") // 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 expiresAt *time.Time var maxDownloads *int var downloads int err := h.DB.QueryRow(` SELECT target_path, expires_at, max_downloads, download_count FROM share_links WHERE token = ? `, id).Scan(&path, &expiresAt, &maxDownloads, &downloads) if err != nil { return c.Status(fiber.StatusNotFound).SendString("not found") } if expiresAt != nil && time.Now().After(*expiresAt) { return c.Status(fiber.StatusForbidden).SendString("expired") } if maxDownloads != nil && downloads >= *maxDownloads { return c.Status(fiber.StatusForbidden).SendString("limit reached") } targetRelPath := filepath.Clean(filepath.FromSlash(path)) if subPath := c.Query("path"); subPath != "" { 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") } info, err := os.Stat(fullPath) if err != nil { return c.Status(fiber.StatusNotFound).SendString("not found") } // Increment download counter h.DB.Exec(`UPDATE share_links SET download_count = download_count + 1 WHERE token = ?`, id) h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s path %s", id, targetRelPath), c.IP()) if info.IsDir() { c.Set("Content-Type", "application/zip") c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", filepath.Base(fullPath))) c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { zipWriter := zip.NewWriter(w) defer zipWriter.Close() filepath.Walk(fullPath, func(p string, i os.FileInfo, e error) error { if e != nil || p == fullPath { return e } relPath, e := filepath.Rel(fullPath, p) if e != nil { return e } if i.IsDir() { relPath += "/" _, e = zipWriter.Create(relPath) return e } zipFile, e := zipWriter.Create(relPath) if e != nil { return e } fsFile, e := os.Open(p) if e != nil { return e } defer fsFile.Close() // Write file to zip _, e = bufio.NewReader(fsFile).WriteTo(zipFile) return e }) }) return nil } return c.SendFile(fullPath) }