This repository has been archived on 2026-07-15. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
drive/backend/handlers/sharing.go
Elijah 27fd6fa4f5
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m16s
Minor appearance changes, sharing live preview
2026-05-22 19:06:21 -07:00

262 lines
7.3 KiB
Go

package handlers
import (
"fmt"
"mime"
"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
mimeType := mime.TypeByExtension(filepath.Ext(info.Name()))
if mimeType == "" {
mimeType = "application/octet-stream"
}
return c.JSON(fiber.Map{
"name": filepath.Base(path),
"size": info.Size(),
"is_dir": info.IsDir(),
"mime_type": mimeType,
})
}
// 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)
}