This commit is contained in:
parent
fa2be029a2
commit
724d70e58b
3339 changed files with 1075535 additions and 0 deletions
236
backend/handlers/sharing.go
Normal file
236
backend/handlers/sharing.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type ShareHandler struct {
|
||||
DB *database.DB
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
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
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(req.Path))
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
var hash *string
|
||||
if req.Password != "" {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
|
||||
if err == nil {
|
||||
s := string(hashed)
|
||||
hash = &s
|
||||
}
|
||||
}
|
||||
|
||||
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 (id, file_path, password_hash, expires_at, max_downloads)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, id, req.Path, 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 id, file_path, expires_at, downloads, max_downloads, created_at,
|
||||
(password_hash IS NOT NULL) 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 id, path, createdAt string
|
||||
var expiresAt *string
|
||||
var downloads int
|
||||
var maxDownloads *int
|
||||
var hasPassword bool
|
||||
|
||||
rows.Scan(&id, &path, &expiresAt, &downloads, &maxDownloads, &createdAt, &hasPassword)
|
||||
|
||||
shares = append(shares, map[string]interface{}{
|
||||
"id": id,
|
||||
"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 id = ?`, 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 file_path, password_hash, expires_at, max_downloads, downloads
|
||||
FROM share_links WHERE id = ?
|
||||
`, 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 err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(req.Password)); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"})
|
||||
}
|
||||
}
|
||||
|
||||
// Send file info
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"})
|
||||
}
|
||||
|
||||
// We return a signed download token or just allow the immediate request.
|
||||
// For simplicity, we'll return a short-lived download token JWT.
|
||||
// Actually, easier: the frontend calls GET /api/public/download/:id?pwd=xxx
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"name": filepath.Base(path),
|
||||
"size": info.Size(),
|
||||
"is_dir": info.IsDir(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
var path string
|
||||
var hash *string
|
||||
var expiresAt *time.Time
|
||||
var maxDownloads *int
|
||||
var downloads int
|
||||
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT file_path, password_hash, expires_at, max_downloads, downloads
|
||||
FROM share_links WHERE id = ?
|
||||
`, id).Scan(&path, &hash, &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")
|
||||
}
|
||||
|
||||
if hash != nil {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(pwd)); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("unauthorized")
|
||||
}
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
||||
|
||||
// Increment download counter
|
||||
h.DB.Exec(`UPDATE share_links SET downloads = downloads + 1 WHERE id = ?`, id)
|
||||
h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s", id), c.IP())
|
||||
|
||||
return c.SendFile(fullPath)
|
||||
}
|
||||
Reference in a new issue