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 \
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
set -eux; \
|
set -eux; \
|
||||||
mkdir -p /out; \
|
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; \
|
test -f /out/pegasus; \
|
||||||
upx -q --lzma /out/pegasus || true
|
upx -q --lzma /out/pegasus || true
|
||||||
|
|
||||||
|
|||||||
44
Jenkinsfile
vendored
44
Jenkinsfile
vendored
@ -64,6 +64,8 @@ spec:
|
|||||||
container('go-tester') {
|
container('go-tester') {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
|
export PEGASUS_SESSION_KEY=test-session-key
|
||||||
|
export PEGASUS_COOKIE_INSECURE=1
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
cd backend
|
cd backend
|
||||||
go install github.com/jstemmer/go-junit-report/v2@latest
|
go install github.com/jstemmer/go-junit-report/v2@latest
|
||||||
@ -108,43 +110,12 @@ spec:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Combine coverage summary') {
|
stage('Quality gate report') {
|
||||||
steps {
|
steps {
|
||||||
container('publisher') {
|
container('publisher') {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
python - <<'PY'
|
python -m testing.pegasus_gate report
|
||||||
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
|
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -166,12 +137,7 @@ PY
|
|||||||
container('publisher') {
|
container('publisher') {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
backend_rc="$(cat build/backend-test.rc 2>/dev/null || echo 1)"
|
python -m testing.pegasus_gate enforce
|
||||||
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
|
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
"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) {
|
func CurrentUser(r *http.Request) (Claims, error) {
|
||||||
c, err := r.Cookie(CookieName)
|
c, err := r.Cookie(CookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Claims{}, err
|
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 {
|
if err != nil {
|
||||||
return Claims{}, err
|
return Claims{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,3 +98,20 @@ func TestCurrentUserMalformedToken(t *testing.T) {
|
|||||||
t.Fatalf("expected parse error for malformed token")
|
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"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Debug enables verbose request and state logging.
|
||||||
var Debug = os.Getenv("PEGASUS_DEBUG") == "1"
|
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"
|
var DryRun = os.Getenv("PEGASUS_DRY_RUN") == "1"
|
||||||
|
|
||||||
|
// Logf writes a log line only when Debug is enabled.
|
||||||
func Logf(format string, args ...any) {
|
func Logf(format string, args ...any) {
|
||||||
if Debug {
|
if Debug {
|
||||||
log.Printf(format, args...)
|
log.Printf(format, args...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RedactHeaders clones a header map and masks sensitive values.
|
||||||
func RedactHeaders(h http.Header) http.Header {
|
func RedactHeaders(h http.Header) http.Header {
|
||||||
cp := http.Header{}
|
cp := http.Header{}
|
||||||
for k, v := range h {
|
for k, v := range h {
|
||||||
|
|||||||
@ -8,14 +8,17 @@ import (
|
|||||||
"strings"
|
"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) {
|
func SafeJoin(root, rel string) (string, error) {
|
||||||
rel = strings.TrimPrefix(rel, "/")
|
rel = strings.TrimPrefix(rel, "/")
|
||||||
p := filepath.Join(root, rel)
|
p := filepath.Join(root, rel)
|
||||||
ap, err := filepath.Abs(p)
|
ap, err := absPath(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
ar, err := filepath.Abs(root)
|
ar, err := absPath(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@ -34,3 +35,52 @@ func TestSafeJoinAllowsRoot(t *testing.T) {
|
|||||||
t.Fatalf("expected root path %q got %q", root, got)
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt" // <-- keep this; used by fmt.Errorf
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Minimal Jellyfin client
|
// AuthResult is the subset of Jellyfin auth response Pegasus needs.
|
||||||
|
type AuthResult struct {
|
||||||
type jfAuthResult struct {
|
|
||||||
AccessToken string `json:"AccessToken"`
|
AccessToken string `json:"AccessToken"`
|
||||||
User struct {
|
User struct {
|
||||||
Id string `json:"Id"`
|
Id string `json:"Id"`
|
||||||
@ -20,11 +19,13 @@ type jfAuthResult struct {
|
|||||||
} `json:"User"`
|
} `json:"User"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jellyfin is a tiny client for the auth and refresh endpoints Pegasus uses.
|
||||||
type Jellyfin struct {
|
type Jellyfin struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewJellyfin builds a lightweight client for Jellyfin auth and refresh calls.
|
||||||
func NewJellyfin() *Jellyfin {
|
func NewJellyfin() *Jellyfin {
|
||||||
return &Jellyfin{
|
return &Jellyfin{
|
||||||
BaseURL: os.Getenv("JELLYFIN_URL"),
|
BaseURL: os.Getenv("JELLYFIN_URL"),
|
||||||
@ -32,9 +33,9 @@ func NewJellyfin() *Jellyfin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate against /Users/AuthenticateByName using Pw (plaintext)
|
// AuthenticateByName signs into Jellyfin with the plaintext password Jellyfin expects.
|
||||||
func (j *Jellyfin) AuthenticateByName(username, password string) (jfAuthResult, error) {
|
func (j *Jellyfin) AuthenticateByName(username, password string) (AuthResult, error) {
|
||||||
var out jfAuthResult
|
var out AuthResult
|
||||||
body := map[string]string{"Username": username, "Pw": password}
|
body := map[string]string{"Username": username, "Pw": password}
|
||||||
b, _ := json.Marshal(body)
|
b, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
|||||||
@ -90,3 +90,14 @@ func TestAuthenticateByNameRequestError(t *testing.T) {
|
|||||||
t.Fatalf("expected transport error")
|
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}$`)
|
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 {
|
func SanitizeDesc(in string) string {
|
||||||
s := strings.TrimSpace(in)
|
s := strings.TrimSpace(in)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
@ -29,6 +30,7 @@ func SanitizeDesc(in string) string {
|
|||||||
return s
|
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) {
|
func ComposeFinalName(dateStr, desc, origFilename string) (string, error) {
|
||||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), "."))
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), "."))
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
|
|||||||
@ -48,3 +48,9 @@ func TestComposeFinalName(t *testing.T) {
|
|||||||
t.Fatalf("unexpected fallback name %q", fallback)
|
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")
|
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"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Claims are the signed session fields Pegasus stores in the browser cookie.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
Username string `json:"u"`
|
Username string `json:"u"`
|
||||||
JFToken string `json:"t"`
|
JFToken string `json:"t"`
|
||||||
@ -17,9 +18,14 @@ type Claims struct {
|
|||||||
|
|
||||||
var sessionKey = []byte(os.Getenv("PEGASUS_SESSION_KEY"))
|
var sessionKey = []byte(os.Getenv("PEGASUS_SESSION_KEY"))
|
||||||
var cookieSecure = os.Getenv("PEGASUS_COOKIE_INSECURE") != "1"
|
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"
|
const CookieName = "pegasus_session"
|
||||||
|
|
||||||
|
// SetSession signs and writes a Pegasus session cookie.
|
||||||
func SetSession(w http.ResponseWriter, username, jfToken string) error {
|
func SetSession(w http.ResponseWriter, username, jfToken string) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||||
@ -30,7 +36,7 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
|
|||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
signed, err := tok.SignedString(sessionKey)
|
signed, err := signJWT(tok)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -45,6 +51,7 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearSession expires the Pegasus session cookie immediately.
|
||||||
func ClearSession(w http.ResponseWriter) {
|
func ClearSession(w http.ResponseWriter) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: CookieName,
|
Name: CookieName,
|
||||||
|
|||||||
@ -95,3 +95,17 @@ func TestClearSessionExpiresCookie(t *testing.T) {
|
|||||||
t.Fatalf("expected secure cookie")
|
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"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StringOrList allows both a single string ("Stein_90s")
|
// StringOrList allows a single string or a list of strings in YAML.
|
||||||
// and a list of strings (["Stein_90s","Home Videos"]) in YAML.
|
|
||||||
type StringOrList []string
|
type StringOrList []string
|
||||||
|
|
||||||
func (s *StringOrList) UnmarshalYAML(value *yaml.Node) error {
|
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 {
|
type UserMap struct {
|
||||||
// Maps Jellyfin username -> one or more relative media subdirs
|
|
||||||
Map map[string]StringOrList `yaml:"map"`
|
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) {
|
func LoadUserMap(path string) (*UserMap, error) {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -90,3 +90,17 @@ func TestResolveAllMissingAndInvalidEntries(t *testing.T) {
|
|||||||
t.Fatalf("expected invalid-mapping error")
|
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
|
// backend/main.go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "log"
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
var fatal = log.Fatal
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
"github.com/tus/tusd/pkg/filestore"
|
|
||||||
tusd "github.com/tus/tusd/pkg/handler"
|
|
||||||
"github.com/tus/tusd/pkg/memorylocker"
|
|
||||||
|
|
||||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed web/dist
|
|
||||||
var webFS embed.FS
|
|
||||||
|
|
||||||
var (
|
|
||||||
mediaRoot = env("PEGASUS_MEDIA_ROOT", "/media")
|
|
||||||
userMapFile = env("PEGASUS_USER_MAP_FILE", "/config/user-map.yaml")
|
|
||||||
tusDir = env("PEGASUS_TUS_DIR", filepath.Join(mediaRoot, ".pegasus-tus"))
|
|
||||||
jf = internal.NewJellyfin()
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
version = "dev" // set via -ldflags "-X main.version=1.2.0 -X main.git=$(git rev-parse --short HEAD) -X main.builtAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
git = ""
|
|
||||||
builtAt = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
type loggingRW struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
status int
|
|
||||||
}
|
|
||||||
|
|
||||||
type listEntry struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
IsDir bool `json:"is_dir"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Mtime int64 `json:"mtime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) }
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot)
|
if err := run(); err != nil {
|
||||||
|
fatal(err)
|
||||||
if os.Getenv("PEGASUS_SESSION_KEY") == "" {
|
|
||||||
log.Fatal("PEGASUS_SESSION_KEY is not set")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
um, err := internal.LoadUserMap(userMapFile)
|
|
||||||
must(err, "load user map")
|
|
||||||
if internal.Debug {
|
|
||||||
keys := make([]string, 0, len(um.Map))
|
|
||||||
for k := range um.Map { keys = append(keys, k) }
|
|
||||||
sort.Strings(keys)
|
|
||||||
show := keys
|
|
||||||
if len(keys) > 10 { show = keys[:10] }
|
|
||||||
internal.Logf("user-map loaded (%d): %v%s", len(keys), show, func() string {
|
|
||||||
if len(keys) > len(show) { return " ..." }
|
|
||||||
return ""
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
// === tusd setup (resumable uploads) ===
|
|
||||||
store := filestore.FileStore{Path: tusDir}
|
|
||||||
// ensure upload scratch dir exists
|
|
||||||
if err := os.MkdirAll(tusDir, 0o2775); err != nil {
|
|
||||||
log.Fatalf("mkdir %s: %v", tusDir, err)
|
|
||||||
}
|
|
||||||
locker := memorylocker.New()
|
|
||||||
composer := tusd.NewStoreComposer()
|
|
||||||
store.UseIn(composer)
|
|
||||||
locker.UseIn(composer)
|
|
||||||
|
|
||||||
// completeC := make(chan tusd.HookEvent)
|
|
||||||
config := tusd.Config{
|
|
||||||
BasePath: "/tus/",
|
|
||||||
StoreComposer: composer,
|
|
||||||
NotifyCompleteUploads: true,
|
|
||||||
MaxSize: 8 * 1024 * 1024 * 1024, // 8GB per file
|
|
||||||
RespectForwardedHeaders: true, // <<< important for https behind Traefik
|
|
||||||
}
|
|
||||||
tusHandler, err := tusd.NewUnroutedHandler(config)
|
|
||||||
must(err, "init tus handler")
|
|
||||||
completeC := tusHandler.CompleteUploads
|
|
||||||
|
|
||||||
// ---- post-finish hook: enforce naming & mapping ----
|
|
||||||
go func() {
|
|
||||||
for ev := range completeC {
|
|
||||||
claims, err := claimsFromHook(ev)
|
|
||||||
if err != nil {
|
|
||||||
internal.Logf("tus: no session: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// read metadata set by the UI
|
|
||||||
meta := ev.Upload.MetaData
|
|
||||||
desc := strings.TrimSpace(meta["desc"])
|
|
||||||
date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty
|
|
||||||
subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
|
|
||||||
lib := strings.Trim(strings.TrimSpace(meta["lib"]), "/")
|
|
||||||
orig := meta["filename"]
|
|
||||||
if orig == "" { orig = "upload.bin" }
|
|
||||||
|
|
||||||
// Decide if description is required by extension (videos only)
|
|
||||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
|
|
||||||
isVideo := map[string]bool{
|
|
||||||
"mp4": true, "mkv": true, "mov": true, "avi": true, "m4v": true,
|
|
||||||
"webm": true, "mpg": true, "mpeg": true, "ts": true, "m2ts": true,
|
|
||||||
}[ext]
|
|
||||||
if isVideo && desc == "" {
|
|
||||||
log.Printf("tus: missing desc for video; rejecting")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if desc == "" { desc = "upload" } // images can default
|
|
||||||
|
|
||||||
// Resolve destination root under /media
|
|
||||||
var libRoot string
|
|
||||||
if lib != "" {
|
|
||||||
lib = sanitizeSegment(lib)
|
|
||||||
libRoot = filepath.Join(mediaRoot, lib) // explicit library
|
|
||||||
} else {
|
|
||||||
if rr, err := um.Resolve(claims.Username); err == nil && rr != "" {
|
|
||||||
libRoot = filepath.Join(mediaRoot, rr) // fallback to first mapped root
|
|
||||||
} else {
|
|
||||||
libRoot = "" // no valid root
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The library root must already exist; we only create one-level subfolders.
|
|
||||||
if libRoot == "" {
|
|
||||||
log.Printf("upload: library root not resolved for user %s", claims.Username)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fi, err := os.Stat(libRoot); err != nil || !fi.IsDir() {
|
|
||||||
log.Printf("upload: library root missing or not accessible: %s (%v)", libRoot, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional one-level subdir under the library
|
|
||||||
destRoot := libRoot
|
|
||||||
if subdir != "" {
|
|
||||||
subdir = sanitizeSegment(subdir)
|
|
||||||
destRoot = filepath.Join(libRoot, subdir)
|
|
||||||
if err := os.MkdirAll(destRoot, 0o2775); err != nil {
|
|
||||||
log.Printf("mkdir %s: %v", destRoot, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compose final filename and move from TUS scratch to final
|
|
||||||
finalName := composeFinalName(date, desc, orig)
|
|
||||||
dst := filepath.Join(destRoot, finalName)
|
|
||||||
if err := moveFromTus(ev, dst); err != nil {
|
|
||||||
log.Printf("move failed: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
jf.RefreshLibrary(claims.JFToken)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// === chi router ===
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Use(corsForTus)
|
|
||||||
|
|
||||||
// health/version
|
|
||||||
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
r.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
writeJSON(w, map[string]any{
|
|
||||||
"version": version,
|
|
||||||
"git": git,
|
|
||||||
"built_at": builtAt,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// auth
|
|
||||||
r.Post("/api/login", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var f struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
|
||||||
http.Error(w, "bad json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res, err := jf.AuthenticateByName(f.Username, f.Password)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// ✅ ensure this username is mapped before creating a session
|
|
||||||
if _, err := um.Resolve(f.Username); err != nil {
|
|
||||||
internal.Logf("login ok but map missing for %q (JF name=%q)", f.Username, res.User.Name)
|
|
||||||
http.Error(w, "no mapping", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// ✅ store the typed login name in the session
|
|
||||||
if err := internal.SetSession(w, f.Username, res.AccessToken); err != nil {
|
|
||||||
http.Error(w, "session error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]any{"ok": true})
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
internal.ClearSession(w)
|
|
||||||
writeJSON(w, map[string]any{"ok": true})
|
|
||||||
})
|
|
||||||
|
|
||||||
// whoami
|
|
||||||
r.Get("/api/whoami", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cl, err := internal.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dr, err := um.Resolve(cl.Username)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "no mapping", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
all, _ := um.ResolveAll(cl.Username) // ignore error here since Resolve succeeded
|
|
||||||
writeJSON(w, map[string]any{
|
|
||||||
"username": cl.Username,
|
|
||||||
"root": dr, // first root (back-compat)
|
|
||||||
"roots": all, // all roots (for future UI)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// list entries
|
|
||||||
r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cl, err := internal.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// choose library: requested (if allowed) or default
|
|
||||||
allRoots, _ := um.ResolveAll(cl.Username)
|
|
||||||
reqLib := sanitizeSegment(r.URL.Query().Get("lib"))
|
|
||||||
var rootRel string
|
|
||||||
if reqLib != "" && contains(allRoots, reqLib) {
|
|
||||||
rootRel = reqLib
|
|
||||||
} else {
|
|
||||||
rootRel, err = um.Resolve(cl.Username)
|
|
||||||
if err != nil {
|
|
||||||
internal.Logf("list: map missing for %q", cl.Username)
|
|
||||||
http.Error(w, "no mapping", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// one-level subdir (optional)
|
|
||||||
q := sanitizeSegment(r.URL.Query().Get("path")) // clamp to single segment
|
|
||||||
|
|
||||||
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
||||||
dirAbs := rootAbs
|
|
||||||
if q != "" {
|
|
||||||
if dirAbs, err = internal.SafeJoin(rootAbs, q); err != nil {
|
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ents, err := os.ReadDir(dirAbs)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]listEntry, 0, len(ents))
|
|
||||||
for _, d := range ents {
|
|
||||||
info, _ := d.Info()
|
|
||||||
var size int64
|
|
||||||
if info != nil && !d.IsDir() {
|
|
||||||
size = info.Size()
|
|
||||||
}
|
|
||||||
var mtime int64
|
|
||||||
if info != nil {
|
|
||||||
mtime = info.ModTime().Unix()
|
|
||||||
}
|
|
||||||
out = append(out, listEntry{
|
|
||||||
Name: d.Name(),
|
|
||||||
Path: filepath.Join(q, d.Name()),
|
|
||||||
IsDir: d.IsDir(),
|
|
||||||
Size: size,
|
|
||||||
Mtime: mtime,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, out)
|
|
||||||
})
|
|
||||||
|
|
||||||
// rename
|
|
||||||
var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}(?:\.[A-Za-z0-9_-]{1,64})?\.[A-Za-z0-9]{1,8}$`)
|
|
||||||
r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cl, err := internal.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var p struct {
|
|
||||||
Lib string `json:"lib"`
|
|
||||||
From string `json:"from"`
|
|
||||||
To string `json:"to"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
||||||
http.Error(w, "bad json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// enforce final name format on files (directories may be free-form one level)
|
|
||||||
if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) {
|
|
||||||
http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// choose library: requested (if allowed) or default
|
|
||||||
allRoots, _ := um.ResolveAll(cl.Username)
|
|
||||||
lib := sanitizeSegment(p.Lib)
|
|
||||||
var rootRel string
|
|
||||||
if lib != "" && contains(allRoots, lib) {
|
|
||||||
rootRel = lib
|
|
||||||
} else {
|
|
||||||
rootRel, _ = um.Resolve(cl.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
||||||
fromAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.From, "/"))
|
|
||||||
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
|
||||||
toAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.To, "/"))
|
|
||||||
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
|
||||||
|
|
||||||
_ = os.MkdirAll(filepath.Dir(toAbs), 0o2775)
|
|
||||||
if internal.DryRun {
|
|
||||||
internal.Logf("[DRY] mv %s -> %s", fromAbs, toAbs)
|
|
||||||
} else if err := os.Rename(fromAbs, toAbs); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError); return
|
|
||||||
}
|
|
||||||
jf.RefreshLibrary(cl.JFToken)
|
|
||||||
writeJSON(w, map[string]any{"ok": true})
|
|
||||||
})
|
|
||||||
|
|
||||||
// delete
|
|
||||||
r.Delete("/api/file", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cl, err := internal.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// choose library: requested (if allowed) or default
|
|
||||||
allRoots, _ := um.ResolveAll(cl.Username)
|
|
||||||
lib := sanitizeSegment(r.URL.Query().Get("lib"))
|
|
||||||
var rootRel string
|
|
||||||
if lib != "" && contains(allRoots, lib) {
|
|
||||||
rootRel = lib
|
|
||||||
} else {
|
|
||||||
rootRel, _ = um.Resolve(cl.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := strings.TrimPrefix(r.URL.Query().Get("path"), "/")
|
|
||||||
rec := r.URL.Query().Get("recursive") == "true"
|
|
||||||
|
|
||||||
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
||||||
abs, err := internal.SafeJoin(rootAbs, path)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if internal.DryRun {
|
|
||||||
internal.Logf("[DRY] rm%s %s", map[bool]string{true: " -r", false: ""}[rec], abs)
|
|
||||||
} else if rec {
|
|
||||||
err = os.RemoveAll(abs)
|
|
||||||
} else {
|
|
||||||
err = os.Remove(abs)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jf.RefreshLibrary(cl.JFToken)
|
|
||||||
writeJSON(w, map[string]any{"ok": true})
|
|
||||||
})
|
|
||||||
|
|
||||||
// mkdir (create subdirectory under the user's mapped root)
|
|
||||||
r.Post("/api/mkdir", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cl, err := internal.CurrentUser(r)
|
|
||||||
if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return }
|
|
||||||
|
|
||||||
var p struct {
|
|
||||||
Lib string `json:"lib"`
|
|
||||||
Path string `json:"path"` // single-level folder name
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
||||||
http.Error(w, "bad json", http.StatusBadRequest); return
|
|
||||||
}
|
|
||||||
|
|
||||||
// choose library: requested (if allowed) or default
|
|
||||||
allRoots, _ := um.ResolveAll(cl.Username)
|
|
||||||
lib := sanitizeSegment(p.Lib)
|
|
||||||
var rootRel string
|
|
||||||
if lib != "" && contains(allRoots, lib) {
|
|
||||||
rootRel = lib
|
|
||||||
} else {
|
|
||||||
rootRel, err = um.Resolve(cl.Username)
|
|
||||||
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
|
||||||
}
|
|
||||||
|
|
||||||
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
||||||
if fi, err := os.Stat(rootAbs); err != nil || !fi.IsDir() {
|
|
||||||
http.Error(w, "library not found", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
seg := sanitizeSegment(p.Path) // single-level + charset + spaces→_
|
|
||||||
if seg == "" {
|
|
||||||
http.Error(w, "folder name is empty", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
abs, err := internal.SafeJoin(rootAbs, seg)
|
|
||||||
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
|
||||||
|
|
||||||
if internal.DryRun {
|
|
||||||
internal.Logf("[DRY] mkdir -p %s", abs)
|
|
||||||
} else if err := os.MkdirAll(abs, 0o2775); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]any{"ok": true})
|
|
||||||
})
|
|
||||||
|
|
||||||
// mount tus (behind auth)
|
|
||||||
r.Route("/tus", func(rt chi.Router) {
|
|
||||||
rt.Use(sessionRequired)
|
|
||||||
// Create upload: POST /tus/
|
|
||||||
rt.Post("/", tusHandler.PostFile)
|
|
||||||
// Upload resource endpoints: /tus/{id}
|
|
||||||
rt.Head("/{id}", tusHandler.HeadFile)
|
|
||||||
rt.Patch("/{id}", tusHandler.PatchFile)
|
|
||||||
rt.Delete("/{id}", tusHandler.DelFile)
|
|
||||||
rt.Get("/{id}", tusHandler.GetFile) // optional
|
|
||||||
})
|
|
||||||
// metrics
|
|
||||||
r.Handle("/metrics", promhttp.Handler())
|
|
||||||
|
|
||||||
// static app (serve embedded web/dist)
|
|
||||||
appFS, _ := fs.Sub(webFS, "web/dist")
|
|
||||||
static := http.FileServer(http.FS(appFS))
|
|
||||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
static.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
// catch-all for SPA routes, GET only
|
|
||||||
r.Method("GET", "/*", http.StripPrefix("/", static))
|
|
||||||
|
|
||||||
// debug endpoints (registered before server starts)
|
|
||||||
r.Get("/debug/env", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !internal.Debug {
|
|
||||||
http.Error(w, "disabled", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]any{
|
|
||||||
"mediaRoot": mediaRoot,
|
|
||||||
"tusDir": tusDir,
|
|
||||||
"userMapFile": userMapFile,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
r.Get("/debug/write-test", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !internal.Debug {
|
|
||||||
http.Error(w, "disabled", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cl, err := internal.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rootRel, err := um.Resolve(cl.Username)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
|
||||||
test := filepath.Join(rootAbs, fmt.Sprintf("TEST.%d.txt", time.Now().Unix()))
|
|
||||||
if internal.DryRun {
|
|
||||||
internal.Logf("[DRY] write %s", test)
|
|
||||||
} else {
|
|
||||||
_ = os.WriteFile(test, []byte("ok\n"), 0o644)
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]string{"wrote": test})
|
|
||||||
})
|
|
||||||
|
|
||||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, "no route for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- wrap router with verbose request logging in debug ----
|
|
||||||
root := http.Handler(r)
|
|
||||||
if internal.Debug {
|
|
||||||
root = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
||||||
id := time.Now().UnixNano()
|
|
||||||
internal.Logf(">> %d %s %s %v", id, req.Method, req.URL.Path, internal.RedactHeaders(req.Header))
|
|
||||||
rw := &loggingRW{ResponseWriter: w, status: 200}
|
|
||||||
start := time.Now()
|
|
||||||
r.ServeHTTP(rw, req)
|
|
||||||
internal.Logf("<< %d %s %s %d %s", id, req.Method, req.URL.Path, rw.status, time.Since(start))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := env("PEGASUS_BIND", ":8080")
|
|
||||||
log.Printf("Pegasus listening on %s", addr)
|
|
||||||
srv := &http.Server{Addr: addr, Handler: root, ReadTimeout: 0, WriteTimeout: 0}
|
|
||||||
log.Fatal(srv.ListenAndServe())
|
|
||||||
}
|
|
||||||
|
|
||||||
// === helpers & middleware ===
|
|
||||||
func sessionRequired(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if _, err := internal.CurrentUser(r); err != nil {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func corsForTus(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// CORS for tus (preflight must succeed; cookies allowed)
|
|
||||||
if origin := r.Header.Get("Origin"); origin != "" {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin) // cannot be * when credentials=true
|
|
||||||
w.Header().Set("Vary", "Origin")
|
|
||||||
}
|
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,HEAD,PATCH,DELETE,OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers",
|
|
||||||
"Content-Type, Tus-Resumable, Upload-Length, Upload-Defer-Length, Upload-Metadata, Upload-Offset, Upload-Concat, Upload-Checksum, X-Requested-With")
|
|
||||||
w.Header().Set("Access-Control-Expose-Headers",
|
|
||||||
"Location, Upload-Offset, Upload-Length, Tus-Resumable, Upload-Checksum")
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func claimsFromHook(ev tusd.HookEvent) (internal.Claims, error) {
|
|
||||||
// Parse our session cookie from incoming request headers captured by tusd
|
|
||||||
req := ev.HTTPRequest
|
|
||||||
if req.Header == nil {
|
|
||||||
return internal.Claims{}, http.ErrNoCookie
|
|
||||||
}
|
|
||||||
// Re-create a dummy http.Request to reuse cookie parsing & jwt verification
|
|
||||||
r := http.Request{Header: http.Header(req.Header)}
|
|
||||||
return internal.CurrentUser(&r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func env(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def }
|
|
||||||
func must(err error, msg string) { if err != nil { log.Fatalf("%s: %v", msg, err) } }
|
|
||||||
|
|
||||||
func sanitizeSegment(s string) string {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
s = strings.ReplaceAll(s, " ", "_")
|
|
||||||
s = strings.Trim(s, "/")
|
|
||||||
if i := strings.IndexByte(s, '/'); i >= 0 { s = s[:i] }
|
|
||||||
// allow only [A-Za-z0-9_.-]
|
|
||||||
out := make([]rune, 0, len(s))
|
|
||||||
for _, r := range s {
|
|
||||||
switch {
|
|
||||||
case r >= 'a' && r <= 'z',
|
|
||||||
r >= 'A' && r <= 'Z',
|
|
||||||
r >= '0' && r <= '9',
|
|
||||||
r == '_', r == '-', r == '.':
|
|
||||||
out = append(out, r)
|
|
||||||
default:
|
|
||||||
out = append(out, '_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) > 64 { out = out[:64] }
|
|
||||||
return string(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(ss []string, v string) bool {
|
|
||||||
for _, s := range ss {
|
|
||||||
if s == v { return true }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanitizeDescriptor keeps [A-Za-z0-9_-], converts whitespace to underscores,
|
|
||||||
// collapses runs, and limits to 64 chars. Used in final filenames.
|
|
||||||
func sanitizeDescriptor(s string) string {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
s = strings.ReplaceAll(s, " ", "_")
|
|
||||||
if s == "" { return "upload" }
|
|
||||||
var b []rune
|
|
||||||
lastUnderscore := false
|
|
||||||
for _, r := range s {
|
|
||||||
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-'
|
|
||||||
if !ok { r = '_' }
|
|
||||||
if r == '_' {
|
|
||||||
if lastUnderscore { continue }
|
|
||||||
lastUnderscore = true
|
|
||||||
} else {
|
|
||||||
lastUnderscore = false
|
|
||||||
}
|
|
||||||
b = append(b, r)
|
|
||||||
if len(b) >= 64 { break }
|
|
||||||
}
|
|
||||||
if len(b) == 0 { return "upload" }
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stemOf strips the final extension and cleans the base.
|
|
||||||
func stemOf(orig string) string {
|
|
||||||
base := strings.TrimSuffix(orig, filepath.Ext(orig))
|
|
||||||
s := sanitizeDescriptor(strings.ReplaceAll(base, ".", "_"))
|
|
||||||
if s == "" { s = "file" }
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// composeFinalName mirrors the UI: YYYY.MM.DD.Description.ext
|
|
||||||
// date is expected as YYYY-MM-DD; falls back to today if missing/bad.
|
|
||||||
func composeFinalName(date, desc, orig string) string {
|
|
||||||
var y, m, d string
|
|
||||||
if len(date) >= 10 && date[4] == '-' && date[7] == '-' {
|
|
||||||
y, m, d = date[:4], date[5:7], date[8:10]
|
|
||||||
} else {
|
|
||||||
now := time.Now()
|
|
||||||
y = fmt.Sprintf("%04d", now.Year())
|
|
||||||
m = fmt.Sprintf("%02d", int(now.Month()))
|
|
||||||
d = fmt.Sprintf("%02d", now.Day())
|
|
||||||
}
|
|
||||||
clean := sanitizeDescriptor(desc)
|
|
||||||
if clean == "" { clean = "upload" }
|
|
||||||
stem := stemOf(orig)
|
|
||||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
|
|
||||||
if ext == "" { ext = "bin" }
|
|
||||||
return fmt.Sprintf("%s.%s.%s.%s.%s.%s", y, m, d, clean, stem, ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// moveFromTus relocates the finished upload file from the TUS scratch directory to dst.
|
|
||||||
// Works with tusd's filestore by probing common data file names.
|
|
||||||
func moveFromTus(ev tusd.HookEvent, dst string) error {
|
|
||||||
// Likely data file names/locations used by tusd filestore across versions
|
|
||||||
candidates := []string{
|
|
||||||
filepath.Join(tusDir, ev.Upload.ID),
|
|
||||||
filepath.Join(tusDir, ev.Upload.ID+".bin"),
|
|
||||||
filepath.Join(tusDir, "data", ev.Upload.ID),
|
|
||||||
filepath.Join(tusDir, "data", ev.Upload.ID+".bin"),
|
|
||||||
filepath.Join(tusDir, "uploads", ev.Upload.ID),
|
|
||||||
filepath.Join(tusDir, "uploads", ev.Upload.ID+".bin"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var src string
|
|
||||||
for _, p := range candidates {
|
|
||||||
if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() {
|
|
||||||
src = p
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if src == "" {
|
|
||||||
// last resort: scan the directory for a file starting with the id
|
|
||||||
entries, _ := os.ReadDir(tusDir)
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.IsDir() && strings.HasPrefix(e.Name(), ev.Upload.ID) {
|
|
||||||
src = filepath.Join(tusDir, e.Name())
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if src == "" {
|
|
||||||
return fmt.Errorf("tus data file not found for id %s", ev.Upload.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o2775); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if internal.DryRun {
|
|
||||||
internal.Logf("[DRY] mv %s -> %s", src, dst)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try fast rename; if it crosses devices, fall back to copy+remove.
|
|
||||||
if err := os.Rename(src, dst); err != nil {
|
|
||||||
in, err2 := os.Open(src)
|
|
||||||
if err2 != nil { return err2 }
|
|
||||||
defer in.Close()
|
|
||||||
out, err3 := os.Create(dst)
|
|
||||||
if err3 != nil { return err3 }
|
|
||||||
if _, err := io.Copy(out, in); err != nil {
|
|
||||||
_ = out.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = out.Close()
|
|
||||||
_ = os.Remove(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove sidecars if present
|
|
||||||
_ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".info"))
|
|
||||||
_ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".json"))
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -49,6 +50,9 @@ func TestSanitizeDescriptorAndStemOf(t *testing.T) {
|
|||||||
if got := sanitizeDescriptor(""); got != "upload" {
|
if got := sanitizeDescriptor(""); got != "upload" {
|
||||||
t.Fatalf("expected upload fallback, got %q", got)
|
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" {
|
if got := stemOf("My.Video.File.mp4"); got != "My_Video_File" {
|
||||||
t.Fatalf("unexpected stem %q", got)
|
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) {
|
func TestWriteJSON(t *testing.T) {
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
writeJSON(rr, map[string]any{"ok": true})
|
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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import {
|
import uploaderUtils from './uploader-utils'
|
||||||
|
|
||||||
|
const {
|
||||||
clampOneLevel,
|
clampOneLevel,
|
||||||
composeName,
|
composeName,
|
||||||
extOf,
|
extOf,
|
||||||
|
createNoResumeFingerprint,
|
||||||
|
createNoResumeStorage,
|
||||||
|
fmt,
|
||||||
isDetailedError,
|
isDetailedError,
|
||||||
isImageFile,
|
isImageFile,
|
||||||
isLikelyMobileUA,
|
isLikelyMobileUA,
|
||||||
@ -12,7 +17,7 @@ import {
|
|||||||
sanitizeDesc,
|
sanitizeDesc,
|
||||||
sanitizeFolderName,
|
sanitizeFolderName,
|
||||||
stemOf,
|
stemOf,
|
||||||
} from './Uploader'
|
} = uploaderUtils
|
||||||
|
|
||||||
describe('Uploader helpers', () => {
|
describe('Uploader helpers', () => {
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>
|
let logSpy: ReturnType<typeof vi.spyOn>
|
||||||
@ -74,4 +79,50 @@ describe('Uploader helpers', () => {
|
|||||||
|
|
||||||
expect(typeof isLikelyMobileUA()).toBe('boolean')
|
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
|
export { default } from './UploaderView'
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|||||||
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> {
|
export async function api<T=any>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const r = await fetch(path, { credentials:'include', ...init });
|
const r = await fetch(path, { credentials:'include', ...init });
|
||||||
if (!r.ok) throw new Error(await r.text());
|
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 }
|
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 = {
|
export type WhoAmI = {
|
||||||
username: string;
|
username: string;
|
||||||
root?: 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
|
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:
|
def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str, str]) -> float:
|
||||||
text = _read_http(f"{pushgateway_url.rstrip('/')}/metrics")
|
text = _read_http(f"{pushgateway_url.rstrip('/')}/metrics")
|
||||||
if not text:
|
if not text:
|
||||||
@ -151,6 +160,7 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
backend_rc_file = Path(os.getenv("BACKEND_TEST_RC_FILE", "build/backend-test.rc"))
|
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"))
|
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)
|
b = _load_junit(backend_junit)
|
||||||
f = _load_junit(frontend_junit)
|
f = _load_junit(frontend_junit)
|
||||||
@ -168,9 +178,24 @@ def main() -> int:
|
|||||||
|
|
||||||
backend_rc = _read_test_exit_code(backend_rc_file)
|
backend_rc = _read_test_exit_code(backend_rc_file)
|
||||||
frontend_rc = _read_test_exit_code(frontend_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 = (
|
outcome = (
|
||||||
"ok"
|
"ok"
|
||||||
if backend_rc == 0
|
if gate_ok
|
||||||
|
and backend_rc == 0
|
||||||
and frontend_rc == 0
|
and frontend_rc == 0
|
||||||
and totals["tests"] > 0
|
and totals["tests"] > 0
|
||||||
and totals["failures"] == 0
|
and totals["failures"] == 0
|
||||||
@ -195,21 +220,13 @@ def main() -> int:
|
|||||||
else:
|
else:
|
||||||
failed_count += 1
|
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 = [
|
payload_lines = [
|
||||||
"# TYPE platform_quality_gate_runs_total counter",
|
"# 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="ok"}} {ok_count:.0f}',
|
||||||
f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_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",
|
"# 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="passed"}} {passed}',
|
||||||
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failures"]}',
|
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"]}',
|
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}',
|
||||||
"# TYPE pegasus_quality_gate_coverage_percent gauge",
|
"# TYPE pegasus_quality_gate_coverage_percent gauge",
|
||||||
f'pegasus_quality_gate_coverage_percent{{suite="{suite}"}} {coverage_pct:.3f}',
|
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",
|
"# TYPE pegasus_quality_gate_build_info gauge",
|
||||||
f"pegasus_quality_gate_build_info{_label_str(labels)} 1",
|
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