pegasus: tighten quality gate
This commit is contained in:
parent
73019679c4
commit
0f4e928622
@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/go/pkg/mod go mod tidy
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
set -eux; \
|
||||
mkdir -p /out; \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/pegasus ./main.go; \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/pegasus .; \
|
||||
test -f /out/pegasus; \
|
||||
upx -q --lzma /out/pegasus || true
|
||||
|
||||
|
||||
44
Jenkinsfile
vendored
44
Jenkinsfile
vendored
@ -64,6 +64,8 @@ spec:
|
||||
container('go-tester') {
|
||||
sh '''
|
||||
set -eu
|
||||
export PEGASUS_SESSION_KEY=test-session-key
|
||||
export PEGASUS_COOKIE_INSECURE=1
|
||||
mkdir -p build
|
||||
cd backend
|
||||
go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
@ -108,43 +110,12 @@ spec:
|
||||
}
|
||||
}
|
||||
|
||||
stage('Combine coverage summary') {
|
||||
stage('Quality gate report') {
|
||||
steps {
|
||||
container('publisher') {
|
||||
sh '''
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
build = Path('build')
|
||||
def read_float(path: Path) -> float:
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
try:
|
||||
return float(path.read_text(encoding='utf-8').strip())
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
backend = read_float(build / 'coverage-backend-percent.txt')
|
||||
frontend = read_float(build / 'coverage-frontend-percent.txt')
|
||||
combined = (backend + frontend) / 2 if (backend or frontend) else 0.0
|
||||
|
||||
(build / 'coverage.json').write_text(
|
||||
json.dumps(
|
||||
{
|
||||
'summary': {
|
||||
'backend_percent': backend,
|
||||
'frontend_percent': frontend,
|
||||
'percent_covered': combined,
|
||||
}
|
||||
},
|
||||
indent=2,
|
||||
) + '\\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
print(f'Combined Pegasus coverage: {combined:.2f}%')
|
||||
PY
|
||||
python -m testing.pegasus_gate report
|
||||
'''
|
||||
}
|
||||
}
|
||||
@ -166,12 +137,7 @@ PY
|
||||
container('publisher') {
|
||||
sh '''
|
||||
set -eu
|
||||
backend_rc="$(cat build/backend-test.rc 2>/dev/null || echo 1)"
|
||||
frontend_rc="$(cat build/frontend-test.rc 2>/dev/null || echo 1)"
|
||||
if [ "${backend_rc}" -ne 0 ] || [ "${frontend_rc}" -ne 0 ]; then
|
||||
echo "Pegasus test suite failed (backend=${backend_rc}, frontend=${frontend_rc})"
|
||||
exit 1
|
||||
fi
|
||||
python -m testing.pegasus_gate enforce
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
57
backend/app.go
Normal file
57
backend/app.go
Normal file
@ -0,0 +1,57 @@
|
||||
// backend/app.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
var serveHTTP = (*http.Server).ListenAndServe
|
||||
|
||||
var run = runServer
|
||||
|
||||
func runServer() error {
|
||||
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") == "" {
|
||||
return fmt.Errorf("PEGASUS_SESSION_KEY is not set")
|
||||
}
|
||||
|
||||
um, err := internal.LoadUserMap(userMapFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load user map: %w", err)
|
||||
}
|
||||
if internal.Debug {
|
||||
keys := make([]string, 0, len(um.Map))
|
||||
for k := range um.Map {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
preview := keys
|
||||
if len(preview) > 10 {
|
||||
preview = preview[:10]
|
||||
}
|
||||
internal.Logf("user-map loaded (%d): %v%s", len(keys), preview, map[bool]string{true: " ...", false: ""}[len(keys) > len(preview)])
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(tusDir, 0o2775); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", tusDir, err)
|
||||
}
|
||||
|
||||
handler := buildRouter(um, jf)
|
||||
addr := env("PEGASUS_BIND", ":8080")
|
||||
log.Printf("Pegasus listening on %s", addr)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 0,
|
||||
WriteTimeout: 0,
|
||||
}
|
||||
return serveHTTP(srv)
|
||||
}
|
||||
214
backend/app_test.go
Normal file
214
backend/app_test.go
Normal file
@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
func TestMainDelegatesToRunApp(t *testing.T) {
|
||||
origRun := run
|
||||
origFatal := fatal
|
||||
defer func() {
|
||||
run = origRun
|
||||
fatal = origFatal
|
||||
}()
|
||||
|
||||
called := false
|
||||
run = func() error {
|
||||
called = true
|
||||
return http.ErrNoCookie
|
||||
}
|
||||
fatal = func(args ...any) {
|
||||
called = true
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
if !called {
|
||||
t.Fatalf("expected main to delegate to runApp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitializesServer(t *testing.T) {
|
||||
t.Setenv("PEGASUS_SESSION_KEY", "test-session-key")
|
||||
origServeHTTP := serveHTTP
|
||||
origMediaRoot := mediaRoot
|
||||
origUserMapFile := userMapFile
|
||||
origTusDir := tusDir
|
||||
origDebug := internal.Debug
|
||||
defer func() {
|
||||
serveHTTP = origServeHTTP
|
||||
mediaRoot = origMediaRoot
|
||||
userMapFile = origUserMapFile
|
||||
tusDir = origTusDir
|
||||
internal.Debug = origDebug
|
||||
}()
|
||||
|
||||
workDir := t.TempDir()
|
||||
mediaRoot = filepath.Join(workDir, "media")
|
||||
tusDir = filepath.Join(workDir, "tus")
|
||||
userMapFile = filepath.Join(workDir, "user-map.yaml")
|
||||
internal.Debug = false
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
|
||||
t.Fatalf("mkdir media root: %v", err)
|
||||
}
|
||||
mapBody := "map:\n brad: library\n"
|
||||
for i := 0; i < 11; i++ {
|
||||
mapBody += fmt.Sprintf(" user%02d: library\n", i)
|
||||
}
|
||||
if err := os.WriteFile(userMapFile, []byte(mapBody), 0o644); err != nil {
|
||||
t.Fatalf("write user map: %v", err)
|
||||
}
|
||||
|
||||
var gotServer *http.Server
|
||||
serveHTTP = func(srv *http.Server) error {
|
||||
gotServer = srv
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := run(); err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
if gotServer == nil {
|
||||
t.Fatalf("expected server to be created")
|
||||
}
|
||||
if gotServer.Addr != ":8080" {
|
||||
t.Fatalf("unexpected default address %q", gotServer.Addr)
|
||||
}
|
||||
if gotServer.Handler == nil {
|
||||
t.Fatalf("expected handler to be configured")
|
||||
}
|
||||
if _, err := os.Stat(tusDir); err != nil {
|
||||
t.Fatalf("expected tus dir to be created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDebugLogging(t *testing.T) {
|
||||
t.Setenv("PEGASUS_SESSION_KEY", "test-session-key")
|
||||
origServeHTTP := serveHTTP
|
||||
origMediaRoot := mediaRoot
|
||||
origUserMapFile := userMapFile
|
||||
origTusDir := tusDir
|
||||
origDebug := internal.Debug
|
||||
defer func() {
|
||||
serveHTTP = origServeHTTP
|
||||
mediaRoot = origMediaRoot
|
||||
userMapFile = origUserMapFile
|
||||
tusDir = origTusDir
|
||||
internal.Debug = origDebug
|
||||
}()
|
||||
|
||||
workDir := t.TempDir()
|
||||
mediaRoot = filepath.Join(workDir, "media")
|
||||
tusDir = filepath.Join(workDir, "tus")
|
||||
userMapFile = filepath.Join(workDir, "user-map.yaml")
|
||||
internal.Debug = true
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
|
||||
t.Fatalf("mkdir media root: %v", err)
|
||||
}
|
||||
mapBody := "map:\n brad: library\n"
|
||||
for i := 0; i < 11; i++ {
|
||||
mapBody += fmt.Sprintf(" user%02d: library\n", i)
|
||||
}
|
||||
if err := os.WriteFile(userMapFile, []byte(mapBody), 0o644); err != nil {
|
||||
t.Fatalf("write user map: %v", err)
|
||||
}
|
||||
|
||||
serveHTTP = func(srv *http.Server) error { return nil }
|
||||
if err := run(); err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsMissingSessionKey(t *testing.T) {
|
||||
t.Setenv("PEGASUS_SESSION_KEY", "")
|
||||
origServeHTTP := serveHTTP
|
||||
origMediaRoot := mediaRoot
|
||||
origUserMapFile := userMapFile
|
||||
origTusDir := tusDir
|
||||
defer func() {
|
||||
serveHTTP = origServeHTTP
|
||||
mediaRoot = origMediaRoot
|
||||
userMapFile = origUserMapFile
|
||||
tusDir = origTusDir
|
||||
}()
|
||||
|
||||
serveHTTP = func(srv *http.Server) error {
|
||||
t.Fatalf("serveHTTP should not be called")
|
||||
return nil
|
||||
}
|
||||
if err := run(); err == nil {
|
||||
t.Fatalf("expected missing session key error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsMissingUserMap(t *testing.T) {
|
||||
t.Setenv("PEGASUS_SESSION_KEY", "test-session-key")
|
||||
origServeHTTP := serveHTTP
|
||||
origMediaRoot := mediaRoot
|
||||
origUserMapFile := userMapFile
|
||||
origTusDir := tusDir
|
||||
defer func() {
|
||||
serveHTTP = origServeHTTP
|
||||
mediaRoot = origMediaRoot
|
||||
userMapFile = origUserMapFile
|
||||
tusDir = origTusDir
|
||||
}()
|
||||
|
||||
workDir := t.TempDir()
|
||||
mediaRoot = filepath.Join(workDir, "media")
|
||||
tusDir = filepath.Join(workDir, "tus")
|
||||
userMapFile = filepath.Join(workDir, "missing.yaml")
|
||||
serveHTTP = func(srv *http.Server) error {
|
||||
t.Fatalf("serveHTTP should not be called")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := run(); err == nil {
|
||||
t.Fatalf("expected load user map error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsTusDirCreationFailure(t *testing.T) {
|
||||
t.Setenv("PEGASUS_SESSION_KEY", "test-session-key")
|
||||
origServeHTTP := serveHTTP
|
||||
origMediaRoot := mediaRoot
|
||||
origUserMapFile := userMapFile
|
||||
origTusDir := tusDir
|
||||
origDebug := internal.Debug
|
||||
defer func() {
|
||||
serveHTTP = origServeHTTP
|
||||
mediaRoot = origMediaRoot
|
||||
userMapFile = origUserMapFile
|
||||
tusDir = origTusDir
|
||||
internal.Debug = origDebug
|
||||
}()
|
||||
|
||||
workDir := t.TempDir()
|
||||
mediaRoot = filepath.Join(workDir, "media")
|
||||
userMapFile = filepath.Join(workDir, "user-map.yaml")
|
||||
_ = os.WriteFile(userMapFile, []byte("map:\n brad: library\n"), 0o644)
|
||||
filePath := filepath.Join(workDir, "not-a-dir")
|
||||
_ = os.WriteFile(filePath, []byte("file"), 0o644)
|
||||
tusDir = filepath.Join(filePath, "sub")
|
||||
internal.Debug = false
|
||||
serveHTTP = func(srv *http.Server) error {
|
||||
t.Fatalf("serveHTTP should not be called")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := run(); err == nil {
|
||||
t.Fatalf("expected tus dir creation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustDoesNotExitOnNil(t *testing.T) {
|
||||
must(nil, "noop")
|
||||
}
|
||||
46
backend/config.go
Normal file
46
backend/config.go
Normal file
@ -0,0 +1,46 @@
|
||||
// backend/config.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
type appConfig struct {
|
||||
mediaRoot string
|
||||
userMapFile string
|
||||
tusDir string
|
||||
jf jellyfinClient
|
||||
}
|
||||
|
||||
func loadAppConfig() appConfig {
|
||||
mediaRoot := env("PEGASUS_MEDIA_ROOT", "/media")
|
||||
return appConfig{
|
||||
mediaRoot: mediaRoot,
|
||||
userMapFile: env("PEGASUS_USER_MAP_FILE", "/config/user-map.yaml"),
|
||||
tusDir: env("PEGASUS_TUS_DIR", filepath.Join(mediaRoot, ".pegasus-tus")),
|
||||
jf: internal.NewJellyfin(),
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
mediaRoot string
|
||||
userMapFile string
|
||||
tusDir string
|
||||
jf jellyfinClient
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
git = ""
|
||||
builtAt = ""
|
||||
)
|
||||
|
||||
func init() {
|
||||
cfg := loadAppConfig()
|
||||
mediaRoot = cfg.mediaRoot
|
||||
userMapFile = cfg.userMapFile
|
||||
tusDir = cfg.tusDir
|
||||
jf = cfg.jf
|
||||
}
|
||||
26
backend/config_test.go
Normal file
26
backend/config_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAppConfigUsesEnvironment(t *testing.T) {
|
||||
t.Setenv("PEGASUS_MEDIA_ROOT", "/srv/media")
|
||||
t.Setenv("PEGASUS_USER_MAP_FILE", "/cfg/user-map.yaml")
|
||||
t.Setenv("PEGASUS_TUS_DIR", "")
|
||||
|
||||
cfg := loadAppConfig()
|
||||
if cfg.mediaRoot != "/srv/media" {
|
||||
t.Fatalf("unexpected media root %q", cfg.mediaRoot)
|
||||
}
|
||||
if cfg.userMapFile != "/cfg/user-map.yaml" {
|
||||
t.Fatalf("unexpected user map file %q", cfg.userMapFile)
|
||||
}
|
||||
if cfg.tusDir != filepath.Join("/srv/media", ".pegasus-tus") {
|
||||
t.Fatalf("unexpected tus dir %q", cfg.tusDir)
|
||||
}
|
||||
if cfg.jf == nil {
|
||||
t.Fatalf("expected jellyfin client")
|
||||
}
|
||||
}
|
||||
70
backend/handlers_auth.go
Normal file
70
backend/handlers_auth.go
Normal file
@ -0,0 +1,70 @@
|
||||
// backend/handlers_auth.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
type jellyfinClient interface {
|
||||
AuthenticateByName(username, password string) (internal.AuthResult, error)
|
||||
RefreshLibrary(userToken string)
|
||||
}
|
||||
|
||||
func loginHandler(um *internal.UserMap, jf jellyfinClient) http.HandlerFunc {
|
||||
return 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
|
||||
}
|
||||
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
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
func logoutHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
internal.ClearSession(w)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func whoamiHandler(um *internal.UserMap) http.HandlerFunc {
|
||||
return 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)
|
||||
writeJSON(w, map[string]any{
|
||||
"username": cl.Username,
|
||||
"root": dr,
|
||||
"roots": all,
|
||||
})
|
||||
}
|
||||
}
|
||||
71
backend/handlers_auth_test.go
Normal file
71
backend/handlers_auth_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
func TestLoginHandlerFailurePaths(t *testing.T) {
|
||||
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}
|
||||
jf := &fakeJellyfin{
|
||||
authErr: http.ErrNoCookie,
|
||||
}
|
||||
|
||||
handler := loginHandler(um, jf)
|
||||
|
||||
t.Run("bad json", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewBufferString("{bad")))
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad json status, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid credentials", func(t *testing.T) {
|
||||
jf.authErr = http.ErrNoCookie
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/login", "", []byte(`{"username":"brad","password":"bad"}`)))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized status, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing mapping", func(t *testing.T) {
|
||||
jf.authErr = nil
|
||||
jf.authResult.AccessToken = "token"
|
||||
jf.authResult.User.Name = "brad"
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/login", "", []byte(`{"username":"missing","password":"pw"}`)))
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden status, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWhoamiHandlerFailurePaths(t *testing.T) {
|
||||
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}
|
||||
handler := whoamiHandler(um)
|
||||
|
||||
t.Run("unauthorized", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/whoami", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized status, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing mapping", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/whoami", nil)
|
||||
cookie := sessionCookie(t, "missing", "token")
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden status, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
275
backend/handlers_fs.go
Normal file
275
backend/handlers_fs.go
Normal file
@ -0,0 +1,275 @@
|
||||
// backend/handlers_fs.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
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 resolveLibraryRoot(um *internal.UserMap, username, requested string) (string, error) {
|
||||
allRoots, _ := um.ResolveAll(username)
|
||||
reqLib := sanitizeSegment(requested)
|
||||
if reqLib != "" && contains(allRoots, reqLib) {
|
||||
return reqLib, nil
|
||||
}
|
||||
return um.Resolve(username)
|
||||
}
|
||||
|
||||
func listHandler(um *internal.UserMap) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cl, err := internal.CurrentUser(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
rootRel, err := resolveLibraryRoot(um, cl.Username, r.URL.Query().Get("lib"))
|
||||
if err != nil {
|
||||
internal.Logf("list: map missing for %q", cl.Username)
|
||||
http.Error(w, "no mapping", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
q := sanitizeSegment(r.URL.Query().Get("path"))
|
||||
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,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].IsDir != out[j].IsDir {
|
||||
return out[i].IsDir
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
writeJSON(w, out)
|
||||
}
|
||||
}
|
||||
|
||||
func renameHandler(um *internal.UserMap, jf jellyfinClient) http.HandlerFunc {
|
||||
return 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
rootRel, err := resolveLibraryRoot(um, cl.Username, p.Lib)
|
||||
if err != nil {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
func deleteHandler(um *internal.UserMap, jf jellyfinClient) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cl, err := internal.CurrentUser(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
rootRel, err := resolveLibraryRoot(um, cl.Username, r.URL.Query().Get("lib"))
|
||||
if err != nil {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
func mkdirHandler(um *internal.UserMap, jf jellyfinClient) http.HandlerFunc {
|
||||
return 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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rootRel, err := resolveLibraryRoot(um, cl.Username, p.Lib)
|
||||
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)
|
||||
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
|
||||
}
|
||||
jf.RefreshLibrary(cl.JFToken)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func debugEnvHandler() http.HandlerFunc {
|
||||
return 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func debugWriteTestHandler(um *internal.UserMap) http.HandlerFunc {
|
||||
return 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})
|
||||
}
|
||||
}
|
||||
447
backend/handlers_fs_test.go
Normal file
447
backend/handlers_fs_test.go
Normal file
@ -0,0 +1,447 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
func TestResolveLibraryRootFallback(t *testing.T) {
|
||||
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library", "alt"}}}
|
||||
got, err := resolveLibraryRoot(um, "brad", "not-allowed")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveLibraryRoot failed: %v", err)
|
||||
}
|
||||
if got != "library" {
|
||||
t.Fatalf("expected default root, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHandlerErrorPaths(t *testing.T) {
|
||||
origMediaRoot := mediaRoot
|
||||
defer func() { mediaRoot = origMediaRoot }()
|
||||
mediaRoot = t.TempDir()
|
||||
|
||||
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}
|
||||
handler := listHandler(um)
|
||||
|
||||
t.Run("unauthorized", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/list", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized status, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("forbidden escape", func(t *testing.T) {
|
||||
cookie := sessionCookie(t, "brad", "token")
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/list?path=../secret", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden status, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenameDeleteMkdirErrorPaths(t *testing.T) {
|
||||
origMediaRoot := mediaRoot
|
||||
origDryRun := internal.DryRun
|
||||
defer func() {
|
||||
mediaRoot = origMediaRoot
|
||||
internal.DryRun = origDryRun
|
||||
}()
|
||||
mediaRoot = t.TempDir()
|
||||
internal.DryRun = true
|
||||
|
||||
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}
|
||||
jf := &fakeJellyfin{}
|
||||
cookie := sessionCookie(t, "brad", "token")
|
||||
|
||||
t.Run("rename bad json", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rename", bytes.NewBufferString("{bad"))
|
||||
req.Header.Set("Cookie", cookie)
|
||||
renameHandler(um, jf).ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad request, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename invalid name", func(t *testing.T) {
|
||||
body := []byte(`{"lib":"library","from":"old.txt","to":"bad-name.txt"}`)
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rename", bytes.NewReader(body))
|
||||
req.Header.Set("Cookie", cookie)
|
||||
renameHandler(um, jf).ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad request, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mkdir bad json and empty folder", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString("{bad"))
|
||||
req.Header.Set("Cookie", cookie)
|
||||
mkdirHandler(um, jf).ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad request, got %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":" "}`))
|
||||
req.Header.Set("Cookie", cookie)
|
||||
mkdirHandler(um, jf).ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad request, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete unauthorized", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
deleteHandler(um, jf).ServeHTTP(rr, httptest.NewRequest(http.MethodDelete, "/api/file?path=a", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDebugHandlersDisabled(t *testing.T) {
|
||||
origDebug := internal.Debug
|
||||
defer func() { internal.Debug = origDebug }()
|
||||
internal.Debug = false
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
debugEnvHandler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/debug/env", nil))
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden when debug disabled, got %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
debugWriteTestHandler(&internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}).ServeHTTP(
|
||||
rr,
|
||||
httptest.NewRequest(http.MethodGet, "/debug/write-test", nil),
|
||||
)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden when debug disabled, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemHandlersMoreBranches(t *testing.T) {
|
||||
origMediaRoot := mediaRoot
|
||||
origDryRun := internal.DryRun
|
||||
origDebug := internal.Debug
|
||||
defer func() {
|
||||
mediaRoot = origMediaRoot
|
||||
internal.DryRun = origDryRun
|
||||
internal.Debug = origDebug
|
||||
}()
|
||||
|
||||
root := t.TempDir()
|
||||
mediaRoot = root
|
||||
internal.DryRun = false
|
||||
internal.Debug = true
|
||||
|
||||
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}
|
||||
jf := &fakeJellyfin{}
|
||||
cookie := sessionCookie(t, "brad", "token")
|
||||
handlerList := listHandler(um)
|
||||
handlerRename := renameHandler(um, jf)
|
||||
handlerDelete := deleteHandler(um, jf)
|
||||
handlerMkdir := mkdirHandler(um, jf)
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(root, "library", "album"), 0o2775); err != nil {
|
||||
t.Fatalf("mkdir library: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "library", "zeta"), 0o2775); err != nil {
|
||||
t.Fatalf("mkdir zeta: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "library", "keep.txt"), []byte("keep"), 0o644); err != nil {
|
||||
t.Fatalf("write keep file: %v", err)
|
||||
}
|
||||
|
||||
t.Run("list same-type sorting", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerList.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected list status %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list nested path", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library&path=album", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerList.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected list status %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list missing directory", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library&path=missing", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerList.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected internal error, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list missing mapping", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library", nil)
|
||||
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
|
||||
handlerList.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden for missing mapping, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename unauthorized and escape", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlerRename.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/rename", bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"2026.01.02.Note.old.txt"}`)))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized rename, got %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
body := bytes.NewBufferString(`{"lib":"library","from":"../escape.txt","to":"2026.01.02.Note.escape.txt"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rename", body)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerRename.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden && rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected from-path escape or missing-source failure, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename forbidden root and dry run", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
body := bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"2026.01.02.Note.old.txt"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rename", body)
|
||||
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
|
||||
handlerRename.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden rename root, got %d", rr.Code)
|
||||
}
|
||||
|
||||
orig := internal.DryRun
|
||||
internal.DryRun = true
|
||||
defer func() { internal.DryRun = orig }()
|
||||
rr = httptest.NewRecorder()
|
||||
body = bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"2026.01.02.Note.old.txt"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/rename", body)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerRename.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected dry-run rename success, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename to-path escape", func(t *testing.T) {
|
||||
orig := internal.DryRun
|
||||
internal.DryRun = false
|
||||
defer func() { internal.DryRun = orig }()
|
||||
rr := httptest.NewRecorder()
|
||||
body := bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"../2026.01.02.Note.escape.txt"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rename", body)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerRename.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden rename escape, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename missing source", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
body := bytes.NewBufferString(`{"lib":"library","from":"missing.txt","to":"2026.01.02.Note.missing.txt"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rename", body)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerRename.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected rename error, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete unauthorized and escape", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlerDelete.ServeHTTP(rr, httptest.NewRequest(http.MethodDelete, "/api/file?path=a", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized delete, got %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=../escape", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerDelete.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden && rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected delete escape failure, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete forbidden root and missing file", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=keep.txt", nil)
|
||||
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
|
||||
handlerDelete.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden delete root, got %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=missing.txt", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerDelete.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected missing-file delete error, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete file without recursion", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=keep.txt", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerDelete.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected delete success, got %d", rr.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "library", "keep.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected keep.txt to be removed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete dry run", func(t *testing.T) {
|
||||
orig := internal.DryRun
|
||||
internal.DryRun = true
|
||||
defer func() { internal.DryRun = orig }()
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=keep.txt", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerDelete.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected dry-run delete success, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mkdir unauthorized and path escape", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlerMkdir.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`)))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized mkdir, got %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
body := bytes.NewBufferString(`{"lib":"library","path":"../escape"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", body)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerMkdir.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest && rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected mkdir escape failure, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mkdir forbidden root and dry run", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`))
|
||||
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
|
||||
handlerMkdir.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden mkdir root, got %d", rr.Code)
|
||||
}
|
||||
|
||||
orig := internal.DryRun
|
||||
internal.DryRun = true
|
||||
defer func() { internal.DryRun = orig }()
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`))
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerMkdir.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected dry-run mkdir success, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mkdir create failure", func(t *testing.T) {
|
||||
orig := internal.DryRun
|
||||
internal.DryRun = false
|
||||
defer func() { internal.DryRun = orig }()
|
||||
|
||||
blocker := filepath.Join(root, "library", "blocker")
|
||||
if err := os.WriteFile(blocker, []byte("file"), 0o644); err != nil {
|
||||
t.Fatalf("write blocker file: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"blocker/child"}`))
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerMkdir.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected mkdir create failure, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mkdir empty path", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":" "}`))
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerMkdir.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected empty folder rejection, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mkdir missing library root", func(t *testing.T) {
|
||||
mediaRoot = filepath.Join(root, "file-root")
|
||||
if err := os.WriteFile(mediaRoot, []byte("not a dir"), 0o644); err != nil {
|
||||
t.Fatalf("write file root: %v", err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
body := bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", body)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerMkdir.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad request for missing library root, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("debug write unauthorized and dry run", func(t *testing.T) {
|
||||
handlerDebug := debugWriteTestHandler(um)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handlerDebug.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/debug/write-test", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized debug write, got %d", rr.Code)
|
||||
}
|
||||
|
||||
orig := internal.DryRun
|
||||
internal.DryRun = true
|
||||
defer func() { internal.DryRun = orig }()
|
||||
rr = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/debug/write-test", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
handlerDebug.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected dry-run debug write success, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("debug write forbidden root", func(t *testing.T) {
|
||||
handlerDebug := debugWriteTestHandler(um)
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/debug/write-test", nil)
|
||||
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
|
||||
handlerDebug.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden debug write root, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -8,12 +8,15 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var parseJWT = jwt.ParseWithClaims
|
||||
|
||||
// CurrentUser parses the Pegasus session cookie and validates its JWT claims.
|
||||
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 })
|
||||
tok, err := parseJWT(c.Value, &Claims{}, func(_ *jwt.Token) (any, error) { return sessionKey, nil })
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
|
||||
@ -98,3 +98,20 @@ func TestCurrentUserMalformedToken(t *testing.T) {
|
||||
t.Fatalf("expected parse error for malformed token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentUserInvalidClaimType(t *testing.T) {
|
||||
origParse := parseJWT
|
||||
defer func() { parseJWT = origParse }()
|
||||
|
||||
parseJWT = func(string, jwt.Claims, jwt.Keyfunc, ...jwt.ParserOption) (*jwt.Token, error) {
|
||||
return &jwt.Token{Valid: true, Claims: jwt.MapClaims{"ok": true}}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: "token"})
|
||||
|
||||
_, err := CurrentUser(req)
|
||||
if err == nil {
|
||||
t.Fatalf("expected invalid-session error")
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,15 +8,20 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Debug enables verbose request and state logging.
|
||||
var Debug = os.Getenv("PEGASUS_DEBUG") == "1"
|
||||
|
||||
// DryRun makes mutating handlers log their intent instead of touching disk.
|
||||
var DryRun = os.Getenv("PEGASUS_DRY_RUN") == "1"
|
||||
|
||||
// Logf writes a log line only when Debug is enabled.
|
||||
func Logf(format string, args ...any) {
|
||||
if Debug {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// RedactHeaders clones a header map and masks sensitive values.
|
||||
func RedactHeaders(h http.Header) http.Header {
|
||||
cp := http.Header{}
|
||||
for k, v := range h {
|
||||
|
||||
@ -8,14 +8,17 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var absPath = filepath.Abs
|
||||
|
||||
// SafeJoin resolves rel under root and rejects any path that escapes the root.
|
||||
func SafeJoin(root, rel string) (string, error) {
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
p := filepath.Join(root, rel)
|
||||
ap, err := filepath.Abs(p)
|
||||
ap, err := absPath(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ar, err := filepath.Abs(root)
|
||||
ar, err := absPath(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@ -34,3 +35,52 @@ func TestSafeJoinAllowsRoot(t *testing.T) {
|
||||
t.Fatalf("expected root path %q got %q", root, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeJoinAbsError(t *testing.T) {
|
||||
origAbs := absPath
|
||||
defer func() { absPath = origAbs }()
|
||||
|
||||
absPath = func(string) (string, error) {
|
||||
return "", errors.New("boom")
|
||||
}
|
||||
|
||||
if _, err := SafeJoin(t.TempDir(), "child"); err == nil {
|
||||
t.Fatalf("expected abs-path error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeJoinRejectsSiblingPrefix(t *testing.T) {
|
||||
origAbs := absPath
|
||||
defer func() { absPath = origAbs }()
|
||||
|
||||
absPath = func(p string) (string, error) {
|
||||
switch p {
|
||||
case "/root":
|
||||
return "/root", nil
|
||||
case "/root/child":
|
||||
return "/rootx/child", nil
|
||||
default:
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := SafeJoin("/root", "child"); err == nil {
|
||||
t.Fatalf("expected sibling-prefix rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeJoinRejectsRootAbsError(t *testing.T) {
|
||||
origAbs := absPath
|
||||
defer func() { absPath = origAbs }()
|
||||
|
||||
absPath = func(p string) (string, error) {
|
||||
if p == "/root" {
|
||||
return "", errors.New("root failed")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
if _, err := SafeJoin("/root", "child"); err == nil {
|
||||
t.Fatalf("expected root abs-path error")
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,15 +4,14 @@ package internal
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt" // <-- keep this; used by fmt.Errorf
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Minimal Jellyfin client
|
||||
|
||||
type jfAuthResult struct {
|
||||
// AuthResult is the subset of Jellyfin auth response Pegasus needs.
|
||||
type AuthResult struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
User struct {
|
||||
Id string `json:"Id"`
|
||||
@ -20,11 +19,13 @@ type jfAuthResult struct {
|
||||
} `json:"User"`
|
||||
}
|
||||
|
||||
// Jellyfin is a tiny client for the auth and refresh endpoints Pegasus uses.
|
||||
type Jellyfin struct {
|
||||
BaseURL string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// NewJellyfin builds a lightweight client for Jellyfin auth and refresh calls.
|
||||
func NewJellyfin() *Jellyfin {
|
||||
return &Jellyfin{
|
||||
BaseURL: os.Getenv("JELLYFIN_URL"),
|
||||
@ -32,9 +33,9 @@ func NewJellyfin() *Jellyfin {
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate against /Users/AuthenticateByName using Pw (plaintext)
|
||||
func (j *Jellyfin) AuthenticateByName(username, password string) (jfAuthResult, error) {
|
||||
var out jfAuthResult
|
||||
// AuthenticateByName signs into Jellyfin with the plaintext password Jellyfin expects.
|
||||
func (j *Jellyfin) AuthenticateByName(username, password string) (AuthResult, error) {
|
||||
var out AuthResult
|
||||
body := map[string]string{"Username": username, "Pw": password}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
|
||||
@ -90,3 +90,14 @@ func TestAuthenticateByNameRequestError(t *testing.T) {
|
||||
t.Fatalf("expected transport error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJellyfinReadsEnv(t *testing.T) {
|
||||
t.Setenv("JELLYFIN_URL", "http://example.invalid")
|
||||
j := NewJellyfin()
|
||||
if j.BaseURL != "http://example.invalid" {
|
||||
t.Fatalf("unexpected base url %q", j.BaseURL)
|
||||
}
|
||||
if j.Client == nil {
|
||||
t.Fatalf("expected default client")
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ var (
|
||||
finalNameValidate = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9]{1,8}$`)
|
||||
)
|
||||
|
||||
// SanitizeDesc normalizes a free-form description into a safe filename segment.
|
||||
func SanitizeDesc(in string) string {
|
||||
s := strings.TrimSpace(in)
|
||||
if s == "" {
|
||||
@ -29,6 +30,7 @@ func SanitizeDesc(in string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// ComposeFinalName builds the on-disk upload filename from the user date, description, and original name.
|
||||
func ComposeFinalName(dateStr, desc, origFilename string) (string, error) {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), "."))
|
||||
if ext == "" {
|
||||
|
||||
@ -48,3 +48,9 @@ func TestComposeFinalName(t *testing.T) {
|
||||
t.Fatalf("unexpected fallback name %q", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeFinalNameRejectsLongExtension(t *testing.T) {
|
||||
if _, err := ComposeFinalName("2026-04-09", "desc", "movie.toolongext"); err == nil {
|
||||
t.Fatalf("expected invalid final-name error")
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,3 +84,25 @@ func TestRefreshLibrarySkipsWithoutURL(t *testing.T) {
|
||||
t.Fatalf("expected timer to self-clear after skip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshLibraryUsesFallbackClientAndHandlesServerError(t *testing.T) {
|
||||
defer safeClearTimer()
|
||||
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("JELLYFIN_URL", srv.URL)
|
||||
t.Setenv("JELLYFIN_API_KEY", "admin-token")
|
||||
|
||||
j := &Jellyfin{}
|
||||
j.RefreshLibrary("fallback-user-token")
|
||||
|
||||
time.Sleep(2500 * time.Millisecond)
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("expected one refresh request, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims are the signed session fields Pegasus stores in the browser cookie.
|
||||
type Claims struct {
|
||||
Username string `json:"u"`
|
||||
JFToken string `json:"t"`
|
||||
@ -17,9 +18,14 @@ type Claims struct {
|
||||
|
||||
var sessionKey = []byte(os.Getenv("PEGASUS_SESSION_KEY"))
|
||||
var cookieSecure = os.Getenv("PEGASUS_COOKIE_INSECURE") != "1"
|
||||
var signJWT = func(tok *jwt.Token) (string, error) {
|
||||
return tok.SignedString(sessionKey)
|
||||
}
|
||||
|
||||
// CookieName is the session cookie name used by Pegasus.
|
||||
const CookieName = "pegasus_session"
|
||||
|
||||
// SetSession signs and writes a Pegasus session cookie.
|
||||
func SetSession(w http.ResponseWriter, username, jfToken string) error {
|
||||
now := time.Now()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||
@ -30,7 +36,7 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
})
|
||||
signed, err := tok.SignedString(sessionKey)
|
||||
signed, err := signJWT(tok)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -45,6 +51,7 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSession expires the Pegasus session cookie immediately.
|
||||
func ClearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
|
||||
@ -95,3 +95,17 @@ func TestClearSessionExpiresCookie(t *testing.T) {
|
||||
t.Fatalf("expected secure cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSessionSigningError(t *testing.T) {
|
||||
origSign := signJWT
|
||||
defer func() { signJWT = origSign }()
|
||||
|
||||
signJWT = func(*jwt.Token) (string, error) {
|
||||
return "", http.ErrNoCookie
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
if err := SetSession(rr, "brad", "jf"); err == nil {
|
||||
t.Fatalf("expected signing error")
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,8 +10,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// StringOrList allows both a single string ("Stein_90s")
|
||||
// and a list of strings (["Stein_90s","Home Videos"]) in YAML.
|
||||
// StringOrList allows a single string or a list of strings in YAML.
|
||||
type StringOrList []string
|
||||
|
||||
func (s *StringOrList) UnmarshalYAML(value *yaml.Node) error {
|
||||
@ -35,11 +34,12 @@ func (s *StringOrList) UnmarshalYAML(value *yaml.Node) error {
|
||||
}
|
||||
}
|
||||
|
||||
// UserMap maps a Jellyfin username to one or more relative media roots.
|
||||
type UserMap struct {
|
||||
// Maps Jellyfin username -> one or more relative media subdirs
|
||||
Map map[string]StringOrList `yaml:"map"`
|
||||
}
|
||||
|
||||
// LoadUserMap loads the YAML mapping file used to resolve Jellyfin users to media roots.
|
||||
func LoadUserMap(path string) (*UserMap, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@ -90,3 +90,17 @@ func TestResolveAllMissingAndInvalidEntries(t *testing.T) {
|
||||
t.Fatalf("expected invalid-mapping error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReturnsFirstMappingAndMissingUser(t *testing.T) {
|
||||
m := &UserMap{Map: map[string]StringOrList{"brad": {"first", "second"}}}
|
||||
got, err := m.Resolve("brad")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve failed: %v", err)
|
||||
}
|
||||
if got != "first" {
|
||||
t.Fatalf("expected first mapping, got %q", got)
|
||||
}
|
||||
if _, err := m.Resolve("missing"); err == nil {
|
||||
t.Fatalf("expected missing-user error")
|
||||
}
|
||||
}
|
||||
|
||||
729
backend/main.go
729
backend/main.go
@ -1,733 +1,12 @@
|
||||
// backend/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"sort"
|
||||
import "log"
|
||||
|
||||
"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) }
|
||||
var fatal = log.Fatal
|
||||
|
||||
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")
|
||||
if err := run(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -49,6 +50,9 @@ func TestSanitizeDescriptorAndStemOf(t *testing.T) {
|
||||
if got := sanitizeDescriptor(""); got != "upload" {
|
||||
t.Fatalf("expected upload fallback, got %q", got)
|
||||
}
|
||||
if got := sanitizeDescriptor("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz"); len(got) != 64 {
|
||||
t.Fatalf("expected truncation to 64 chars, got %d", len(got))
|
||||
}
|
||||
if got := stemOf("My.Video.File.mp4"); got != "My_Video_File" {
|
||||
t.Fatalf("unexpected stem %q", got)
|
||||
}
|
||||
@ -259,6 +263,198 @@ func TestMoveFromTusMissingDataFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFromTusFallbackCopiesWhenRenameFails(t *testing.T) {
|
||||
origTusDir := tusDir
|
||||
origRenameFile := renameFile
|
||||
tusDir = t.TempDir()
|
||||
renameFile = func(string, string) error {
|
||||
return fmt.Errorf("rename blocked")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
tusDir = origTusDir
|
||||
renameFile = origRenameFile
|
||||
})
|
||||
|
||||
origDryRun := internal.DryRun
|
||||
internal.DryRun = false
|
||||
t.Cleanup(func() {
|
||||
internal.DryRun = origDryRun
|
||||
})
|
||||
|
||||
id := "upload-id-copy"
|
||||
src := filepath.Join(tusDir, id)
|
||||
if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write src failed: %v", err)
|
||||
}
|
||||
dst := filepath.Join(t.TempDir(), "fallback", "file.bin")
|
||||
ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}
|
||||
|
||||
if err := moveFromTus(ev, dst); err != nil {
|
||||
t.Fatalf("moveFromTus fallback failed: %v", err)
|
||||
}
|
||||
b, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("read dst failed: %v", err)
|
||||
}
|
||||
if string(b) != "payload" {
|
||||
t.Fatalf("unexpected dst payload %q", string(b))
|
||||
}
|
||||
if _, err := os.Stat(src); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected source to be removed, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFromTusScansForPrefixedFiles(t *testing.T) {
|
||||
origTusDir := tusDir
|
||||
origRenameFile := renameFile
|
||||
tusDir = t.TempDir()
|
||||
renameFile = func(string, string) error {
|
||||
return fmt.Errorf("rename blocked")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
tusDir = origTusDir
|
||||
renameFile = origRenameFile
|
||||
})
|
||||
|
||||
origDryRun := internal.DryRun
|
||||
internal.DryRun = false
|
||||
t.Cleanup(func() {
|
||||
internal.DryRun = origDryRun
|
||||
})
|
||||
|
||||
id := "pref"
|
||||
src := filepath.Join(tusDir, id+"-suffix.bin")
|
||||
if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write src failed: %v", err)
|
||||
}
|
||||
dst := filepath.Join(t.TempDir(), "scan", "file.bin")
|
||||
ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}
|
||||
|
||||
if err := moveFromTus(ev, dst); err != nil {
|
||||
t.Fatalf("moveFromTus scan failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dst); err != nil {
|
||||
t.Fatalf("expected scanned destination to exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFromTusOpenAndCreateFailures(t *testing.T) {
|
||||
t.Run("open failure", func(t *testing.T) {
|
||||
origTusDir := tusDir
|
||||
origRenameFile := renameFile
|
||||
tusDir = t.TempDir()
|
||||
renameFile = func(string, string) error {
|
||||
return fmt.Errorf("rename blocked")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
tusDir = origTusDir
|
||||
renameFile = origRenameFile
|
||||
})
|
||||
|
||||
id := "open-failure"
|
||||
src := filepath.Join(tusDir, id)
|
||||
if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write src failed: %v", err)
|
||||
}
|
||||
renameFile = func(string, string) error {
|
||||
_ = os.Remove(src)
|
||||
return fmt.Errorf("rename blocked")
|
||||
}
|
||||
|
||||
err := moveFromTus(tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}, filepath.Join(t.TempDir(), "dst.bin"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected open failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create failure", func(t *testing.T) {
|
||||
origTusDir := tusDir
|
||||
origRenameFile := renameFile
|
||||
tusDir = t.TempDir()
|
||||
renameFile = func(string, string) error {
|
||||
return fmt.Errorf("rename blocked")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
tusDir = origTusDir
|
||||
renameFile = origRenameFile
|
||||
})
|
||||
|
||||
id := "create-failure"
|
||||
src := filepath.Join(tusDir, id)
|
||||
if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write src failed: %v", err)
|
||||
}
|
||||
dst := filepath.Join(t.TempDir(), "blocked-dst.bin")
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatalf("mkdir dst blocker failed: %v", err)
|
||||
}
|
||||
|
||||
err := moveFromTus(tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}, dst)
|
||||
if err == nil {
|
||||
t.Fatalf("expected create failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mkdir failure", func(t *testing.T) {
|
||||
origTusDir := tusDir
|
||||
origRenameFile := renameFile
|
||||
tusDir = t.TempDir()
|
||||
renameFile = func(string, string) error {
|
||||
return fmt.Errorf("rename blocked")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
tusDir = origTusDir
|
||||
renameFile = origRenameFile
|
||||
})
|
||||
|
||||
id := "mkdir-failure"
|
||||
src := filepath.Join(tusDir, id)
|
||||
if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write src failed: %v", err)
|
||||
}
|
||||
blocker := filepath.Join(t.TempDir(), "blocker")
|
||||
if err := os.WriteFile(blocker, []byte("block"), 0o644); err != nil {
|
||||
t.Fatalf("write blocker failed: %v", err)
|
||||
}
|
||||
dst := filepath.Join(blocker, "child.bin")
|
||||
|
||||
err := moveFromTus(tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}, dst)
|
||||
if err == nil {
|
||||
t.Fatalf("expected mkdir failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("io copy failure", func(t *testing.T) {
|
||||
origTusDir := tusDir
|
||||
origRenameFile := renameFile
|
||||
tusDir = t.TempDir()
|
||||
renameFile = func(src, _ string) error {
|
||||
if err := os.Remove(src); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Mkdir(src, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("rename blocked")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
tusDir = origTusDir
|
||||
renameFile = origRenameFile
|
||||
})
|
||||
|
||||
id := "copy-failure"
|
||||
src := filepath.Join(tusDir, id)
|
||||
if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write src failed: %v", err)
|
||||
}
|
||||
dst := filepath.Join(t.TempDir(), "copy-failure.bin")
|
||||
err := moveFromTus(tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}, dst)
|
||||
if err == nil {
|
||||
t.Fatalf("expected copy failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
writeJSON(rr, map[string]any{"ok": true})
|
||||
|
||||
90
backend/middleware.go
Normal file
90
backend/middleware.go
Normal file
@ -0,0 +1,90 @@
|
||||
// backend/middleware.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tus/tusd/pkg/handler"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
type loggingRW struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (l *loggingRW) WriteHeader(code int) {
|
||||
l.status = code
|
||||
l.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
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) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
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 handler.HookEvent) (internal.Claims, error) {
|
||||
req := ev.HTTPRequest
|
||||
if req.Header == nil {
|
||||
return internal.Claims{}, http.ErrNoCookie
|
||||
}
|
||||
r := http.Request{Header: http.Header(req.Header)}
|
||||
return internal.CurrentUser(&r)
|
||||
}
|
||||
|
||||
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>"}
|
||||
continue
|
||||
}
|
||||
cp[k] = append([]string(nil), v...)
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func debugHandler(next http.Handler) http.Handler {
|
||||
if !internal.Debug {
|
||||
return next
|
||||
}
|
||||
return 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, redactHeaders(req.Header))
|
||||
rw := &loggingRW{ResponseWriter: w, status: http.StatusOK}
|
||||
start := time.Now()
|
||||
next.ServeHTTP(rw, req)
|
||||
internal.Logf("<< %d %s %s %d %s", id, req.Method, req.URL.Path, rw.status, time.Since(start))
|
||||
})
|
||||
}
|
||||
53
backend/middleware_test.go
Normal file
53
backend/middleware_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
func TestRedactHeaders(t *testing.T) {
|
||||
got := redactHeaders(http.Header{
|
||||
"Cookie": []string{"session"},
|
||||
"Authorization": []string{"bearer token"},
|
||||
"X-Token": []string{"secret"},
|
||||
"X-Other": []string{"ok"},
|
||||
})
|
||||
|
||||
if got.Get("Cookie") != "<redacted>" {
|
||||
t.Fatalf("expected cookie to be redacted, got %q", got.Get("Cookie"))
|
||||
}
|
||||
if got.Get("Authorization") != "<redacted>" {
|
||||
t.Fatalf("expected authorization to be redacted, got %q", got.Get("Authorization"))
|
||||
}
|
||||
if got.Get("X-Token") != "<redacted>" {
|
||||
t.Fatalf("expected token header to be redacted, got %q", got.Get("X-Token"))
|
||||
}
|
||||
if got.Get("X-Other") != "ok" {
|
||||
t.Fatalf("expected non-sensitive header to pass through, got %q", got.Get("X-Other"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugHandlerWrapsHandler(t *testing.T) {
|
||||
origDebug := internal.Debug
|
||||
defer func() { internal.Debug = origDebug }()
|
||||
internal.Debug = true
|
||||
|
||||
called := false
|
||||
h := debugHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/test", nil))
|
||||
|
||||
if !called {
|
||||
t.Fatalf("expected wrapped handler to run")
|
||||
}
|
||||
if rr.Code != http.StatusTeapot {
|
||||
t.Fatalf("unexpected status %d", rr.Code)
|
||||
}
|
||||
}
|
||||
526
backend/router_test.go
Normal file
526
backend/router_test.go
Normal file
@ -0,0 +1,526 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
handler "github.com/tus/tusd/pkg/handler"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
type fakeJellyfin struct {
|
||||
mu sync.Mutex
|
||||
authResult internal.AuthResult
|
||||
authErr error
|
||||
refreshTokens []string
|
||||
}
|
||||
|
||||
func (f *fakeJellyfin) AuthenticateByName(username, password string) (internal.AuthResult, error) {
|
||||
return f.authResult, f.authErr
|
||||
}
|
||||
|
||||
func (f *fakeJellyfin) RefreshLibrary(userToken string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.refreshTokens = append(f.refreshTokens, userToken)
|
||||
}
|
||||
|
||||
func (f *fakeJellyfin) refreshCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.refreshTokens)
|
||||
}
|
||||
|
||||
func testUserMap(t *testing.T, payload string) *internal.UserMap {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "user-map.yaml")
|
||||
if err := os.WriteFile(path, []byte(payload), 0o644); err != nil {
|
||||
t.Fatalf("write user map: %v", err)
|
||||
}
|
||||
um, err := internal.LoadUserMap(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load user map: %v", err)
|
||||
}
|
||||
return um
|
||||
}
|
||||
|
||||
func sessionCookie(t *testing.T, username, token string) string {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
if err := internal.SetSession(rr, username, token); err != nil {
|
||||
t.Fatalf("SetSession: %v", err)
|
||||
}
|
||||
cookies := rr.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatalf("expected session cookie")
|
||||
}
|
||||
return cookies[0].Name + "=" + cookies[0].Value
|
||||
}
|
||||
|
||||
func requestWithCookie(method, target, cookie string, body []byte) *http.Request {
|
||||
req := httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestRouterLifecycleAndFilesystemRoutes(t *testing.T) {
|
||||
origMediaRoot := mediaRoot
|
||||
origTusDir := tusDir
|
||||
origDebug := internal.Debug
|
||||
origDryRun := internal.DryRun
|
||||
defer func() {
|
||||
mediaRoot = origMediaRoot
|
||||
tusDir = origTusDir
|
||||
internal.Debug = origDebug
|
||||
internal.DryRun = origDryRun
|
||||
}()
|
||||
|
||||
root := t.TempDir()
|
||||
mediaRoot = filepath.Join(root, "media")
|
||||
tusDir = filepath.Join(root, "tus")
|
||||
internal.Debug = false
|
||||
internal.DryRun = false
|
||||
if err := os.MkdirAll(filepath.Join(mediaRoot, "library", "album"), 0o2775); err != nil {
|
||||
t.Fatalf("mkdir media root: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mediaRoot, "library", "old.txt"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatalf("write source file: %v", err)
|
||||
}
|
||||
|
||||
um := testUserMap(t, "map:\n brad: library\n")
|
||||
jf := &fakeJellyfin{
|
||||
authResult: internal.AuthResult{
|
||||
AccessToken: "jf-token",
|
||||
},
|
||||
}
|
||||
jf.authResult.User.Name = "brad"
|
||||
|
||||
router := buildRouter(um, jf)
|
||||
|
||||
t.Run("health and version", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected health status %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/version", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected version status %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("login whoami logout", func(t *testing.T) {
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "brad", "password": "pass"})
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/login", "", loginBody))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected login status %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
cookies := rr.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatalf("expected login cookie")
|
||||
}
|
||||
session := cookies[0].Name + "=" + cookies[0].Value
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/api/whoami", session, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected whoami status %d", rr.Code)
|
||||
}
|
||||
var profile map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &profile); err != nil {
|
||||
t.Fatalf("decode whoami: %v", err)
|
||||
}
|
||||
if profile["username"] != "brad" {
|
||||
t.Fatalf("unexpected whoami payload %#v", profile)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/logout", session, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected logout status %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
session := sessionCookie(t, "brad", "jf-token")
|
||||
|
||||
t.Run("list mkdir rename delete", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/api/list?lib=library", session, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected list status %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
mkdirBody, _ := json.Marshal(map[string]string{"lib": "library", "path": "new-folder"})
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/mkdir", session, mkdirBody))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected mkdir status %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(mediaRoot, "library", "new-folder")); err != nil {
|
||||
t.Fatalf("expected new folder to exist: %v", err)
|
||||
}
|
||||
|
||||
renameBody, _ := json.Marshal(map[string]string{
|
||||
"lib": "library",
|
||||
"from": "old.txt",
|
||||
"to": "2026.01.02.Note.old.txt",
|
||||
})
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/rename", session, renameBody))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected rename status %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(mediaRoot, "library", "2026.01.02.Note.old.txt")); err != nil {
|
||||
t.Fatalf("expected renamed file to exist: %v", err)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, requestWithCookie(http.MethodDelete, "/api/file?lib=library&path=new-folder&recursive=true", session, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected delete status %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(mediaRoot, "library", "new-folder")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected deleted folder to be absent, err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static metrics and tus auth", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected static status %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected metrics status %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/tus/123", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected tus auth to reject unauthenticated request, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
if jf.refreshCount() == 0 {
|
||||
t.Fatalf("expected at least one Jellyfin refresh call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugRoutesAndUploadProcessing(t *testing.T) {
|
||||
origMediaRoot := mediaRoot
|
||||
origTusDir := tusDir
|
||||
origDebug := internal.Debug
|
||||
origDryRun := internal.DryRun
|
||||
defer func() {
|
||||
mediaRoot = origMediaRoot
|
||||
tusDir = origTusDir
|
||||
internal.Debug = origDebug
|
||||
internal.DryRun = origDryRun
|
||||
}()
|
||||
|
||||
root := t.TempDir()
|
||||
mediaRoot = filepath.Join(root, "media")
|
||||
tusDir = filepath.Join(root, "tus")
|
||||
internal.Debug = true
|
||||
internal.DryRun = false
|
||||
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
|
||||
t.Fatalf("mkdir media root: %v", err)
|
||||
}
|
||||
|
||||
um := testUserMap(t, "map:\n brad: library\n")
|
||||
jf := &fakeJellyfin{
|
||||
authResult: internal.AuthResult{AccessToken: "jf-token"},
|
||||
}
|
||||
jf.authResult.User.Name = "brad"
|
||||
|
||||
router := buildRouter(um, jf)
|
||||
session := sessionCookie(t, "brad", "jf-token")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/debug/env", session, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected debug env status %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/debug/write-test", session, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected debug write-test status %d", rr.Code)
|
||||
}
|
||||
|
||||
srcID := "upload-id-123"
|
||||
if err := os.MkdirAll(tusDir, 0o2775); err != nil {
|
||||
t.Fatalf("mkdir tus dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tusDir, srcID+".bin"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write tus payload: %v", err)
|
||||
}
|
||||
|
||||
event := handler.HookEvent{
|
||||
HTTPRequest: handler.HTTPRequest{
|
||||
Header: http.Header{"Cookie": []string{session}},
|
||||
},
|
||||
Upload: handler.FileInfo{
|
||||
ID: srcID,
|
||||
MetaData: map[string]string{
|
||||
"filename": "video.mp4",
|
||||
"desc": "Trip",
|
||||
"date": "2026-01-02",
|
||||
"lib": "library",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
ch := make(chan handler.HookEvent, 2)
|
||||
go func() {
|
||||
watchUploadCompletions(ch, um, jf)
|
||||
close(done)
|
||||
}()
|
||||
ch <- event
|
||||
ch <- handler.HookEvent{
|
||||
HTTPRequest: handler.HTTPRequest{
|
||||
Header: http.Header{"Cookie": []string{session}},
|
||||
},
|
||||
Upload: handler.FileInfo{
|
||||
ID: "bad-id",
|
||||
MetaData: map[string]string{
|
||||
"filename": "clip.mp4",
|
||||
"lib": "library",
|
||||
},
|
||||
},
|
||||
}
|
||||
close(ch)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("watchUploadCompletions did not drain channel")
|
||||
}
|
||||
|
||||
expected := filepath.Join(mediaRoot, "library", composeFinalName("2026-01-02", "Trip", "video.mp4"))
|
||||
if _, err := os.Stat(expected); err != nil {
|
||||
t.Fatalf("expected uploaded file at destination: %v", err)
|
||||
}
|
||||
if jf.refreshCount() == 0 {
|
||||
t.Fatalf("expected upload completion to refresh Jellyfin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCompletedUploadErrorPaths(t *testing.T) {
|
||||
origMediaRoot := mediaRoot
|
||||
origTusDir := tusDir
|
||||
origDryRun := internal.DryRun
|
||||
defer func() {
|
||||
mediaRoot = origMediaRoot
|
||||
tusDir = origTusDir
|
||||
internal.DryRun = origDryRun
|
||||
}()
|
||||
|
||||
root := t.TempDir()
|
||||
mediaRoot = filepath.Join(root, "media")
|
||||
tusDir = filepath.Join(root, "tus")
|
||||
internal.DryRun = false
|
||||
|
||||
um := testUserMap(t, "map:\n brad: library\n")
|
||||
jf := &fakeJellyfin{authResult: internal.AuthResult{AccessToken: "jf-token"}}
|
||||
jf.authResult.User.Name = "brad"
|
||||
session := sessionCookie(t, "brad", "jf-token")
|
||||
|
||||
t.Run("missing session", func(t *testing.T) {
|
||||
ev := handler.HookEvent{}
|
||||
if err := processCompletedUpload(ev, um, jf); err == nil {
|
||||
t.Fatalf("expected missing session error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing mapping", func(t *testing.T) {
|
||||
ev := handler.HookEvent{
|
||||
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{sessionCookie(t, "nobody", "token")}}},
|
||||
Upload: handler.FileInfo{
|
||||
ID: "missing-mapping",
|
||||
MetaData: map[string]string{
|
||||
"filename": "clip.mp4",
|
||||
"desc": "Trip",
|
||||
"lib": "library",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := processCompletedUpload(ev, um, jf); err == nil {
|
||||
t.Fatalf("expected missing mapping error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing library root isolated", func(t *testing.T) {
|
||||
origMediaRoot := mediaRoot
|
||||
origTusDir := tusDir
|
||||
defer func() {
|
||||
mediaRoot = origMediaRoot
|
||||
tusDir = origTusDir
|
||||
}()
|
||||
|
||||
mediaRoot = filepath.Join(t.TempDir(), "media")
|
||||
tusDir = filepath.Join(t.TempDir(), "tus")
|
||||
if err := os.MkdirAll(tusDir, 0o2775); err != nil {
|
||||
t.Fatalf("mkdir tus dir: %v", err)
|
||||
}
|
||||
id := "missing-root-isolated"
|
||||
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write tus payload: %v", err)
|
||||
}
|
||||
ev := handler.HookEvent{
|
||||
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
|
||||
Upload: handler.FileInfo{
|
||||
ID: id,
|
||||
MetaData: map[string]string{
|
||||
"filename": "clip.mp4",
|
||||
"desc": "Trip",
|
||||
"lib": "library",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := processCompletedUpload(ev, um, jf); err == nil {
|
||||
t.Fatalf("expected missing library root error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing description for video", func(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
|
||||
t.Fatalf("mkdir media root: %v", err)
|
||||
}
|
||||
id := "video-no-desc"
|
||||
if err := os.MkdirAll(tusDir, 0o2775); err != nil {
|
||||
t.Fatalf("mkdir tus dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write tus payload: %v", err)
|
||||
}
|
||||
ev := handler.HookEvent{
|
||||
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
|
||||
Upload: handler.FileInfo{
|
||||
ID: id,
|
||||
MetaData: map[string]string{
|
||||
"filename": "video.mp4",
|
||||
"lib": "library",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := processCompletedUpload(ev, um, jf); err == nil {
|
||||
t.Fatalf("expected missing desc error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing library root", func(t *testing.T) {
|
||||
id := "missing-root"
|
||||
ev := handler.HookEvent{
|
||||
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
|
||||
Upload: handler.FileInfo{
|
||||
ID: id,
|
||||
MetaData: map[string]string{
|
||||
"filename": "clip.mp4",
|
||||
"desc": "Trip",
|
||||
"lib": "library",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := processCompletedUpload(ev, um, jf); err == nil {
|
||||
t.Fatalf("expected missing root error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("subdir mkdir failure", func(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
|
||||
t.Fatalf("mkdir media root: %v", err)
|
||||
}
|
||||
blocker := filepath.Join(mediaRoot, "library", "bad")
|
||||
if err := os.WriteFile(blocker, []byte("file"), 0o644); err != nil {
|
||||
t.Fatalf("write blocker file: %v", err)
|
||||
}
|
||||
id := "subdir-failure"
|
||||
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write tus payload: %v", err)
|
||||
}
|
||||
ev := handler.HookEvent{
|
||||
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
|
||||
Upload: handler.FileInfo{
|
||||
ID: id,
|
||||
MetaData: map[string]string{
|
||||
"filename": "clip.mp4",
|
||||
"desc": "Trip",
|
||||
"lib": "library",
|
||||
"subdir": "bad/child",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := processCompletedUpload(ev, um, jf); err == nil {
|
||||
t.Fatalf("expected mkdir failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-video defaults description", func(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
|
||||
t.Fatalf("mkdir media root: %v", err)
|
||||
}
|
||||
id := "image-default-desc"
|
||||
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write tus payload: %v", err)
|
||||
}
|
||||
ev := handler.HookEvent{
|
||||
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
|
||||
Upload: handler.FileInfo{
|
||||
ID: id,
|
||||
MetaData: map[string]string{
|
||||
"filename": "photo.jpg",
|
||||
"lib": "library",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := processCompletedUpload(ev, um, jf); err != nil {
|
||||
t.Fatalf("expected default-desc upload to succeed: %v", err)
|
||||
}
|
||||
expected := filepath.Join(mediaRoot, "library", composeFinalName("", "upload", "photo.jpg"))
|
||||
if _, err := os.Stat(expected); err != nil {
|
||||
t.Fatalf("expected defaulted upload output: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing filename falls back to upload.bin", func(t *testing.T) {
|
||||
id := "missing-filename"
|
||||
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write tus payload: %v", err)
|
||||
}
|
||||
ev := handler.HookEvent{
|
||||
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
|
||||
Upload: handler.FileInfo{
|
||||
ID: id,
|
||||
MetaData: map[string]string{
|
||||
"desc": "Trip",
|
||||
"lib": "library",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := processCompletedUpload(ev, um, jf); err != nil {
|
||||
t.Fatalf("expected upload with fallback filename to succeed: %v", err)
|
||||
}
|
||||
expected := filepath.Join(mediaRoot, "library", composeFinalName("", "Trip", "upload.bin"))
|
||||
if _, err := os.Stat(expected); err != nil {
|
||||
t.Fatalf("expected fallback filename output: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
71
backend/routes.go
Normal file
71
backend/routes.go
Normal file
@ -0,0 +1,71 @@
|
||||
// backend/routes.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
var webFS embed.FS
|
||||
|
||||
func buildRouter(um *internal.UserMap, jf jellyfinClient) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(corsForTus)
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
r.Post("/api/login", loginHandler(um, jf))
|
||||
r.Post("/api/logout", logoutHandler())
|
||||
r.Get("/api/whoami", whoamiHandler(um))
|
||||
r.Get("/api/list", listHandler(um))
|
||||
r.Post("/api/rename", renameHandler(um, jf))
|
||||
r.Delete("/api/file", deleteHandler(um, jf))
|
||||
r.Post("/api/mkdir", mkdirHandler(um, jf))
|
||||
|
||||
tusHandler := newTusHandler(um, jf)
|
||||
r.Route("/tus", func(rt chi.Router) {
|
||||
rt.Use(sessionRequired)
|
||||
rt.Post("/", tusHandler.PostFile)
|
||||
rt.Head("/{id}", tusHandler.HeadFile)
|
||||
rt.Patch("/{id}", tusHandler.PatchFile)
|
||||
rt.Delete("/{id}", tusHandler.DelFile)
|
||||
rt.Get("/{id}", tusHandler.GetFile)
|
||||
})
|
||||
|
||||
r.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
appFS, err := fs.Sub(webFS, "web/dist")
|
||||
if err == nil {
|
||||
static := http.FileServer(http.FS(appFS))
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
static.ServeHTTP(w, r)
|
||||
})
|
||||
r.Method("GET", "/*", http.StripPrefix("/", static))
|
||||
}
|
||||
|
||||
r.Get("/debug/env", debugEnvHandler())
|
||||
r.Get("/debug/write-test", debugWriteTestHandler(um))
|
||||
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "no route for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
||||
})
|
||||
|
||||
return debugHandler(r)
|
||||
}
|
||||
102
backend/upload_handler.go
Normal file
102
backend/upload_handler.go
Normal file
@ -0,0 +1,102 @@
|
||||
// backend/upload_handler.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tus/tusd/pkg/filestore"
|
||||
handler "github.com/tus/tusd/pkg/handler"
|
||||
"github.com/tus/tusd/pkg/memorylocker"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
func newTusHandler(um *internal.UserMap, jf jellyfinClient) *handler.UnroutedHandler {
|
||||
store := filestore.FileStore{Path: tusDir}
|
||||
locker := memorylocker.New()
|
||||
composer := handler.NewStoreComposer()
|
||||
store.UseIn(composer)
|
||||
locker.UseIn(composer)
|
||||
|
||||
config := handler.Config{
|
||||
BasePath: "/tus/",
|
||||
StoreComposer: composer,
|
||||
NotifyCompleteUploads: true,
|
||||
MaxSize: 8 * 1024 * 1024 * 1024,
|
||||
RespectForwardedHeaders: true,
|
||||
}
|
||||
tusHandler, err := handler.NewUnroutedHandler(config)
|
||||
must(err, "init tus handler")
|
||||
|
||||
go watchUploadCompletions(tusHandler.CompleteUploads, um, jf)
|
||||
return tusHandler
|
||||
}
|
||||
|
||||
func watchUploadCompletions(completeC <-chan handler.HookEvent, um *internal.UserMap, jf jellyfinClient) {
|
||||
for ev := range completeC {
|
||||
if err := processCompletedUpload(ev, um, jf); err != nil {
|
||||
log.Printf("tus upload processing failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processCompletedUpload(ev handler.HookEvent, um *internal.UserMap, jf jellyfinClient) error {
|
||||
claims, err := claimsFromHook(ev)
|
||||
if err != nil {
|
||||
internal.Logf("tus: no session: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
meta := ev.Upload.MetaData
|
||||
desc := strings.TrimSpace(meta["desc"])
|
||||
date := strings.TrimSpace(meta["date"])
|
||||
subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
|
||||
lib := strings.Trim(strings.TrimSpace(meta["lib"]), "/")
|
||||
orig := meta["filename"]
|
||||
if orig == "" {
|
||||
orig = "upload.bin"
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
return fmt.Errorf("missing desc for video")
|
||||
}
|
||||
if desc == "" {
|
||||
desc = "upload"
|
||||
}
|
||||
|
||||
rootRel, err := resolveLibraryRoot(um, claims.Username, lib)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
libRoot, _ := internal.SafeJoin(mediaRoot, rootRel)
|
||||
if fi, err := os.Stat(libRoot); err != nil || !fi.IsDir() {
|
||||
return fmt.Errorf("library root missing or not accessible")
|
||||
}
|
||||
|
||||
destRoot := libRoot
|
||||
if subdir != "" {
|
||||
subdir = sanitizeSegment(subdir)
|
||||
destRoot = filepath.Join(libRoot, subdir)
|
||||
if err := os.MkdirAll(destRoot, 0o2775); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
finalName := composeFinalName(date, desc, orig)
|
||||
dst := filepath.Join(destRoot, finalName)
|
||||
if err := moveFromTus(ev, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jf.RefreshLibrary(claims.JFToken)
|
||||
return nil
|
||||
}
|
||||
136
backend/uploads.go
Normal file
136
backend/uploads.go
Normal file
@ -0,0 +1,136 @@
|
||||
// backend/uploads.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tus/tusd/pkg/handler"
|
||||
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
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}$`)
|
||||
var renameFile = os.Rename
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func stemOf(orig string) string {
|
||||
base := strings.TrimSuffix(orig, filepath.Ext(orig))
|
||||
s := sanitizeDescriptor(strings.ReplaceAll(base, ".", "_"))
|
||||
return s
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
func moveFromTus(ev handler.HookEvent, dst string) error {
|
||||
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 == "" {
|
||||
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
|
||||
}
|
||||
|
||||
if err := renameFile(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)
|
||||
}
|
||||
|
||||
_ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".info"))
|
||||
_ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".json"))
|
||||
return nil
|
||||
}
|
||||
67
backend/util.go
Normal file
67
backend/util.go
Normal file
@ -0,0 +1,67 @@
|
||||
// backend/util.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 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 contains(ss []string, v string) bool {
|
||||
for _, s := range ss {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
55
backend/util_test.go
Normal file
55
backend/util_test.go
Normal file
@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type failingWriter struct {
|
||||
header http.Header
|
||||
}
|
||||
|
||||
func (f *failingWriter) Header() http.Header {
|
||||
if f.header == nil {
|
||||
f.header = http.Header{}
|
||||
}
|
||||
return f.header
|
||||
}
|
||||
|
||||
func (f *failingWriter) WriteHeader(status int) {}
|
||||
|
||||
func (f *failingWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("boom")
|
||||
}
|
||||
|
||||
func TestWriteJSONWriteError(t *testing.T) {
|
||||
w := &failingWriter{}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
if got := w.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("expected fallback error content type, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvDefaultsAndContains(t *testing.T) {
|
||||
if got := env("PEGASUS_TEST_MISSING", "fallback"); got != "fallback" {
|
||||
t.Fatalf("unexpected env fallback %q", got)
|
||||
}
|
||||
if !contains([]string{"a", "b"}, "a") {
|
||||
t.Fatalf("expected contains to find value")
|
||||
}
|
||||
if contains([]string{"a", "b"}, "z") {
|
||||
t.Fatalf("expected contains to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeSegmentTrimsAndClamps(t *testing.T) {
|
||||
if got := sanitizeSegment(" /A bad/segment "); got != "A_bad" {
|
||||
t.Fatalf("unexpected sanitized segment %q", got)
|
||||
}
|
||||
|
||||
long := "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
if got := sanitizeSegment(long); len(got) != 64 {
|
||||
t.Fatalf("expected 64-char clamp, got %d", len(got))
|
||||
}
|
||||
}
|
||||
9
frontend/src/Uploader.entry.test.ts
Normal file
9
frontend/src/Uploader.entry.test.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Uploader from './Uploader'
|
||||
|
||||
describe('Uploader entrypoint', () => {
|
||||
it('re-exports the uploader view component', () => {
|
||||
expect(typeof Uploader).toBe('function')
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,14 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
import uploaderUtils from './uploader-utils'
|
||||
|
||||
const {
|
||||
clampOneLevel,
|
||||
composeName,
|
||||
extOf,
|
||||
createNoResumeFingerprint,
|
||||
createNoResumeStorage,
|
||||
fmt,
|
||||
isDetailedError,
|
||||
isImageFile,
|
||||
isLikelyMobileUA,
|
||||
@ -12,7 +17,7 @@ import {
|
||||
sanitizeDesc,
|
||||
sanitizeFolderName,
|
||||
stemOf,
|
||||
} from './Uploader'
|
||||
} = uploaderUtils
|
||||
|
||||
describe('Uploader helpers', () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
@ -74,4 +79,50 @@ describe('Uploader helpers', () => {
|
||||
|
||||
expect(typeof isLikelyMobileUA()).toBe('boolean')
|
||||
})
|
||||
|
||||
it('covers the mobile UA and size formatting branches', () => {
|
||||
const originalMatchMedia = window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true }),
|
||||
})
|
||||
expect(isLikelyMobileUA()).toBe(true)
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: false }),
|
||||
})
|
||||
expect(isLikelyMobileUA()).toBe(false)
|
||||
|
||||
if (originalMatchMedia) {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: originalMatchMedia,
|
||||
})
|
||||
} else {
|
||||
delete (window as any).matchMedia
|
||||
}
|
||||
|
||||
expect(fmt(1024 * 1024 * 1024)).toBe('1.0 GB')
|
||||
})
|
||||
|
||||
it('formats sizes and exposes no-resume upload helpers', async () => {
|
||||
expect(fmt(0)).toBe('0 B')
|
||||
expect(fmt(1536)).toBe('1.5 KB')
|
||||
|
||||
const storage = createNoResumeStorage()
|
||||
await expect(storage.addUpload({})).resolves.toBeUndefined()
|
||||
await expect(storage.removeUpload({})).resolves.toBeUndefined()
|
||||
await expect(storage.listUploads()).resolves.toEqual([])
|
||||
await expect(storage.findUploadsByFingerprint('abc')).resolves.toEqual([])
|
||||
await expect(createNoResumeFingerprint()).resolves.toMatch(/^noresume-/)
|
||||
})
|
||||
|
||||
it('returns false without a window and normalizes non-array rows', () => {
|
||||
const originalWindow = window
|
||||
vi.stubGlobal('window', undefined as any)
|
||||
expect(isLikelyMobileUA()).toBe(false)
|
||||
vi.stubGlobal('window', originalWindow as any)
|
||||
expect(normalizeRows('bad input' as any)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,756 +1 @@
|
||||
// frontend/src/Uploader.tsx
|
||||
import React 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; date: string; finalName: string; progress?: number; err?: string }
|
||||
|
||||
// ---------- helpers ----------
|
||||
export function sanitizeDesc(s: string) {
|
||||
s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
if (!s) s = 'upload'
|
||||
return s.slice(0, 64)
|
||||
}
|
||||
export function sanitizeFolderName(s: string) {
|
||||
// 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-]
|
||||
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '')
|
||||
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_')
|
||||
return s.slice(0, 64)
|
||||
}
|
||||
export function extOf(n: string) {
|
||||
const i = n.lastIndexOf('.')
|
||||
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
|
||||
}
|
||||
export function stemOf(n: string) {
|
||||
const i = n.lastIndexOf('.')
|
||||
const stem = i > -1 ? n.slice(0, i) : n
|
||||
// keep safe charset and avoid extra dots within segments
|
||||
return sanitizeDesc(stem.replace(/\./g, '_')) || 'file'
|
||||
}
|
||||
export function composeName(date: string, desc: string, orig: string) {
|
||||
const d = date || new Date().toISOString().slice(0, 10)
|
||||
const [Y, M, D] = d.split('-')
|
||||
const sDesc = sanitizeDesc(desc)
|
||||
const sStem = stemOf(orig)
|
||||
const ext = extOf(orig) || 'bin'
|
||||
// New pattern: YYYY.MM.DD.Description.OriginalStem.ext
|
||||
return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}`
|
||||
}
|
||||
export function clampOneLevel(p: string) {
|
||||
if (!p) return ''
|
||||
return p.replace(/^\/+|\/+$/g, '').split('/')[0] || ''
|
||||
}
|
||||
export function normalizeRows(listRaw: any[]): FileRow[] {
|
||||
return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({
|
||||
name: r?.name ?? r?.Name ?? '',
|
||||
path: r?.path ?? r?.Path ?? '',
|
||||
is_dir: Boolean(r?.is_dir ?? r?.IsDir ?? r?.isDir ?? false),
|
||||
size: Number(r?.size ?? r?.Size ?? 0),
|
||||
mtime: Number(r?.mtime ?? r?.Mtime ?? 0),
|
||||
}))
|
||||
}
|
||||
const videoExt = new Set(['mp4', 'mkv', 'mov', 'avi', 'm4v', 'webm', 'mpg', 'mpeg', 'ts', 'm2ts'])
|
||||
const imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff'])
|
||||
const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '')
|
||||
export const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name))
|
||||
export const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name))
|
||||
|
||||
export function isDetailedError(e: unknown): e is tus.DetailedError {
|
||||
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any))
|
||||
}
|
||||
|
||||
export function isLikelyMobileUA(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const ua = navigator.userAgent || ''
|
||||
const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
|
||||
return coarse || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
|
||||
}
|
||||
function useIsMobile(): boolean {
|
||||
const [mobile, setMobile] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
setMobile(isLikelyMobileUA())
|
||||
}, [])
|
||||
return mobile
|
||||
}
|
||||
|
||||
// Disable tus resume completely (v2 API: addUpload/removeUpload/listUploads)
|
||||
const NoResumeUrlStorage: any = {
|
||||
addUpload: async (_u: any) => {},
|
||||
removeUpload: async (_u: any) => {},
|
||||
listUploads: async () => [],
|
||||
findUploadsByFingerprint: async (_fp: string) => [],
|
||||
}
|
||||
const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
// ---------- thumbnail ----------
|
||||
function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) {
|
||||
const [url, setUrl] = React.useState<string>()
|
||||
React.useEffect(() => {
|
||||
const u = URL.createObjectURL(file)
|
||||
setUrl(u)
|
||||
return () => {
|
||||
try {
|
||||
URL.revokeObjectURL(u)
|
||||
} catch {}
|
||||
}
|
||||
}, [file])
|
||||
if (!url) return null
|
||||
const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 }
|
||||
if (isImageFile(file)) return <img src={url} alt="" style={baseStyle} className="preview-thumb" />
|
||||
if (isVideoFile(file)) return <video src={url} muted preload="metadata" playsInline style={baseStyle} className="preview-thumb" />
|
||||
return (
|
||||
<div style={{ ...baseStyle, display: 'grid', placeItems: 'center' }} className="preview-thumb">
|
||||
📄
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- component ----------
|
||||
export default function Uploader() {
|
||||
const mobile = useIsMobile()
|
||||
|
||||
const [me, setMe] = React.useState<WhoAmI | undefined>()
|
||||
const [libs, setLibs] = React.useState<string[]>([])
|
||||
const [lib, setLib] = React.useState<string>('') // required to upload
|
||||
|
||||
const [sub, setSub] = React.useState<string>('')
|
||||
const [rootDirs, setRootDirs] = React.useState<string[]>([])
|
||||
const [rows, setRows] = React.useState<FileRow[]>([])
|
||||
|
||||
const [status, setStatus] = React.useState<string>('')
|
||||
const [globalDate, setGlobalDate] = React.useState<string>(new Date().toISOString().slice(0, 10))
|
||||
const [uploading, setUploading] = React.useState<boolean>(false)
|
||||
const [sel, setSel] = React.useState<Sel[]>([])
|
||||
const [bulkDesc, setBulkDesc] = React.useState<string>('') // helper: apply to all videos
|
||||
const folderInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
// keep raw input for folder name (let user type anything; sanitize on create)
|
||||
const [newFolderRaw, setNewFolderRaw] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = folderInputRef.current
|
||||
if (!el) return
|
||||
if (mobile) {
|
||||
el.removeAttribute('webkitdirectory')
|
||||
el.removeAttribute('directory')
|
||||
} else {
|
||||
el.setAttribute('webkitdirectory', '')
|
||||
el.setAttribute('directory', '')
|
||||
}
|
||||
}, [mobile])
|
||||
|
||||
// initial load
|
||||
React.useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
setStatus('Loading profile…')
|
||||
const m = await api<WhoAmI>('/api/whoami')
|
||||
setMe(m as any)
|
||||
const mm: any = m
|
||||
const L: string[] =
|
||||
Array.isArray(mm?.roots) ? mm.roots : Array.isArray(mm?.libs) ? mm.libs : typeof mm?.root === 'string' && mm.root ? [mm.root] : []
|
||||
setLibs(L)
|
||||
setLib('') // do NOT auto-pick; user will choose
|
||||
setSub('')
|
||||
setStatus('Choose a library to start')
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message || e || '')
|
||||
setStatus(`Profile error: ${msg}`)
|
||||
if (msg.toLowerCase().includes('no mapping')) {
|
||||
alert('Your account is not linked to any upload library yet. Please contact the admin to be granted access.')
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' })
|
||||
} catch {}
|
||||
location.replace('/')
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
;(async () => {
|
||||
if (!lib) {
|
||||
setRootDirs([])
|
||||
setRows([])
|
||||
setSub('')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setStatus(`Loading library “${lib}”…`)
|
||||
await refresh(lib, sub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] refresh error', e)
|
||||
setStatus(`List error: ${e?.message || e}`)
|
||||
}
|
||||
})()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lib])
|
||||
|
||||
async function refresh(currLib: string, currSub: string) {
|
||||
if (!currLib) return
|
||||
const one = clampOneLevel(currSub)
|
||||
const listRoot = normalizeRows(await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path: '' })}`))
|
||||
const rootDirNames = listRoot
|
||||
.filter((e) => e.is_dir)
|
||||
.map((e) => e.name)
|
||||
.filter(Boolean)
|
||||
setRootDirs(rootDirNames.sort((a, b) => a.localeCompare(b)))
|
||||
const subOk = one && rootDirNames.includes(one) ? one : ''
|
||||
if (subOk !== currSub) setSub(subOk)
|
||||
|
||||
const path = subOk ? subOk : ''
|
||||
const list = subOk ? await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path })}`) : listRoot
|
||||
setRows(normalizeRows(list))
|
||||
|
||||
const show = `/${[currLib, path].filter(Boolean).join('/')}`
|
||||
setStatus(`Ready · Destination: ${show}`)
|
||||
}
|
||||
|
||||
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 desc = '' // start empty; required later for videos only
|
||||
return { file: f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
|
||||
})
|
||||
console.log('[Pegasus] selected files', arr.map((a) => a.file.name))
|
||||
setSel(arr)
|
||||
}
|
||||
|
||||
// recompute finalName when global date changes
|
||||
React.useEffect(() => {
|
||||
setSel((old) => old.map((x) => ({ ...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name) })))
|
||||
}, [globalDate])
|
||||
|
||||
// Warn before closing mid-upload
|
||||
React.useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (uploading) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [uploading])
|
||||
|
||||
// Apply description to all videos helper
|
||||
function applyDescToAllVideos() {
|
||||
if (!bulkDesc.trim()) return
|
||||
setSel((old) =>
|
||||
old.map((x) => (isVideoFile(x.file) ? { ...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name) } : x))
|
||||
)
|
||||
}
|
||||
|
||||
async function doUpload() {
|
||||
if (!me) {
|
||||
setStatus('Not signed in')
|
||||
return
|
||||
}
|
||||
if (!lib) {
|
||||
alert('Please select a Library to upload into.')
|
||||
return
|
||||
}
|
||||
// Require description only for videos:
|
||||
const missingVideos = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
|
||||
if (missingVideos > 0) {
|
||||
alert(`Please add a short description for all videos. ${missingVideos} video(s) missing a description.`)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('Starting upload…')
|
||||
setUploading(true)
|
||||
try {
|
||||
for (const s of sel) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
|
||||
endpoint: '/tus/',
|
||||
chunkSize: 5 * 1024 * 1024,
|
||||
retryDelays: [0, 1000, 3000, 5000, 10000],
|
||||
metadata: {
|
||||
filename: s.file.name,
|
||||
lib: lib,
|
||||
subdir: sub || '',
|
||||
date: s.date,
|
||||
desc: s.desc, // server enforces: required for videos only
|
||||
},
|
||||
onError: (err: Error | tus.DetailedError) => {
|
||||
let msg = String(err)
|
||||
if (isDetailedError(err)) {
|
||||
const status = (err.originalRequest as any)?.status
|
||||
const statusText = (err.originalRequest as any)?.statusText
|
||||
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
|
||||
}
|
||||
console.error('[Pegasus] tus error', s.file.name, err)
|
||||
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, err: msg } : x)))
|
||||
setStatus(`✖ ${s.file.name}: ${msg}`)
|
||||
alert(`Upload failed: ${s.file.name}\n\n${msg}`)
|
||||
reject(err as any)
|
||||
},
|
||||
onProgress: (sent: number, total: number) => {
|
||||
const pct = Math.floor((sent / Math.max(total, 1)) * 100)
|
||||
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: pct } : x)))
|
||||
setStatus(`⬆ ${s.finalName}: ${pct}%`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: 100, err: undefined } : x)))
|
||||
setStatus(`✔ ${s.finalName} uploaded`)
|
||||
console.log('[Pegasus] tus success', s.file.name)
|
||||
resolve()
|
||||
},
|
||||
}
|
||||
opts.withCredentials = true
|
||||
;(opts as any).urlStorage = NoResumeUrlStorage
|
||||
;(opts as any).fingerprint = NoResumeFingerprint
|
||||
|
||||
const up = new tus.Upload(s.file, opts)
|
||||
console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, lib, sub })
|
||||
up.start()
|
||||
})
|
||||
}
|
||||
setStatus('All uploads complete')
|
||||
setSel([])
|
||||
await refresh(lib, sub)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- one-level subfolder ops --------
|
||||
async function createSubfolder(nameRaw: string) {
|
||||
const name = sanitizeFolderName(nameRaw)
|
||||
if (!name) return
|
||||
try {
|
||||
await api(`/api/mkdir`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ lib: lib, path: name }),
|
||||
})
|
||||
await refresh(lib, name) // jump into new folder
|
||||
setSub(name)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] mkdir error', e)
|
||||
alert(`Create folder failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
async function renameFolder(oldName: string) {
|
||||
const nn = prompt('New folder name:', oldName)
|
||||
const newName = sanitizeFolderName(nn || '')
|
||||
if (!newName || newName === oldName) return
|
||||
try {
|
||||
await api('/api/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ lib: lib, from: oldName, to: newName }),
|
||||
})
|
||||
const newSub = sub === oldName ? newName : sub
|
||||
setSub(newSub)
|
||||
await refresh(lib, newSub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] rename folder error', e)
|
||||
alert(`Rename failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
async function deleteFolder(name: string) {
|
||||
if (!confirm(`Delete folder “${name}” (and its contents)?`)) return
|
||||
try {
|
||||
await api(`/api/file?${new URLSearchParams({ lib: lib, path: name, recursive: 'true' })}`, { method: 'DELETE' })
|
||||
const newSub = sub === name ? '' : sub
|
||||
setSub(newSub)
|
||||
await refresh(lib, newSub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] delete folder error', e)
|
||||
alert(`Delete failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// destination listing (actions: rename files only at library root)
|
||||
async function renamePath(oldp: string) {
|
||||
const base = oldp.split('/').pop() || ''
|
||||
const name = prompt('New name (YYYY.MM.DD.Description.OrigStem.ext):', base)
|
||||
if (!name) return
|
||||
try {
|
||||
await api('/api/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ lib: lib, from: oldp, to: oldp.split('/').slice(0, -1).concat(sanitizeFolderName(name)).join('/') }),
|
||||
})
|
||||
await refresh(lib, sub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] rename error', e)
|
||||
alert(`Rename failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
async function deletePath(p: string, recursive: boolean) {
|
||||
if (!confirm(`Delete ${p}${recursive ? ' (recursive)' : ''}?`)) return
|
||||
try {
|
||||
await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive ? 'true' : 'false' })}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh(lib, sub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] delete error', e)
|
||||
alert(`Delete failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// sort rows
|
||||
const sortedRows = React.useMemo(() => {
|
||||
const arr = Array.isArray(rows) ? rows.slice() : []
|
||||
return arr.sort((a, b) => {
|
||||
const dirFirst = Number(b?.is_dir ? 1 : 0) - Number(a?.is_dir ? 1 : 0)
|
||||
if (dirFirst !== 0) return dirFirst
|
||||
const an = a?.name ?? ''
|
||||
const bn = b?.name ?? ''
|
||||
return an.localeCompare(bn)
|
||||
})
|
||||
}, [rows])
|
||||
|
||||
// Existing filenames in the destination (to block collisions)
|
||||
const existingNames = React.useMemo(() => new Set(sortedRows.filter((r) => !r.is_dir).map((r) => r.name)), [sortedRows])
|
||||
|
||||
// Duplicates inside this batch
|
||||
const duplicateNamesInSelection = React.useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
sel.forEach((s) => counts.set(s.finalName, (counts.get(s.finalName) || 0) + 1))
|
||||
return new Set(Array.from(counts.entries()).filter(([, c]) => c > 1).map(([n]) => n))
|
||||
}, [sel])
|
||||
|
||||
const hasNameIssues = React.useMemo(
|
||||
() => sel.some((s) => duplicateNamesInSelection.has(s.finalName) || existingNames.has(s.finalName)),
|
||||
[sel, duplicateNamesInSelection, existingNames]
|
||||
)
|
||||
|
||||
const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
|
||||
const videosNeedingDesc = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header context */}
|
||||
<section>
|
||||
<hgroup>
|
||||
<h2>Signed in: {me?.username}</h2>
|
||||
<p className="meta">
|
||||
Destination: <strong>{destPath || '/(choose a library)'}</strong>
|
||||
</p>
|
||||
</hgroup>
|
||||
</section>
|
||||
|
||||
{/* Choose content */}
|
||||
<section>
|
||||
<h3>Choose content</h3>
|
||||
<div className="grid-3">
|
||||
<label>
|
||||
Default date
|
||||
<input type="date" value={globalDate} onChange={(e) => setGlobalDate(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="file-picker">
|
||||
{mobile ? (
|
||||
<>
|
||||
<label>
|
||||
Gallery/Photos
|
||||
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
|
||||
</label>
|
||||
<label>
|
||||
Camera (optional)
|
||||
<input type="file" accept="image/*,video/*" capture="environment" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
Select file(s)
|
||||
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
|
||||
</label>
|
||||
<label>
|
||||
Select folder(s)
|
||||
<input type="file" multiple ref={folderInputRef} onChange={(e) => e.target.files && handleChoose(e.target.files)} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Review */}
|
||||
<section>
|
||||
<h3>Review Files</h3>
|
||||
|
||||
{sel.length === 0 ? (
|
||||
<p className="meta">Select at least one file.</p>
|
||||
) : (
|
||||
<>
|
||||
<article>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<label>
|
||||
Description for all videos (optional)
|
||||
<input placeholder="Short video description" value={bulkDesc} onChange={(e) => setBulkDesc(e.target.value)} />
|
||||
</label>
|
||||
<button type="button" className="stretch" onClick={applyDescToAllVideos} disabled={!bulkDesc.trim()}>
|
||||
Apply to all videos
|
||||
</button>
|
||||
{videosNeedingDesc > 0 ? (
|
||||
<small className="meta bad">{videosNeedingDesc} video(s) need a description</small>
|
||||
) : (
|
||||
<small className="meta mono-wrap">All videos have descriptions</small>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{sel.map((s, i) => (
|
||||
<article key={i} className="video-card">
|
||||
<div className="thumb-row">
|
||||
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
|
||||
</div>
|
||||
|
||||
<h4 className="filename wrap-anywhere" title={s.file.name}>
|
||||
{s.file.name}
|
||||
</h4>
|
||||
|
||||
<label>
|
||||
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
|
||||
<input
|
||||
required={isVideoFile(s.file)}
|
||||
aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
|
||||
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
|
||||
value={s.desc}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setSel((old) => old.map((x, idx) => (idx === i ? { ...x, desc: v, finalName: composeName(x.date, v, x.file.name) } : x)))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Date
|
||||
<input
|
||||
type="date"
|
||||
value={s.date}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setSel((old) => old.map((x, idx) => (idx === i ? { ...x, date: v, finalName: composeName(v, x.desc, x.file.name) } : x)))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<small className="meta" title={s.finalName}>
|
||||
→ {s.finalName}
|
||||
</small>
|
||||
{typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
|
||||
{s.err && <small className="meta bad">Error: {s.err}</small>}
|
||||
{duplicateNamesInSelection.has(s.finalName) && (
|
||||
<small className="meta bad">Duplicate name in this batch. Adjust description or date.</small>
|
||||
)}
|
||||
{existingNames.has(s.finalName) && <small className="meta bad">A file with this name already exists here.</small>}
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Destination */}
|
||||
<section>
|
||||
<h3>Select Destination</h3>
|
||||
<p className="meta">Manage a library & choose a destination.</p>
|
||||
|
||||
{/* Library only (required). No subfolder dropdown. */}
|
||||
<div className="grid-3">
|
||||
<label>
|
||||
Library (required)
|
||||
<select
|
||||
value={lib}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setLib(v)
|
||||
setSub('')
|
||||
if (v) void refresh(v, '')
|
||||
}}
|
||||
>
|
||||
<option value="">— Select a library —</option>
|
||||
{libs.map((L) => (
|
||||
<option key={L} value={L}>
|
||||
{L}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!lib ? (
|
||||
<p className="meta">Select a library to create subfolders and view contents.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* New subfolder: free typing; sanitize on create */}
|
||||
<div className="grid-3">
|
||||
<label style={{ gridColumn: '1 / -1' }}>
|
||||
Add a new subfolder (optional):
|
||||
<input
|
||||
placeholder="letters, numbers, underscores, dashes"
|
||||
value={newFolderRaw}
|
||||
onChange={(e) => setNewFolderRaw(e.target.value)}
|
||||
inputMode="text"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{newFolderRaw.trim() && (
|
||||
<div className="row-center" style={{ gridColumn: '1 / -1', marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void createSubfolder(newFolderRaw)
|
||||
setNewFolderRaw('')
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current selection with actions; includes Home button to go to root */}
|
||||
<article className="card">
|
||||
<div className="row-between" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
<span className="meta">Current Sub-Folder:</span> <strong className="wrap-anywhere">{sub || '(library root)'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{sub && (
|
||||
<div className="sub-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title="Go to library root"
|
||||
aria-label="Go to library root"
|
||||
onClick={() => { setSub(''); void refresh(lib, '') }}
|
||||
>
|
||||
🏠
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void renameFolder(sub)}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn danger"
|
||||
title="Delete subfolder"
|
||||
aria-label="Delete subfolder"
|
||||
onClick={() => void deleteFolder(sub)}
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Subfolders: only Select (right), name + icon (left); clamp width to avoid horizontal overflow */}
|
||||
<details open style={{ marginTop: 8 }}>
|
||||
<summary>Subfolders</summary>
|
||||
{rootDirs.length === 0 ? (
|
||||
<p className="meta">
|
||||
No subfolders yet. You’ll upload into <b>/{lib}</b>.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{rootDirs.map((d) => (
|
||||
<article key={d} className="list-row">
|
||||
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
|
||||
<div className="wrap-anywhere" title={d}>📁 {d}</div>
|
||||
</div>
|
||||
<div className="row-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSub(d)
|
||||
void refresh(lib, d)
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
|
||||
{/* Contents: info left (clamped), actions right */}
|
||||
<details open style={{ marginTop: 8 }}>
|
||||
<summary>Contents of {destPath || '/(choose)'}</summary>
|
||||
{sortedRows.length === 0 ? (
|
||||
<p className="meta">Empty.</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{sortedRows.map((f) => (
|
||||
<article key={f.path} className="list-row">
|
||||
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
|
||||
<div className="wrap-anywhere" title={f.name || '(unnamed)'}>{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}</div>
|
||||
<small className="meta meta-wrap">{f.is_dir ? 'folder' : fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}</small>
|
||||
</div>
|
||||
<div className="row-right">
|
||||
{f.is_dir ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSub(f.name)
|
||||
void refresh(lib, f.name)
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="icon-btn bad"
|
||||
title="Delete"
|
||||
aria-label={`Delete ${f.name}`}
|
||||
onClick={() => void deletePath(f.path, false)}
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer style={{ display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<small className="meta" aria-live="polite">
|
||||
{status}
|
||||
</small>
|
||||
{hasNameIssues && <small className="meta bad">Resolve duplicate/existing filename conflicts to enable Upload.</small>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const disabled = !lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues
|
||||
if (!disabled) void doUpload()
|
||||
}}
|
||||
disabled={!lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues}
|
||||
>
|
||||
Upload {sel.length ? `(${sel.length})` : ''}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
export { default } from './UploaderView'
|
||||
|
||||
178
frontend/src/UploaderView.test.tsx
Normal file
178
frontend/src/UploaderView.test.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import UploaderView from './UploaderView'
|
||||
|
||||
const controllerMock = vi.hoisted(() => ({
|
||||
useUploaderController: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./uploader-controller', () => ({
|
||||
default: controllerMock.useUploaderController,
|
||||
}))
|
||||
|
||||
function makeFile(name: string, type: string) {
|
||||
return new File(['x'], name, { type })
|
||||
}
|
||||
|
||||
function makeController(overrides: Record<string, unknown> = {}) {
|
||||
const setSel = vi.fn()
|
||||
const setBulkDesc = vi.fn()
|
||||
const setGlobalDate = vi.fn()
|
||||
const setLib = vi.fn()
|
||||
const setSub = vi.fn()
|
||||
const setNewFolderRaw = vi.fn()
|
||||
const refresh = vi.fn()
|
||||
return {
|
||||
mobile: false,
|
||||
me: { username: 'brad' },
|
||||
libs: ['alpha', 'beta'],
|
||||
lib: 'alpha',
|
||||
sub: 'videos',
|
||||
rootDirs: ['archive', 'videos'],
|
||||
rows: [],
|
||||
status: 'Ready',
|
||||
globalDate: '2026-04-10',
|
||||
uploading: false,
|
||||
sel: [
|
||||
{ file: makeFile('photo.jpg', 'image/jpeg'), desc: '', date: '2026-04-10', finalName: '2026.04.10.upload.photo.jpg', progress: 0 },
|
||||
{ file: makeFile('clip.mp4', 'video/mp4'), desc: '', date: '2026-04-10', finalName: '2026.04.10.upload.clip.mp4', progress: 0 },
|
||||
{ file: makeFile('note.pdf', 'application/pdf'), desc: '', date: '2026-04-10', finalName: '2026.04.10.upload.note.pdf', progress: 0 },
|
||||
],
|
||||
bulkDesc: 'holiday',
|
||||
folderInputRef: { current: document.createElement('input') },
|
||||
newFolderRaw: 'new-folder',
|
||||
setGlobalDate,
|
||||
setLib,
|
||||
setSub,
|
||||
setNewFolderRaw,
|
||||
setBulkDesc,
|
||||
setSel,
|
||||
handleChoose: vi.fn(),
|
||||
applyDescToAllVideos: vi.fn(),
|
||||
doUpload: vi.fn(),
|
||||
createSubfolder: vi.fn(),
|
||||
renameFolder: vi.fn(),
|
||||
deleteFolder: vi.fn(),
|
||||
renamePath: vi.fn(),
|
||||
deletePath: vi.fn(),
|
||||
refresh,
|
||||
sortedRows: [
|
||||
{ name: 'archive', path: 'archive', is_dir: true, size: 0, mtime: 0 },
|
||||
{ name: 'clip.mp4', path: 'clip.mp4', is_dir: false, size: 2048, mtime: 1713000000 },
|
||||
],
|
||||
existingNames: new Set(['clip.mp4']),
|
||||
duplicateNamesInSelection: new Set(['2026.04.10.upload.clip.mp4']),
|
||||
hasNameIssues: false,
|
||||
destPath: '/alpha/videos',
|
||||
videosNeedingDesc: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (!('createObjectURL' in URL)) {
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:thumb'), configurable: true })
|
||||
} else {
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:thumb')
|
||||
}
|
||||
if (!('revokeObjectURL' in URL)) {
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true })
|
||||
} else {
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('UploaderView', () => {
|
||||
it('renders populated state and forwards interactions', async () => {
|
||||
const controller = makeController()
|
||||
controllerMock.useUploaderController.mockReturnValue(controller)
|
||||
|
||||
render(<UploaderView />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Signed in: brad')).toBeTruthy())
|
||||
expect(screen.getByText(/Destination:/)).toBeTruthy()
|
||||
expect(screen.getByText('Ready')).toBeTruthy()
|
||||
await waitFor(() => expect(document.querySelector('img')).not.toBeNull())
|
||||
expect(document.querySelector('video')).not.toBeNull()
|
||||
expect(screen.getByText('📄')).toBeTruthy()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Default date'), { target: { value: '2026-04-11' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Short video description'), { target: { value: 'family trip' } })
|
||||
|
||||
const optionalImageInputs = screen.getAllByPlaceholderText('Optional for image')
|
||||
fireEvent.change(optionalImageInputs[0], { target: { value: 'photo desc' } })
|
||||
fireEvent.change(optionalImageInputs[1], { target: { value: 'note desc' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Required for video'), { target: { value: 'clip desc' } })
|
||||
fireEvent.change(screen.getAllByDisplayValue('2026-04-10')[1], { target: { value: '2026-04-12' } })
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'beta' } })
|
||||
|
||||
expect(controller.setGlobalDate).toHaveBeenCalledWith('2026-04-11')
|
||||
expect(controller.setBulkDesc).toHaveBeenCalledWith('family trip')
|
||||
expect(controller.setSel).toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply to all videos' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
fireEvent.click(screen.getByLabelText('Go to library root'))
|
||||
fireEvent.click(screen.getByLabelText('Delete subfolder'))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
|
||||
fireEvent.click(screen.getByLabelText('Delete clip.mp4'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Upload \(3\)/ }))
|
||||
|
||||
expect(controller.applyDescToAllVideos).toHaveBeenCalled()
|
||||
expect(controller.createSubfolder).toHaveBeenCalledWith('new-folder')
|
||||
expect(controller.renameFolder).toHaveBeenCalledWith('videos')
|
||||
expect(controller.refresh).toHaveBeenCalled()
|
||||
expect(controller.deleteFolder).toHaveBeenCalledWith('videos')
|
||||
expect(controller.deletePath).toHaveBeenCalledWith('clip.mp4', false)
|
||||
expect(controller.doUpload).toHaveBeenCalled()
|
||||
expect(screen.getByText('photo.jpg')).toBeTruthy()
|
||||
expect(screen.getByText('clip.mp4')).toBeTruthy()
|
||||
expect(screen.getByText('note.pdf')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the empty-library state', () => {
|
||||
controllerMock.useUploaderController.mockReturnValue(
|
||||
makeController({
|
||||
lib: '',
|
||||
sub: '',
|
||||
rootDirs: [],
|
||||
sel: [],
|
||||
sortedRows: [],
|
||||
videosNeedingDesc: 0,
|
||||
hasNameIssues: false,
|
||||
destPath: '/(choose a library)',
|
||||
})
|
||||
)
|
||||
|
||||
render(<UploaderView />)
|
||||
|
||||
expect(screen.getByText('Select at least one file.')).toBeTruthy()
|
||||
expect(screen.getByText('Select a library to create subfolders and view contents.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders empty destination sections when the library has no children', () => {
|
||||
controllerMock.useUploaderController.mockReturnValue(
|
||||
makeController({
|
||||
lib: 'alpha',
|
||||
sub: '',
|
||||
rootDirs: [],
|
||||
sel: [],
|
||||
sortedRows: [],
|
||||
destPath: '/alpha',
|
||||
videosNeedingDesc: 0,
|
||||
})
|
||||
)
|
||||
|
||||
render(<UploaderView />)
|
||||
|
||||
expect(screen.getByText(/No subfolders yet/)).toBeTruthy()
|
||||
expect(screen.getByText('Empty.')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
327
frontend/src/UploaderView.tsx
Normal file
327
frontend/src/UploaderView.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import PreviewThumb from './uploader-thumb'
|
||||
import uploaderUtils from './uploader-utils'
|
||||
import useUploaderController from './uploader-controller'
|
||||
|
||||
const { composeName, isVideoFile, fmt } = uploaderUtils
|
||||
|
||||
export default function UploaderView() {
|
||||
const c = useUploaderController()
|
||||
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<hgroup>
|
||||
<h2>Signed in: {c.me?.username}</h2>
|
||||
<p className="meta">
|
||||
Destination: <strong>{c.destPath || '/(choose a library)'}</strong>
|
||||
</p>
|
||||
</hgroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Choose content</h3>
|
||||
<div className="grid-3">
|
||||
<label>
|
||||
Default date
|
||||
<input type="date" value={c.globalDate} onChange={(e) => c.setGlobalDate(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="file-picker">
|
||||
{c.mobile ? (
|
||||
<>
|
||||
<label>
|
||||
Gallery/Photos
|
||||
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
|
||||
</label>
|
||||
<label>
|
||||
Camera (optional)
|
||||
<input type="file" accept="image/*,video/*" capture="environment" onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
Select file(s)
|
||||
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
|
||||
</label>
|
||||
<label>
|
||||
Select folder(s)
|
||||
<input type="file" multiple ref={c.folderInputRef} onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Review Files</h3>
|
||||
|
||||
{c.sel.length === 0 ? (
|
||||
<p className="meta">Select at least one file.</p>
|
||||
) : (
|
||||
<>
|
||||
<article>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<label>
|
||||
Description for all videos (optional)
|
||||
<input
|
||||
placeholder="Short video description"
|
||||
value={c.bulkDesc}
|
||||
onChange={(e) => c.setBulkDesc(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="stretch" onClick={c.applyDescToAllVideos} disabled={!c.bulkDesc.trim()}>
|
||||
Apply to all videos
|
||||
</button>
|
||||
{c.videosNeedingDesc > 0 ? (
|
||||
<small className="meta bad">{c.videosNeedingDesc} video(s) need a description</small>
|
||||
) : (
|
||||
<small className="meta mono-wrap">All videos have descriptions</small>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{c.sel.map((s, i) => (
|
||||
<article key={i} className="video-card">
|
||||
<div className="thumb-row">
|
||||
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
|
||||
</div>
|
||||
|
||||
<h4 className="filename wrap-anywhere" title={s.file.name}>
|
||||
{s.file.name}
|
||||
</h4>
|
||||
|
||||
<label>
|
||||
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
|
||||
<input
|
||||
required={isVideoFile(s.file)}
|
||||
aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
|
||||
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
|
||||
value={s.desc}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
c.setSel((old) => old.map((x, idx) => (idx === i ? { ...x, desc: v, finalName: composeName(x.date, v, x.file.name) } : x)))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Date
|
||||
<input
|
||||
type="date"
|
||||
value={s.date}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
c.setSel((old) => old.map((x, idx) => (idx === i ? { ...x, date: v, finalName: composeName(v, x.desc, x.file.name) } : x)))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<small className="meta" title={s.finalName}>
|
||||
→ {s.finalName}
|
||||
</small>
|
||||
{typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
|
||||
{s.err && <small className="meta bad">Error: {s.err}</small>}
|
||||
{c.duplicateNamesInSelection.has(s.finalName) && (
|
||||
<small className="meta bad">Duplicate name in this batch. Adjust description or date.</small>
|
||||
)}
|
||||
{c.existingNames.has(s.finalName) && <small className="meta bad">A file with this name already exists here.</small>}
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Select Destination</h3>
|
||||
<p className="meta">Manage a library & choose a destination.</p>
|
||||
|
||||
<div className="grid-3">
|
||||
<label>
|
||||
Library (required)
|
||||
<select
|
||||
value={c.lib}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
c.setLib(v)
|
||||
c.setSub('')
|
||||
if (v) void c.refresh(v, '')
|
||||
}}
|
||||
>
|
||||
<option value="">— Select a library —</option>
|
||||
{c.libs.map((L) => (
|
||||
<option key={L} value={L}>
|
||||
{L}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!c.lib ? (
|
||||
<p className="meta">Select a library to create subfolders and view contents.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid-3">
|
||||
<label style={{ gridColumn: '1 / -1' }}>
|
||||
Add a new subfolder (optional):
|
||||
<input
|
||||
placeholder="letters, numbers, underscores, dashes"
|
||||
value={c.newFolderRaw}
|
||||
onChange={(e) => c.setNewFolderRaw(e.target.value)}
|
||||
inputMode="text"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{c.newFolderRaw.trim() && (
|
||||
<div className="row-center" style={{ gridColumn: '1 / -1', marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void c.createSubfolder(c.newFolderRaw)
|
||||
c.setNewFolderRaw('')
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<article className="card">
|
||||
<div className="row-between" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
<span className="meta">Current Sub-Folder:</span> <strong className="wrap-anywhere">{c.sub || '(library root)'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{c.sub && (
|
||||
<div className="sub-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title="Go to library root"
|
||||
aria-label="Go to library root"
|
||||
onClick={() => {
|
||||
c.setSub('')
|
||||
void c.refresh(c.lib, '')
|
||||
}}
|
||||
>
|
||||
🏠
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={() => void c.renameFolder(c.sub)}>
|
||||
Rename
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn danger"
|
||||
title="Delete subfolder"
|
||||
aria-label="Delete subfolder"
|
||||
onClick={() => void c.deleteFolder(c.sub)}
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<details open style={{ marginTop: 8 }}>
|
||||
<summary>Subfolders</summary>
|
||||
{c.rootDirs.length === 0 ? (
|
||||
<p className="meta">
|
||||
No subfolders yet. You’ll upload into <b>/{c.lib}</b>.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{c.rootDirs.map((d) => (
|
||||
<article key={d} className="list-row">
|
||||
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
|
||||
<div className="wrap-anywhere" title={d}>
|
||||
📁 {d}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
c.setSub(d)
|
||||
void c.refresh(c.lib, d)
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
|
||||
<details open style={{ marginTop: 8 }}>
|
||||
<summary>Contents of {c.destPath || '/(choose)'}</summary>
|
||||
{c.sortedRows.length === 0 ? (
|
||||
<p className="meta">Empty.</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{c.sortedRows.map((f) => (
|
||||
<article key={f.path} className="list-row">
|
||||
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
|
||||
<div className="wrap-anywhere" title={f.name || '(unnamed)'}>
|
||||
{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}
|
||||
</div>
|
||||
<small className="meta meta-wrap">
|
||||
{f.is_dir ? 'folder' : fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}
|
||||
</small>
|
||||
</div>
|
||||
<div className="row-right">
|
||||
{f.is_dir ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
c.setSub(f.name)
|
||||
void c.refresh(c.lib, f.name)
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="icon-btn bad"
|
||||
title="Delete"
|
||||
aria-label={`Delete ${f.name}`}
|
||||
onClick={() => void c.deletePath(f.path, false)}
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer style={{ display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<small className="meta" aria-live="polite">
|
||||
{c.status}
|
||||
</small>
|
||||
{c.hasNameIssues && <small className="meta bad">Resolve duplicate/existing filename conflicts to enable Upload.</small>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const disabled = !c.lib || !c.sel.length || c.uploading || c.videosNeedingDesc > 0 || c.hasNameIssues
|
||||
if (!disabled) void c.doUpload()
|
||||
}}
|
||||
disabled={!c.lib || !c.sel.length || c.uploading || c.videosNeedingDesc > 0 || c.hasNameIssues}
|
||||
>
|
||||
Upload {c.sel.length ? `(${c.sel.length})` : ''}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -7,6 +7,7 @@ declare module 'tus-js-client' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Issue an authenticated fetch against the Pegasus API and normalize the response body. */
|
||||
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());
|
||||
@ -16,6 +17,7 @@ export async function api<T=any>(path: string, init?: RequestInit): Promise<T> {
|
||||
try { return JSON.parse(txt) as T } catch { return txt as any as T }
|
||||
}
|
||||
|
||||
/** WHOAMI payload returned by the backend and kept backward-compatible during migration. */
|
||||
export type WhoAmI = {
|
||||
username: string;
|
||||
root?: string;
|
||||
|
||||
39
frontend/src/main.test.tsx
Normal file
39
frontend/src/main.test.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const harness = vi.hoisted(() => {
|
||||
const renderMock = vi.fn()
|
||||
const createRootMock = vi.fn(() => ({ render: renderMock }))
|
||||
return { renderMock, createRootMock }
|
||||
})
|
||||
|
||||
vi.mock('react-dom/client', () => ({
|
||||
createRoot: harness.createRootMock,
|
||||
}))
|
||||
|
||||
vi.mock('./App', () => ({
|
||||
default: function MockApp() {
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
describe('main entrypoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
harness.createRootMock.mockClear()
|
||||
harness.renderMock.mockClear()
|
||||
})
|
||||
|
||||
it('mounts the app into #root', async () => {
|
||||
await import('./main')
|
||||
|
||||
expect(harness.createRootMock).toHaveBeenCalled()
|
||||
expect(harness.renderMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails fast when the root node is missing', async () => {
|
||||
document.body.innerHTML = ''
|
||||
|
||||
await expect(import('./main')).rejects.toThrow('Missing <div id="root"></div> in index.html')
|
||||
})
|
||||
})
|
||||
348
frontend/src/uploader-controller.test.tsx
Normal file
348
frontend/src/uploader-controller.test.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
import { act, render, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const harness = vi.hoisted(() => {
|
||||
const apiMock = vi.fn()
|
||||
const uploadState = {
|
||||
mode: 'success' as 'success' | 'error',
|
||||
dispatchBeforeUnload: false,
|
||||
lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined,
|
||||
pauseUpload: false,
|
||||
finishUpload: undefined as (() => void) | undefined,
|
||||
}
|
||||
|
||||
class UploadMock {
|
||||
opts: any
|
||||
file: File
|
||||
|
||||
constructor(file: File, opts: any) {
|
||||
this.file = file
|
||||
this.opts = opts
|
||||
}
|
||||
|
||||
start() {
|
||||
if (uploadState.mode === 'error') {
|
||||
this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } })
|
||||
return
|
||||
}
|
||||
if (uploadState.pauseUpload) {
|
||||
uploadState.finishUpload = () => {
|
||||
this.opts.onProgress?.(5, 10)
|
||||
this.opts.onSuccess?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (uploadState.dispatchBeforeUnload) {
|
||||
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
|
||||
uploadState.lastBeforeUnloadEvent = event
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
this.opts.onProgress?.(5, 10)
|
||||
this.opts.onSuccess?.()
|
||||
}
|
||||
}
|
||||
|
||||
return { apiMock, uploadState, UploadMock }
|
||||
})
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
api: harness.apiMock,
|
||||
}))
|
||||
|
||||
vi.mock('tus-js-client', () => ({
|
||||
Upload: harness.UploadMock,
|
||||
}))
|
||||
|
||||
import useUploaderController from './uploader-controller'
|
||||
|
||||
function makeFile(name: string, type: string) {
|
||||
return new File(['x'], name, { type })
|
||||
}
|
||||
|
||||
function installGlobals() {
|
||||
vi.stubGlobal('alert', vi.fn())
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
vi.stubGlobal('prompt', vi.fn(() => 'renamed'))
|
||||
vi.stubGlobal('location', { replace: vi.fn() } as any)
|
||||
}
|
||||
|
||||
function installApi() {
|
||||
harness.apiMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/whoami') {
|
||||
return { username: 'brad', roots: ['alpha', 'beta'] }
|
||||
}
|
||||
if (path.startsWith('/api/list?')) {
|
||||
const params = new URLSearchParams(path.split('?')[1])
|
||||
if (params.get('path')) {
|
||||
return [
|
||||
{ name: 'nested', path: 'nested', is_dir: true, size: 0, mtime: 0 },
|
||||
{ name: 'nested.mp4', path: 'nested/nested.mp4', is_dir: false, size: 4096, mtime: 1713000000 },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ name: 'videos', path: 'videos', is_dir: true, size: 0, mtime: 0 },
|
||||
{ name: 'archive', path: 'archive', is_dir: true, size: 0, mtime: 0 },
|
||||
{ name: 'clip.mp4', path: 'clip.mp4', is_dir: false, size: 2048, mtime: 1713000000 },
|
||||
]
|
||||
}
|
||||
if (path === '/api/mkdir' || path === '/api/rename' || path.startsWith('/api/file?') || path === '/api/logout') {
|
||||
return {}
|
||||
}
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
harness.apiMock.mockReset()
|
||||
harness.uploadState.mode = 'success'
|
||||
harness.uploadState.dispatchBeforeUnload = false
|
||||
harness.uploadState.lastBeforeUnloadEvent = undefined
|
||||
harness.uploadState.pauseUpload = false
|
||||
harness.uploadState.finishUpload = undefined
|
||||
installGlobals()
|
||||
installApi()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useUploaderController', () => {
|
||||
it('syncs folder input attributes for desktop and mobile UAs', async () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'iPhone' } as any)
|
||||
|
||||
function Harness() {
|
||||
const controller = useUploaderController()
|
||||
return <input data-testid="folder" ref={controller.folderInputRef} {...({ webkitdirectory: '', directory: '' } as any)} />
|
||||
}
|
||||
|
||||
const { getByTestId } = render(<Harness />)
|
||||
const input = getByTestId('folder') as HTMLInputElement
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.hasAttribute('webkitdirectory')).toBe(false)
|
||||
expect(input.hasAttribute('directory')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns early before the profile loads', async () => {
|
||||
const { result } = renderHook(() => useUploaderController())
|
||||
|
||||
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
||||
|
||||
act(() => {
|
||||
result.current.setMe(undefined)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.me).toBeUndefined())
|
||||
await act(async () => {
|
||||
await result.current.doUpload()
|
||||
})
|
||||
await waitFor(() => expect(result.current.status).toBe('Not signed in'))
|
||||
})
|
||||
|
||||
it('rejects uploads that still need video descriptions', async () => {
|
||||
const { result } = renderHook(() => useUploaderController())
|
||||
|
||||
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
||||
|
||||
act(() => {
|
||||
result.current.setLib('alpha')
|
||||
result.current.handleChoose([makeFile('clip.mp4', 'video/mp4')] as any)
|
||||
})
|
||||
await waitFor(() => expect(result.current.sel).toHaveLength(1))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doUpload()
|
||||
})
|
||||
|
||||
expect((globalThis as any).alert).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Please add a short description for all videos')
|
||||
)
|
||||
})
|
||||
|
||||
it('loads data, supports folder ops, and uploads successfully', async () => {
|
||||
const { result } = renderHook(() => useUploaderController())
|
||||
|
||||
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
||||
|
||||
act(() => {
|
||||
result.current.setLib('alpha')
|
||||
})
|
||||
await waitFor(() => expect(result.current.rootDirs).toEqual(['archive', 'videos']))
|
||||
expect(result.current.destPath).toBe('/alpha')
|
||||
|
||||
act(() => {
|
||||
result.current.handleChoose([makeFile('clip.mp4', 'video/mp4'), makeFile('photo.jpg', 'image/jpeg')] as any)
|
||||
})
|
||||
await waitFor(() => expect(result.current.sel).toHaveLength(2))
|
||||
|
||||
act(() => {
|
||||
result.current.setBulkDesc('family trip')
|
||||
})
|
||||
await waitFor(() => expect(result.current.bulkDesc).toBe('family trip'))
|
||||
|
||||
act(() => {
|
||||
result.current.applyDescToAllVideos()
|
||||
})
|
||||
await waitFor(() => expect(result.current.sel[0].desc).toBe('family trip'))
|
||||
|
||||
act(() => {
|
||||
result.current.setGlobalDate('2026-04-11')
|
||||
})
|
||||
await waitFor(() => expect(result.current.sel[0].date).toBe('2026-04-11'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createSubfolder('new folder')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.renameFolder('videos')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.deleteFolder('archive')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.renamePath('clip.mp4')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.deletePath('clip.mp4', false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh('alpha', 'videos')
|
||||
})
|
||||
expect(result.current.sortedRows[0].name).toBe('nested')
|
||||
expect(result.current.existingNames.has('nested.mp4')).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doUpload()
|
||||
})
|
||||
|
||||
expect(harness.apiMock).toHaveBeenCalledWith('/api/mkdir', expect.any(Object))
|
||||
expect(harness.apiMock).toHaveBeenCalledWith('/api/rename', expect.any(Object))
|
||||
expect(harness.apiMock).toHaveBeenCalledWith(expect.stringContaining('/api/file?'), expect.any(Object))
|
||||
expect(result.current.sel).toEqual([])
|
||||
expect(result.current.status).toContain('Ready')
|
||||
})
|
||||
|
||||
it('surfaces upload failures and the not-signed-in guard', async () => {
|
||||
harness.uploadState.mode = 'error'
|
||||
const { result } = renderHook(() => useUploaderController())
|
||||
|
||||
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doUpload()
|
||||
})
|
||||
expect((globalThis as any).alert).toHaveBeenCalledWith('Please select a Library to upload into.')
|
||||
|
||||
act(() => {
|
||||
result.current.setLib('alpha')
|
||||
result.current.setSel([
|
||||
{ file: makeFile('clip.mp4', 'video/mp4'), desc: 'clip', date: '2026-04-10', finalName: '2026.04.10.clip.clip.mp4', progress: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.sel).toHaveLength(1))
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
throw new Error('mkdir failed')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.createSubfolder('broken folder')
|
||||
})
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
throw new Error('rename folder failed')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.renameFolder('videos')
|
||||
})
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
throw new Error('delete folder failed')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.deleteFolder('archive')
|
||||
})
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
throw new Error('rename failed')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.renamePath('clip.mp4')
|
||||
})
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
throw new Error('delete failed')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.deletePath('clip.mp4', false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doUpload().catch(() => {})
|
||||
})
|
||||
|
||||
expect((globalThis as any).alert).toHaveBeenCalledWith(expect.stringContaining('Upload failed'))
|
||||
})
|
||||
|
||||
it('surfaces profile errors and logs out on missing mappings', async () => {
|
||||
harness.apiMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/whoami') {
|
||||
throw new Error('no mapping found')
|
||||
}
|
||||
if (path === '/api/logout') {
|
||||
return {}
|
||||
}
|
||||
if (path.startsWith('/api/list?') || path === '/api/mkdir' || path === '/api/rename' || path.startsWith('/api/file?')) {
|
||||
return {}
|
||||
}
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useUploaderController())
|
||||
|
||||
await waitFor(() => expect(result.current.status).toMatch(/^Profile error:/))
|
||||
expect((globalThis as any).alert).toHaveBeenCalledWith(
|
||||
'Your account is not linked to any upload library yet. Please contact the admin to be granted access.'
|
||||
)
|
||||
expect((globalThis as any).location.replace).toHaveBeenCalledWith('/')
|
||||
expect(harness.apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
|
||||
})
|
||||
|
||||
it('blocks unload while an upload is in flight', async () => {
|
||||
harness.uploadState.pauseUpload = true
|
||||
const { result } = renderHook(() => useUploaderController())
|
||||
|
||||
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
||||
|
||||
act(() => {
|
||||
result.current.setLib('alpha')
|
||||
result.current.setSel([
|
||||
{ file: makeFile('clip.mp4', 'video/mp4'), desc: 'clip', date: '2026-04-10', finalName: '2026.04.10.clip.clip.mp4', progress: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.sel).toHaveLength(1))
|
||||
|
||||
const uploadPromise = result.current.doUpload()
|
||||
await waitFor(() => expect(result.current.uploading).toBe(true))
|
||||
|
||||
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
|
||||
const preventDefault = vi.spyOn(event, 'preventDefault')
|
||||
await act(async () => {
|
||||
window.dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
harness.uploadState.finishUpload?.()
|
||||
await uploadPromise
|
||||
})
|
||||
|
||||
expect(result.current.status).toContain('Ready')
|
||||
})
|
||||
})
|
||||
422
frontend/src/uploader-controller.ts
Normal file
422
frontend/src/uploader-controller.ts
Normal file
@ -0,0 +1,422 @@
|
||||
import React from 'react'
|
||||
import * as tus from 'tus-js-client'
|
||||
|
||||
import { api, WhoAmI } from './api'
|
||||
import uploaderUtils from './uploader-utils'
|
||||
|
||||
const {
|
||||
clampOneLevel,
|
||||
composeName,
|
||||
createNoResumeFingerprint,
|
||||
createNoResumeStorage,
|
||||
isDetailedError,
|
||||
isVideoFile,
|
||||
normalizeRows,
|
||||
sanitizeDesc,
|
||||
sanitizeFolderName,
|
||||
} = uploaderUtils
|
||||
|
||||
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
|
||||
type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string }
|
||||
|
||||
type ControllerState = {
|
||||
mobile: boolean
|
||||
me: WhoAmI | undefined
|
||||
setMe: React.Dispatch<React.SetStateAction<WhoAmI | undefined>>
|
||||
libs: string[]
|
||||
lib: string
|
||||
sub: string
|
||||
rootDirs: string[]
|
||||
rows: FileRow[]
|
||||
status: string
|
||||
globalDate: string
|
||||
uploading: boolean
|
||||
sel: Sel[]
|
||||
bulkDesc: string
|
||||
folderInputRef: React.RefObject<HTMLInputElement>
|
||||
newFolderRaw: string
|
||||
setGlobalDate: React.Dispatch<React.SetStateAction<string>>
|
||||
setLib: React.Dispatch<React.SetStateAction<string>>
|
||||
setSub: React.Dispatch<React.SetStateAction<string>>
|
||||
setNewFolderRaw: React.Dispatch<React.SetStateAction<string>>
|
||||
setBulkDesc: React.Dispatch<React.SetStateAction<string>>
|
||||
setSel: React.Dispatch<React.SetStateAction<Sel[]>>
|
||||
handleChoose: (files: FileList) => void
|
||||
applyDescToAllVideos: () => void
|
||||
doUpload: () => Promise<void>
|
||||
createSubfolder: (nameRaw: string) => Promise<void>
|
||||
renameFolder: (oldName: string) => Promise<void>
|
||||
deleteFolder: (name: string) => Promise<void>
|
||||
renamePath: (oldp: string) => Promise<void>
|
||||
deletePath: (p: string, recursive: boolean) => Promise<void>
|
||||
refresh: (currLib: string, currSub: string) => Promise<void>
|
||||
sortedRows: FileRow[]
|
||||
existingNames: Set<string>
|
||||
duplicateNamesInSelection: Set<string>
|
||||
hasNameIssues: boolean
|
||||
destPath: string
|
||||
videosNeedingDesc: number
|
||||
}
|
||||
|
||||
const NoResumeUrlStorage = createNoResumeStorage()
|
||||
|
||||
function useIsMobile(): boolean {
|
||||
const [mobile, setMobile] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
setMobile(uploaderUtils.isLikelyMobileUA())
|
||||
}, [])
|
||||
return mobile
|
||||
}
|
||||
|
||||
export default function useUploaderController(): ControllerState {
|
||||
const mobile = useIsMobile()
|
||||
|
||||
const [me, setMe] = React.useState<WhoAmI | undefined>()
|
||||
const [libs, setLibs] = React.useState<string[]>([])
|
||||
const [lib, setLib] = React.useState<string>('')
|
||||
|
||||
const [sub, setSub] = React.useState<string>('')
|
||||
const [rootDirs, setRootDirs] = React.useState<string[]>([])
|
||||
const [rows, setRows] = React.useState<FileRow[]>([])
|
||||
|
||||
const [status, setStatus] = React.useState<string>('')
|
||||
const [globalDate, setGlobalDate] = React.useState<string>(new Date().toISOString().slice(0, 10))
|
||||
const [uploading, setUploading] = React.useState<boolean>(false)
|
||||
const [sel, setSel] = React.useState<Sel[]>([])
|
||||
const [bulkDesc, setBulkDesc] = React.useState<string>('')
|
||||
const folderInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const [newFolderRaw, setNewFolderRaw] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = folderInputRef.current
|
||||
if (!el) return
|
||||
if (mobile) {
|
||||
el.removeAttribute('webkitdirectory')
|
||||
el.removeAttribute('directory')
|
||||
} else {
|
||||
el.setAttribute('webkitdirectory', '')
|
||||
el.setAttribute('directory', '')
|
||||
}
|
||||
}, [mobile])
|
||||
|
||||
React.useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
setStatus('Loading profile…')
|
||||
const m = await api<WhoAmI>('/api/whoami')
|
||||
setMe(m as any)
|
||||
const mm: any = m
|
||||
const L: string[] =
|
||||
Array.isArray(mm?.roots) ? mm.roots : Array.isArray(mm?.libs) ? mm.libs : typeof mm?.root === 'string' && mm.root ? [mm.root] : []
|
||||
setLibs(L)
|
||||
setLib('')
|
||||
setSub('')
|
||||
setStatus('Choose a library to start')
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message || e || '')
|
||||
setStatus(`Profile error: ${msg}`)
|
||||
if (msg.toLowerCase().includes('no mapping')) {
|
||||
alert('Your account is not linked to any upload library yet. Please contact the admin to be granted access.')
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' })
|
||||
} catch {
|
||||
// Best-effort logout cleanup only.
|
||||
}
|
||||
location.replace('/')
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
;(async () => {
|
||||
if (!lib) {
|
||||
setRootDirs([])
|
||||
setRows([])
|
||||
setSub('')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setStatus(`Loading library “${lib}”…`)
|
||||
await refresh(lib, sub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] refresh error', e)
|
||||
setStatus(`List error: ${e?.message || e}`)
|
||||
}
|
||||
})()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lib])
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (uploading) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [uploading])
|
||||
|
||||
async function refresh(currLib: string, currSub: string) {
|
||||
if (!currLib) return
|
||||
const one = clampOneLevel(currSub)
|
||||
const listRoot = normalizeRows(await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path: '' })}`))
|
||||
const rootDirNames = listRoot
|
||||
.filter((e) => e.is_dir)
|
||||
.map((e) => e.name)
|
||||
.filter(Boolean)
|
||||
setRootDirs(rootDirNames.sort((a, b) => a.localeCompare(b)))
|
||||
const subOk = one && rootDirNames.includes(one) ? one : ''
|
||||
if (subOk !== currSub) setSub(subOk)
|
||||
|
||||
const path = subOk ? subOk : ''
|
||||
const list = subOk ? await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path })}`) : listRoot
|
||||
setRows(normalizeRows(list))
|
||||
|
||||
const show = `/${[currLib, path].filter(Boolean).join('/')}`
|
||||
setStatus(`Ready · Destination: ${show}`)
|
||||
}
|
||||
|
||||
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 desc = ''
|
||||
return { file: f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
|
||||
})
|
||||
console.log('[Pegasus] selected files', arr.map((a) => a.file.name))
|
||||
setSel(arr)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
setSel((old) => old.map((x) => ({ ...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name) })))
|
||||
}, [globalDate])
|
||||
|
||||
function applyDescToAllVideos() {
|
||||
if (!bulkDesc.trim()) return
|
||||
setSel((old) =>
|
||||
old.map((x) => (isVideoFile(x.file) ? { ...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name) } : x))
|
||||
)
|
||||
}
|
||||
|
||||
async function doUpload() {
|
||||
if (!me) {
|
||||
setStatus('Not signed in')
|
||||
return
|
||||
}
|
||||
if (!lib) {
|
||||
alert('Please select a Library to upload into.')
|
||||
return
|
||||
}
|
||||
const missingVideos = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
|
||||
if (missingVideos > 0) {
|
||||
alert(`Please add a short description for all videos. ${missingVideos} video(s) missing a description.`)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('Starting upload…')
|
||||
setUploading(true)
|
||||
try {
|
||||
for (const s of sel) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
|
||||
endpoint: '/tus/',
|
||||
chunkSize: 5 * 1024 * 1024,
|
||||
retryDelays: [0, 1000, 3000, 5000, 10000],
|
||||
metadata: {
|
||||
filename: s.file.name,
|
||||
lib: lib,
|
||||
subdir: sub || '',
|
||||
date: s.date,
|
||||
desc: s.desc,
|
||||
},
|
||||
onError: (err: Error | tus.DetailedError) => {
|
||||
let msg = String(err)
|
||||
if (isDetailedError(err)) {
|
||||
const status = (err.originalRequest as any)?.status
|
||||
const statusText = (err.originalRequest as any)?.statusText
|
||||
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
|
||||
}
|
||||
console.error('[Pegasus] tus error', s.file.name, err)
|
||||
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, err: msg } : x)))
|
||||
setStatus(`✖ ${s.file.name}: ${msg}`)
|
||||
alert(`Upload failed: ${s.file.name}\n\n${msg}`)
|
||||
reject(err as any)
|
||||
},
|
||||
onProgress: (sent: number, total: number) => {
|
||||
const pct = Math.floor((sent / Math.max(total, 1)) * 100)
|
||||
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: pct } : x)))
|
||||
setStatus(`⬆ ${s.finalName}: ${pct}%`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: 100, err: undefined } : x)))
|
||||
setStatus(`✔ ${s.finalName} uploaded`)
|
||||
console.log('[Pegasus] tus success', s.file.name)
|
||||
resolve()
|
||||
},
|
||||
}
|
||||
opts.withCredentials = true
|
||||
;(opts as any).urlStorage = NoResumeUrlStorage
|
||||
;(opts as any).fingerprint = createNoResumeFingerprint
|
||||
|
||||
const up = new tus.Upload(s.file, opts)
|
||||
console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, lib, sub })
|
||||
up.start()
|
||||
})
|
||||
}
|
||||
setStatus('All uploads complete')
|
||||
setSel([])
|
||||
await refresh(lib, sub)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function createSubfolder(nameRaw: string) {
|
||||
const name = sanitizeFolderName(nameRaw)
|
||||
if (!name) return
|
||||
try {
|
||||
await api(`/api/mkdir`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ lib: lib, path: name }),
|
||||
})
|
||||
await refresh(lib, name)
|
||||
setSub(name)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] mkdir error', e)
|
||||
alert(`Create folder failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function renameFolder(oldName: string) {
|
||||
const nn = prompt('New folder name:', oldName)
|
||||
const newName = sanitizeFolderName(nn || '')
|
||||
if (!newName || newName === oldName) return
|
||||
try {
|
||||
await api('/api/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ lib: lib, from: oldName, to: newName }),
|
||||
})
|
||||
const newSub = sub === oldName ? newName : sub
|
||||
setSub(newSub)
|
||||
await refresh(lib, newSub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] rename folder error', e)
|
||||
alert(`Rename failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(name: string) {
|
||||
if (!confirm(`Delete folder “${name}” (and its contents)?`)) return
|
||||
try {
|
||||
await api(`/api/file?${new URLSearchParams({ lib: lib, path: name, recursive: 'true' })}`, { method: 'DELETE' })
|
||||
const newSub = sub === name ? '' : sub
|
||||
setSub(newSub)
|
||||
await refresh(lib, newSub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] delete folder error', e)
|
||||
alert(`Delete failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function renamePath(oldp: string) {
|
||||
const base = oldp.split('/').pop() || ''
|
||||
const name = prompt('New name (YYYY.MM.DD.Description.OrigStem.ext):', base)
|
||||
if (!name) return
|
||||
try {
|
||||
await api('/api/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ lib: lib, from: oldp, to: oldp.split('/').slice(0, -1).concat(sanitizeFolderName(name)).join('/') }),
|
||||
})
|
||||
await refresh(lib, sub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] rename error', e)
|
||||
alert(`Rename failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePath(p: string, recursive: boolean) {
|
||||
if (!confirm(`Delete ${p}${recursive ? ' (recursive)' : ''}?`)) return
|
||||
try {
|
||||
await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive ? 'true' : 'false' })}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh(lib, sub)
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] delete error', e)
|
||||
alert(`Delete failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
const sortedRows = React.useMemo(() => {
|
||||
const arr = Array.isArray(rows) ? rows.slice() : []
|
||||
return arr.sort((a, b) => {
|
||||
const dirFirst = Number(b?.is_dir ? 1 : 0) - Number(a?.is_dir ? 1 : 0)
|
||||
if (dirFirst !== 0) return dirFirst
|
||||
const an = a?.name ?? ''
|
||||
const bn = b?.name ?? ''
|
||||
return an.localeCompare(bn)
|
||||
})
|
||||
}, [rows])
|
||||
|
||||
const existingNames = React.useMemo(() => new Set(sortedRows.filter((r) => !r.is_dir).map((r) => r.name)), [sortedRows])
|
||||
|
||||
const duplicateNamesInSelection = React.useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
sel.forEach((s) => counts.set(s.finalName, (counts.get(s.finalName) || 0) + 1))
|
||||
return new Set(Array.from(counts.entries()).filter(([, c]) => c > 1).map(([n]) => n))
|
||||
}, [sel])
|
||||
|
||||
const hasNameIssues = React.useMemo(
|
||||
() => sel.some((s) => duplicateNamesInSelection.has(s.finalName) || existingNames.has(s.finalName)),
|
||||
[sel, duplicateNamesInSelection, existingNames]
|
||||
)
|
||||
|
||||
const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
|
||||
const videosNeedingDesc = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
|
||||
|
||||
return {
|
||||
mobile,
|
||||
me,
|
||||
setMe,
|
||||
libs,
|
||||
lib,
|
||||
sub,
|
||||
rootDirs,
|
||||
rows,
|
||||
status,
|
||||
globalDate,
|
||||
uploading,
|
||||
sel,
|
||||
bulkDesc,
|
||||
folderInputRef,
|
||||
newFolderRaw,
|
||||
setGlobalDate,
|
||||
setLib,
|
||||
setSub,
|
||||
setNewFolderRaw,
|
||||
setBulkDesc,
|
||||
setSel,
|
||||
handleChoose,
|
||||
applyDescToAllVideos,
|
||||
doUpload,
|
||||
createSubfolder,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
renamePath,
|
||||
deletePath,
|
||||
refresh,
|
||||
sortedRows,
|
||||
existingNames,
|
||||
duplicateNamesInSelection,
|
||||
hasNameIssues,
|
||||
destPath,
|
||||
videosNeedingDesc,
|
||||
}
|
||||
}
|
||||
32
frontend/src/uploader-thumb.tsx
Normal file
32
frontend/src/uploader-thumb.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
|
||||
import uploaderUtils from './uploader-utils'
|
||||
|
||||
const { isImageFile, isVideoFile } = uploaderUtils
|
||||
|
||||
export default function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) {
|
||||
const [url, setUrl] = React.useState<string>()
|
||||
|
||||
React.useEffect(() => {
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
setUrl(objectUrl)
|
||||
return () => {
|
||||
try {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
} catch {
|
||||
// Ignore revocation races during unmount.
|
||||
}
|
||||
}
|
||||
}, [file])
|
||||
|
||||
if (!url) return null
|
||||
|
||||
const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 }
|
||||
if (isImageFile(file)) return <img src={url} alt="" style={baseStyle} className="preview-thumb" />
|
||||
if (isVideoFile(file)) return <video src={url} muted preload="metadata" playsInline style={baseStyle} className="preview-thumb" />
|
||||
return (
|
||||
<div style={{ ...baseStyle, display: 'grid', placeItems: 'center' }} className="preview-thumb">
|
||||
📄
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
frontend/src/uploader-utils.ts
Normal file
141
frontend/src/uploader-utils.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import * as tus from 'tus-js-client'
|
||||
|
||||
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
|
||||
type RowLike = {
|
||||
name?: unknown
|
||||
Name?: unknown
|
||||
path?: unknown
|
||||
Path?: unknown
|
||||
is_dir?: unknown
|
||||
IsDir?: unknown
|
||||
isDir?: unknown
|
||||
size?: unknown
|
||||
Size?: unknown
|
||||
mtime?: unknown
|
||||
Mtime?: unknown
|
||||
}
|
||||
|
||||
type NoResumeStorage = {
|
||||
addUpload: (_u: any) => Promise<void>
|
||||
removeUpload: (_u: any) => Promise<void>
|
||||
listUploads: () => Promise<any[]>
|
||||
findUploadsByFingerprint: (_fp: string) => Promise<any[]>
|
||||
}
|
||||
|
||||
const videoExt = new Set(['mp4', 'mkv', 'mov', 'avi', 'm4v', 'webm', 'mpg', 'mpeg', 'ts', 'm2ts'])
|
||||
const imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff'])
|
||||
|
||||
const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '')
|
||||
|
||||
function sanitizeDesc(s: string) {
|
||||
s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
if (!s) s = 'upload'
|
||||
return s.slice(0, 64)
|
||||
}
|
||||
|
||||
function sanitizeFolderName(s: string) {
|
||||
// Only allow a single path segment and collapse punctuation to keep mkdir/rename predictable.
|
||||
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '')
|
||||
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_')
|
||||
return s.slice(0, 64)
|
||||
}
|
||||
|
||||
function extOf(n: string) {
|
||||
const i = n.lastIndexOf('.')
|
||||
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
|
||||
}
|
||||
|
||||
function stemOf(n: string) {
|
||||
const i = n.lastIndexOf('.')
|
||||
const stem = i > -1 ? n.slice(0, i) : n
|
||||
// Keep the stem safe and avoid extra dots inside the composed upload name.
|
||||
return sanitizeDesc(stem.replace(/\./g, '_')) || 'file'
|
||||
}
|
||||
|
||||
function composeName(date: string, desc: string, orig: string) {
|
||||
const d = date || new Date().toISOString().slice(0, 10)
|
||||
const [Y, M, D] = d.split('-')
|
||||
const sDesc = sanitizeDesc(desc)
|
||||
const sStem = stemOf(orig)
|
||||
const ext = extOf(orig) || 'bin'
|
||||
return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}`
|
||||
}
|
||||
|
||||
function clampOneLevel(p: string) {
|
||||
if (!p) return ''
|
||||
return p.replace(/^\/+|\/+$/g, '').split('/')[0] || ''
|
||||
}
|
||||
|
||||
function normalizeRows(listRaw: unknown[]): FileRow[] {
|
||||
return (Array.isArray(listRaw) ? listRaw : []).map((r: unknown) => {
|
||||
const row = (r && typeof r === 'object' ? r : {}) as RowLike
|
||||
return {
|
||||
name: String(row.name ?? row.Name ?? ''),
|
||||
path: String(row.path ?? row.Path ?? ''),
|
||||
is_dir: Boolean(row.is_dir ?? row.IsDir ?? row.isDir ?? false),
|
||||
size: Number(row.size ?? row.Size ?? 0),
|
||||
mtime: Number(row.mtime ?? row.Mtime ?? 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name))
|
||||
const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name))
|
||||
|
||||
function isDetailedError(e: unknown): e is tus.DetailedError {
|
||||
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any))
|
||||
}
|
||||
|
||||
function isLikelyMobileUA(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const ua = navigator.userAgent || ''
|
||||
const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
|
||||
return coarse || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
if (n < 1024) return `${n} B`
|
||||
const units = ['KB', 'MB', 'GB', 'TB']
|
||||
let idx = -1
|
||||
do {
|
||||
n /= 1024
|
||||
idx++
|
||||
} while (n >= 1024 && idx < units.length - 1)
|
||||
return `${n.toFixed(1)} ${units[idx]}`
|
||||
}
|
||||
|
||||
function createNoResumeStorage(): NoResumeStorage {
|
||||
return {
|
||||
async addUpload(_u: any) {},
|
||||
async removeUpload(_u: any) {},
|
||||
async listUploads() {
|
||||
return []
|
||||
},
|
||||
async findUploadsByFingerprint(_fp: string) {
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoResumeFingerprint() {
|
||||
return `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
const uploaderUtils = {
|
||||
sanitizeDesc,
|
||||
sanitizeFolderName,
|
||||
extOf,
|
||||
stemOf,
|
||||
composeName,
|
||||
clampOneLevel,
|
||||
normalizeRows,
|
||||
isVideoFile,
|
||||
isImageFile,
|
||||
isDetailedError,
|
||||
isLikelyMobileUA,
|
||||
fmt,
|
||||
createNoResumeStorage,
|
||||
createNoResumeFingerprint,
|
||||
}
|
||||
|
||||
export default uploaderUtils
|
||||
@ -97,6 +97,15 @@ def _read_test_exit_code(path: Path) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
def _load_gate_summary(path: Path) -> dict[str, object]:
|
||||
if not path.exists():
|
||||
return {"ok": False, "issues": []}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"ok": False, "issues": []}
|
||||
|
||||
|
||||
def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str, str]) -> float:
|
||||
text = _read_http(f"{pushgateway_url.rstrip('/')}/metrics")
|
||||
if not text:
|
||||
@ -151,6 +160,7 @@ def main() -> int:
|
||||
)
|
||||
backend_rc_file = Path(os.getenv("BACKEND_TEST_RC_FILE", "build/backend-test.rc"))
|
||||
frontend_rc_file = Path(os.getenv("FRONTEND_TEST_RC_FILE", "build/frontend-test.rc"))
|
||||
gate_summary = _load_gate_summary(Path(os.getenv("GATE_SUMMARY_FILE", "build/gate-summary.json")))
|
||||
|
||||
b = _load_junit(backend_junit)
|
||||
f = _load_junit(frontend_junit)
|
||||
@ -168,9 +178,24 @@ def main() -> int:
|
||||
|
||||
backend_rc = _read_test_exit_code(backend_rc_file)
|
||||
frontend_rc = _read_test_exit_code(frontend_rc_file)
|
||||
backend_suite_result = "passed" if backend_rc == 0 else "failed"
|
||||
frontend_suite_result = "passed" if frontend_rc == 0 else "failed"
|
||||
branch = os.getenv("BRANCH_NAME", "")
|
||||
build_number = os.getenv("BUILD_NUMBER", "")
|
||||
commit = os.getenv("GIT_COMMIT", "")
|
||||
|
||||
labels = {
|
||||
"suite": suite,
|
||||
"branch": branch,
|
||||
"build_number": build_number,
|
||||
"commit": commit,
|
||||
}
|
||||
gate_ok = bool(gate_summary.get("ok"))
|
||||
gate_issues = gate_summary.get("issues") or []
|
||||
outcome = (
|
||||
"ok"
|
||||
if backend_rc == 0
|
||||
if gate_ok
|
||||
and backend_rc == 0
|
||||
and frontend_rc == 0
|
||||
and totals["tests"] > 0
|
||||
and totals["failures"] == 0
|
||||
@ -195,21 +220,13 @@ def main() -> int:
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
branch = os.getenv("BRANCH_NAME", "")
|
||||
build_number = os.getenv("BUILD_NUMBER", "")
|
||||
commit = os.getenv("GIT_COMMIT", "")
|
||||
|
||||
labels = {
|
||||
"suite": suite,
|
||||
"branch": branch,
|
||||
"build_number": build_number,
|
||||
"commit": commit,
|
||||
}
|
||||
|
||||
payload_lines = [
|
||||
"# TYPE platform_quality_gate_runs_total counter",
|
||||
f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count:.0f}',
|
||||
f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_count:.0f}',
|
||||
"# TYPE pegasus_test_suite_result gauge",
|
||||
f'pegasus_test_suite_result{{suite="backend",status="{backend_suite_result}"}} 1',
|
||||
f'pegasus_test_suite_result{{suite="frontend",status="{frontend_suite_result}"}} 1',
|
||||
"# TYPE pegasus_quality_gate_tests_total gauge",
|
||||
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="passed"}} {passed}',
|
||||
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failures"]}',
|
||||
@ -217,6 +234,10 @@ def main() -> int:
|
||||
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}',
|
||||
"# TYPE pegasus_quality_gate_coverage_percent gauge",
|
||||
f'pegasus_quality_gate_coverage_percent{{suite="{suite}"}} {coverage_pct:.3f}',
|
||||
"# TYPE pegasus_quality_gate_status gauge",
|
||||
f'pegasus_quality_gate_status{{suite="{suite}",result="{"ok" if gate_ok else "failed"}"}} 1',
|
||||
"# TYPE pegasus_quality_gate_issues_total gauge",
|
||||
f'pegasus_quality_gate_issues_total{{suite="{suite}"}} {len(gate_issues)}',
|
||||
"# TYPE pegasus_quality_gate_build_info gauge",
|
||||
f"pegasus_quality_gate_build_info{_label_str(labels)} 1",
|
||||
]
|
||||
|
||||
1
testing/__init__.py
Normal file
1
testing/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Pegasus test and quality-gate helpers."""
|
||||
270
testing/pegasus_gate.py
Normal file
270
testing/pegasus_gate.py
Normal file
@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evaluate Pegasus quality checks from test artifacts and source files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND = ROOT / "backend"
|
||||
FRONTEND = ROOT / "frontend"
|
||||
BUILD = ROOT / "build"
|
||||
|
||||
PROD_EXTS = {".go", ".ts", ".tsx", ".py"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GateIssue:
|
||||
check: str
|
||||
path: str
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GateReport:
|
||||
ok: bool
|
||||
issues: list[GateIssue]
|
||||
file_count: int
|
||||
backend_coverage: dict[str, float]
|
||||
frontend_coverage: dict[str, dict[str, float]]
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path | None = None) -> tuple[int, str]:
|
||||
proc = subprocess.run(cmd, cwd=cwd or ROOT, capture_output=True, text=True)
|
||||
return proc.returncode, proc.stdout + proc.stderr
|
||||
|
||||
|
||||
def _rel(path: Path) -> str:
|
||||
return str(path.resolve().relative_to(ROOT))
|
||||
|
||||
|
||||
def _production_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for path in ROOT.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name.endswith("_test.go"):
|
||||
continue
|
||||
if path.name.endswith(".test.ts") or path.name.endswith(".test.tsx"):
|
||||
continue
|
||||
if path.name == "AGENTS.md":
|
||||
continue
|
||||
if path.suffix not in PROD_EXTS and path.name not in {"Dockerfile", "Jenkinsfile"}:
|
||||
continue
|
||||
if any(part in {".git", "node_modules", "dist", "coverage", "build"} for part in path.parts):
|
||||
continue
|
||||
rel = path.relative_to(ROOT)
|
||||
if rel.parts[0] == "backend" and path.suffix == ".go" and path.name.endswith(".go"):
|
||||
files.append(path)
|
||||
elif rel.parts[0] == "frontend" and len(rel.parts) >= 3 and rel.parts[1] == "src" and path.suffix in {".ts", ".tsx"}:
|
||||
if rel.parts[2] in {"test", "types"}:
|
||||
continue
|
||||
if path.name.endswith(".test.ts") or path.name.endswith(".test.tsx") or path.name.endswith(".d.ts"):
|
||||
continue
|
||||
files.append(path)
|
||||
elif path.name in {"Dockerfile", "Jenkinsfile"}:
|
||||
files.append(path)
|
||||
elif rel.parts[0] == "scripts" and path.suffix == ".py":
|
||||
files.append(path)
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def _line_count(path: Path) -> int:
|
||||
return len(path.read_text(encoding="utf-8").splitlines())
|
||||
|
||||
|
||||
def _check_loc(files: Iterable[Path]) -> list[GateIssue]:
|
||||
issues: list[GateIssue] = []
|
||||
for path in files:
|
||||
if _line_count(path) > 500:
|
||||
issues.append(GateIssue("loc", _rel(path), f"{_line_count(path)} lines exceeds 500"))
|
||||
return issues
|
||||
|
||||
|
||||
def _go_exported_comment_issues(files: Iterable[Path]) -> list[GateIssue]:
|
||||
issues: list[GateIssue] = []
|
||||
for path in files:
|
||||
if path.suffix != ".go" or path.name.endswith("_test.go"):
|
||||
continue
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
for idx, line in enumerate(lines):
|
||||
m = re.match(r"^(func|type|var|const)\s+([A-Z][A-Za-z0-9_]*)", line.strip())
|
||||
if not m:
|
||||
continue
|
||||
prev = idx - 1
|
||||
while prev >= 0 and not lines[prev].strip():
|
||||
prev -= 1
|
||||
if prev < 0 or not lines[prev].lstrip().startswith("//"):
|
||||
issues.append(GateIssue("go-doc", _rel(path), f"exported {m.group(1)} {m.group(2)} lacks leading comment"))
|
||||
return issues
|
||||
|
||||
|
||||
def _ts_export_comment_issues(files: Iterable[Path]) -> list[GateIssue]:
|
||||
issues: list[GateIssue] = []
|
||||
for path in files:
|
||||
if path.suffix not in {".ts", ".tsx"} or path.name.endswith(".test.ts") or path.name.endswith(".test.tsx"):
|
||||
continue
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
for idx, line in enumerate(lines):
|
||||
if not re.match(r"^\s*export\s+(function|const|type)\s+[A-Z_a-z]", line):
|
||||
continue
|
||||
prev = idx - 1
|
||||
while prev >= 0 and not lines[prev].strip():
|
||||
prev -= 1
|
||||
if prev < 0 or not lines[prev].lstrip().startswith("/**"):
|
||||
issues.append(GateIssue("ts-doc", _rel(path), "exported symbol lacks JSDoc contract"))
|
||||
return issues
|
||||
|
||||
|
||||
def _go_vet() -> list[GateIssue]:
|
||||
rc, output = _run(["go", "vet", "./..."], cwd=BACKEND)
|
||||
if rc == 0:
|
||||
return []
|
||||
return [GateIssue("go-vet", "backend", output.strip() or "go vet failed")]
|
||||
|
||||
|
||||
def _tsc_check() -> list[GateIssue]:
|
||||
rc, output = _run(["npm", "exec", "tsc", "--", "--noEmit"], cwd=FRONTEND)
|
||||
if rc == 0:
|
||||
return []
|
||||
return [GateIssue("tsc", "frontend", output.strip() or "tsc failed")]
|
||||
|
||||
|
||||
def _parse_go_coverage(path: Path) -> dict[str, float]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
_, output = _run(["go", "tool", "cover", "-func", str(path)], cwd=BACKEND)
|
||||
cover: dict[str, float] = {}
|
||||
for line in output.splitlines():
|
||||
if "\t" not in line or line.startswith("total:"):
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
file_part = parts[0].split(":")[0]
|
||||
pct = float(parts[-1].rstrip("%"))
|
||||
file_norm = file_part.replace("\\", "/")
|
||||
if "/backend/" in file_norm:
|
||||
file_norm = "backend/" + file_norm.split("/backend/", 1)[1]
|
||||
elif file_norm.startswith("backend/"):
|
||||
pass
|
||||
else:
|
||||
file_norm = file_norm.rsplit("/", 1)[-1]
|
||||
cover[file_norm] = pct
|
||||
return cover
|
||||
|
||||
|
||||
def _parse_frontend_coverage(path: Path) -> dict[str, dict[str, float]]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
def _pct(value: object) -> float:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
match = re.search(r"[0-9]+(?:\.[0-9]+)?", value)
|
||||
if match:
|
||||
return float(match.group(0))
|
||||
return 0.0
|
||||
|
||||
def _normalize_key(raw: str) -> str:
|
||||
key = raw.replace("\\", "/")
|
||||
if key.startswith("./"):
|
||||
key = key[2:]
|
||||
for prefix in (str(ROOT).replace("\\", "/") + "/", str(FRONTEND).replace("\\", "/") + "/"):
|
||||
if key.startswith(prefix):
|
||||
key = key[len(prefix):]
|
||||
break
|
||||
key = key.lstrip("/")
|
||||
if key.startswith("frontend/"):
|
||||
return key
|
||||
return f"frontend/{key}"
|
||||
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
out: dict[str, dict[str, float]] = {}
|
||||
for file_path, summary in payload.items():
|
||||
if file_path == "total":
|
||||
continue
|
||||
lines = summary.get("lines") or {}
|
||||
statements = summary.get("statements") or {}
|
||||
out[_normalize_key(str(file_path))] = {
|
||||
"lines": _pct(lines.get("pct")),
|
||||
"statements": _pct(statements.get("pct")),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _check_coverage(files: Iterable[Path]) -> list[GateIssue]:
|
||||
issues: list[GateIssue] = []
|
||||
go_cov = _parse_go_coverage(BUILD / "coverage-backend.out")
|
||||
fe_cov = _parse_frontend_coverage(BUILD / "frontend-coverage" / "coverage-summary.json")
|
||||
|
||||
for path in files:
|
||||
rel = _rel(path)
|
||||
if path.suffix == ".go":
|
||||
pct = go_cov.get(rel, 0.0)
|
||||
if pct < 95.0:
|
||||
issues.append(GateIssue("coverage", rel, f"go line coverage {pct:.2f}% < 95%"))
|
||||
elif path.suffix in {".ts", ".tsx"}:
|
||||
pct = fe_cov.get(rel, {}).get("lines", 0.0)
|
||||
if pct < 95.0:
|
||||
issues.append(GateIssue("coverage", rel, f"frontend line coverage {pct:.2f}% < 95%"))
|
||||
return issues
|
||||
|
||||
|
||||
def evaluate() -> GateReport:
|
||||
files = _production_files()
|
||||
issues = []
|
||||
issues.extend(_check_loc(files))
|
||||
issues.extend(_go_exported_comment_issues(files))
|
||||
issues.extend(_ts_export_comment_issues(files))
|
||||
issues.extend(_go_vet())
|
||||
issues.extend(_tsc_check())
|
||||
issues.extend(_check_coverage(files))
|
||||
|
||||
go_cov = _parse_go_coverage(BUILD / "coverage-backend.out")
|
||||
fe_cov = _parse_frontend_coverage(BUILD / "frontend-coverage" / "coverage-summary.json")
|
||||
report = GateReport(
|
||||
ok=not issues,
|
||||
issues=issues,
|
||||
file_count=len(files),
|
||||
backend_coverage=go_cov,
|
||||
frontend_coverage=fe_cov,
|
||||
)
|
||||
BUILD.mkdir(exist_ok=True)
|
||||
(BUILD / "gate-summary.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": report.ok,
|
||||
"issues": [asdict(issue) for issue in report.issues],
|
||||
"file_count": report.file_count,
|
||||
"backend_coverage": report.backend_coverage,
|
||||
"frontend_coverage": report.frontend_coverage,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("mode", nargs="?", choices={"report", "enforce"}, default="enforce")
|
||||
args = parser.parse_args()
|
||||
|
||||
report = evaluate()
|
||||
print(json.dumps({"ok": report.ok, "issues": [asdict(i) for i in report.issues]}, indent=2))
|
||||
if args.mode == "enforce" and not report.ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
x
Reference in New Issue
Block a user