first successful upload
This commit is contained in:
parent
04d1e28f1f
commit
d0766e05cb
@ -15,9 +15,8 @@ import (
|
|||||||
type jfAuthResult struct {
|
type jfAuthResult struct {
|
||||||
AccessToken string `json:"AccessToken"`
|
AccessToken string `json:"AccessToken"`
|
||||||
User struct {
|
User struct {
|
||||||
Id string `json:"Id"`
|
Id string `json:"Id"`
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
Username string `json:"Name"`
|
|
||||||
} `json:"User"`
|
} `json:"User"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,7 +40,7 @@ func (j *Jellyfin) AuthenticateByName(username, password string) (jfAuthResult,
|
|||||||
|
|
||||||
req, _ := http.NewRequest("POST", j.BaseURL+"/Users/AuthenticateByName", bytes.NewReader(b))
|
req, _ := http.NewRequest("POST", j.BaseURL+"/Users/AuthenticateByName", bytes.NewReader(b))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
// Provide Jellyfin's MediaBrowser client descriptor (no token yet)
|
// Jellyfin client descriptor (no token yet)
|
||||||
req.Header.Set("Authorization",
|
req.Header.Set("Authorization",
|
||||||
`MediaBrowser Client="Pegasus", Device="Pegasus Web", DeviceId="pegasus-web", Version="1.0.0"`)
|
`MediaBrowser Client="Pegasus", Device="Pegasus Web", DeviceId="pegasus-web", Version="1.0.0"`)
|
||||||
|
|
||||||
@ -60,7 +59,6 @@ func (j *Jellyfin) AuthenticateByName(username, password string) (jfAuthResult,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (j *Jellyfin) RefreshLibrary(token string) {
|
func (j *Jellyfin) RefreshLibrary(token string) {
|
||||||
// GET /Library/Refresh with token header is widely supported
|
|
||||||
req, _ := http.NewRequest("GET", j.BaseURL+"/Library/Refresh", nil)
|
req, _ := http.NewRequest("GET", j.BaseURL+"/Library/Refresh", nil)
|
||||||
req.Header.Set("X-MediaBrowser-Token", token)
|
req.Header.Set("X-MediaBrowser-Token", token)
|
||||||
_, _ = j.Client.Do(req)
|
_, _ = j.Client.Do(req)
|
||||||
|
|||||||
@ -5,28 +5,82 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StringOrList allows both a single string ("Stein_90s")
|
||||||
|
// and a list of strings (["Stein_90s","Home Videos"]) in YAML.
|
||||||
|
type StringOrList []string
|
||||||
|
|
||||||
|
func (s *StringOrList) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
switch value.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var str string
|
||||||
|
if err := value.Decode(&str); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = StringOrList{str}
|
||||||
|
return nil
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var list []string
|
||||||
|
if err := value.Decode(&list); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = StringOrList(list)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("expected string or list, got kind=%v", value.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type UserMap struct {
|
type UserMap struct {
|
||||||
// Maps Jellyfin usernames -> relative media subdir (e.g., "mary_grace_allison" -> "Allison")
|
// Maps Jellyfin username -> one or more relative media subdirs
|
||||||
Map map[string]string `yaml:"map"`
|
Map map[string]StringOrList `yaml:"map"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadUserMap(path string) (*UserMap, error) {
|
func LoadUserMap(path string) (*UserMap, error) {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil { return nil, err }
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
var m UserMap
|
var m UserMap
|
||||||
if err := yaml.Unmarshal(b, &m); err != nil { return nil, err }
|
if err := yaml.Unmarshal(b, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve returns the first configured library for the user (back-compat).
|
||||||
func (m *UserMap) Resolve(username string) (string, error) {
|
func (m *UserMap) Resolve(username string) (string, error) {
|
||||||
if dir, ok := m.Map[username]; ok && dir != "" {
|
roots, err := m.ResolveAll(username)
|
||||||
clean := filepath.Clean(dir)
|
if err != nil {
|
||||||
if clean == "." || clean == "/" { return "", fmt.Errorf("invalid target dir") }
|
return "", err
|
||||||
return clean, nil
|
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("no mapping for user %q", username)
|
return roots[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAll returns all configured libraries for the user.
|
||||||
|
func (m *UserMap) ResolveAll(username string) ([]string, error) {
|
||||||
|
v, ok := m.Map[username]
|
||||||
|
if !ok || len(v) == 0 {
|
||||||
|
return nil, fmt.Errorf("no mapping for user %q", username)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(v))
|
||||||
|
for _, dir := range v {
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(dir)
|
||||||
|
if clean == "." || clean == "/" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, clean)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("no valid mapping for user %q", username)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"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"
|
||||||
@ -44,6 +45,11 @@ type loggingRW struct {
|
|||||||
status int
|
status int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
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() {
|
||||||
@ -55,6 +61,17 @@ func main() {
|
|||||||
|
|
||||||
um, err := internal.LoadUserMap(userMapFile)
|
um, err := internal.LoadUserMap(userMapFile)
|
||||||
must(err, "load user map")
|
must(err, "load user map")
|
||||||
|
if internal.Debug {
|
||||||
|
keys := make([]string, 0, len(um.Map))
|
||||||
|
for k := range um.Map { keys = append(keys, k) }
|
||||||
|
sort.Strings(keys)
|
||||||
|
show := keys
|
||||||
|
if len(keys) > 10 { show = keys[:10] }
|
||||||
|
internal.Logf("user-map loaded (%d): %v%s", len(keys), show, func() string {
|
||||||
|
if len(keys) > len(show) { return " ..." }
|
||||||
|
return ""
|
||||||
|
}())
|
||||||
|
}
|
||||||
|
|
||||||
// === tusd setup (resumable uploads) ===
|
// === tusd setup (resumable uploads) ===
|
||||||
store := filestore.FileStore{Path: tusDir}
|
store := filestore.FileStore{Path: tusDir}
|
||||||
@ -69,11 +86,11 @@ func main() {
|
|||||||
|
|
||||||
// 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,
|
MaxSize: 4 * 1024 * 1024 * 1024, // 4GB per file
|
||||||
MaxSize: 4 * 1024 * 1024 * 1024, // 4GB per file
|
RespectForwardedHeaders: true, // <<< important for https behind Traefik
|
||||||
}
|
}
|
||||||
tusHandler, err := tusd.NewUnroutedHandler(config)
|
tusHandler, err := tusd.NewUnroutedHandler(config)
|
||||||
must(err, "init tus handler")
|
must(err, "init tus handler")
|
||||||
@ -153,7 +170,7 @@ func main() {
|
|||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
r.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
|
r.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"version": version,
|
"version": version,
|
||||||
"git": git,
|
"git": git,
|
||||||
"built_at": builtAt,
|
"built_at": builtAt,
|
||||||
@ -175,15 +192,23 @@ func main() {
|
|||||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := internal.SetSession(w, res.User.Username, res.AccessToken); err != nil {
|
// ✅ ensure this username is mapped before creating a session
|
||||||
|
if _, err := um.Resolve(f.Username); err != nil {
|
||||||
|
internal.Logf("login ok but map missing for %q (JF name=%q)", f.Username, res.User.Name)
|
||||||
|
http.Error(w, "no mapping", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// ✅ store the typed login name in the session
|
||||||
|
if err := internal.SetSession(w, f.Username, res.AccessToken); err != nil {
|
||||||
http.Error(w, "session error", http.StatusInternalServerError)
|
http.Error(w, "session error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) {
|
r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
internal.ClearSession(w)
|
internal.ClearSession(w)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
})
|
})
|
||||||
|
|
||||||
// whoami
|
// whoami
|
||||||
@ -198,11 +223,24 @@ func main() {
|
|||||||
http.Error(w, "no mapping", http.StatusForbidden)
|
http.Error(w, "no mapping", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"username": cl.Username, "root": dr})
|
all, _ := um.ResolveAll(cl.Username) // ignore error here since Resolve succeeded
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"username": cl.Username,
|
||||||
|
"root": dr, // first root (back-compat)
|
||||||
|
"roots": all, // all roots (for future UI)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// list entries
|
// list entries
|
||||||
r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type entry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Mtime int64 `json:"mtime"`
|
||||||
|
}
|
||||||
|
|
||||||
cl, err := internal.CurrentUser(r)
|
cl, err := internal.CurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
@ -210,8 +248,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
rootRel, err := um.Resolve(cl.Username)
|
rootRel, err := um.Resolve(cl.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
internal.Logf("mkdir map missing for %q", cl.Username)
|
||||||
return
|
http.Error(w, "no mapping", 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)
|
||||||
@ -258,7 +296,7 @@ func main() {
|
|||||||
}(),
|
}(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(out)
|
writeJSON(w, out)
|
||||||
})
|
})
|
||||||
|
|
||||||
// rename
|
// rename
|
||||||
@ -304,7 +342,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
jf.RefreshLibrary(cl.JFToken)
|
jf.RefreshLibrary(cl.JFToken)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
})
|
})
|
||||||
|
|
||||||
// delete
|
// delete
|
||||||
@ -333,7 +371,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
jf.RefreshLibrary(cl.JFToken)
|
jf.RefreshLibrary(cl.JFToken)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
})
|
})
|
||||||
|
|
||||||
// mkdir (create subdirectory under the user's mapped root)
|
// mkdir (create subdirectory under the user's mapped root)
|
||||||
@ -354,7 +392,7 @@ func main() {
|
|||||||
} else if err := os.MkdirAll(abs, 0o755); err != nil {
|
} else if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError); return
|
http.Error(w, err.Error(), http.StatusInternalServerError); return
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
})
|
})
|
||||||
|
|
||||||
// mount tus (behind auth)
|
// mount tus (behind auth)
|
||||||
@ -386,7 +424,7 @@ func main() {
|
|||||||
http.Error(w, "disabled", http.StatusForbidden)
|
http.Error(w, "disabled", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"mediaRoot": mediaRoot,
|
"mediaRoot": mediaRoot,
|
||||||
"tusDir": tusDir,
|
"tusDir": tusDir,
|
||||||
"userMapFile": userMapFile,
|
"userMapFile": userMapFile,
|
||||||
@ -414,7 +452,7 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
_ = os.WriteFile(test, []byte("ok\n"), 0o644)
|
_ = os.WriteFile(test, []byte("ok\n"), 0o644)
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"wrote": test})
|
writeJSON(w, map[string]string{"wrote": test})
|
||||||
})
|
})
|
||||||
|
|
||||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// frontend/src/Uploader.tsx
|
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
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'
|
||||||
@ -6,6 +5,9 @@ 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; date: string; finalName: string; progress?: number; err?: string }
|
type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string }
|
||||||
|
|
||||||
|
// Simple bundle heartbeat so you can confirm the loaded JS is current
|
||||||
|
console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
|
||||||
|
|
||||||
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,'_')
|
||||||
if(!s) s = 'upload'
|
if(!s) s = 'upload'
|
||||||
@ -25,7 +27,7 @@ function isDetailedError(e: unknown): e is tus.DetailedError {
|
|||||||
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>('') // relative to user's mapped root
|
const [cwd, setCwd] = useState<string>('') // relative to user's mapped root
|
||||||
const [rows, setRows] = useState<FileRow[]>([]) // <-- missing before
|
const [rows, setRows] = useState<FileRow[]>([])
|
||||||
const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}`
|
const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}`
|
||||||
const [status, setStatus] = useState<string>('')
|
const [status, setStatus] = useState<string>('')
|
||||||
const [globalDate, setGlobalDate] = useState<string>(new Date().toISOString().slice(0,10))
|
const [globalDate, setGlobalDate] = useState<string>(new Date().toISOString().slice(0,10))
|
||||||
@ -33,7 +35,7 @@ export default function Uploader(){
|
|||||||
const [sel, setSel] = useState<Sel[]>([])
|
const [sel, setSel] = useState<Sel[]>([])
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null) // to set webkitdirectory
|
const folderInputRef = useRef<HTMLInputElement>(null) // to set webkitdirectory
|
||||||
|
|
||||||
// enable directory selection on the folder picker (non-standard attr)
|
// Enable directory selection on the folder picker (non-standard attr)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = folderInputRef.current
|
const el = folderInputRef.current
|
||||||
if (el) {
|
if (el) {
|
||||||
@ -47,11 +49,20 @@ export default function Uploader(){
|
|||||||
const m = await api<WhoAmI>('/api/whoami'); setMe(m)
|
const m = await api<WhoAmI>('/api/whoami'); setMe(m)
|
||||||
const list = await api<FileRow[]>('/api/list?path='+encodeURIComponent(path))
|
const list = await api<FileRow[]>('/api/list?path='+encodeURIComponent(path))
|
||||||
setRows(list); setCwd(path)
|
setRows(list); setCwd(path)
|
||||||
} catch (e: any) {
|
setStatus(`Ready · Destination: /${[m.root, path].filter(Boolean).join('/')}`)
|
||||||
setStatus(`List error: ${e.message || e}`)
|
console.log('[Pegasus] list ok', { path, count: list.length })
|
||||||
|
} catch (e:any) {
|
||||||
|
const msg = String(e?.message || e || '')
|
||||||
|
console.error('[Pegasus] list error', e)
|
||||||
|
setStatus(`List error: ${msg}`)
|
||||||
|
if (msg.toLowerCase().includes('no mapping')) {
|
||||||
|
alert('Your account is not linked to any upload library yet. Please contact the admin to be granted access.')
|
||||||
|
try { await api('/api/logout', { method:'POST' }) } catch {}
|
||||||
|
location.replace('/') // back to login
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEffect(()=>{ refresh('') }, [])
|
useEffect(()=>{ setStatus('Loading profile & folder list…'); refresh('') }, [])
|
||||||
|
|
||||||
function handleChoose(files: FileList){
|
function handleChoose(files: FileList){
|
||||||
const arr = Array.from(files).map(f=>{
|
const arr = Array.from(files).map(f=>{
|
||||||
@ -60,80 +71,112 @@ export default function Uploader(){
|
|||||||
const desc = '' // start empty; user must fill before upload
|
const desc = '' // start empty; user must fill before upload
|
||||||
return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
|
return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
|
||||||
})
|
})
|
||||||
|
console.log('[Pegasus] selected files', arr.map(a=>a.file.name))
|
||||||
setSel(arr)
|
setSel(arr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the global date changes, recompute per-file finalName (leave per-file date in place)
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)})))
|
setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)})))
|
||||||
}, [globalDate])
|
}, [globalDate])
|
||||||
|
|
||||||
|
// Warn before closing mid-upload
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } }
|
const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } }
|
||||||
window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler)
|
window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler)
|
||||||
}, [uploading])
|
}, [uploading])
|
||||||
|
|
||||||
async function doUpload(){
|
async function doUpload(){
|
||||||
if(!me) return
|
if(!me) { setStatus('Not signed in'); 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('Starting upload...')
|
setStatus('Starting upload…')
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
for(const s of sel){
|
try{
|
||||||
await new Promise<void>((resolve,reject)=>{
|
for(const s of sel){
|
||||||
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
|
// eslint-disable-next-line no-await-in-loop
|
||||||
endpoint: '/tus/',
|
await new Promise<void>((resolve,reject)=>{
|
||||||
chunkSize: 5*1024*1024,
|
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
|
||||||
retryDelays: [0, 1000, 3000, 5000, 10000],
|
endpoint: '/tus/',
|
||||||
metadata: {
|
chunkSize: 5*1024*1024,
|
||||||
filename: s.file.name,
|
retryDelays: [0, 1000, 3000, 5000, 10000],
|
||||||
subdir: cwd || "",
|
resume: false, // ignore any old http:// resume URLs
|
||||||
date: s.date, // per-file YYYY-MM-DD
|
removeFingerprintOnSuccess: true, // keep storage clean going forward
|
||||||
desc: s.desc // server composes final
|
metadata: {
|
||||||
},
|
filename: s.file.name,
|
||||||
onError: (err: Error | tus.DetailedError)=>{
|
subdir: cwd || "",
|
||||||
let msg = String(err)
|
date: s.date, // per-file YYYY-MM-DD
|
||||||
if (isDetailedError(err)) {
|
desc: s.desc // server composes final
|
||||||
const status = (err.originalRequest as any)?.status
|
},
|
||||||
const statusText = (err.originalRequest as any)?.statusText
|
onError: (err: Error | tus.DetailedError)=>{
|
||||||
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
|
let msg = String(err)
|
||||||
}
|
if (isDetailedError(err)) {
|
||||||
setSel(old => old.map(x => x.file===s.file ? ({...x, err: msg}) : x))
|
const status = (err.originalRequest as any)?.status
|
||||||
setStatus(`✖ ${s.file.name}: ${msg}`)
|
const statusText = (err.originalRequest as any)?.statusText
|
||||||
alert(`Upload failed: ${s.file.name}\n\n${msg}`)
|
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
|
||||||
reject(err as any)
|
}
|
||||||
},
|
console.error('[Pegasus] tus error', s.file.name, err)
|
||||||
onProgress: (sent: number, total: number)=>{
|
setSel(old => old.map(x => x.file===s.file ? ({...x, err: msg}) : x))
|
||||||
const pct = Math.floor(sent/Math.max(total,1)*100)
|
setStatus(`✖ ${s.file.name}: ${msg}`)
|
||||||
setSel(old => old.map(x => x.file===s.file ? ({...x, progress: pct}) : x))
|
alert(`Upload failed: ${s.file.name}\n\n${msg}`)
|
||||||
setStatus(`⬆ ${s.finalName}: ${pct}%`)
|
reject(err as any)
|
||||||
},
|
},
|
||||||
onSuccess: ()=>{
|
onProgress: (sent: number, total: number)=>{
|
||||||
setSel(old => old.map(x => x.file===s.file ? ({...x, progress: 100, err: undefined}) : x))
|
const pct = Math.floor(sent/Math.max(total,1)*100)
|
||||||
setStatus(`✔ ${s.finalName} uploaded`); resolve()
|
setSel(old => old.map(x => x.file===s.file ? ({...x, progress: pct}) : x))
|
||||||
},
|
setStatus(`⬆ ${s.finalName}: ${pct}%`)
|
||||||
}
|
},
|
||||||
// ensure cookie is sent even if your tus typings don’t have this field
|
onSuccess: ()=>{
|
||||||
opts.withCredentials = true
|
setSel(old => old.map(x => x.file===s.file ? ({...x, progress: 100, err: undefined}) : x))
|
||||||
|
setStatus(`✔ ${s.finalName} uploaded`)
|
||||||
|
console.log('[Pegasus] tus success', s.file.name)
|
||||||
|
resolve()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Ensure cookie is sent even if the tus typings don’t list this field
|
||||||
|
opts.withCredentials = true
|
||||||
|
|
||||||
const up = new tus.Upload(s.file, opts)
|
const up = new tus.Upload(s.file, opts)
|
||||||
up.start()
|
console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, cwd })
|
||||||
})
|
up.start()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setStatus('All uploads complete')
|
||||||
|
setSel([])
|
||||||
|
await refresh(cwd)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
}
|
}
|
||||||
setUploading(false); setSel([]); refresh(cwd); setStatus('All uploads complete')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rename(oldp:string){
|
async function rename(oldp:string){
|
||||||
const name = prompt('New name (YYYY.MM.DD.description.ext):', oldp.split('/').pop()||''); if(!name) return
|
const name = prompt('New name (YYYY.MM.DD.description.ext):', oldp.split('/').pop()||''); if(!name) return
|
||||||
await api('/api/rename', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({from:oldp, to: (oldp.split('/').slice(0,-1).concat(name)).join('/')}) })
|
try{
|
||||||
refresh(cwd)
|
await api('/api/rename', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'content-type':'application/json'},
|
||||||
|
body: JSON.stringify({from:oldp, to: (oldp.split('/').slice(0,-1).concat(name)).join('/')})
|
||||||
|
})
|
||||||
|
await refresh(cwd)
|
||||||
|
} catch(e:any){
|
||||||
|
console.error('[Pegasus] rename error', e)
|
||||||
|
alert(`Rename failed:\n${e?.message || e}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(p:string, recursive:boolean){
|
async function del(p:string, recursive:boolean){
|
||||||
if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return
|
if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return
|
||||||
await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' })
|
try{
|
||||||
refresh(cwd)
|
await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' })
|
||||||
|
await refresh(cwd)
|
||||||
|
} catch(e:any){
|
||||||
|
console.error('[Pegasus] delete error', e)
|
||||||
|
alert(`Delete failed:\n${e?.message || e}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goUp(){
|
function goUp(){
|
||||||
const up = cwd.split('/').slice(0,-1).join('/')
|
const up = cwd.split('/').slice(0,-1).join('/')
|
||||||
setCwd(up); refresh(up)
|
setCwd(up); void refresh(up)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<>
|
return (<>
|
||||||
@ -142,32 +185,64 @@ export default function Uploader(){
|
|||||||
<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">Destination: <b>{destPath || '/(unknown)'}</b></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">Default date (applied to new selections)</label>
|
<label className="meta">Default date (applied to new selections)</label>
|
||||||
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
|
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
|
<div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
|
||||||
<div>
|
<div>
|
||||||
<label className="meta">Gallery/Photos</label>
|
<label className="meta">Gallery/Photos</label>
|
||||||
<input type="file" multiple accept="image/*,video/*" capture="environment"
|
<input
|
||||||
onChange={e=> e.target.files && handleChoose(e.target.files)} />
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,video/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={e=> e.target.files && handleChoose(e.target.files)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="hide-on-mobile">
|
<div className="hide-on-mobile">
|
||||||
<label className="meta">Files/Folders</label>
|
<label className="meta">Files/Folders</label>
|
||||||
{/* set webkitdirectory/directory via ref to satisfy TS */}
|
{/* set webkitdirectory/directory via ref to satisfy TS */}
|
||||||
<input type="file" multiple ref={folderInputRef}
|
<input
|
||||||
onChange={e=> e.target.files && handleChoose(e.target.files)} />
|
type="file"
|
||||||
|
multiple
|
||||||
|
ref={folderInputRef}
|
||||||
|
onChange={e=> e.target.files && handleChoose(e.target.files)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn" onClick={doUpload}
|
|
||||||
disabled={!sel.length || uploading || sel.some(s=>!s.desc.trim())}>
|
{(() => {
|
||||||
Upload {sel.length? `(${sel.length})` : ''}
|
const uploadDisabled = !sel.length || uploading || sel.some(s=>!s.desc.trim())
|
||||||
</button>
|
let disabledReason = ''
|
||||||
{sel.length>0 && sel.some(s=>!s.desc.trim()) && (
|
if (!sel.length) disabledReason = 'Select at least one file.'
|
||||||
<div className="meta bad">Add a short description for every file to enable Upload.</div>
|
else if (uploading) disabledReason = 'Upload in progress…'
|
||||||
)}
|
else if (sel.some(s=>!s.desc.trim())) disabledReason = 'Add a short description for every file.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={()=>{
|
||||||
|
console.log('[Pegasus] Upload click', { files: sel.length, uploading, disabled: uploadDisabled })
|
||||||
|
setStatus('Upload button clicked')
|
||||||
|
if (!uploadDisabled) { void doUpload() }
|
||||||
|
}}
|
||||||
|
disabled={uploadDisabled}
|
||||||
|
aria-disabled={uploadDisabled}
|
||||||
|
>
|
||||||
|
Upload {sel.length? `(${sel.length})` : ''}
|
||||||
|
</button>
|
||||||
|
{disabledReason && <div className="meta bad">{disabledReason}</div>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sel.length>0 && (
|
{sel.length>0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h4>Ready to upload</h4>
|
<h4>Ready to upload</h4>
|
||||||
@ -175,28 +250,40 @@ 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} placeholder="Short description (required)" onChange={e=> {
|
<input
|
||||||
const v=e.target.value
|
value={s.desc}
|
||||||
setSel(old => old.map((x,idx)=> idx===i
|
placeholder="Short description (required)"
|
||||||
? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)})
|
onChange={e=> {
|
||||||
: 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}}>
|
<div style={{display:'grid', gridTemplateColumns:'auto 1fr', gap:8, alignItems:'center', marginTop:6}}>
|
||||||
<span className="meta">Date</span>
|
<span className="meta">Date</span>
|
||||||
<input type="date" value={s.date} onChange={e=>{
|
<input
|
||||||
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))
|
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>
|
||||||
<div className="meta">→ {s.finalName}</div>
|
<div className="meta">→ {s.finalName}</div>
|
||||||
{typeof s.progress === 'number' && (<>
|
{typeof s.progress === 'number' && (
|
||||||
<progress max={100} value={s.progress}></progress>
|
<>
|
||||||
{s.err && <div className="meta bad">Error: {s.err}</div>}
|
<progress max={100} value={s.progress}></progress>
|
||||||
</>)}
|
{s.err && <div className="meta bad">Error: {s.err}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="meta" style={{marginTop:8}}>{status}</div>
|
<div className="meta" style={{marginTop:8}}>{status}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -207,7 +294,7 @@ export default function Uploader(){
|
|||||||
<div className="meta">
|
<div className="meta">
|
||||||
No items to show here. You’ll upload into <b>{destPath}</b>.
|
No items to show here. You’ll upload into <b>{destPath}</b>.
|
||||||
</div>
|
</div>
|
||||||
<CreateFolder cwd={cwd} onCreate={(p)=>{ setCwd(p); refresh(p) }} />
|
<CreateFolder cwd={cwd} onCreate={(p)=>{ setCwd(p); void refresh(p) }} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
@ -222,8 +309,8 @@ 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={(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={(e)=>{e.preventDefault(); setCwd(f.path); void refresh(f.path)}}>Open</a> · <a href="#" onClick={(e)=>{e.preventDefault(); void rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(f.path,true)}}>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></>)
|
: (<><a href="#" onClick={(e)=>{e.preventDefault(); void rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(f.path,false)}}>Delete</a></>)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -242,14 +329,20 @@ function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void
|
|||||||
const clean = name.trim().replace(/[\/]+/g,'/').replace(/^\//,'').replace(/[^\w\-\s.]/g,'_');
|
const clean = name.trim().replace(/[\/]+/g,'/').replace(/^\//,'').replace(/[^\w\-\s.]/g,'_');
|
||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
const path = [cwd, clean].filter(Boolean).join('/');
|
const path = [cwd, clean].filter(Boolean).join('/');
|
||||||
await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) });
|
console.log('[Pegasus] mkdir click', { path })
|
||||||
onCreate(path);
|
try {
|
||||||
setName('');
|
await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) });
|
||||||
|
onCreate(path);
|
||||||
|
setName('');
|
||||||
|
} catch (e:any) {
|
||||||
|
console.error('[Pegasus] mkdir error', e)
|
||||||
|
alert(`Create folder failed:\n${e?.message || e}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div style={{marginTop:10, display:'flex', gap:8, alignItems:'center'}}>
|
<div style={{marginTop:10, display:'flex', gap:8, alignItems:'center'}}>
|
||||||
<input placeholder="New folder name" value={name} onChange={e=>setName(e.target.value)} />
|
<input placeholder="New folder name" value={name} onChange={e=>setName(e.target.value)} />
|
||||||
<button className="btn" onClick={submit} disabled={!name.trim()}>Create</button>
|
<button type="button" className="btn" onClick={submit} disabled={!name.trim()}>Create</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,31 @@
|
|||||||
// frontend/src/api.ts
|
// frontend/src/api.ts
|
||||||
|
import 'tus-js-client'
|
||||||
|
|
||||||
|
import type { UrlStorage } from 'tus-js-client'
|
||||||
|
|
||||||
|
declare module 'tus-js-client' {
|
||||||
|
interface UploadOptions {
|
||||||
|
/** Send cookies on CORS requests */
|
||||||
|
withCredentials?: boolean
|
||||||
|
/** Disable reading/writing resume URLs (older defs may miss this) */
|
||||||
|
resume?: boolean
|
||||||
|
/** Do not store fingerprints for resuming */
|
||||||
|
storeFingerprintForResuming?: boolean
|
||||||
|
/** Remove any stored fingerprint after a successful upload */
|
||||||
|
removeFingerprintOnSuccess?: boolean
|
||||||
|
/** Allow providing a no-op storage to fully disable resuming */
|
||||||
|
urlStorage?: UrlStorage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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());
|
||||||
const ct = r.headers.get('content-type') || '';
|
const ct = r.headers.get('content-type') || '';
|
||||||
return (ct.includes('json') ? r.json() : (r.text() as any)) as T;
|
if (ct.includes('json')) return r.json() as Promise<T>;
|
||||||
|
// Fallback: try to parse body as JSON; otherwise return text
|
||||||
|
const txt = await r.text();
|
||||||
|
try { return JSON.parse(txt) as T } catch { return txt as any as T }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WhoAmI = { username: string; root: string };
|
export type WhoAmI = { username: string; root: string };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user