Compare commits

...

2 Commits

Author SHA1 Message Date
04d1e28f1f many fixes, hopefully uploading 2025-09-16 00:05:16 -05:00
4456e07fe2 pegasus fix 2025-09-15 12:09:02 -05:00
24 changed files with 4498 additions and 168 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
.gitignore
**/node_modules
frontend/dist
frontend/build
**/.DS_Store

View File

@ -1,25 +1,54 @@
# ---------- Frontend ---------- # syntax=docker/dockerfile:1.7
############################
# Frontend build (Vite/React)
############################
FROM node:20-alpine AS fe FROM node:20-alpine AS fe
WORKDIR /src/frontend WORKDIR /src/frontend
COPY frontend/ ./ COPY frontend/package*.json ./
RUN npm ci && npm run build RUN npm ci
COPY frontend/ .
# default Vite outDir is "dist" under CWD
RUN npm run build
# ---------- Backend ---------- # expose artifacts in a neutral location
RUN mkdir -p /out && cp -r dist/* /out/
############################
# Backend build (Go)
############################
FROM golang:1.22-alpine AS be FROM golang:1.22-alpine AS be
RUN apk add --no-cache ca-certificates upx RUN apk add --no-cache ca-certificates upx git
WORKDIR /src ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
COPY backend/ ./backend/ GOPROXY=https://proxy.golang.org,direct \
# copy built frontend assets into backend/web/dist for embedding GOPRIVATE=scm.bstein.dev
COPY --from=fe /src/frontend/dist ./backend/web/dist
WORKDIR /src/backend
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/pegasus .
RUN upx -q /out/pegasus || true
# ---------- Runtime ---------- WORKDIR /src/backend
FROM gcr.io/distroless/static:nonroot
WORKDIR / # 1) Copy mod files first for better caching, include both go.mod and go.sum
COPY --from=be /out/pegasus /pegasus COPY backend/go.mod backend/go.sum ./
# 2) Warm module cache
RUN --mount=type=cache,target=/go/pkg/mod go mod download
# 3) Copy the rest of the backend sources
COPY backend/ .
# 4) Bring in the FE assets where the embed expects them
# (your code likely has: //go:embed web/dist/**)
COPY --from=fe /out ./web/dist
# 5) In case code imports added deps not present during step (1), tidy now
RUN --mount=type=cache,target=/go/pkg/mod go mod tidy
# 6) Build the binary; fail if go build fails. Allow UPX to fail harmlessly.
RUN --mount=type=cache,target=/go/pkg/mod \
go build -trimpath -ldflags="-s -w" -o /pegasus ./main.go && \
upx -q --lzma /pegasus || true
############################
# Final, minimal image
############################
FROM gcr.io/distroless/static:nonroot AS final
COPY --from=be /pegasus /pegasus
USER nonroot:nonroot USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/pegasus"] ENTRYPOINT ["/pegasus"]

BIN
backend/backend Executable file

Binary file not shown.

View File

@ -1,4 +1,4 @@
module github.com/your-org/pegasus module scm.bstein.dev/bstein/Pegasus/backend
go 1.22 go 1.22
@ -6,8 +6,18 @@ require (
github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/chi/v5 v5.0.12
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/prometheus/client_golang v1.18.0 github.com/prometheus/client_golang v1.18.0
github.com/tus/tusd/pkg/filestore v1.13.0 github.com/tus/tusd v1.13.0
github.com/tus/tusd/pkg/handler v1.13.0
github.com/tus/tusd/pkg/locker/memorylocker v1.13.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
golang.org/x/sys v0.15.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

1952
backend/go.sum Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
// backend/internal/auth.go
package internal package internal
import ( import (

View File

@ -1,3 +1,4 @@
// backend/internal/debuglog.go
package internal package internal
import ( import (

View File

@ -1,3 +1,4 @@
// backend/internal/fs.go
package internal package internal
import ( import (

View File

@ -1,3 +1,4 @@
// backend/internal/jellyfin.go
package internal package internal
import ( import (

View File

@ -1,3 +1,4 @@
// backend/internal/naming.go
package internal package internal
import ( import (

View File

@ -1,3 +1,4 @@
// backend/internal/session.go
package internal package internal
import ( import (
@ -15,6 +16,7 @@ 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"
const CookieName = "pegasus_session" const CookieName = "pegasus_session"
@ -30,12 +32,26 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
}) })
signed, err := tok.SignedString(sessionKey) signed, err := tok.SignedString(sessionKey)
if err != nil { return err } if err != nil { return err }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: CookieName, Value: signed, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, Name: CookieName,
}) Value: signed,
Path: "/",
HttpOnly: true,
Secure: cookieSecure,
SameSite: http.SameSiteLaxMode,
})
return nil return nil
} }
func ClearSession(w http.ResponseWriter) { func ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Expires: time.Unix(0,0), Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Expires: time.Unix(0,0),
MaxAge: -1,
Path: "/",
HttpOnly: true,
Secure: cookieSecure,
SameSite: http.SameSiteLaxMode,
})
} }

View File

@ -1,3 +1,4 @@
// backend/internal/usermap.go
package internal package internal
import ( import (

View File

@ -1,103 +1,140 @@
// backend/main.go
package main package main
import ( import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt"
"io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
"regexp"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tus/tusd/pkg/filestore" "github.com/tus/tusd/pkg/filestore"
tusd "github.com/tus/tusd/pkg/handler" tusd "github.com/tus/tusd/pkg/handler"
"github.com/tus/tusd/pkg/locker/memorylocker" "github.com/tus/tusd/pkg/memorylocker"
"scm.bstein.dev/pegasus/backend/internal" "scm.bstein.dev/bstein/Pegasus/backend/internal"
) )
//go:embed web/dist/* //go:embed web/dist
var webFS embed.FS var webFS embed.FS
var ( var (
mediaRoot = env("PEGASUS_MEDIA_ROOT", "/media") mediaRoot = env("PEGASUS_MEDIA_ROOT", "/media")
userMapFile = env("PEGASUS_USER_MAP_FILE", "/config/user-map.yaml") userMapFile = env("PEGASUS_USER_MAP_FILE", "/config/user-map.yaml")
tusDir = env("PEGASUS_TUS_DIR", filepath.Join(mediaRoot, ".pegasus-tus")) tusDir = env("PEGASUS_TUS_DIR", filepath.Join(mediaRoot, ".pegasus-tus"))
jf = internal.NewJellyfin() 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 { type loggingRW struct {
http.ResponseWriter http.ResponseWriter
status int status int
} }
func (l *loggingRW) WriteHeader(code int){ l.status = code; l.ResponseWriter.WriteHeader(code) }
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) internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot)
if os.Getenv("PEGASUS_SESSION_KEY") == "" {
log.Fatal("PEGASUS_SESSION_KEY is not set")
}
um, err := internal.LoadUserMap(userMapFile) um, err := internal.LoadUserMap(userMapFile)
must(err, "load user map") must(err, "load user map")
// === tusd setup (resumable uploads) === // === tusd setup (resumable uploads) ===
store := filestore.FileStore{Path: tusDir} store := filestore.FileStore{Path: tusDir}
// ensure upload scratch dir exists
if err := os.MkdirAll(tusDir, 0o755); err != nil {
log.Fatalf("mkdir %s: %v", tusDir, err)
}
locker := memorylocker.New() locker := memorylocker.New()
composer := tusd.NewStoreComposer() composer := tusd.NewStoreComposer()
store.UseIn(composer) store.UseIn(composer)
locker.UseIn(composer) locker.UseIn(composer)
completeC := make(chan tusd.HookEvent) // completeC := make(chan tusd.HookEvent)
config := tusd.Config{ config := tusd.Config{
BasePath: "/tus/", BasePath: "/tus/",
StoreComposer: composer, StoreComposer: composer,
NotifyCompleteUploads: true, NotifyCompleteUploads: true,
CompleteUploads: completeC, // CompleteUploads: completeC,
MaxSize: 0, // unlimited MaxSize: 4 * 1024 * 1024 * 1024, // 4GB per file
} }
tusHandler, err := tusd.NewUnroutedHandler(config) tusHandler, err := tusd.NewUnroutedHandler(config)
must(err, "init tus handler") must(err, "init tus handler")
completeC := tusHandler.CompleteUploads
// ---- post-finish hook: enforce naming & mapping ---- // ---- post-finish hook: enforce naming & mapping ----
go func() { go func() {
for ev := range completeC { for ev := range completeC {
claims, err := claimsFromHook(ev) claims, err := claimsFromHook(ev)
if err != nil { internal.Logf("tus: no session: %v", err); continue } if err != nil {
internal.Logf("tus: no session: %v", err)
continue
}
// read metadata set by the UI // read metadata set by the UI
meta := ev.Upload.MetaData meta := ev.Upload.MetaData
desc := strings.TrimSpace(meta["desc"]) desc := strings.TrimSpace(meta["desc"])
if desc == "" { internal.Logf("tus: missing desc; rejecting"); continue } if desc == "" {
date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty internal.Logf("tus: missing desc; rejecting")
continue
}
date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty
subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/") subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
orig := meta["filename"] orig := meta["filename"]
if orig == "" { orig = "upload.bin" } if orig == "" {
orig = "upload.bin"
}
// resolve per-user root // resolve per-user root
userRootRel, err := um.Resolve(claims.Username) userRootRel, err := um.Resolve(claims.Username)
if err != nil { internal.Logf("tus: user map missing: %v", err); continue } if err != nil {
internal.Logf("tus: user map missing: %v", err)
continue
}
// compose final name & target // compose final name & target
finalName, err := internal.ComposeFinalName(date, desc, orig) finalName, err := internal.ComposeFinalName(date, desc, orig)
if err != nil { internal.Logf("tus: bad target name: %v", err); continue } if err != nil {
internal.Logf("tus: bad target name: %v", err)
continue
}
rootAbs, _ := internal.SafeJoin(mediaRoot, userRootRel) rootAbs, _ := internal.SafeJoin(mediaRoot, userRootRel)
var targetAbs string var targetAbs string
if subdir == "" { if subdir == "" {
targetAbs, err = internal.SafeJoin(rootAbs, finalName) targetAbs, err = internal.SafeJoin(rootAbs, finalName)
} else { } else {
targetAbs, err = internal.SafeJoin(rootAbs, filepath.Join(subdir, finalName)) targetAbs, err = internal.SafeJoin(rootAbs, filepath.Join(subdir, finalName))
}
if err != nil {
internal.Logf("tus: path escape prevented: %v", err)
continue
} }
if err != nil { internal.Logf("tus: path escape prevented: %v", err); continue }
srcPath := ev.Upload.Storage["Path"] srcPath := ev.Upload.Storage["Path"]
_ = os.MkdirAll(filepath.Dir(targetAbs), 0o755) _ = os.MkdirAll(filepath.Dir(targetAbs), 0o755)
if internal.DryRun { if internal.DryRun {
internal.Logf("[DRY] move %s -> %s", srcPath, targetAbs) internal.Logf("[DRY] move %s -> %s", srcPath, targetAbs)
} else if err := os.Rename(srcPath, targetAbs); err != nil { } else if err := os.Rename(srcPath, targetAbs); err != nil {
internal.Logf("move failed: %v", err); continue internal.Logf("move failed: %v", err)
continue
} }
internal.Logf("uploaded: %s", targetAbs) internal.Logf("uploaded: %s", targetAbs)
@ -110,45 +147,115 @@ func main() {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(corsForTus) 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) {
_ = json.NewEncoder(w).Encode(map[string]any{
"version": version,
"git": git,
"built_at": builtAt,
})
})
// auth // auth
r.Post("/api/login", func(w http.ResponseWriter, r *http.Request) { r.Post("/api/login", func(w http.ResponseWriter, r *http.Request) {
var f struct{ Username, Password string } var f struct {
if err := json.NewDecoder(r.Body).Decode(&f); err != nil { http.Error(w, "bad json", 400); return } Username string `json:"username"`
res, err := jf.AuthenticateByName(f.Username, f.Password) // password login Password string `json:"password"`
if err != nil { http.Error(w, "invalid credentials", 401); return } }
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 := internal.SetSession(w, res.User.Username, res.AccessToken); err != nil { if err := internal.SetSession(w, res.User.Username, res.AccessToken); err != nil {
http.Error(w, "session error", 500); return http.Error(w, "session error", http.StatusInternalServerError)
return
} }
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}) })
r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) { internal.ClearSession(w); _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) }) r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) {
internal.ClearSession(w)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
})
// whoami // whoami
r.Get("/api/whoami", func(w http.ResponseWriter, r *http.Request) { r.Get("/api/whoami", func(w http.ResponseWriter, r *http.Request) {
cl, err := internal.CurrentUser(r); if err != nil { http.Error(w, "unauthorized", 401); return } cl, err := internal.CurrentUser(r)
dr, err := um.Resolve(cl.Username); if err != nil { http.Error(w, "no mapping", 403); return } 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
}
_ = json.NewEncoder(w).Encode(map[string]any{"username": cl.Username, "root": dr}) _ = json.NewEncoder(w).Encode(map[string]any{"username": cl.Username, "root": dr})
}) })
// list entries // list entries
r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) { r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) {
cl, err := internal.CurrentUser(r); if err != nil { http.Error(w, "unauthorized", 401); return } cl, err := internal.CurrentUser(r)
rootRel, err := um.Resolve(cl.Username); if err != nil { http.Error(w, "forbidden", 403); return } 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
}
q := strings.TrimPrefix(r.URL.Query().Get("path"), "/") q := strings.TrimPrefix(r.URL.Query().Get("path"), "/")
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
var dirAbs string var dirAbs string
if q == "" { dirAbs = rootAbs } else { if q == "" {
dirAbs, err = internal.SafeJoin(rootAbs, q); if err != nil { http.Error(w, "forbidden", 403); return } dirAbs = rootAbs
} else {
dirAbs, err = internal.SafeJoin(rootAbs, q)
if 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
}
type entry struct {
Name string
Path string
IsDir bool
Size int64
Mtime int64
} }
ents, err := os.ReadDir(dirAbs); if err != nil { http.Error(w, err.Error(), 500); return }
type entry struct{ Name, Path string; IsDir bool; Size int64; Mtime int64 }
var out []entry var out []entry
for _, d := range ents { for _, d := range ents {
info, _ := d.Info() info, _ := d.Info()
out = append(out, entry{ out = append(out, entry{
Name: d.Name(), Path: filepath.Join(q, d.Name()), IsDir: d.IsDir(), Name: d.Name(),
Size: func() int64 { if info != nil && !d.IsDir() { return info.Size() }; return 0 }(), Path: filepath.Join(q, d.Name()),
Mtime: func() int64 { if info != nil { return info.ModTime().Unix() }; return 0 }(), IsDir: d.IsDir(),
Size: func() int64 {
if info != nil && !d.IsDir() {
return info.Size()
}
return 0
}(),
Mtime: func() int64 {
if info != nil {
return info.ModTime().Unix()
}
return 0
}(),
}) })
} }
_ = json.NewEncoder(w).Encode(out) _ = json.NewEncoder(w).Encode(out)
@ -157,97 +264,188 @@ func main() {
// rename // rename
var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9]{1,8}$`) var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9]{1,8}$`)
r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) { r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) {
cl, err := internal.CurrentUser(r); if err != nil { http.Error(w,"unauthorized",401); return } cl, err := internal.CurrentUser(r)
var p struct{ From, To string } if err != nil {
if err := json.NewDecoder(r.Body).Decode(&p); err != nil { http.Error(w,"bad json",400); return } http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var p struct {
From string `json:"from"`
To string `json:"to"`
}
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// enforce final name on files (allow any name for directories) // enforce final name on files (allow any name for directories)
if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) { if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) {
http.Error(w, "new name must match YYYY.MM.DD.Description.ext", 400); return http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest)
return
} }
rootRel, _ := um.Resolve(cl.Username) rootRel, _ := um.Resolve(cl.Username)
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
fromAbs, err := internal.SafeJoin(rootAbs, p.From); if err != nil { http.Error(w,"forbidden",403); return } fromAbs, err := internal.SafeJoin(rootAbs, p.From)
toAbs, err := internal.SafeJoin(rootAbs, p.To); if err != nil { http.Error(w,"forbidden",403); return } if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
toAbs, err := internal.SafeJoin(rootAbs, p.To)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
_ = os.MkdirAll(filepath.Dir(toAbs), 0o755) _ = os.MkdirAll(filepath.Dir(toAbs), 0o755)
if internal.DryRun { internal.Logf("[DRY] mv %s -> %s", fromAbs, toAbs) } else if err := os.Rename(fromAbs, toAbs); err != nil { http.Error(w, err.Error(), 500); return } 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) jf.RefreshLibrary(cl.JFToken)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}) })
// delete // delete
r.Delete("/api/file", func(w http.ResponseWriter, r *http.Request) { r.Delete("/api/file", func(w http.ResponseWriter, r *http.Request) {
cl, err := internal.CurrentUser(r); if err != nil { http.Error(w, "unauthorized", 401); return } cl, err := internal.CurrentUser(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rootRel, _ := um.Resolve(cl.Username) rootRel, _ := um.Resolve(cl.Username)
path := r.URL.Query().Get("path"); rec := r.URL.Query().Get("recursive") == "true" path := r.URL.Query().Get("path")
rec := r.URL.Query().Get("recursive") == "true"
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
abs, err := internal.SafeJoin(rootAbs, path); if err != nil { http.Error(w, "forbidden", 403); return } abs, err := internal.SafeJoin(rootAbs, path)
if rec { err = os.RemoveAll(abs) } else { err = os.Remove(abs) } if err != nil {
if err != nil { http.Error(w, err.Error(), 500); return } http.Error(w, "forbidden", http.StatusForbidden)
return
}
if rec {
err = os.RemoveAll(abs)
} else {
err = os.Remove(abs)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
jf.RefreshLibrary(cl.JFToken) jf.RefreshLibrary(cl.JFToken)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) _ = json.NewEncoder(w).Encode(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{ Path string `json:"path"` } // e.g., "Trips/2025-09"
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "bad json", http.StatusBadRequest); return
}
rootRel, err := um.Resolve(cl.Username)
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
abs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.Path, "/"))
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
if internal.DryRun {
internal.Logf("[DRY] mkdir -p %s", abs)
} else if err := os.MkdirAll(abs, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError); return
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
})
// mount tus (behind auth) // mount tus (behind auth)
r.Route("/tus", func(rt chi.Router) { r.Route("/tus", func(rt chi.Router) {
rt.Use(sessionRequired) rt.Use(sessionRequired)
rt.Post("/*", tusHandler.PostFile) // Create upload: POST /tus/
rt.Head("/*", tusHandler.HeadFile) rt.Post("/", tusHandler.PostFile)
rt.Patch("/*", tusHandler.PatchFile) // Upload resource endpoints: /tus/{id}
rt.Del("/*", tusHandler.DelFile) rt.Head("/{id}", tusHandler.HeadFile)
rt.Get("/*", tusHandler.GetFile) // optional rt.Patch("/{id}", tusHandler.PatchFile)
rt.Delete("/{id}", tusHandler.DelFile)
rt.Get("/{id}", tusHandler.GetFile) // optional
}) })
// metrics
// static app
r.Handle("/metrics", promhttp.Handler()) 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) { r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.FileServer(http.FS(webFS)).ServeHTTP(w, r) static.ServeHTTP(w, r)
}) })
r.Handle("/static/*", http.StripPrefix("/", http.FileServer(http.FS(webFS)))) // catch-all for SPA routes, GET only
r.Method("GET", "/*", http.StripPrefix("/", static))
addr := env("PEGASUS_BIND", ":8080") // debug endpoints (registered before server starts)
log.Printf("Pegasus listening on %s", addr)
srv := &http.Server{Addr: addr, Handler: r, ReadTimeout: 0, WriteTimeout: 0}
log.Fatal(srv.ListenAndServe())
// debug endpoints
r.Get("/debug/env", func(w http.ResponseWriter, r *http.Request) { r.Get("/debug/env", func(w http.ResponseWriter, r *http.Request) {
if !internal.Debug { http.Error(w, "disabled", 403); return } if !internal.Debug {
http.Error(w, "disabled", http.StatusForbidden)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"mediaRoot": mediaRoot, "tusDir": tusDir, "userMapFile": userMapFile, "mediaRoot": mediaRoot,
"tusDir": tusDir,
"userMapFile": userMapFile,
}) })
}) })
r.Get("/debug/write-test", func(w http.ResponseWriter, r *http.Request) { r.Get("/debug/write-test", func(w http.ResponseWriter, r *http.Request) {
if !internal.Debug { http.Error(w, "disabled", 403); return } if !internal.Debug {
cl, err := internal.CurrentUser(r); if err != nil { http.Error(w,"unauthorized",401); return } http.Error(w, "disabled", http.StatusForbidden)
rootRel, err := um.Resolve(cl.Username); if err != nil { http.Error(w,"forbidden",403); return } 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) rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
test := filepath.Join(rootAbs, fmt.Sprintf("TEST.%d.txt", time.Now().Unix())) 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) } if internal.DryRun {
internal.Logf("[DRY] write %s", test)
} else {
_ = os.WriteFile(test, []byte("ok\n"), 0o644)
}
_ = json.NewEncoder(w).Encode(map[string]string{"wrote": test}) _ = json.NewEncoder(w).Encode(map[string]string{"wrote": test})
}) })
}
// ---- wrap router with verbose request logging in debug ---- r.NotFound(func(w http.ResponseWriter, r *http.Request) {
root := http.Handler(r) http.Error(w, "no route for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
if internal.Debug { })
root = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
id := time.Now().UnixNano() // ---- wrap router with verbose request logging in debug ----
internal.Logf(">> %d %s %s %v", id, req.Method, req.URL.Path, internal.RedactHeaders(req.Header)) root := http.Handler(r)
rw := &loggingRW{ResponseWriter:w, status:200} if internal.Debug {
start := time.Now() root = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
r.ServeHTTP(rw, req) id := time.Now().UnixNano()
internal.Logf("<< %d %s %s %d %s", id, req.Method, req.URL.Path, rw.status, time.Since(start)) 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())
} }
srv := &http.Server{Addr: addr, Handler: root, ReadTimeout:0, WriteTimeout:0}
// === helpers & middleware === // === helpers & middleware ===
func sessionRequired(next http.Handler) http.Handler { func sessionRequired(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := internal.CurrentUser(r); err != nil { if _, err := internal.CurrentUser(r); err != nil {
http.Error(w, "unauthorized", 401); return http.Error(w, "unauthorized", http.StatusUnauthorized)
return
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
@ -255,12 +453,22 @@ func sessionRequired(next http.Handler) http.Handler {
func corsForTus(next http.Handler) http.Handler { func corsForTus(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Allow tus headers for resumable uploads // CORS for tus (preflight must succeed; cookies allowed)
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) 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-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, X-Requested-With") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,HEAD,PATCH,DELETE,OPTIONS")
w.Header().Set("Access-Control-Expose-Headers", "Location, Upload-Offset, Upload-Length, Tus-Resumable") w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == "OPTIONS" { w.WriteHeader(204); return } 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) next.ServeHTTP(w, r)
}) })
} }
@ -268,11 +476,13 @@ func corsForTus(next http.Handler) http.Handler {
func claimsFromHook(ev tusd.HookEvent) (internal.Claims, error) { func claimsFromHook(ev tusd.HookEvent) (internal.Claims, error) {
// Parse our session cookie from incoming request headers captured by tusd // Parse our session cookie from incoming request headers captured by tusd
req := ev.HTTPRequest req := ev.HTTPRequest
if req.Header == nil { return internal.Claims{}, http.ErrNoCookie } if req.Header == nil {
return internal.Claims{}, http.ErrNoCookie
}
// Re-create a dummy http.Request to reuse cookie parsing & jwt verification // Re-create a dummy http.Request to reuse cookie parsing & jwt verification
r := http.Request{Header: http.Header(req.Header)} r := http.Request{Header: http.Header(req.Header)}
return internal.CurrentUser(&r) return internal.CurrentUser(&r)
} }
func env(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def } 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 must(err error, msg string) { if err != nil { log.Fatalf("%s: %v", msg, err) } }

1946
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,14 +9,15 @@
"preview": "vite preview --port 5173" "preview": "vite preview --port 5173"
}, },
"dependencies": { "dependencies": {
"tus-js-client": "^4.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"tus-js-client": "^4.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.56", "@types/react": "^18.3.24",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.3.7",
"typescript": "^5.5.3", "@vitejs/plugin-react": "^5.0.2",
"vite": "^5.2.0" "typescript": "^5.9.2",
"vite": "^7.1.5"
} }
} }

View File

@ -1,3 +1,4 @@
// frontend/src/App.tsx
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { api } from './api' import { api } from './api'
import Login from './Login' import Login from './Login'
@ -9,7 +10,7 @@ export default function App(){
useEffect(()=>{ api('/api/whoami').then(()=>setAuthed(true)).catch(()=>setAuthed(false)) }, []) useEffect(()=>{ api('/api/whoami').then(()=>setAuthed(true)).catch(()=>setAuthed(false)) }, [])
return ( return (
<> <>
<header><h1>🪽 Pegasus</h1>{authed && <button className="btn" onClick={()=> api('/api/logout',{method:'POST'}).then(()=>location.reload())}>Logout</button>}</header> <header><h1>🪽 Pegasus</h1>{authed && <button className="btn" onClick={()=> api('/api/logout',{method:'POST'}).then(()=>location.replace('/'))}>Logout</button>}</header>
<main>{authed ? <Uploader/> : <Login onLogin={()=>setAuthed(true)} /> }</main> <main>{authed ? <Uploader/> : <Login onLogin={()=>setAuthed(true)} /> }</main>
</> </>
) )

View File

@ -1,3 +1,4 @@
// frontend/src/Login.tsx
import React, { useState } from 'react' import React, { useState } from 'react'
import { api } from './api' import { api } from './api'

View File

@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react' // frontend/src/Uploader.tsx
import React, { useEffect, useRef, useState } from 'react'
import { api, WhoAmI } from './api' import { api, WhoAmI } from './api'
import * as tus from 'tus-js-client' import * as tus from 'tus-js-client'
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number } type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
type Sel = { file: File; desc: string; finalName: string } type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string }
function sanitizeDesc(s:string){ function sanitizeDesc(s:string){
s = s.trim().replace(/[^A-Za-z0-9 _-]+/g,'_').replace(/\s+/g,'_') s = s.trim().replace(/[^A-Za-z0-9 _-]+/g,'_').replace(/\s+/g,'_')
@ -16,16 +17,39 @@ function composeName(date:string, desc:string, orig:string){
const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}` const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}`
} }
// Type guard to narrow tus error
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 default function Uploader(){ export default function Uploader(){
const [me, setMe] = useState<WhoAmI|undefined>() const [me, setMe] = useState<WhoAmI|undefined>()
const [cwd, setCwd] = useState<string>('') const [cwd, setCwd] = useState<string>('') // relative to user's mapped root
const [rows, setRows] = useState<FileRow[]>([]) const [rows, setRows] = useState<FileRow[]>([]) // <-- missing before
const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}`
const [status, setStatus] = useState<string>('') const [status, setStatus] = useState<string>('')
const [date, setDate] = useState<string>(new Date().toISOString().slice(0,10)) const [globalDate, setGlobalDate] = useState<string>(new Date().toISOString().slice(0,10))
const [uploading, setUploading] = useState<boolean>(false)
const [sel, setSel] = useState<Sel[]>([]) const [sel, setSel] = useState<Sel[]>([])
const folderInputRef = useRef<HTMLInputElement>(null) // to set webkitdirectory
async function refresh(path=''){ const m = await api<WhoAmI>('/api/whoami'); setMe(m) // enable directory selection on the folder picker (non-standard attr)
const list = await api<FileRow[]>('/api/list?path='+encodeURIComponent(path)); setRows(list); setCwd(path) useEffect(() => {
const el = folderInputRef.current
if (el) {
el.setAttribute('webkitdirectory','')
el.setAttribute('directory','')
}
}, [])
async function refresh(path=''){
try {
const m = await api<WhoAmI>('/api/whoami'); setMe(m)
const list = await api<FileRow[]>('/api/list?path='+encodeURIComponent(path))
setRows(list); setCwd(path)
} catch (e: any) {
setStatus(`List error: ${e.message || e}`)
}
} }
useEffect(()=>{ refresh('') }, []) useEffect(()=>{ refresh('') }, [])
@ -33,39 +57,68 @@ export default function Uploader(){
const arr = Array.from(files).map(f=>{ const arr = Array.from(files).map(f=>{
const base = (f as any).webkitRelativePath || f.name const base = (f as any).webkitRelativePath || f.name
const name = base.split('/').pop() || f.name const name = base.split('/').pop() || f.name
const guess = name.replace(/\.[^/.]+$/,'').replace(/[_-]+/g,' ') const desc = '' // start empty; user must fill before upload
const desc = sanitizeDesc(guess) return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
return { file:f, desc, finalName: composeName(date, desc, name) }
}) })
setSel(arr) setSel(arr)
} }
useEffect(()=>{ setSel(old => old.map(x=> ({...x, finalName: composeName(date, x.desc, x.file.name)}))) }, [date])
useEffect(()=>{
setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)})))
}, [globalDate])
useEffect(()=>{
const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } }
window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler)
}, [uploading])
async function doUpload(){ async function doUpload(){
if(!me) return if(!me) return
if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return } if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return }
setStatus('') setStatus('Starting upload...')
setUploading(true)
for(const s of sel){ for(const s of sel){
await new Promise<void>((resolve,reject)=>{ await new Promise<void>((resolve,reject)=>{
const up = new tus.Upload(s.file, { const opts: tus.UploadOptions & { withCredentials?: boolean } = {
endpoint: '/tus/', endpoint: '/tus/',
chunkSize: 5*1024*1024, chunkSize: 5*1024*1024,
retryDelays: [0, 1000, 3000, 5000, 10000], retryDelays: [0, 1000, 3000, 5000, 10000],
withCredentials: true,
metadata: { metadata: {
filename: s.file.name, filename: s.file.name,
subdir: cwd || "", subdir: cwd || "",
date, // YYYY-MM-DD date: s.date, // per-file YYYY-MM-DD
desc: s.desc // server composes YYYY.MM.DD.desc.ext desc: s.desc // server composes final
}, },
onError: (err)=>{ setStatus(`${s.file.name}: ${err}`); reject(err) }, onError: (err: Error | tus.DetailedError)=>{
onProgress: (sent, total)=>{ const pct = Math.floor(sent/total*100); setStatus(`${s.finalName}: ${pct}%`) }, let msg = String(err)
onSuccess: ()=>{ setStatus(`${s.finalName} uploaded`); resolve() }, if (isDetailedError(err)) {
}) const status = (err.originalRequest as any)?.status
const statusText = (err.originalRequest as any)?.statusText
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
}
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`); resolve()
},
}
// ensure cookie is sent even if your tus typings dont have this field
opts.withCredentials = true
const up = new tus.Upload(s.file, opts)
up.start() up.start()
}) })
} }
setSel([]); refresh(cwd) setUploading(false); setSel([]); refresh(cwd); setStatus('All uploads complete')
} }
async function rename(oldp:string){ async function rename(oldp:string){
@ -78,23 +131,42 @@ export default function Uploader(){
await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' }) await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' })
refresh(cwd) refresh(cwd)
} }
function goUp(){
const up = cwd.split('/').slice(0,-1).join('/')
setCwd(up); refresh(up)
}
return (<> return (<>
<section className="card"> <section className="card">
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', gap:8, flexWrap:'wrap'}}> <div style={{display:'flex', justifyContent:'space-between', alignItems:'center', gap:8, flexWrap:'wrap'}}>
<div><b>Signed in:</b> {me?.username} · <span className="meta">root: /{me?.root}</span></div> <div><b>Signed in:</b> {me?.username} · <span className="meta">root: /{me?.root}</span></div>
<div className="meta">{cwd ? `/${cwd}` : 'Choose a folder below'}</div> <div className="meta">Destination: <b>{destPath || '/(unknown)'}</b></div>
</div> </div>
<div className="grid" style={{alignItems:'end'}}> <div className="grid" style={{alignItems:'end'}}>
<div> <div>
<label className="meta">Date (autoapplied)</label> <label className="meta">Default date (applied to new selections)</label>
<input type="date" value={date} onChange={e=> setDate(e.target.value)} /> <input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
</div> </div>
<div> <div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
<label className="meta">Pick files or folders</label> <div>
<input type="file" multiple webkitdirectory="true" onChange={e=> e.target.files && handleChoose(e.target.files)} /> <label className="meta">Gallery/Photos</label>
<input type="file" multiple accept="image/*,video/*" capture="environment"
onChange={e=> e.target.files && handleChoose(e.target.files)} />
</div>
<div className="hide-on-mobile">
<label className="meta">Files/Folders</label>
{/* set webkitdirectory/directory via ref to satisfy TS */}
<input type="file" multiple ref={folderInputRef}
onChange={e=> e.target.files && handleChoose(e.target.files)} />
</div>
</div> </div>
<button className="btn" onClick={doUpload} disabled={!sel.length}>Upload {sel.length? `(${sel.length})` : ''}</button> <button className="btn" onClick={doUpload}
disabled={!sel.length || uploading || sel.some(s=>!s.desc.trim())}>
Upload {sel.length? `(${sel.length})` : ''}
</button>
{sel.length>0 && sel.some(s=>!s.desc.trim()) && (
<div className="meta bad">Add a short description for every file to enable Upload.</div>
)}
</div> </div>
{sel.length>0 && ( {sel.length>0 && (
<div className="card"> <div className="card">
@ -103,10 +175,23 @@ export default function Uploader(){
{sel.map((s,i)=>( {sel.map((s,i)=>(
<div key={i} className="item"> <div key={i} className="item">
<div className="meta">{s.file.name}</div> <div className="meta">{s.file.name}</div>
<input value={s.desc} onChange={e=> { <input value={s.desc} placeholder="Short description (required)" onChange={e=> {
const v=e.target.value; setSel(old => old.map((x,idx)=> idx===i ? ({...x, desc:v, finalName: composeName(date, v, x.file.name)}) : x )) const v=e.target.value
setSel(old => old.map((x,idx)=> idx===i
? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)})
: x ))
}} /> }} />
<div style={{display:'grid', gridTemplateColumns:'auto 1fr', gap:8, alignItems:'center', marginTop:6}}>
<span className="meta">Date</span>
<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))
}} />
</div>
<div className="meta"> {s.finalName}</div> <div className="meta"> {s.finalName}</div>
{typeof s.progress === 'number' && (<>
<progress max={100} value={s.progress}></progress>
{s.err && <div className="meta bad">Error: {s.err}</div>}
</>)}
</div> </div>
))} ))}
</div> </div>
@ -117,10 +202,18 @@ export default function Uploader(){
<section className="card"> <section className="card">
<h3>Folder</h3> <h3>Folder</h3>
{rows.length === 0 ? (
<div className="item">
<div className="meta">
No items to show here. Youll upload into <b>{destPath}</b>.
</div>
<CreateFolder cwd={cwd} onCreate={(p)=>{ setCwd(p); refresh(p) }} />
</div>
) : (
<div className="grid"> <div className="grid">
{cwd && ( {cwd && (
<div className="item"> <div className="item">
<a className="name" href="#" onClick={()=> setCwd(cwd.split('/').slice(0,-1).join('/')) || refresh(cwd.split('/').slice(0,-1).join('/'))}> Up</a> <a className="name" href="#" onClick={(e)=>{e.preventDefault(); goUp()}}> Up</a>
</div> </div>
)} )}
{rows.sort((a,b)=> (Number(b.is_dir)-Number(a.is_dir)) || a.name.localeCompare(b.name)).map(f=> {rows.sort((a,b)=> (Number(b.is_dir)-Number(a.is_dir)) || a.name.localeCompare(b.name)).map(f=>
@ -129,14 +222,34 @@ export default function Uploader(){
<div className="meta">{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}</div> <div className="meta">{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}</div>
<div className="meta"> <div className="meta">
{f.is_dir {f.is_dir
? (<><a href="#" onClick={()=>{ setCwd(f.path); refresh(f.path) }}>Open</a> · <a href="#" onClick={()=>rename(f.path)}>Rename</a> · <a className="bad" href="#" onClick={()=>del(f.path,true)}>Delete</a></>) ? (<><a href="#" onClick={(e)=>{e.preventDefault(); setCwd(f.path); refresh(f.path)}}>Open</a> · <a href="#" onClick={(e)=>{e.preventDefault(); rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); del(f.path,true)}}>Delete</a></>)
: (<><a href="#" onClick={()=>rename(f.path)}>Rename</a> · <a className="bad" href="#" onClick={()=>del(f.path,false)}>Delete</a></>) : (<><a href="#" onClick={(e)=>{e.preventDefault(); rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); del(f.path,false)}}>Delete</a></>)
} }
</div> </div>
</div> </div>
)} )}
</div> </div>
)}
</section> </section>
</>) </>)
} }
function fmt(n:number){ if(n<1024) return n+' B'; const u=['KB','MB','GB','TB']; let i=-1; do{ n/=1024; i++ }while(n>=1024&&i<u.length-1); return n.toFixed(1)+' '+u[i] } 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] }
function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void }) {
const [name, setName] = React.useState('');
async function submit() {
const clean = name.trim().replace(/[\/]+/g,'/').replace(/^\//,'').replace(/[^\w\-\s.]/g,'_');
if (!clean) return;
const path = [cwd, clean].filter(Boolean).join('/');
await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) });
onCreate(path);
setName('');
}
return (
<div style={{marginTop:10, display:'flex', gap:8, alignItems:'center'}}>
<input placeholder="New folder name" value={name} onChange={e=>setName(e.target.value)} />
<button className="btn" onClick={submit} disabled={!name.trim()}>Create</button>
</div>
);
}

View File

@ -1,3 +1,4 @@
// frontend/src/api.ts
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());

View File

@ -1,3 +1,4 @@
// frontend/src/main.tsx
import React from 'react' import React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App' import App from './App'

View File

@ -1,3 +1,4 @@
/* frontend/src/styles.css */
:root{--bg:#0f1222;--fg:#e8e8f0;--muted:#9aa0a6;--card:#171a2e;--accent:#7aa2f7;--bad:#ef5350;--good:#66bb6a} :root{--bg:#0f1222;--fg:#e8e8f0;--muted:#9aa0a6;--card:#171a2e;--accent:#7aa2f7;--bad:#ef5350;--good:#66bb6a}
*{box-sizing:border-box} *{box-sizing:border-box}
html,body,#root{height:100%} html,body,#root{height:100%}
@ -7,10 +8,17 @@ h1{font-size:18px;margin:0}
main{max-width:960px;margin:0 auto;padding:16px} main{max-width:960px;margin:0 auto;padding:16px}
.card{background:var(--card);border:1px solid #242847;border-radius:12px;padding:14px;margin:10px 0} .card{background:var(--card);border:1px solid #242847;border-radius:12px;padding:14px;margin:10px 0}
.btn{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px} .btn{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px}
input[type=file]::-webkit-file-upload-button{padding:10px;border-radius:8px}
input[type=file]{padding:10px}
.hide-on-mobile{display:block}
input[type=file],input[type=text],input[type=password]{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px;width:100%} input[type=file],input[type=text],input[type=password]{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px;width:100%}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
.item{padding:12px;border:1px solid #2a2f45;border-radius:10px;background:#1a1e34} .item{padding:12px;border:1px solid #2a2f45;border-radius:10px;background:#1a1e34}
.meta{font-size:12px;color:var(--muted)} .meta{font-size:12px;color:var(--muted)}
progress{width:100%;height:8px} progress{width:100%;height:8px}
.bad{color:var(--bad)} .good{color:var(--good)} .bad{color:var(--bad)} .good{color:var(--good)}
@media (max-width:640px){ header{padding:12px} .grid{grid-template-columns:1fr 1fr} } @media (max-width:640px){ header{padding:12px}
.grid{grid-template-columns:1fr}
.hide-on-mobile{display:none}
.btn{padding:14px 16px}
}

9
frontend/src/types/tus-augment.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
// frontend/src/types/tus-augment.d.ts
// Augment tus-js-client to include withCredentials in UploadOptions
import 'tus-js-client'
declare module 'tus-js-client' {
interface UploadOptions {
/** When true, set XHR.withCredentials so cookies are sent with CORS requests */
withCredentials?: boolean;
}
}

15
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "ES2020"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["vite/client"]
},
"include": ["src", "vite-env.d.ts"]
}

View File

@ -1,6 +1,11 @@
// frontend/vite.config.ts
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: { outDir: '../backend/web/dist' } build: {
outDir: 'dist',
emptyOutDir: true,
},
}) })