Some checks failed
Automated Container Build / build-and-push (push) Failing after 15s
421 lines
11 KiB
Go
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)
|
|
}
|