All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s
255 lines
7.2 KiB
Go
255 lines
7.2 KiB
Go
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))
|
|
info, err := os.Stat(fullPath)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
|
}
|
|
|
|
isDirInt := 0
|
|
if info.IsDir() {
|
|
isDirInt = 1
|
|
}
|
|
|
|
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 (token, target_path, is_dir, password_hash, expires_at, max_downloads, download_count)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
|
`, id, req.Path, 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("2006-01-02 15:04:05-07:00", *expiresAt)
|
|
if err == nil && time.Now().After(t) {
|
|
// To handle different time formats, we could parse it more robustly
|
|
// but for now, we'll let frontend also check or we just check here.
|
|
}
|
|
t2, err2 := time.Parse(time.RFC3339, *expiresAt)
|
|
if err2 == nil && time.Now().After(t2) {
|
|
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 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 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).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 download_count = download_count + 1 WHERE token = ?`, id)
|
|
h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s", id), c.IP())
|
|
|
|
return c.SendFile(fullPath)
|
|
}
|