pegasus/backend/main.go

528 lines
16 KiB
Go
Raw Normal View History

2025-09-15 12:09:02 -05:00
// backend/main.go
2025-09-08 00:48:47 -05:00
package main
import (
"embed"
"encoding/json"
2025-09-15 12:09:02 -05:00
"fmt"
"io/fs"
2025-09-08 00:48:47 -05:00
"log"
"net/http"
"os"
"path/filepath"
2025-09-15 12:09:02 -05:00
"regexp"
2025-09-08 00:48:47 -05:00
"strings"
"time"
2025-09-16 04:32:16 -05:00
"sort"
2025-09-08 00:48:47 -05:00
"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"
2025-09-15 12:09:02 -05:00
"github.com/tus/tusd/pkg/memorylocker"
2025-09-08 00:48:47 -05:00
2025-09-15 12:09:02 -05:00
"scm.bstein.dev/bstein/Pegasus/backend/internal"
2025-09-08 00:48:47 -05:00
)
2025-09-16 00:05:16 -05:00
//go:embed web/dist
2025-09-08 00:48:47 -05:00
var webFS embed.FS
var (
2025-09-15 12:09:02 -05:00
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()
2025-09-08 00:48:47 -05:00
)
2025-09-16 00:05:16 -05:00
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 = ""
)
2025-09-08 00:48:47 -05:00
type loggingRW struct {
2025-09-15 12:09:02 -05:00
http.ResponseWriter
status int
2025-09-08 00:48:47 -05:00
}
2025-09-15 12:09:02 -05:00
2025-09-16 07:37:10 -05:00
type listEntry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
}
2025-09-16 04:32:16 -05:00
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
2025-09-16 07:37:10 -05:00
if err := json.NewEncoder(w).Encode(v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
2025-09-16 04:32:16 -05:00
}
2025-09-15 12:09:02 -05:00
func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) }
2025-09-08 00:48:47 -05:00
func main() {
internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot)
2025-09-16 00:05:16 -05:00
if os.Getenv("PEGASUS_SESSION_KEY") == "" {
log.Fatal("PEGASUS_SESSION_KEY is not set")
}
2025-09-08 00:48:47 -05:00
um, err := internal.LoadUserMap(userMapFile)
must(err, "load user map")
2025-09-16 04:32:16 -05:00
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 ""
}())
}
2025-09-08 00:48:47 -05:00
// === tusd setup (resumable uploads) ===
store := filestore.FileStore{Path: tusDir}
2025-09-16 00:05:16 -05:00
// ensure upload scratch dir exists
if err := os.MkdirAll(tusDir, 0o755); err != nil {
log.Fatalf("mkdir %s: %v", tusDir, err)
}
2025-09-08 00:48:47 -05:00
locker := memorylocker.New()
composer := tusd.NewStoreComposer()
store.UseIn(composer)
locker.UseIn(composer)
2025-09-15 12:09:02 -05:00
// completeC := make(chan tusd.HookEvent)
2025-09-08 00:48:47 -05:00
config := tusd.Config{
2025-09-16 04:32:16 -05:00
BasePath: "/tus/",
StoreComposer: composer,
NotifyCompleteUploads: true,
MaxSize: 4 * 1024 * 1024 * 1024, // 4GB per file
RespectForwardedHeaders: true, // <<< important for https behind Traefik
2025-09-08 00:48:47 -05:00
}
tusHandler, err := tusd.NewUnroutedHandler(config)
must(err, "init tus handler")
2025-09-15 12:09:02 -05:00
completeC := tusHandler.CompleteUploads
2025-09-08 00:48:47 -05:00
// ---- post-finish hook: enforce naming & mapping ----
go func() {
for ev := range completeC {
claims, err := claimsFromHook(ev)
2025-09-15 12:09:02 -05:00
if err != nil {
internal.Logf("tus: no session: %v", err)
continue
}
2025-09-08 00:48:47 -05:00
// read metadata set by the UI
meta := ev.Upload.MetaData
desc := strings.TrimSpace(meta["desc"])
2025-09-15 12:09:02 -05:00
if desc == "" {
internal.Logf("tus: missing desc; rejecting")
continue
}
date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty
2025-09-08 00:48:47 -05:00
subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
orig := meta["filename"]
2025-09-15 12:09:02 -05:00
if orig == "" {
orig = "upload.bin"
}
2025-09-08 00:48:47 -05:00
// resolve per-user root
userRootRel, err := um.Resolve(claims.Username)
2025-09-15 12:09:02 -05:00
if err != nil {
internal.Logf("tus: user map missing: %v", err)
continue
}
2025-09-08 00:48:47 -05:00
// compose final name & target
finalName, err := internal.ComposeFinalName(date, desc, orig)
2025-09-15 12:09:02 -05:00
if err != nil {
internal.Logf("tus: bad target name: %v", err)
continue
}
2025-09-08 00:48:47 -05:00
rootAbs, _ := internal.SafeJoin(mediaRoot, userRootRel)
var targetAbs string
if subdir == "" {
2025-09-15 12:09:02 -05:00
targetAbs, err = internal.SafeJoin(rootAbs, finalName)
2025-09-08 00:48:47 -05:00
} else {
2025-09-15 12:09:02 -05:00
targetAbs, err = internal.SafeJoin(rootAbs, filepath.Join(subdir, finalName))
}
if err != nil {
internal.Logf("tus: path escape prevented: %v", err)
continue
2025-09-08 00:48:47 -05:00
}
srcPath := ev.Upload.Storage["Path"]
_ = os.MkdirAll(filepath.Dir(targetAbs), 0o755)
if internal.DryRun {
2025-09-15 12:09:02 -05:00
internal.Logf("[DRY] move %s -> %s", srcPath, targetAbs)
2025-09-08 00:48:47 -05:00
} else if err := os.Rename(srcPath, targetAbs); err != nil {
2025-09-15 12:09:02 -05:00
internal.Logf("move failed: %v", err)
continue
2025-09-08 00:48:47 -05:00
}
internal.Logf("uploaded: %s", targetAbs)
// kick Jellyfin refresh
jf.RefreshLibrary(claims.JFToken)
}
}()
// === chi router ===
r := chi.NewRouter()
r.Use(corsForTus)
2025-09-16 00:05:16 -05:00
// 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) {
2025-09-16 04:32:16 -05:00
writeJSON(w, map[string]any{
2025-09-16 00:05:16 -05:00
"version": version,
"git": git,
"built_at": builtAt,
})
})
2025-09-08 00:48:47 -05:00
// auth
r.Post("/api/login", func(w http.ResponseWriter, r *http.Request) {
2025-09-15 12:09:02 -05:00
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
}
2025-09-16 04:32:16 -05:00
// ✅ 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 {
2025-09-15 12:09:02 -05:00
http.Error(w, "session error", http.StatusInternalServerError)
return
2025-09-08 00:48:47 -05:00
}
2025-09-16 04:32:16 -05:00
writeJSON(w, map[string]any{"ok": true})
2025-09-08 00:48:47 -05:00
})
2025-09-16 04:32:16 -05:00
2025-09-15 12:09:02 -05:00
r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) {
internal.ClearSession(w)
2025-09-16 04:32:16 -05:00
writeJSON(w, map[string]any{"ok": true})
2025-09-15 12:09:02 -05:00
})
2025-09-08 00:48:47 -05:00
// whoami
r.Get("/api/whoami", func(w http.ResponseWriter, r *http.Request) {
2025-09-15 12:09:02 -05:00
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
}
2025-09-16 04:32:16 -05:00
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)
})
2025-09-08 00:48:47 -05:00
})
// list entries
r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) {
2025-09-15 12:09:02 -05:00
cl, err := internal.CurrentUser(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rootRel, err := um.Resolve(cl.Username)
if err != nil {
2025-09-16 07:37:10 -05:00
internal.Logf("list: map missing for %q", cl.Username)
http.Error(w, "no mapping", http.StatusForbidden)
return
2025-09-15 12:09:02 -05:00
}
2025-09-16 07:37:10 -05:00
2025-09-08 00:48:47 -05:00
q := strings.TrimPrefix(r.URL.Query().Get("path"), "/")
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
2025-09-16 07:37:10 -05:00
2025-09-08 00:48:47 -05:00
var dirAbs string
2025-09-15 12:09:02 -05:00
if q == "" {
dirAbs = rootAbs
} else {
dirAbs, err = internal.SafeJoin(rootAbs, q)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
2025-09-16 07:37:10 -05:00
2025-09-15 12:09:02 -05:00
ents, err := os.ReadDir(dirAbs)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
2025-09-16 07:37:10 -05:00
out := make([]listEntry, 0, len(ents))
2025-09-08 00:48:47 -05:00
for _, d := range ents {
info, _ := d.Info()
2025-09-16 07:37:10 -05:00
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{
2025-09-15 12:09:02 -05:00
Name: d.Name(),
Path: filepath.Join(q, d.Name()),
IsDir: d.IsDir(),
2025-09-16 07:37:10 -05:00
Size: size,
Mtime: mtime,
2025-09-08 00:48:47 -05:00
})
}
2025-09-16 07:37:10 -05:00
writeJSON(w, out)
2025-09-08 00:48:47 -05:00
})
// rename
var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9]{1,8}$`)
r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) {
2025-09-15 12:09:02 -05:00
cl, err := internal.CurrentUser(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var p struct {
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
}
2025-09-08 00:48:47 -05:00
// enforce final name on files (allow any name for directories)
if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) {
2025-09-15 12:09:02 -05:00
http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest)
return
2025-09-08 00:48:47 -05:00
}
rootRel, _ := um.Resolve(cl.Username)
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
2025-09-15 12:09:02 -05:00
fromAbs, err := internal.SafeJoin(rootAbs, p.From)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
toAbs, err := internal.SafeJoin(rootAbs, p.To)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
2025-09-08 00:48:47 -05:00
_ = os.MkdirAll(filepath.Dir(toAbs), 0o755)
2025-09-15 12:09:02 -05:00
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
}
2025-09-08 00:48:47 -05:00
jf.RefreshLibrary(cl.JFToken)
2025-09-16 04:32:16 -05:00
writeJSON(w, map[string]any{"ok": true})
2025-09-08 00:48:47 -05:00
})
// delete
r.Delete("/api/file", func(w http.ResponseWriter, r *http.Request) {
2025-09-15 12:09:02 -05:00
cl, err := internal.CurrentUser(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
2025-09-08 00:48:47 -05:00
rootRel, _ := um.Resolve(cl.Username)
2025-09-15 12:09:02 -05:00
path := r.URL.Query().Get("path")
rec := r.URL.Query().Get("recursive") == "true"
2025-09-08 00:48:47 -05:00
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
2025-09-15 12:09:02 -05:00
abs, err := internal.SafeJoin(rootAbs, path)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if rec {
err = os.RemoveAll(abs)
} else {
err = os.Remove(abs)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
2025-09-08 00:48:47 -05:00
jf.RefreshLibrary(cl.JFToken)
2025-09-16 04:32:16 -05:00
writeJSON(w, map[string]any{"ok": true})
2025-09-08 00:48:47 -05:00
})
2025-09-16 00:05:16 -05:00
// 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{ Path string `json:"path"` } // e.g., "Trips/2025-09"
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "bad json", http.StatusBadRequest); return
}
rootRel, err := um.Resolve(cl.Username)
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
abs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.Path, "/"))
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, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError); return
}
2025-09-16 04:32:16 -05:00
writeJSON(w, map[string]any{"ok": true})
2025-09-16 00:05:16 -05:00
})
2025-09-08 00:48:47 -05:00
// mount tus (behind auth)
r.Route("/tus", func(rt chi.Router) {
2025-09-16 00:05:16 -05:00
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
2025-09-08 00:48:47 -05:00
})
2025-09-15 12:09:02 -05:00
// metrics
2025-09-08 00:48:47 -05:00
r.Handle("/metrics", promhttp.Handler())
2025-09-15 12:09:02 -05:00
// static app (serve embedded web/dist)
appFS, _ := fs.Sub(webFS, "web/dist")
static := http.FileServer(http.FS(appFS))
2025-09-08 00:48:47 -05:00
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
2025-09-15 12:09:02 -05:00
static.ServeHTTP(w, r)
2025-09-08 00:48:47 -05:00
})
2025-09-16 00:05:16 -05:00
// catch-all for SPA routes, GET only
r.Method("GET", "/*", http.StripPrefix("/", static))
2025-09-08 00:48:47 -05:00
2025-09-15 12:09:02 -05:00
// debug endpoints (registered before server starts)
2025-09-08 00:48:47 -05:00
r.Get("/debug/env", func(w http.ResponseWriter, r *http.Request) {
2025-09-15 12:09:02 -05:00
if !internal.Debug {
http.Error(w, "disabled", http.StatusForbidden)
return
}
2025-09-16 04:32:16 -05:00
writeJSON(w, map[string]any{
2025-09-15 12:09:02 -05:00
"mediaRoot": mediaRoot,
"tusDir": tusDir,
"userMapFile": userMapFile,
2025-09-08 00:48:47 -05:00
})
})
r.Get("/debug/write-test", func(w http.ResponseWriter, r *http.Request) {
2025-09-15 12:09:02 -05:00
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
}
2025-09-08 00:48:47 -05:00
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
test := filepath.Join(rootAbs, fmt.Sprintf("TEST.%d.txt", time.Now().Unix()))
2025-09-15 12:09:02 -05:00
if internal.DryRun {
internal.Logf("[DRY] write %s", test)
} else {
_ = os.WriteFile(test, []byte("ok\n"), 0o644)
}
2025-09-16 04:32:16 -05:00
writeJSON(w, map[string]string{"wrote": test})
2025-09-08 00:48:47 -05:00
})
2025-09-16 00:05:16 -05:00
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no route for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
})
2025-09-15 12:09:02 -05:00
// ---- 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())
2025-09-08 00:48:47 -05:00
}
// === 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 {
2025-09-15 12:09:02 -05:00
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
2025-09-08 00:48:47 -05:00
}
next.ServeHTTP(w, r)
})
}
func corsForTus(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2025-09-16 00:05:16 -05:00
// 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")
}
2025-09-08 00:48:47 -05:00
w.Header().Set("Access-Control-Allow-Credentials", "true")
2025-09-16 00:05:16 -05:00
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
2025-09-15 12:09:02 -05:00
}
2025-09-08 00:48:47 -05:00
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
2025-09-15 12:09:02 -05:00
if req.Header == nil {
return internal.Claims{}, http.ErrNoCookie
}
2025-09-08 00:48:47 -05:00
// Re-create a dummy http.Request to reuse cookie parsing & jwt verification
r := http.Request{Header: http.Header(req.Header)}
return internal.CurrentUser(&r)
}
2025-09-15 12:09:02 -05:00
func env(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def }
2025-09-08 00:48:47 -05:00
func must(err error, msg string) { if err != nil { log.Fatalf("%s: %v", msg, err) } }