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 6772ba8c43
Some checks failed
Automated Container Build / build-and-push (push) Failing after 15s
feat: complete remediation phase 3
2026-05-26 14:23:50 -07:00

421 lines
11 KiB
Go

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)
}