first pass on pegasus
This commit is contained in:
parent
7eff728cc1
commit
90fc9bd143
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
backend/web/dist
|
||||
.git
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@ -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"]
|
||||
13
backend/go.mod
Normal file
13
backend/go.mod
Normal file
@ -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
|
||||
)
|
||||
19
backend/internal/auth.go
Normal file
19
backend/internal/auth.go
Normal file
@ -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")
|
||||
}
|
||||
28
backend/internal/debuglog.go
Normal file
28
backend/internal/debuglog.go
Normal file
@ -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{"<redacted>"}
|
||||
} else {
|
||||
cp[k] = append([]string(nil), v...)
|
||||
}
|
||||
}
|
||||
return cp
|
||||
}
|
||||
19
backend/internal/fs.go
Normal file
19
backend/internal/fs.go
Normal file
@ -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
|
||||
}
|
||||
66
backend/internal/jellyfin.go
Normal file
66
backend/internal/jellyfin.go
Normal file
@ -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)
|
||||
}
|
||||
43
backend/internal/naming.go
Normal file
43
backend/internal/naming.go
Normal file
@ -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
|
||||
}
|
||||
41
backend/internal/session.go
Normal file
41
backend/internal/session.go
Normal file
@ -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})
|
||||
}
|
||||
31
backend/internal/usermap.go
Normal file
31
backend/internal/usermap.go
Normal file
@ -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)
|
||||
}
|
||||
278
backend/main.go
Normal file
278
backend/main.go
Normal file
@ -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) } }
|
||||
8
frontend/index.html
Normal file
8
frontend/index.html
Normal file
@ -0,0 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pegasus</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
16
frontend/src/App.tsx
Normal file
16
frontend/src/App.tsx
Normal file
@ -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<boolean>(false)
|
||||
useEffect(()=>{ api('/api/whoami').then(()=>setAuthed(true)).catch(()=>setAuthed(false)) }, [])
|
||||
return (
|
||||
<>
|
||||
<header><h1>🪽 Pegasus</h1>{authed && <button className="btn" onClick={()=> api('/api/logout',{method:'POST'}).then(()=>location.reload())}>Logout</button>}</header>
|
||||
<main>{authed ? <Uploader/> : <Login onLogin={()=>setAuthed(true)} /> }</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
frontend/src/Login.tsx
Normal file
24
frontend/src/Login.tsx
Normal file
@ -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<string|undefined>()
|
||||
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 (
|
||||
<section className="card" style={{maxWidth:420, margin:'48px auto'}}>
|
||||
<h3>Sign in</h3>
|
||||
<form onSubmit={submit}>
|
||||
<div style={{display:'grid', gap:8}}>
|
||||
<input placeholder="username" value={u} onChange={e=>setU(e.target.value)} />
|
||||
<input type="password" placeholder="password" value={p} onChange={e=>setP(e.target.value)} />
|
||||
<button className="btn" type="submit">Login</button>
|
||||
{err && <div className="meta bad">{err}</div>}
|
||||
</div>
|
||||
</form>
|
||||
<p className="meta">Credentials are verified against your Jellyfin server.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
142
frontend/src/Uploader.tsx
Normal file
142
frontend/src/Uploader.tsx
Normal file
@ -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<WhoAmI|undefined>()
|
||||
const [cwd, setCwd] = useState<string>('')
|
||||
const [rows, setRows] = useState<FileRow[]>([])
|
||||
const [status, setStatus] = useState<string>('')
|
||||
const [date, setDate] = useState<string>(new Date().toISOString().slice(0,10))
|
||||
const [sel, setSel] = useState<Sel[]>([])
|
||||
|
||||
async function refresh(path=''){ const m = await api<WhoAmI>('/api/whoami'); setMe(m)
|
||||
const list = await api<FileRow[]>('/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<void>((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 (<>
|
||||
<section className="card">
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', gap:8, flexWrap:'wrap'}}>
|
||||
<div><b>Signed in:</b> {me?.username} · <span className="meta">root: /{me?.root}</span></div>
|
||||
<div className="meta">{cwd ? `/${cwd}` : 'Choose a folder below'}</div>
|
||||
</div>
|
||||
<div className="grid" style={{alignItems:'end'}}>
|
||||
<div>
|
||||
<label className="meta">Date (auto‑applied)</label>
|
||||
<input type="date" value={date} onChange={e=> setDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="meta">Pick files or folders</label>
|
||||
<input type="file" multiple webkitdirectory="true" onChange={e=> e.target.files && handleChoose(e.target.files)} />
|
||||
</div>
|
||||
<button className="btn" onClick={doUpload} disabled={!sel.length}>Upload {sel.length? `(${sel.length})` : ''}</button>
|
||||
</div>
|
||||
{sel.length>0 && (
|
||||
<div className="card">
|
||||
<h4>Ready to upload</h4>
|
||||
<div className="grid">
|
||||
{sel.map((s,i)=>(
|
||||
<div key={i} className="item">
|
||||
<div className="meta">{s.file.name}</div>
|
||||
<input value={s.desc} onChange={e=> {
|
||||
const v=e.target.value; setSel(old => old.map((x,idx)=> idx===i ? ({...x, desc:v, finalName: composeName(date, v, x.file.name)}) : x ))
|
||||
}} />
|
||||
<div className="meta">→ {s.finalName}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="meta" style={{marginTop:8}}>{status}</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>Folder</h3>
|
||||
<div className="grid">
|
||||
{cwd && (
|
||||
<div className="item">
|
||||
<a className="name" href="#" onClick={()=> setCwd(cwd.split('/').slice(0,-1).join('/')) || refresh(cwd.split('/').slice(0,-1).join('/'))}>⬆ Up</a>
|
||||
</div>
|
||||
)}
|
||||
{rows.sort((a,b)=> (Number(b.is_dir)-Number(a.is_dir)) || a.name.localeCompare(b.name)).map(f=>
|
||||
<div key={f.path} className="item">
|
||||
<div className="name">{f.is_dir?'📁':'🎞️'} {f.name}</div>
|
||||
<div className="meta">{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}</div>
|
||||
<div className="meta">
|
||||
{f.is_dir
|
||||
? (<><a href="#" onClick={()=>{ setCwd(f.path); refresh(f.path) }}>Open</a> · <a href="#" onClick={()=>rename(f.path)}>Rename</a> · <a className="bad" href="#" onClick={()=>del(f.path,true)}>Delete</a></>)
|
||||
: (<><a href="#" onClick={()=>rename(f.path)}>Rename</a> · <a className="bad" href="#" onClick={()=>del(f.path,false)}>Delete</a></>)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>)
|
||||
}
|
||||
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<u.length-1); return n.toFixed(1)+' '+u[i] }
|
||||
7
frontend/src/api.ts
Normal file
7
frontend/src/api.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export async function api<T=any>(path: string, init?: RequestInit): Promise<T> {
|
||||
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 };
|
||||
4
frontend/src/main.tsx
Normal file
4
frontend/src/main.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
createRoot(document.getElementById('root')!).render(<App/>)
|
||||
16
frontend/src/styles.css
Normal file
16
frontend/src/styles.css
Normal file
@ -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} }
|
||||
6
frontend/vite.config.ts
Normal file
6
frontend/vite.config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: { outDir: '../backend/web/dist' }
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user