diff --git a/backend/internal/auth.go b/backend/internal/auth.go index 2fa31b8..ea11a8c 100644 --- a/backend/internal/auth.go +++ b/backend/internal/auth.go @@ -1,3 +1,4 @@ +// backend/internal/auth.go package internal import ( diff --git a/backend/internal/debuglog.go b/backend/internal/debuglog.go index ee593cb..18fc38d 100644 --- a/backend/internal/debuglog.go +++ b/backend/internal/debuglog.go @@ -1,3 +1,4 @@ +// backend/internal/debuglog.go package internal import ( diff --git a/backend/internal/fs.go b/backend/internal/fs.go index d4880a9..92888ec 100644 --- a/backend/internal/fs.go +++ b/backend/internal/fs.go @@ -1,3 +1,4 @@ +// backend/internal/fs.go package internal import ( diff --git a/backend/internal/jellyfin.go b/backend/internal/jellyfin.go index 5f9b8b1..1e0e842 100644 --- a/backend/internal/jellyfin.go +++ b/backend/internal/jellyfin.go @@ -1,3 +1,4 @@ +// backend/internal/jellyfin.go package internal import ( diff --git a/backend/internal/naming.go b/backend/internal/naming.go index 89be16b..0ef9950 100644 --- a/backend/internal/naming.go +++ b/backend/internal/naming.go @@ -1,3 +1,4 @@ +// backend/internal/naming.go package internal import ( diff --git a/backend/internal/session.go b/backend/internal/session.go index ef8c180..2fbc16f 100644 --- a/backend/internal/session.go +++ b/backend/internal/session.go @@ -1,3 +1,4 @@ +// backend/internal/session.go package internal import ( @@ -43,5 +44,14 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error { } 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, + }) } diff --git a/backend/internal/usermap.go b/backend/internal/usermap.go index a0d7bb9..4502990 100644 --- a/backend/internal/usermap.go +++ b/backend/internal/usermap.go @@ -1,3 +1,4 @@ +// backend/internal/usermap.go package internal import ( diff --git a/backend/main.go b/backend/main.go index 6ddf222..05f99d0 100644 --- a/backend/main.go +++ b/backend/main.go @@ -23,6 +23,7 @@ import ( "scm.bstein.dev/bstein/Pegasus/backend/internal" ) +//go:embed web/dist var webFS embed.FS var ( @@ -32,6 +33,12 @@ var ( 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 @@ -42,11 +49,19 @@ func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.Wr func main() { internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot) + if os.Getenv("PEGASUS_SESSION_KEY") == "" { + log.Fatal("PEGASUS_SESSION_KEY is not set") + } + um, err := internal.LoadUserMap(userMapFile) must(err, "load user map") // === tusd setup (resumable uploads) === 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() composer := tusd.NewStoreComposer() store.UseIn(composer) @@ -58,7 +73,7 @@ func main() { StoreComposer: composer, NotifyCompleteUploads: true, // CompleteUploads: completeC, - MaxSize: 0, // unlimited + MaxSize: 4 * 1024 * 1024 * 1024, // 4GB per file } tusHandler, err := tusd.NewUnroutedHandler(config) must(err, "init tus handler") @@ -132,6 +147,19 @@ func main() { 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) { + _ = json.NewEncoder(w).Encode(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 { @@ -308,16 +336,38 @@ func main() { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) }) - // mount tus (behind auth) - r.Route("/tus", func(rt chi.Router) { - rt.Use(sessionRequired) - rt.Post("/*", tusHandler.PostFile) - rt.Head("/*", tusHandler.HeadFile) - rt.Patch("/*", tusHandler.PatchFile) - rt.Delete("/*", tusHandler.DelFile) - rt.Get("/*", tusHandler.GetFile) // optional + // 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) + 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()) @@ -327,8 +377,8 @@ func main() { r.Get("/", func(w http.ResponseWriter, r *http.Request) { static.ServeHTTP(w, r) }) - // catch-all (must be last) - r.Handle("/*", http.StripPrefix("/", static)) + // 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) { @@ -367,6 +417,10 @@ func main() { _ = json.NewEncoder(w).Encode(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 { @@ -399,14 +453,21 @@ func sessionRequired(next http.Handler) http.Handler { func corsForTus(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Allow tus headers for resumable uploads - w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) + // 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-Headers", "Content-Type, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, X-Requested-With") - w.Header().Set("Access-Control-Expose-Headers", "Location, Upload-Offset, Upload-Length, Tus-Resumable") - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusNoContent) - return + 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) }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d32d45..3778fdb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +// frontend/src/App.tsx import React, { useEffect, useState } from 'react' import { api } from './api' import Login from './Login' @@ -9,7 +10,7 @@ export default function App(){ useEffect(()=>{ api('/api/whoami').then(()=>setAuthed(true)).catch(()=>setAuthed(false)) }, []) return ( <> -

🪽 Pegasus

{authed && }
+

🪽 Pegasus

{authed && }
{authed ? : setAuthed(true)} /> }
) diff --git a/frontend/src/Login.tsx b/frontend/src/Login.tsx index fb71c13..020f24e 100644 --- a/frontend/src/Login.tsx +++ b/frontend/src/Login.tsx @@ -1,3 +1,4 @@ +// frontend/src/Login.tsx import React, { useState } from 'react' import { api } from './api' diff --git a/frontend/src/Uploader.tsx b/frontend/src/Uploader.tsx index bd1217b..2c3e856 100644 --- a/frontend/src/Uploader.tsx +++ b/frontend/src/Uploader.tsx @@ -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 * as tus from 'tus-js-client' type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number } -type Sel = { file: File; desc: string; finalName: string } +type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string } function sanitizeDesc(s:string){ 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)}` } +// 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(){ const [me, setMe] = useState() - const [cwd, setCwd] = useState('') - const [rows, setRows] = useState([]) + const [cwd, setCwd] = useState('') // relative to user's mapped root + const [rows, setRows] = useState([]) // <-- missing before + const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}` const [status, setStatus] = useState('') - const [date, setDate] = useState(new Date().toISOString().slice(0,10)) + const [globalDate, setGlobalDate] = useState(new Date().toISOString().slice(0,10)) + const [uploading, setUploading] = useState(false) const [sel, setSel] = useState([]) + const folderInputRef = useRef(null) // to set webkitdirectory - async function refresh(path=''){ const m = await api('/api/whoami'); setMe(m) - const list = await api('/api/list?path='+encodeURIComponent(path)); setRows(list); setCwd(path) + // enable directory selection on the folder picker (non-standard attr) + useEffect(() => { + const el = folderInputRef.current + if (el) { + el.setAttribute('webkitdirectory','') + el.setAttribute('directory','') + } + }, []) + + async function refresh(path=''){ + try { + const m = await api('/api/whoami'); setMe(m) + const list = await api('/api/list?path='+encodeURIComponent(path)) + setRows(list); setCwd(path) + } catch (e: any) { + setStatus(`List error: ${e.message || e}`) + } } useEffect(()=>{ refresh('') }, []) @@ -33,39 +57,68 @@ export default function Uploader(){ const arr = Array.from(files).map(f=>{ const base = (f as any).webkitRelativePath || f.name const name = base.split('/').pop() || f.name - const guess = name.replace(/\.[^/.]+$/,'').replace(/[_-]+/g,' ') - const desc = sanitizeDesc(guess) - return { file:f, desc, finalName: composeName(date, desc, name) } + const desc = '' // start empty; user must fill before upload + return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 } }) 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(){ if(!me) 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){ await new Promise((resolve,reject)=>{ - const up = new tus.Upload(s.file, { + const opts: tus.UploadOptions & { withCredentials?: boolean } = { endpoint: '/tus/', chunkSize: 5*1024*1024, retryDelays: [0, 1000, 3000, 5000, 10000], - withCredentials: true, metadata: { filename: s.file.name, subdir: cwd || "", - date, // YYYY-MM-DD - desc: s.desc // server composes YYYY.MM.DD.desc.ext + date: s.date, // per-file YYYY-MM-DD + desc: s.desc // server composes final }, - onError: (err)=>{ setStatus(`✖ ${s.file.name}: ${err}`); reject(err) }, - onProgress: (sent, total)=>{ const pct = Math.floor(sent/total*100); setStatus(`⬆ ${s.finalName}: ${pct}%`) }, - onSuccess: ()=>{ setStatus(`✔ ${s.finalName} uploaded`); resolve() }, - }) + 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() + } + 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 don’t have this field + opts.withCredentials = true + + const up = new tus.Upload(s.file, opts) up.start() }) } - setSel([]); refresh(cwd) + setUploading(false); setSel([]); refresh(cwd); setStatus('All uploads complete') } 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' }) refresh(cwd) } + function goUp(){ + const up = cwd.split('/').slice(0,-1).join('/') + setCwd(up); refresh(up) + } return (<>
Signed in: {me?.username} · root: /{me?.root}
-
{cwd ? `/${cwd}` : 'Choose a folder below'}
+
Destination: {destPath || '/(unknown)'}
- - setDate(e.target.value)} /> + + setGlobalDate(e.target.value)} />
-
- - e.target.files && handleChoose(e.target.files)} /> +
+
+ + e.target.files && handleChoose(e.target.files)} /> +
+
+ + {/* set webkitdirectory/directory via ref to satisfy TS */} + e.target.files && handleChoose(e.target.files)} /> +
- + + {sel.length>0 && sel.some(s=>!s.desc.trim()) && ( +
Add a short description for every file to enable Upload.
+ )}
{sel.length>0 && (
@@ -103,10 +175,23 @@ export default function Uploader(){ {sel.map((s,i)=>(
{s.file.name}
- { - 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 )) }} /> +
+ Date + { + 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)) + }} /> +
→ {s.finalName}
+ {typeof s.progress === 'number' && (<> + + {s.err &&
Error: {s.err}
} + )}
))}
@@ -117,10 +202,18 @@ export default function Uploader(){

Folder

+ {rows.length === 0 ? ( +
+
+ No items to show here. You’ll upload into {destPath}. +
+ { setCwd(p); refresh(p) }} /> +
+ ) : (
{cwd && ( )} {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(){
{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}
)}
+ )}
) } + 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&&ivoid }) { + 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 ( +
+ setName(e.target.value)} /> + +
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 20cd162..8b7cb50 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,3 +1,4 @@ +// frontend/src/api.ts export async function api(path: string, init?: RequestInit): Promise { const r = await fetch(path, { credentials:'include', ...init }); if (!r.ok) throw new Error(await r.text()); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1c58885..2f47a0b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,3 +1,4 @@ +// frontend/src/main.tsx import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' diff --git a/frontend/src/styles.css b/frontend/src/styles.css index dd7b3c6..5794557 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,3 +1,4 @@ +/* frontend/src/styles.css */ :root{--bg:#0f1222;--fg:#e8e8f0;--muted:#9aa0a6;--card:#171a2e;--accent:#7aa2f7;--bad:#ef5350;--good:#66bb6a} *{box-sizing:border-box} html,body,#root{height:100%} @@ -7,10 +8,17 @@ h1{font-size:18px;margin:0} main{max-width:960px;margin:0 auto;padding:16px} .card{background:var(--card);border:1px solid #242847;border-radius:12px;padding:14px;margin:10px 0} .btn{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px} +input[type=file]::-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%} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px} .item{padding:12px;border:1px solid #2a2f45;border-radius:10px;background:#1a1e34} .meta{font-size:12px;color:var(--muted)} progress{width:100%;height:8px} .bad{color:var(--bad)} .good{color:var(--good)} -@media (max-width:640px){ header{padding:12px} .grid{grid-template-columns:1fr 1fr} } +@media (max-width:640px){ header{padding:12px} + .grid{grid-template-columns:1fr} + .hide-on-mobile{display:none} + .btn{padding:14px 16px} +} diff --git a/frontend/src/types/tus-augment.d.ts b/frontend/src/types/tus-augment.d.ts new file mode 100644 index 0000000..16ca170 --- /dev/null +++ b/frontend/src/types/tus-augment.d.ts @@ -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; + } +}