734 lines
22 KiB
Go
734 lines
22 KiB
Go
// backend/main.go
|
|
package main
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"sort"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
"github.com/tus/tusd/pkg/filestore"
|
|
tusd "github.com/tus/tusd/pkg/handler"
|
|
"github.com/tus/tusd/pkg/memorylocker"
|
|
|
|
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
|
)
|
|
|
|
//go:embed web/dist
|
|
var webFS embed.FS
|
|
|
|
var (
|
|
mediaRoot = env("PEGASUS_MEDIA_ROOT", "/media")
|
|
userMapFile = env("PEGASUS_USER_MAP_FILE", "/config/user-map.yaml")
|
|
tusDir = env("PEGASUS_TUS_DIR", filepath.Join(mediaRoot, ".pegasus-tus"))
|
|
jf = internal.NewJellyfin()
|
|
)
|
|
|
|
var (
|
|
version = "dev" // set via -ldflags "-X main.version=1.2.0 -X main.git=$(git rev-parse --short HEAD) -X main.builtAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
git = ""
|
|
builtAt = ""
|
|
)
|
|
|
|
type loggingRW struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
type listEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
IsDir bool `json:"is_dir"`
|
|
Size int64 `json:"size"`
|
|
Mtime int64 `json:"mtime"`
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) }
|
|
|
|
func main() {
|
|
internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot)
|
|
|
|
if os.Getenv("PEGASUS_SESSION_KEY") == "" {
|
|
log.Fatal("PEGASUS_SESSION_KEY is not set")
|
|
}
|
|
|
|
um, err := internal.LoadUserMap(userMapFile)
|
|
must(err, "load user map")
|
|
if internal.Debug {
|
|
keys := make([]string, 0, len(um.Map))
|
|
for k := range um.Map { keys = append(keys, k) }
|
|
sort.Strings(keys)
|
|
show := keys
|
|
if len(keys) > 10 { show = keys[:10] }
|
|
internal.Logf("user-map loaded (%d): %v%s", len(keys), show, func() string {
|
|
if len(keys) > len(show) { return " ..." }
|
|
return ""
|
|
}())
|
|
}
|
|
|
|
// === tusd setup (resumable uploads) ===
|
|
store := filestore.FileStore{Path: tusDir}
|
|
// ensure upload scratch dir exists
|
|
if err := os.MkdirAll(tusDir, 0o2775); err != nil {
|
|
log.Fatalf("mkdir %s: %v", tusDir, err)
|
|
}
|
|
locker := memorylocker.New()
|
|
composer := tusd.NewStoreComposer()
|
|
store.UseIn(composer)
|
|
locker.UseIn(composer)
|
|
|
|
// completeC := make(chan tusd.HookEvent)
|
|
config := tusd.Config{
|
|
BasePath: "/tus/",
|
|
StoreComposer: composer,
|
|
NotifyCompleteUploads: true,
|
|
MaxSize: 8 * 1024 * 1024 * 1024, // 8GB per file
|
|
RespectForwardedHeaders: true, // <<< important for https behind Traefik
|
|
}
|
|
tusHandler, err := tusd.NewUnroutedHandler(config)
|
|
must(err, "init tus handler")
|
|
completeC := tusHandler.CompleteUploads
|
|
|
|
// ---- post-finish hook: enforce naming & mapping ----
|
|
go func() {
|
|
for ev := range completeC {
|
|
claims, err := claimsFromHook(ev)
|
|
if err != nil {
|
|
internal.Logf("tus: no session: %v", err)
|
|
continue
|
|
}
|
|
|
|
// read metadata set by the UI
|
|
meta := ev.Upload.MetaData
|
|
desc := strings.TrimSpace(meta["desc"])
|
|
date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty
|
|
subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
|
|
lib := strings.Trim(strings.TrimSpace(meta["lib"]), "/")
|
|
orig := meta["filename"]
|
|
if orig == "" { orig = "upload.bin" }
|
|
|
|
// Decide if description is required by extension (videos only)
|
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
|
|
isVideo := map[string]bool{
|
|
"mp4": true, "mkv": true, "mov": true, "avi": true, "m4v": true,
|
|
"webm": true, "mpg": true, "mpeg": true, "ts": true, "m2ts": true,
|
|
}[ext]
|
|
if isVideo && desc == "" {
|
|
log.Printf("tus: missing desc for video; rejecting")
|
|
return
|
|
}
|
|
if desc == "" { desc = "upload" } // images can default
|
|
|
|
// Resolve destination root under /media
|
|
var libRoot string
|
|
if lib != "" {
|
|
lib = sanitizeSegment(lib)
|
|
libRoot = filepath.Join(mediaRoot, lib) // explicit library
|
|
} else {
|
|
if rr, err := um.Resolve(claims.Username); err == nil && rr != "" {
|
|
libRoot = filepath.Join(mediaRoot, rr) // fallback to first mapped root
|
|
} else {
|
|
libRoot = "" // no valid root
|
|
}
|
|
}
|
|
|
|
// The library root must already exist; we only create one-level subfolders.
|
|
if libRoot == "" {
|
|
log.Printf("upload: library root not resolved for user %s", claims.Username)
|
|
continue
|
|
}
|
|
if fi, err := os.Stat(libRoot); err != nil || !fi.IsDir() {
|
|
log.Printf("upload: library root missing or not accessible: %s (%v)", libRoot, err)
|
|
continue
|
|
}
|
|
|
|
// Optional one-level subdir under the library
|
|
destRoot := libRoot
|
|
if subdir != "" {
|
|
subdir = sanitizeSegment(subdir)
|
|
destRoot = filepath.Join(libRoot, subdir)
|
|
if err := os.MkdirAll(destRoot, 0o2775); err != nil {
|
|
log.Printf("mkdir %s: %v", destRoot, err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Compose final filename and move from TUS scratch to final
|
|
finalName := composeFinalName(date, desc, orig)
|
|
dst := filepath.Join(destRoot, finalName)
|
|
if err := moveFromTus(ev, dst); err != nil {
|
|
log.Printf("move failed: %v", err)
|
|
continue
|
|
}
|
|
|
|
jf.RefreshLibrary(claims.JFToken)
|
|
}
|
|
}()
|
|
|
|
// === chi router ===
|
|
r := chi.NewRouter()
|
|
r.Use(corsForTus)
|
|
|
|
// health/version
|
|
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
r.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"version": version,
|
|
"git": git,
|
|
"built_at": builtAt,
|
|
})
|
|
})
|
|
|
|
// auth
|
|
r.Post("/api/login", func(w http.ResponseWriter, r *http.Request) {
|
|
var f struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
res, err := jf.AuthenticateByName(f.Username, f.Password)
|
|
if err != nil {
|
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
// ✅ ensure this username is mapped before creating a session
|
|
if _, err := um.Resolve(f.Username); err != nil {
|
|
internal.Logf("login ok but map missing for %q (JF name=%q)", f.Username, res.User.Name)
|
|
http.Error(w, "no mapping", http.StatusForbidden)
|
|
return
|
|
}
|
|
// ✅ store the typed login name in the session
|
|
if err := internal.SetSession(w, f.Username, res.AccessToken); err != nil {
|
|
http.Error(w, "session error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
})
|
|
|
|
r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) {
|
|
internal.ClearSession(w)
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
})
|
|
|
|
// whoami
|
|
r.Get("/api/whoami", func(w http.ResponseWriter, r *http.Request) {
|
|
cl, err := internal.CurrentUser(r)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
dr, err := um.Resolve(cl.Username)
|
|
if err != nil {
|
|
http.Error(w, "no mapping", http.StatusForbidden)
|
|
return
|
|
}
|
|
all, _ := um.ResolveAll(cl.Username) // ignore error here since Resolve succeeded
|
|
writeJSON(w, map[string]any{
|
|
"username": cl.Username,
|
|
"root": dr, // first root (back-compat)
|
|
"roots": all, // all roots (for future UI)
|
|
})
|
|
})
|
|
|
|
// list entries
|
|
r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) {
|
|
cl, err := internal.CurrentUser(r)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// choose library: requested (if allowed) or default
|
|
allRoots, _ := um.ResolveAll(cl.Username)
|
|
reqLib := sanitizeSegment(r.URL.Query().Get("lib"))
|
|
var rootRel string
|
|
if reqLib != "" && contains(allRoots, reqLib) {
|
|
rootRel = reqLib
|
|
} else {
|
|
rootRel, err = um.Resolve(cl.Username)
|
|
if err != nil {
|
|
internal.Logf("list: map missing for %q", cl.Username)
|
|
http.Error(w, "no mapping", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
// one-level subdir (optional)
|
|
q := sanitizeSegment(r.URL.Query().Get("path")) // clamp to single segment
|
|
|
|
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
dirAbs := rootAbs
|
|
if q != "" {
|
|
if dirAbs, err = internal.SafeJoin(rootAbs, q); err != nil {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
ents, err := os.ReadDir(dirAbs)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
out := make([]listEntry, 0, len(ents))
|
|
for _, d := range ents {
|
|
info, _ := d.Info()
|
|
var size int64
|
|
if info != nil && !d.IsDir() {
|
|
size = info.Size()
|
|
}
|
|
var mtime int64
|
|
if info != nil {
|
|
mtime = info.ModTime().Unix()
|
|
}
|
|
out = append(out, listEntry{
|
|
Name: d.Name(),
|
|
Path: filepath.Join(q, d.Name()),
|
|
IsDir: d.IsDir(),
|
|
Size: size,
|
|
Mtime: mtime,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, out)
|
|
})
|
|
|
|
// rename
|
|
var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}(?:\.[A-Za-z0-9_-]{1,64})?\.[A-Za-z0-9]{1,8}$`)
|
|
r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) {
|
|
cl, err := internal.CurrentUser(r)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
var p struct {
|
|
Lib string `json:"lib"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// enforce final name format on files (directories may be free-form one level)
|
|
if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) {
|
|
http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// choose library: requested (if allowed) or default
|
|
allRoots, _ := um.ResolveAll(cl.Username)
|
|
lib := sanitizeSegment(p.Lib)
|
|
var rootRel string
|
|
if lib != "" && contains(allRoots, lib) {
|
|
rootRel = lib
|
|
} else {
|
|
rootRel, _ = um.Resolve(cl.Username)
|
|
}
|
|
|
|
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
fromAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.From, "/"))
|
|
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
|
toAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.To, "/"))
|
|
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
|
|
|
_ = os.MkdirAll(filepath.Dir(toAbs), 0o2775)
|
|
if internal.DryRun {
|
|
internal.Logf("[DRY] mv %s -> %s", fromAbs, toAbs)
|
|
} else if err := os.Rename(fromAbs, toAbs); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError); return
|
|
}
|
|
jf.RefreshLibrary(cl.JFToken)
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
})
|
|
|
|
// delete
|
|
r.Delete("/api/file", func(w http.ResponseWriter, r *http.Request) {
|
|
cl, err := internal.CurrentUser(r)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// choose library: requested (if allowed) or default
|
|
allRoots, _ := um.ResolveAll(cl.Username)
|
|
lib := sanitizeSegment(r.URL.Query().Get("lib"))
|
|
var rootRel string
|
|
if lib != "" && contains(allRoots, lib) {
|
|
rootRel = lib
|
|
} else {
|
|
rootRel, _ = um.Resolve(cl.Username)
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Query().Get("path"), "/")
|
|
rec := r.URL.Query().Get("recursive") == "true"
|
|
|
|
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
abs, err := internal.SafeJoin(rootAbs, path)
|
|
if err != nil {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
if internal.DryRun {
|
|
internal.Logf("[DRY] rm%s %s", map[bool]string{true: " -r", false: ""}[rec], abs)
|
|
} else if rec {
|
|
err = os.RemoveAll(abs)
|
|
} else {
|
|
err = os.Remove(abs)
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
jf.RefreshLibrary(cl.JFToken)
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
})
|
|
|
|
// mkdir (create subdirectory under the user's mapped root)
|
|
r.Post("/api/mkdir", func(w http.ResponseWriter, r *http.Request) {
|
|
cl, err := internal.CurrentUser(r)
|
|
if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return }
|
|
|
|
var p struct {
|
|
Lib string `json:"lib"`
|
|
Path string `json:"path"` // single-level folder name
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest); return
|
|
}
|
|
|
|
// choose library: requested (if allowed) or default
|
|
allRoots, _ := um.ResolveAll(cl.Username)
|
|
lib := sanitizeSegment(p.Lib)
|
|
var rootRel string
|
|
if lib != "" && contains(allRoots, lib) {
|
|
rootRel = lib
|
|
} else {
|
|
rootRel, err = um.Resolve(cl.Username)
|
|
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
|
}
|
|
|
|
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
if fi, err := os.Stat(rootAbs); err != nil || !fi.IsDir() {
|
|
http.Error(w, "library not found", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
seg := sanitizeSegment(p.Path) // single-level + charset + spaces→_
|
|
if seg == "" {
|
|
http.Error(w, "folder name is empty", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
abs, err := internal.SafeJoin(rootAbs, seg)
|
|
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
|
|
|
if internal.DryRun {
|
|
internal.Logf("[DRY] mkdir -p %s", abs)
|
|
} else if err := os.MkdirAll(abs, 0o2775); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
})
|
|
|
|
// mount tus (behind auth)
|
|
r.Route("/tus", func(rt chi.Router) {
|
|
rt.Use(sessionRequired)
|
|
// Create upload: POST /tus/
|
|
rt.Post("/", tusHandler.PostFile)
|
|
// Upload resource endpoints: /tus/{id}
|
|
rt.Head("/{id}", tusHandler.HeadFile)
|
|
rt.Patch("/{id}", tusHandler.PatchFile)
|
|
rt.Delete("/{id}", tusHandler.DelFile)
|
|
rt.Get("/{id}", tusHandler.GetFile) // optional
|
|
})
|
|
// metrics
|
|
r.Handle("/metrics", promhttp.Handler())
|
|
|
|
// static app (serve embedded web/dist)
|
|
appFS, _ := fs.Sub(webFS, "web/dist")
|
|
static := http.FileServer(http.FS(appFS))
|
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
static.ServeHTTP(w, r)
|
|
})
|
|
// catch-all for SPA routes, GET only
|
|
r.Method("GET", "/*", http.StripPrefix("/", static))
|
|
|
|
// debug endpoints (registered before server starts)
|
|
r.Get("/debug/env", func(w http.ResponseWriter, r *http.Request) {
|
|
if !internal.Debug {
|
|
http.Error(w, "disabled", http.StatusForbidden)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"mediaRoot": mediaRoot,
|
|
"tusDir": tusDir,
|
|
"userMapFile": userMapFile,
|
|
})
|
|
})
|
|
r.Get("/debug/write-test", func(w http.ResponseWriter, r *http.Request) {
|
|
if !internal.Debug {
|
|
http.Error(w, "disabled", http.StatusForbidden)
|
|
return
|
|
}
|
|
cl, err := internal.CurrentUser(r)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
rootRel, err := um.Resolve(cl.Username)
|
|
if err != nil {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
test := filepath.Join(rootAbs, fmt.Sprintf("TEST.%d.txt", time.Now().Unix()))
|
|
if internal.DryRun {
|
|
internal.Logf("[DRY] write %s", test)
|
|
} else {
|
|
_ = os.WriteFile(test, []byte("ok\n"), 0o644)
|
|
}
|
|
writeJSON(w, map[string]string{"wrote": test})
|
|
})
|
|
|
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "no route for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
|
})
|
|
|
|
// ---- wrap router with verbose request logging in debug ----
|
|
root := http.Handler(r)
|
|
if internal.Debug {
|
|
root = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
id := time.Now().UnixNano()
|
|
internal.Logf(">> %d %s %s %v", id, req.Method, req.URL.Path, internal.RedactHeaders(req.Header))
|
|
rw := &loggingRW{ResponseWriter: w, status: 200}
|
|
start := time.Now()
|
|
r.ServeHTTP(rw, req)
|
|
internal.Logf("<< %d %s %s %d %s", id, req.Method, req.URL.Path, rw.status, time.Since(start))
|
|
})
|
|
}
|
|
|
|
addr := env("PEGASUS_BIND", ":8080")
|
|
log.Printf("Pegasus listening on %s", addr)
|
|
srv := &http.Server{Addr: addr, Handler: root, ReadTimeout: 0, WriteTimeout: 0}
|
|
log.Fatal(srv.ListenAndServe())
|
|
}
|
|
|
|
// === helpers & middleware ===
|
|
func sessionRequired(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if _, err := internal.CurrentUser(r); err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func corsForTus(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// CORS for tus (preflight must succeed; cookies allowed)
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin) // cannot be * when credentials=true
|
|
w.Header().Set("Vary", "Origin")
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,HEAD,PATCH,DELETE,OPTIONS")
|
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
|
w.Header().Set("Access-Control-Allow-Headers",
|
|
"Content-Type, Tus-Resumable, Upload-Length, Upload-Defer-Length, Upload-Metadata, Upload-Offset, Upload-Concat, Upload-Checksum, X-Requested-With")
|
|
w.Header().Set("Access-Control-Expose-Headers",
|
|
"Location, Upload-Offset, Upload-Length, Tus-Resumable, Upload-Checksum")
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func claimsFromHook(ev tusd.HookEvent) (internal.Claims, error) {
|
|
// Parse our session cookie from incoming request headers captured by tusd
|
|
req := ev.HTTPRequest
|
|
if req.Header == nil {
|
|
return internal.Claims{}, http.ErrNoCookie
|
|
}
|
|
// Re-create a dummy http.Request to reuse cookie parsing & jwt verification
|
|
r := http.Request{Header: http.Header(req.Header)}
|
|
return internal.CurrentUser(&r)
|
|
}
|
|
|
|
func env(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def }
|
|
func must(err error, msg string) { if err != nil { log.Fatalf("%s: %v", msg, err) } }
|
|
|
|
func sanitizeSegment(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.ReplaceAll(s, " ", "_")
|
|
s = strings.Trim(s, "/")
|
|
if i := strings.IndexByte(s, '/'); i >= 0 { s = s[:i] }
|
|
// allow only [A-Za-z0-9_.-]
|
|
out := make([]rune, 0, len(s))
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z',
|
|
r >= 'A' && r <= 'Z',
|
|
r >= '0' && r <= '9',
|
|
r == '_', r == '-', r == '.':
|
|
out = append(out, r)
|
|
default:
|
|
out = append(out, '_')
|
|
}
|
|
}
|
|
if len(out) > 64 { out = out[:64] }
|
|
return string(out)
|
|
}
|
|
|
|
func contains(ss []string, v string) bool {
|
|
for _, s := range ss {
|
|
if s == v { return true }
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sanitizeDescriptor keeps [A-Za-z0-9_-], converts whitespace to underscores,
|
|
// collapses runs, and limits to 64 chars. Used in final filenames.
|
|
func sanitizeDescriptor(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.ReplaceAll(s, " ", "_")
|
|
if s == "" { return "upload" }
|
|
var b []rune
|
|
lastUnderscore := false
|
|
for _, r := range s {
|
|
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-'
|
|
if !ok { r = '_' }
|
|
if r == '_' {
|
|
if lastUnderscore { continue }
|
|
lastUnderscore = true
|
|
} else {
|
|
lastUnderscore = false
|
|
}
|
|
b = append(b, r)
|
|
if len(b) >= 64 { break }
|
|
}
|
|
if len(b) == 0 { return "upload" }
|
|
return string(b)
|
|
}
|
|
|
|
// stemOf strips the final extension and cleans the base.
|
|
func stemOf(orig string) string {
|
|
base := strings.TrimSuffix(orig, filepath.Ext(orig))
|
|
s := sanitizeDescriptor(strings.ReplaceAll(base, ".", "_"))
|
|
if s == "" { s = "file" }
|
|
return s
|
|
}
|
|
|
|
// composeFinalName mirrors the UI: YYYY.MM.DD.Description.ext
|
|
// date is expected as YYYY-MM-DD; falls back to today if missing/bad.
|
|
func composeFinalName(date, desc, orig string) string {
|
|
var y, m, d string
|
|
if len(date) >= 10 && date[4] == '-' && date[7] == '-' {
|
|
y, m, d = date[:4], date[5:7], date[8:10]
|
|
} else {
|
|
now := time.Now()
|
|
y = fmt.Sprintf("%04d", now.Year())
|
|
m = fmt.Sprintf("%02d", int(now.Month()))
|
|
d = fmt.Sprintf("%02d", now.Day())
|
|
}
|
|
clean := sanitizeDescriptor(desc)
|
|
if clean == "" { clean = "upload" }
|
|
stem := stemOf(orig)
|
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
|
|
if ext == "" { ext = "bin" }
|
|
return fmt.Sprintf("%s.%s.%s.%s.%s.%s", y, m, d, clean, stem, ext)
|
|
}
|
|
|
|
// moveFromTus relocates the finished upload file from the TUS scratch directory to dst.
|
|
// Works with tusd's filestore by probing common data file names.
|
|
func moveFromTus(ev tusd.HookEvent, dst string) error {
|
|
// Likely data file names/locations used by tusd filestore across versions
|
|
candidates := []string{
|
|
filepath.Join(tusDir, ev.Upload.ID),
|
|
filepath.Join(tusDir, ev.Upload.ID+".bin"),
|
|
filepath.Join(tusDir, "data", ev.Upload.ID),
|
|
filepath.Join(tusDir, "data", ev.Upload.ID+".bin"),
|
|
filepath.Join(tusDir, "uploads", ev.Upload.ID),
|
|
filepath.Join(tusDir, "uploads", ev.Upload.ID+".bin"),
|
|
}
|
|
|
|
var src string
|
|
for _, p := range candidates {
|
|
if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() {
|
|
src = p
|
|
break
|
|
}
|
|
}
|
|
if src == "" {
|
|
// last resort: scan the directory for a file starting with the id
|
|
entries, _ := os.ReadDir(tusDir)
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasPrefix(e.Name(), ev.Upload.ID) {
|
|
src = filepath.Join(tusDir, e.Name())
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if src == "" {
|
|
return fmt.Errorf("tus data file not found for id %s", ev.Upload.ID)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o2775); err != nil {
|
|
return err
|
|
}
|
|
if internal.DryRun {
|
|
internal.Logf("[DRY] mv %s -> %s", src, dst)
|
|
return nil
|
|
}
|
|
|
|
// Try fast rename; if it crosses devices, fall back to copy+remove.
|
|
if err := os.Rename(src, dst); err != nil {
|
|
in, err2 := os.Open(src)
|
|
if err2 != nil { return err2 }
|
|
defer in.Close()
|
|
out, err3 := os.Create(dst)
|
|
if err3 != nil { return err3 }
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
_ = out.Close()
|
|
return err
|
|
}
|
|
_ = out.Close()
|
|
_ = os.Remove(src)
|
|
}
|
|
|
|
// Remove sidecars if present
|
|
_ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".info"))
|
|
_ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".json"))
|
|
return nil
|
|
}
|