From 90fc9bd143564d2494dcf901396c85146852855b Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 8 Sep 2025 00:48:47 -0500 Subject: [PATCH] first pass on pegasus --- .gitignore | 5 + Dockerfile | 25 ++++ backend/go.mod | 13 ++ backend/internal/auth.go | 19 +++ backend/internal/debuglog.go | 28 ++++ backend/internal/fs.go | 19 +++ backend/internal/jellyfin.go | 66 +++++++++ backend/internal/naming.go | 43 ++++++ backend/internal/session.go | 41 ++++++ backend/internal/usermap.go | 31 ++++ backend/main.go | 278 +++++++++++++++++++++++++++++++++++ frontend/index.html | 8 + frontend/package.json | 22 +++ frontend/src/App.tsx | 16 ++ frontend/src/Login.tsx | 24 +++ frontend/src/Uploader.tsx | 142 ++++++++++++++++++ frontend/src/api.ts | 7 + frontend/src/main.tsx | 4 + frontend/src/styles.css | 16 ++ frontend/vite.config.ts | 6 + 20 files changed, 813 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 backend/go.mod create mode 100644 backend/internal/auth.go create mode 100644 backend/internal/debuglog.go create mode 100644 backend/internal/fs.go create mode 100644 backend/internal/jellyfin.go create mode 100644 backend/internal/naming.go create mode 100644 backend/internal/session.go create mode 100644 backend/internal/usermap.go create mode 100644 backend/main.go create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/Login.tsx create mode 100644 frontend/src/Uploader.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3906925 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +frontend/node_modules +frontend/dist +backend/web/dist +.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a47360c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# ---------- Frontend ---------- +FROM node:20-alpine AS fe +WORKDIR /src/frontend +COPY frontend/ ./ +RUN npm ci && npm run build + +# ---------- Backend ---------- +FROM golang:1.22-alpine AS be +RUN apk add --no-cache ca-certificates upx +WORKDIR /src +COPY backend/ ./backend/ +# copy built frontend assets into backend/web/dist for embedding +COPY --from=fe /src/frontend/dist ./backend/web/dist +WORKDIR /src/backend +RUN go mod download +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/pegasus . +RUN upx -q /out/pegasus || true + +# ---------- Runtime ---------- +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=be /out/pegasus /pegasus +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/pegasus"] diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..9f8108b --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,13 @@ +module github.com/your-org/pegasus + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.0.12 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/prometheus/client_golang v1.18.0 + github.com/tus/tusd/pkg/filestore v1.13.0 + github.com/tus/tusd/pkg/handler v1.13.0 + github.com/tus/tusd/pkg/locker/memorylocker v1.13.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/backend/internal/auth.go b/backend/internal/auth.go new file mode 100644 index 0000000..2fa31b8 --- /dev/null +++ b/backend/internal/auth.go @@ -0,0 +1,19 @@ +package internal + +import ( + "errors" + "net/http" + + "github.com/golang-jwt/jwt/v5" +) + +func CurrentUser(r *http.Request) (Claims, error) { + c, err := r.Cookie(CookieName) + if err != nil { return Claims{}, err } + tok, err := jwt.ParseWithClaims(c.Value, &Claims{}, func(_ *jwt.Token) (any, error) { return sessionKey, nil }) + if err != nil { return Claims{}, err } + if cl, ok := tok.Claims.(*Claims); ok && tok.Valid { + return *cl, nil + } + return Claims{}, errors.New("invalid session") +} diff --git a/backend/internal/debuglog.go b/backend/internal/debuglog.go new file mode 100644 index 0000000..ee593cb --- /dev/null +++ b/backend/internal/debuglog.go @@ -0,0 +1,28 @@ +package internal + +import ( + "log" + "net/http" + "os" + "strings" +) + +var Debug = os.Getenv("PEGASUS_DEBUG") == "1" +var DryRun = os.Getenv("PEGASUS_DRY_RUN") == "1" + +func Logf(format string, args ...any) { + if Debug { log.Printf(format, args...) } +} + +func RedactHeaders(h http.Header) http.Header { + cp := http.Header{} + for k, v := range h { + kk := strings.ToLower(k) + if kk == "cookie" || strings.HasPrefix(kk, "authorization") || strings.Contains(kk, "token") { + cp[k] = []string{""} + } else { + cp[k] = append([]string(nil), v...) + } + } + return cp +} diff --git a/backend/internal/fs.go b/backend/internal/fs.go new file mode 100644 index 0000000..d4880a9 --- /dev/null +++ b/backend/internal/fs.go @@ -0,0 +1,19 @@ +package internal + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +func SafeJoin(root, rel string) (string, error) { + rel = strings.TrimPrefix(rel, "/") + p := filepath.Join(root, rel) + ap, err := filepath.Abs(p); if err != nil { return "", err } + ar, err := filepath.Abs(root); if err != nil { return "", err } + if !strings.HasPrefix(ap, ar+string(os.PathSeparator)) && ap != ar { + return "", errors.New("path escapes root") + } + return ap, nil +} diff --git a/backend/internal/jellyfin.go b/backend/internal/jellyfin.go new file mode 100644 index 0000000..5f9b8b1 --- /dev/null +++ b/backend/internal/jellyfin.go @@ -0,0 +1,66 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +// Minimal Jellyfin client + +type jfAuthResult struct { + AccessToken string `json:"AccessToken"` + User struct { + Id string `json:"Id"` + Name string `json:"Name"` + Username string `json:"Name"` + } `json:"User"` +} + +type Jellyfin struct { + BaseURL string + Client *http.Client +} + +func NewJellyfin() *Jellyfin { + return &Jellyfin{ + BaseURL: os.Getenv("JELLYFIN_URL"), + Client: &http.Client{Timeout: 10 * time.Second}, + } +} + +// Authenticate against /Users/AuthenticateByName using Pw (plaintext) +func (j *Jellyfin) AuthenticateByName(username, password string) (jfAuthResult, error) { + var out jfAuthResult + body := map[string]string{"Username": username, "Pw": password} + b, _ := json.Marshal(body) + + req, _ := http.NewRequest("POST", j.BaseURL+"/Users/AuthenticateByName", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + // Provide Jellyfin's MediaBrowser client descriptor (no token yet) + req.Header.Set("Authorization", + `MediaBrowser Client="Pegasus", Device="Pegasus Web", DeviceId="pegasus-web", Version="1.0.0"`) + + resp, err := j.Client.Do(req) + if err != nil { + return out, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return out, fmt.Errorf("login failed: %s", resp.Status) + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return out, err + } + return out, nil +} + +func (j *Jellyfin) RefreshLibrary(token string) { + // GET /Library/Refresh with token header is widely supported + req, _ := http.NewRequest("GET", j.BaseURL+"/Library/Refresh", nil) + req.Header.Set("X-MediaBrowser-Token", token) + _, _ = j.Client.Do(req) +} diff --git a/backend/internal/naming.go b/backend/internal/naming.go new file mode 100644 index 0000000..89be16b --- /dev/null +++ b/backend/internal/naming.go @@ -0,0 +1,43 @@ +package internal + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + "time" +) + +var ( + descMax = 64 + allowedDesc = regexp.MustCompile(`[^A-Za-z0-9 _-]`) // to be replaced by `_` + dateOnly = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // UI sends YYYY-MM-DD + finalNameValidate = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9]{1,8}$`) +) + +func SanitizeDesc(in string) string { + s := strings.TrimSpace(in) + if s == "" { s = "upload" } + s = allowedDesc.ReplaceAllString(s, "_") + s = strings.Join(strings.Fields(s), "_") // collapse whitespace + if len(s) > descMax { s = s[:descMax] } + return s +} + +func ComposeFinalName(dateStr, desc, origFilename string) (string, error) { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), ".")) + if ext == "" { ext = "bin" } + + var t time.Time + var err error + if dateOnly.MatchString(dateStr) { + t, err = time.Parse("2006-01-02", dateStr) + } else { + t = time.Now() + } + if err != nil { return "", fmt.Errorf("bad date") } + + final := fmt.Sprintf("%04d.%02d.%02d.%s.%s", t.Year(), int(t.Month()), t.Day(), SanitizeDesc(desc), ext) + if !finalNameValidate.MatchString(final) { return "", fmt.Errorf("invalid final name") } + return final, nil +} diff --git a/backend/internal/session.go b/backend/internal/session.go new file mode 100644 index 0000000..aca86f7 --- /dev/null +++ b/backend/internal/session.go @@ -0,0 +1,41 @@ +package internal + +import ( + "net/http" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + Username string `json:"u"` + JFToken string `json:"t"` + jwt.RegisteredClaims +} + +var sessionKey = []byte(os.Getenv("PEGASUS_SESSION_KEY")) + +const CookieName = "pegasus_session" + +func SetSession(w http.ResponseWriter, username, jfToken string) error { + now := time.Now() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + Username: username, + JFToken: jfToken, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + }, + }) + signed, err := tok.SignedString(sessionKey) + if err != nil { return err } + http.SetCookie(w, &http.Cookie{ + Name: CookieName, Value: signed, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, + }) + return nil +} + +func ClearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Expires: time.Unix(0,0), Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) +} diff --git a/backend/internal/usermap.go b/backend/internal/usermap.go new file mode 100644 index 0000000..a0d7bb9 --- /dev/null +++ b/backend/internal/usermap.go @@ -0,0 +1,31 @@ +package internal + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type UserMap struct { + // Maps Jellyfin usernames -> relative media subdir (e.g., "mary_grace_allison" -> "Allison") + Map map[string]string `yaml:"map"` +} + +func LoadUserMap(path string) (*UserMap, error) { + b, err := os.ReadFile(path) + if err != nil { return nil, err } + var m UserMap + if err := yaml.Unmarshal(b, &m); err != nil { return nil, err } + return &m, nil +} + +func (m *UserMap) Resolve(username string) (string, error) { + if dir, ok := m.Map[username]; ok && dir != "" { + clean := filepath.Clean(dir) + if clean == "." || clean == "/" { return "", fmt.Errorf("invalid target dir") } + return clean, nil + } + return "", fmt.Errorf("no mapping for user %q", username) +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..743e4a9 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,278 @@ +package main + +import ( + "embed" + "encoding/json" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + "regexp" + + "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/locker/memorylocker" + + "scm.bstein.dev/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() +) + +type loggingRW struct { + http.ResponseWriter + status int +} +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) + + um, err := internal.LoadUserMap(userMapFile) + must(err, "load user map") + + // === tusd setup (resumable uploads) === + store := filestore.FileStore{Path: tusDir} + 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, + CompleteUploads: completeC, + MaxSize: 0, // unlimited + } + tusHandler, err := tusd.NewUnroutedHandler(config) + must(err, "init tus handler") + + // ---- 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"]) + if desc == "" { internal.Logf("tus: missing desc; rejecting"); continue } + date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty + subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/") + orig := meta["filename"] + if orig == "" { orig = "upload.bin" } + + // resolve per-user root + userRootRel, err := um.Resolve(claims.Username) + if err != nil { internal.Logf("tus: user map missing: %v", err); continue } + + // compose final name & target + finalName, err := internal.ComposeFinalName(date, desc, orig) + if err != nil { internal.Logf("tus: bad target name: %v", err); continue } + + rootAbs, _ := internal.SafeJoin(mediaRoot, userRootRel) + var targetAbs string + if subdir == "" { + targetAbs, err = internal.SafeJoin(rootAbs, finalName) + } else { + targetAbs, err = internal.SafeJoin(rootAbs, filepath.Join(subdir, finalName)) + } + if err != nil { internal.Logf("tus: path escape prevented: %v", err); continue } + + srcPath := ev.Upload.Storage["Path"] + _ = os.MkdirAll(filepath.Dir(targetAbs), 0o755) + if internal.DryRun { + internal.Logf("[DRY] move %s -> %s", srcPath, targetAbs) + } else if err := os.Rename(srcPath, targetAbs); err != nil { + internal.Logf("move failed: %v", err); continue + } + internal.Logf("uploaded: %s", targetAbs) + + // kick Jellyfin refresh + jf.RefreshLibrary(claims.JFToken) + } + }() + + // === chi router === + r := chi.NewRouter() + r.Use(corsForTus) + + // auth + r.Post("/api/login", func(w http.ResponseWriter, r *http.Request) { + var f struct{ Username, Password string } + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { http.Error(w, "bad json", 400); return } + res, err := jf.AuthenticateByName(f.Username, f.Password) // password login + if err != nil { http.Error(w, "invalid credentials", 401); return } + if err := internal.SetSession(w, res.User.Username, res.AccessToken); err != nil { + http.Error(w, "session error", 500); return + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + }) + r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) { internal.ClearSession(w); _ = json.NewEncoder(w).Encode(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", 401); return } + dr, err := um.Resolve(cl.Username); if err != nil { http.Error(w, "no mapping", 403); return } + _ = json.NewEncoder(w).Encode(map[string]any{"username": cl.Username, "root": dr}) + }) + + // 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", 401); return } + rootRel, err := um.Resolve(cl.Username); if err != nil { http.Error(w, "forbidden", 403); return } + q := strings.TrimPrefix(r.URL.Query().Get("path"), "/") + rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) + var dirAbs string + if q == "" { dirAbs = rootAbs } else { + dirAbs, err = internal.SafeJoin(rootAbs, q); if err != nil { http.Error(w, "forbidden", 403); return } + } + ents, err := os.ReadDir(dirAbs); if err != nil { http.Error(w, err.Error(), 500); return } + type entry struct{ Name, Path string; IsDir bool; Size int64; Mtime int64 } + var out []entry + for _, d := range ents { + info, _ := d.Info() + out = append(out, entry{ + Name: d.Name(), Path: filepath.Join(q, d.Name()), IsDir: d.IsDir(), + Size: func() int64 { if info != nil && !d.IsDir() { return info.Size() }; return 0 }(), + Mtime: func() int64 { if info != nil { return info.ModTime().Unix() }; return 0 }(), + }) + } + _ = json.NewEncoder(w).Encode(out) + }) + + // 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) { + cl, err := internal.CurrentUser(r); if err != nil { http.Error(w,"unauthorized",401); return } + var p struct{ From, To string } + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { http.Error(w,"bad json",400); return } + + // enforce final name on files (allow any name for directories) + if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) { + http.Error(w, "new name must match YYYY.MM.DD.Description.ext", 400); return + } + + rootRel, _ := um.Resolve(cl.Username) + rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) + fromAbs, err := internal.SafeJoin(rootAbs, p.From); if err != nil { http.Error(w,"forbidden",403); return } + toAbs, err := internal.SafeJoin(rootAbs, p.To); if err != nil { http.Error(w,"forbidden",403); return } + _ = os.MkdirAll(filepath.Dir(toAbs), 0o755) + 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(), 500); return } + jf.RefreshLibrary(cl.JFToken) + _ = json.NewEncoder(w).Encode(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", 401); return } + rootRel, _ := um.Resolve(cl.Username) + path := 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", 403); return } + if rec { err = os.RemoveAll(abs) } else { err = os.Remove(abs) } + if err != nil { http.Error(w, err.Error(), 500); return } + jf.RefreshLibrary(cl.JFToken) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + }) + + // mount tus (behind auth) + r.Route("/tus", func(rt chi.Router) { + rt.Use(sessionRequired) + rt.Post("/*", tusHandler.PostFile) + rt.Head("/*", tusHandler.HeadFile) + rt.Patch("/*", tusHandler.PatchFile) + rt.Del("/*", tusHandler.DelFile) + rt.Get("/*", tusHandler.GetFile) // optional + }) + + // static app + r.Handle("/metrics", promhttp.Handler()) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.FileServer(http.FS(webFS)).ServeHTTP(w, r) + }) + r.Handle("/static/*", http.StripPrefix("/", http.FileServer(http.FS(webFS)))) + + addr := env("PEGASUS_BIND", ":8080") + log.Printf("Pegasus listening on %s", addr) + srv := &http.Server{Addr: addr, Handler: r, ReadTimeout: 0, WriteTimeout: 0} + log.Fatal(srv.ListenAndServe()) + + // debug endpoints + r.Get("/debug/env", func(w http.ResponseWriter, r *http.Request) { + if !internal.Debug { http.Error(w, "disabled", 403); return } + _ = json.NewEncoder(w).Encode(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", 403); return } + cl, err := internal.CurrentUser(r); if err != nil { http.Error(w,"unauthorized",401); return } + rootRel, err := um.Resolve(cl.Username); if err != nil { http.Error(w,"forbidden",403); 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) } + _ = json.NewEncoder(w).Encode(map[string]string{"wrote": test}) + }) +} + +// ---- 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)) + }) +} +srv := &http.Server{Addr: addr, Handler: root, ReadTimeout:0, WriteTimeout:0} + +// === 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", 401); return + } + next.ServeHTTP(w, r) + }) +} + +func corsForTus(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Allow tus headers for resumable uploads + w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, X-Requested-With") + w.Header().Set("Access-Control-Expose-Headers", "Location, Upload-Offset, Upload-Length, Tus-Resumable") + if r.Method == "OPTIONS" { w.WriteHeader(204); 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) } } diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3d9a3ad --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,8 @@ + + + + + Pegasus + +
+ diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8acddba --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "pegasus-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --port 5173" + }, + "dependencies": { + "tus-js-client": "^4.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.2.56", + "@types/react-dom": "^18.2.19", + "typescript": "^5.5.3", + "vite": "^5.2.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8d32d45 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,16 @@ +import React, { useEffect, useState } from 'react' +import { api } from './api' +import Login from './Login' +import Uploader from './Uploader' +import './styles.css' + +export default function App(){ + const [authed, setAuthed] = useState(false) + useEffect(()=>{ api('/api/whoami').then(()=>setAuthed(true)).catch(()=>setAuthed(false)) }, []) + return ( + <> +

🪽 Pegasus

{authed && }
+
{authed ? : setAuthed(true)} /> }
+ + ) +} diff --git a/frontend/src/Login.tsx b/frontend/src/Login.tsx new file mode 100644 index 0000000..fb71c13 --- /dev/null +++ b/frontend/src/Login.tsx @@ -0,0 +1,24 @@ +import React, { useState } from 'react' +import { api } from './api' + +export default function Login({ onLogin }: { onLogin: () => void }) { + const [u, setU] = useState(''); const [p, setP] = useState(''); const [err, setErr] = useState() + async function submit(e: React.FormEvent){ e.preventDefault(); setErr(undefined) + try { await api('/api/login', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({username:u, password:p}) }); onLogin() } + catch(e:any){ setErr(e.message) } + } + return ( +
+

Sign in

+
+
+ setU(e.target.value)} /> + setP(e.target.value)} /> + + {err &&
{err}
} +
+
+

Credentials are verified against your Jellyfin server.

+
+ ) +} diff --git a/frontend/src/Uploader.tsx b/frontend/src/Uploader.tsx new file mode 100644 index 0000000..bd1217b --- /dev/null +++ b/frontend/src/Uploader.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useState } from 'react' +import { api, WhoAmI } from './api' +import * as tus from 'tus-js-client' + +type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number } +type Sel = { file: File; desc: string; finalName: string } + +function sanitizeDesc(s:string){ + s = s.trim().replace(/[^A-Za-z0-9 _-]+/g,'_').replace(/\s+/g,'_') + if(!s) s = 'upload' + return s.slice(0,64) +} +function extOf(n:string){ const i=n.lastIndexOf('.'); return i>-1 ? n.slice(i+1).toLowerCase() : 'bin' } +function composeName(date:string, desc:string, orig:string){ + const d = date || new Date().toISOString().slice(0,10) // YYYY-MM-DD + const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}` +} + +export default function Uploader(){ + const [me, setMe] = useState() + const [cwd, setCwd] = useState('') + const [rows, setRows] = useState([]) + const [status, setStatus] = useState('') + const [date, setDate] = useState(new Date().toISOString().slice(0,10)) + const [sel, setSel] = useState([]) + + async function refresh(path=''){ const m = await api('/api/whoami'); setMe(m) + const list = await api('/api/list?path='+encodeURIComponent(path)); setRows(list); setCwd(path) + } + useEffect(()=>{ refresh('') }, []) + + function handleChoose(files: FileList){ + const arr = Array.from(files).map(f=>{ + const base = (f as any).webkitRelativePath || f.name + const name = base.split('/').pop() || f.name + const guess = name.replace(/\.[^/.]+$/,'').replace(/[_-]+/g,' ') + const desc = sanitizeDesc(guess) + return { file:f, desc, finalName: composeName(date, desc, name) } + }) + setSel(arr) + } + useEffect(()=>{ setSel(old => old.map(x=> ({...x, finalName: composeName(date, x.desc, x.file.name)}))) }, [date]) + + async function doUpload(){ + if(!me) return + if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return } + setStatus('') + for(const s of sel){ + await new Promise((resolve,reject)=>{ + const up = new tus.Upload(s.file, { + endpoint: '/tus/', + chunkSize: 5*1024*1024, + retryDelays: [0, 1000, 3000, 5000, 10000], + withCredentials: true, + metadata: { + filename: s.file.name, + subdir: cwd || "", + date, // YYYY-MM-DD + desc: s.desc // server composes YYYY.MM.DD.desc.ext + }, + onError: (err)=>{ setStatus(`✖ ${s.file.name}: ${err}`); reject(err) }, + onProgress: (sent, total)=>{ const pct = Math.floor(sent/total*100); setStatus(`⬆ ${s.finalName}: ${pct}%`) }, + onSuccess: ()=>{ setStatus(`✔ ${s.finalName} uploaded`); resolve() }, + }) + up.start() + }) + } + setSel([]); refresh(cwd) + } + + async function rename(oldp:string){ + const name = prompt('New name (YYYY.MM.DD.description.ext):', oldp.split('/').pop()||''); if(!name) return + await api('/api/rename', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({from:oldp, to: (oldp.split('/').slice(0,-1).concat(name)).join('/')}) }) + refresh(cwd) + } + async function del(p:string, recursive:boolean){ + if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return + await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' }) + refresh(cwd) + } + + return (<> +
+
+
Signed in: {me?.username} · root: /{me?.root}
+
{cwd ? `/${cwd}` : 'Choose a folder below'}
+
+
+
+ + setDate(e.target.value)} /> +
+
+ + e.target.files && handleChoose(e.target.files)} /> +
+ +
+ {sel.length>0 && ( +
+

Ready to upload

+
+ {sel.map((s,i)=>( +
+
{s.file.name}
+ { + const v=e.target.value; setSel(old => old.map((x,idx)=> idx===i ? ({...x, desc:v, finalName: composeName(date, v, x.file.name)}) : x )) + }} /> +
→ {s.finalName}
+
+ ))} +
+
+ )} +
{status}
+
+ +
+

Folder

+
+ {cwd && ( + + )} + {rows.sort((a,b)=> (Number(b.is_dir)-Number(a.is_dir)) || a.name.localeCompare(b.name)).map(f=> +
+
{f.is_dir?'📁':'🎞️'} {f.name}
+
{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}
+ +
+ )} +
+
+ ) +} +function fmt(n:number){ if(n<1024) return n+' B'; const u=['KB','MB','GB','TB']; let i=-1; do{ n/=1024; i++ }while(n>=1024&&i(path: string, init?: RequestInit): Promise { + const r = await fetch(path, { credentials:'include', ...init }); + if (!r.ok) throw new Error(await r.text()); + const ct = r.headers.get('content-type') || ''; + return (ct.includes('json') ? r.json() : (r.text() as any)) as T; +} +export type WhoAmI = { username: string; root: string }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1c58885 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,4 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +createRoot(document.getElementById('root')!).render() diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..dd7b3c6 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,16 @@ +:root{--bg:#0f1222;--fg:#e8e8f0;--muted:#9aa0a6;--card:#171a2e;--accent:#7aa2f7;--bad:#ef5350;--good:#66bb6a} +*{box-sizing:border-box} +html,body,#root{height:100%} +body{margin:0;font:16px/1.45 system-ui;background:var(--bg);color:var(--fg)} +header{display:flex;gap:8px;align-items:center;justify-content:space-between;padding:14px 16px;background:#12152a;position:sticky;top:0} +h1{font-size:18px;margin:0} +main{max-width:960px;margin:0 auto;padding:16px} +.card{background:var(--card);border:1px solid #242847;border-radius:12px;padding:14px;margin:10px 0} +.btn{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px} +input[type=file],input[type=text],input[type=password]{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px;width:100%} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px} +.item{padding:12px;border:1px solid #2a2f45;border-radius:10px;background:#1a1e34} +.meta{font-size:12px;color:var(--muted)} +progress{width:100%;height:8px} +.bad{color:var(--bad)} .good{color:var(--good)} +@media (max-width:640px){ header{padding:12px} .grid{grid-template-columns:1fr 1fr} } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..62b152f --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +export default defineConfig({ + plugins: [react()], + build: { outDir: '../backend/web/dist' } +})