pegasus fix
This commit is contained in:
parent
90fc9bd143
commit
4456e07fe2
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
**/node_modules
|
||||||
|
frontend/dist
|
||||||
|
frontend/build
|
||||||
|
**/.DS_Store
|
||||||
65
Dockerfile
65
Dockerfile
@ -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
BIN
backend/backend
Executable file
Binary file not shown.
@ -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
1952
backend/go.sum
Normal file
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,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,9 +31,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
329
backend/main.go
329
backend/main.go
@ -1,40 +1,43 @@
|
|||||||
|
// 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/*
|
|
||||||
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()
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
@ -49,55 +52,74 @@ func main() {
|
|||||||
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: 0, // unlimited
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
|
||||||
@ -112,43 +134,100 @@ func main() {
|
|||||||
|
|
||||||
// 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,34 +236,74 @@ 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})
|
||||||
})
|
})
|
||||||
@ -195,59 +314,84 @@ func main() {
|
|||||||
rt.Post("/*", tusHandler.PostFile)
|
rt.Post("/*", tusHandler.PostFile)
|
||||||
rt.Head("/*", tusHandler.HeadFile)
|
rt.Head("/*", tusHandler.HeadFile)
|
||||||
rt.Patch("/*", tusHandler.PatchFile)
|
rt.Patch("/*", tusHandler.PatchFile)
|
||||||
rt.Del("/*", tusHandler.DelFile)
|
rt.Delete("/*", tusHandler.DelFile)
|
||||||
rt.Get("/*", tusHandler.GetFile) // optional
|
rt.Get("/*", tusHandler.GetFile) // optional
|
||||||
})
|
})
|
||||||
|
|
||||||
// static app
|
// metrics
|
||||||
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 (must be last)
|
||||||
|
r.Handle("/*", 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 ----
|
// ---- wrap router with verbose request logging in debug ----
|
||||||
root := http.Handler(r)
|
root := http.Handler(r)
|
||||||
if internal.Debug {
|
if internal.Debug {
|
||||||
root = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
root = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
id := time.Now().UnixNano()
|
id := time.Now().UnixNano()
|
||||||
internal.Logf(">> %d %s %s %v", id, req.Method, req.URL.Path, internal.RedactHeaders(req.Header))
|
internal.Logf(">> %d %s %s %v", id, req.Method, req.URL.Path, internal.RedactHeaders(req.Header))
|
||||||
rw := &loggingRW{ResponseWriter:w, status:200}
|
rw := &loggingRW{ResponseWriter: w, status: 200}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
r.ServeHTTP(rw, req)
|
r.ServeHTTP(rw, req)
|
||||||
internal.Logf("<< %d %s %s %d %s", id, req.Method, req.URL.Path, rw.status, time.Since(start))
|
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)
|
||||||
})
|
})
|
||||||
@ -260,7 +404,10 @@ func corsForTus(next http.Handler) http.Handler {
|
|||||||
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-Headers", "Content-Type, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, X-Requested-With")
|
||||||
w.Header().Set("Access-Control-Expose-Headers", "Location, Upload-Offset, Upload-Length, Tus-Resumable")
|
w.Header().Set("Access-Control-Expose-Headers", "Location, Upload-Offset, Upload-Length, Tus-Resumable")
|
||||||
if r.Method == "OPTIONS" { w.WriteHeader(204); return }
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -268,11 +415,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
1946
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
frontend/tsconfig.json
Normal file
15
frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user