pegasus/backend/main.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
}