Inital build
Some checks failed
Automated Container Build / build-and-push (push) Failing after 12s

This commit is contained in:
Elijah 2026-05-22 12:29:43 -07:00
parent fa2be029a2
commit 724d70e58b
3339 changed files with 1075535 additions and 0 deletions

322
backend/handlers/tus.go Normal file
View 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
}