This commit is contained in:
parent
fa2be029a2
commit
724d70e58b
3339 changed files with 1075535 additions and 0 deletions
322
backend/handlers/tus.go
Normal file
322
backend/handlers/tus.go
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"git.elijahkuntz.com/Elijah/drive/workers"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/cespare/xxhash/v2"
|
||||
)
|
||||
|
||||
// TusHandler implements a simplified tus v1.0.0 protocol for resumable uploads.
|
||||
// This handles the core Creation and Termination extensions needed for reliable
|
||||
// large file uploads over unstable connections.
|
||||
type TusHandler struct {
|
||||
DB *database.DB
|
||||
Config *config.Config
|
||||
mu sync.RWMutex
|
||||
uploads map[string]*tusUpload
|
||||
}
|
||||
|
||||
type tusUpload struct {
|
||||
ID string
|
||||
FilePath string // final destination path (relative)
|
||||
FileName string
|
||||
FileSize int64
|
||||
Offset int64
|
||||
TempPath string // path to the incomplete upload on disk
|
||||
MimeType string
|
||||
}
|
||||
|
||||
func NewTusHandler(db *database.DB, cfg *config.Config) *TusHandler {
|
||||
return &TusHandler{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
uploads: make(map[string]*tusUpload),
|
||||
}
|
||||
}
|
||||
|
||||
// Options returns tus protocol capability headers.
|
||||
// OPTIONS /api/tus
|
||||
func (h *TusHandler) Options(c *fiber.Ctx) error {
|
||||
c.Set("Tus-Resumable", "1.0.0")
|
||||
c.Set("Tus-Version", "1.0.0")
|
||||
c.Set("Tus-Extension", "creation,termination")
|
||||
c.Set("Tus-Max-Size", "107374182400") // 100 GB
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// Create initializes a new resumable upload.
|
||||
// POST /api/tus
|
||||
func (h *TusHandler) Create(c *fiber.Ctx) error {
|
||||
uploadLength := c.Get("Upload-Length")
|
||||
if uploadLength == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Upload-Length header is required"})
|
||||
}
|
||||
|
||||
fileSize, err := strconv.ParseInt(uploadLength, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid Upload-Length"})
|
||||
}
|
||||
|
||||
// Parse metadata (contains filename and destination path)
|
||||
metadata := parseTusMetadata(c.Get("Upload-Metadata"))
|
||||
fileName := metadata["filename"]
|
||||
destPath := metadata["path"]
|
||||
|
||||
if fileName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "filename metadata is required"})
|
||||
}
|
||||
if destPath == "" {
|
||||
destPath = "."
|
||||
}
|
||||
|
||||
// Generate unique upload ID
|
||||
uploadID, err := generateUploadID()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate upload ID"})
|
||||
}
|
||||
|
||||
// Create temp file for the incomplete upload
|
||||
tempDir := filepath.Join(h.Config.DataDir, "uploads")
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
tempPath := filepath.Join(tempDir, uploadID)
|
||||
|
||||
// Create the empty temp file
|
||||
f, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create upload"})
|
||||
}
|
||||
f.Close()
|
||||
|
||||
upload := &tusUpload{
|
||||
ID: uploadID,
|
||||
FilePath: filepath.ToSlash(filepath.Join(destPath, fileName)),
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
Offset: 0,
|
||||
TempPath: tempPath,
|
||||
MimeType: mime.TypeByExtension(filepath.Ext(fileName)),
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.uploads[uploadID] = upload
|
||||
h.mu.Unlock()
|
||||
|
||||
c.Set("Tus-Resumable", "1.0.0")
|
||||
c.Set("Location", fmt.Sprintf("/api/tus/%s", uploadID))
|
||||
c.Set("Upload-Offset", "0")
|
||||
|
||||
return c.SendStatus(fiber.StatusCreated)
|
||||
}
|
||||
|
||||
// Head returns the current offset of an upload (for resuming).
|
||||
// HEAD /api/tus/:id
|
||||
func (h *TusHandler) Head(c *fiber.Ctx) error {
|
||||
uploadID := c.Params("id")
|
||||
|
||||
h.mu.RLock()
|
||||
upload, exists := h.uploads[uploadID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
c.Set("Tus-Resumable", "1.0.0")
|
||||
c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10))
|
||||
c.Set("Upload-Length", strconv.FormatInt(upload.FileSize, 10))
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
// Patch appends a chunk of data to an existing upload.
|
||||
// PATCH /api/tus/:id
|
||||
func (h *TusHandler) Patch(c *fiber.Ctx) error {
|
||||
uploadID := c.Params("id")
|
||||
|
||||
h.mu.RLock()
|
||||
upload, exists := h.uploads[uploadID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
// Validate offset matches
|
||||
clientOffset := c.Get("Upload-Offset")
|
||||
if clientOffset == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Upload-Offset header required"})
|
||||
}
|
||||
|
||||
offset, err := strconv.ParseInt(clientOffset, 10, 64)
|
||||
if err != nil || offset != upload.Offset {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||
"error": "offset mismatch",
|
||||
"expected_offset": upload.Offset,
|
||||
})
|
||||
}
|
||||
|
||||
// Open temp file at the correct offset and write the chunk
|
||||
f, err := os.OpenFile(upload.TempPath, os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open upload file"})
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Seek(upload.Offset, io.SeekStart); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "seek failed"})
|
||||
}
|
||||
|
||||
body := c.Body()
|
||||
n, err := f.Write(body)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"})
|
||||
}
|
||||
|
||||
// Update offset
|
||||
h.mu.Lock()
|
||||
upload.Offset += int64(n)
|
||||
h.mu.Unlock()
|
||||
|
||||
c.Set("Tus-Resumable", "1.0.0")
|
||||
c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10))
|
||||
|
||||
// Check if upload is complete
|
||||
if upload.Offset >= upload.FileSize {
|
||||
go h.finalizeUpload(upload, c.IP())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// Delete cancels an in-progress upload (Termination extension).
|
||||
// DELETE /api/tus/:id
|
||||
func (h *TusHandler) Delete(c *fiber.Ctx) error {
|
||||
uploadID := c.Params("id")
|
||||
|
||||
h.mu.Lock()
|
||||
upload, exists := h.uploads[uploadID]
|
||||
if exists {
|
||||
delete(h.uploads, uploadID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
os.Remove(upload.TempPath)
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// finalizeUpload moves the completed temp file to its destination and computes checksum.
|
||||
func (h *TusHandler) finalizeUpload(upload *tusUpload, ip string) {
|
||||
// Resolve final destination
|
||||
destFull := filepath.Join(h.Config.StorageDir, filepath.FromSlash(upload.FilePath))
|
||||
|
||||
// Ensure parent dir exists
|
||||
os.MkdirAll(filepath.Dir(destFull), 0755)
|
||||
|
||||
// Version existing file if it exists
|
||||
CreateVersion(h.DB, h.Config, destFull, filepath.ToSlash(upload.FilePath))
|
||||
|
||||
// Move temp file to final location
|
||||
if err := os.Rename(upload.TempPath, destFull); err != nil {
|
||||
fmt.Printf("Error finalizing upload %s: %v\n", upload.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate XXHash checksum
|
||||
checksum := ""
|
||||
if f, err := os.Open(destFull); err == nil {
|
||||
hasher := xxhash.New()
|
||||
io.Copy(hasher, f)
|
||||
f.Close()
|
||||
checksum = fmt.Sprintf("%016x", hasher.Sum64())
|
||||
}
|
||||
|
||||
// Upsert file record
|
||||
h.DB.Exec(`
|
||||
INSERT INTO files (path, name, size, mime_type, checksum)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
mime_type = excluded.mime_type,
|
||||
checksum = excluded.checksum,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, upload.FilePath, upload.FileName, upload.FileSize, upload.MimeType, checksum)
|
||||
|
||||
h.DB.AddAuditLog("upload_complete", fmt.Sprintf("%s (%d bytes, tus)", upload.FilePath, upload.FileSize), ip)
|
||||
|
||||
// Queue thumbnail generation
|
||||
if workers.Instance != nil {
|
||||
workers.Instance.Enqueue(workers.ThumbnailJob{
|
||||
FilePath: upload.FilePath,
|
||||
Checksum: checksum,
|
||||
MimeType: upload.MimeType,
|
||||
})
|
||||
}
|
||||
|
||||
// Remove from active uploads
|
||||
h.mu.Lock()
|
||||
delete(h.uploads, upload.ID)
|
||||
h.mu.Unlock()
|
||||
|
||||
fmt.Printf("✅ Upload finalized: %s (checksum: %s)\n", upload.FilePath, checksum)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func parseTusMetadata(header string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
if header == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
pairs := strings.Split(header, ",")
|
||||
for _, pair := range pairs {
|
||||
pair = strings.TrimSpace(pair)
|
||||
parts := strings.SplitN(pair, " ", 2)
|
||||
if len(parts) == 2 {
|
||||
// tus metadata values are base64 encoded
|
||||
decoded, err := decodeBase64(parts[1])
|
||||
if err == nil {
|
||||
result[parts[0]] = decoded
|
||||
}
|
||||
} else if len(parts) == 1 {
|
||||
result[parts[0]] = ""
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func decodeBase64(s string) (string, error) {
|
||||
// Try standard base64 first, then URL-safe
|
||||
decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(s)
|
||||
}
|
||||
return string(decoded), err
|
||||
}
|
||||
|
||||
func generateUploadID() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
Reference in a new issue