diff --git a/Dockerfile b/Dockerfile index f4e2292..2d6bca0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,52 +3,54 @@ ############################ # Frontend build (Vite/React) ############################ -FROM node:20-alpine AS fe +# Run toolchains on the build machine, not target arch +FROM --platform=$BUILDPLATFORM node:20-alpine AS fe WORKDIR /src/frontend COPY frontend/package*.json ./ -RUN npm ci +RUN --mount=type=cache,target=/root/.npm npm ci COPY frontend/ . -# default Vite outDir is "dist" under CWD RUN npm run build - # 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 --platform=$BUILDPLATFORM golang:1.22-alpine AS be RUN apk add --no-cache ca-certificates upx git -ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ +ARG TARGETOS +ARG TARGETARCH +ENV CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ GOPROXY=https://proxy.golang.org,direct \ GOPRIVATE=scm.bstein.dev WORKDIR /src/backend -# 1) Copy mod files first for better caching, include both go.mod and go.sum +# 1) Cache modules 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 +# 2) Source COPY backend/ . -# 4) Bring in the FE assets where the embed expects them -# (your code likely has: //go:embed web/dist/**) +# 3) FE assets where the embed expects them (//go:embed web/dist/**) COPY --from=fe /out ./web/dist -# 5) In case code imports added deps not present during step (1), tidy now +# 4) Tidy (in case new deps appeared) 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. +# 5) Build the binary; fail if build fails; allow UPX to fail only. RUN --mount=type=cache,target=/go/pkg/mod \ - go build -trimpath -ldflags="-s -w" -o /pegasus ./main.go && \ - upx -q --lzma /pegasus || true + set -eux; \ + mkdir -p /out; \ + go build -trimpath -ldflags="-s -w" -o /out/pegasus ./main.go; \ + test -f /out/pegasus; \ + upx -q --lzma /out/pegasus || true ############################ # Final, minimal image ############################ FROM gcr.io/distroless/static:nonroot AS final -COPY --from=be /pegasus /pegasus +COPY --from=be /out/pegasus /pegasus USER nonroot:nonroot ENTRYPOINT ["/pegasus"] diff --git a/backend/main.go b/backend/main.go index 2d2ffec..36fdf5f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -45,9 +45,19 @@ type loggingRW struct { status int } +type listEntry struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + Mtime int64 `json:"mtime"` +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) } @@ -233,14 +243,6 @@ func main() { // list entries 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) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -248,11 +250,14 @@ func main() { } rootRel, err := um.Resolve(cl.Username) if err != nil { - internal.Logf("mkdir map missing for %q", cl.Username) - http.Error(w, "no mapping", http.StatusForbidden); return + internal.Logf("list: map missing for %q", cl.Username) + http.Error(w, "no mapping", http.StatusForbidden) + return } + q := strings.TrimPrefix(r.URL.Query().Get("path"), "/") rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) + var dirAbs string if q == "" { dirAbs = rootAbs @@ -263,40 +268,36 @@ func main() { 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 - } - var out []entry + + out := make([]listEntry, 0, len(ents)) for _, d := range ents { info, _ := d.Info() - out = append(out, entry{ + + var size int64 + if info != nil && !d.IsDir() { + size = info.Size() + } + var mtime int64 + if info != nil { + mtime = info.ModTime().Unix() + } + + out = append(out, listEntry{ Name: d.Name(), Path: filepath.Join(q, d.Name()), IsDir: d.IsDir(), - Size: func() int64 { - if info != nil && !d.IsDir() { - return info.Size() - } - return 0 - }(), - Mtime: func() int64 { - if info != nil { - return info.ModTime().Unix() - } - return 0 - }(), + Size: size, + Mtime: mtime, }) } - writeJSON(w, out) + + writeJSON(w, out) }) // rename diff --git a/frontend/src/Uploader.tsx b/frontend/src/Uploader.tsx index d4482c7..ef27929 100644 --- a/frontend/src/Uploader.tsx +++ b/frontend/src/Uploader.tsx @@ -1,13 +1,24 @@ -import React, { useEffect, useRef, useState } from 'react' +// frontend/src/Uploader.tsx +import React, { useEffect, useMemo, 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; date: string; finalName: string; progress?: number; err?: string } +const [mobile, setMobile] = useState(false) +useEffect(()=>{ setMobile(isLikelyMobile()) }, []) // Simple bundle heartbeat so you can confirm the loaded JS is current console.log('[Pegasus] FE bundle activated at', new Date().toISOString()) +function isLikelyMobile(): boolean { + if (typeof window === 'undefined') return false + const ua = navigator.userAgent || '' + const touch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches + // catch Android/iOS/iPadOS + return touch || /Mobi|Android|iPhone|iPad|iPod/i.test(ua) +} + function sanitizeDesc(s:string){ s = s.trim().replace(/[^A-Za-z0-9 _-]+/g,'_').replace(/\s+/g,'_') if(!s) s = 'upload' @@ -19,37 +30,87 @@ 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 +// 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)) } +// Normalize API list rows (works with old CamelCase or new snake_case) +function normalizeRows(raw: any): FileRow[] { + if (!Array.isArray(raw)) return [] + return raw.map((r: any) => ({ + name: r?.name ?? r?.Name ?? '', + path: r?.path ?? r?.Path ?? '', + is_dir: typeof r?.is_dir === 'boolean' ? r.is_dir + : typeof r?.IsDir === 'boolean' ? r.IsDir + : false, + size: typeof r?.size === 'number' ? r.size : (typeof r?.Size === 'number' ? r.Size : 0), + mtime: typeof r?.mtime === 'number' ? r.mtime : (typeof r?.Mtime === 'number' ? r.Mtime : 0), + })).filter(r => r && typeof r.name === 'string' && typeof r.path === 'string') +} + +// simple mobile detection (no CSS change needed) +function useIsMobile() { + const [isMobile, setIsMobile] = useState(false) + useEffect(() => { + const mq = window.matchMedia('(max-width: 640px)') + const handler = (e: MediaQueryListEvent | MediaQueryList) => + setIsMobile(('matches' in e ? e.matches : (e as MediaQueryList).matches)) + handler(mq) + mq.addEventListener('change', handler as any) + return () => mq.removeEventListener('change', handler as any) + }, []) + return isMobile +} + export default function Uploader(){ const [me, setMe] = useState() - const [cwd, setCwd] = useState('') // relative to user's mapped root + const [libraries, setLibraries] = useState([]) // from whoami.roots or [whoami.root] + const [selectedLib, setSelectedLib] = useState('') // must be chosen to upload + + const [subdir, setSubdir] = useState('') // one-level subfolder name (no '/') const [rows, setRows] = useState([]) - const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}` + const destPath = `/${[selectedLib, subdir].filter(Boolean).join('/')}` + const [status, setStatus] = useState('') 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 + const isMobile = useIsMobile() // Enable directory selection on the folder picker (non-standard attr) useEffect(() => { const el = folderInputRef.current - if (el) { + if (!el) return + if (mobile) { + // ensure we don't accidentally force folder mode on mobile + el.removeAttribute('webkitdirectory') + el.removeAttribute('directory') + } else { el.setAttribute('webkitdirectory','') el.setAttribute('directory','') } - }, []) + }, [mobile]) + // Fetch whoami + list 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) - setStatus(`Ready · Destination: /${[m.root, path].filter(Boolean).join('/')}`) + const m = await api('/api/whoami') + setMe(m as WhoAmI) + + const libs: string[] = + Array.isArray(m?.roots) && m.roots.length ? m.roots.slice() + : m?.root ? [m.root] : [] + + setLibraries(libs) + // Auto-select if exactly one library + setSelectedLib(prev => prev || (libs.length === 1 ? libs[0] : '')) + + const listRaw = await api('/api/list?path='+encodeURIComponent(path)) + const list = normalizeRows(listRaw) + setRows(list); setSubdir(path) + setStatus(`Ready · Destination: /${[libs[0] ?? '', path].filter(Boolean).join('/')}`) console.log('[Pegasus] list ok', { path, count: list.length }) } catch (e:any) { const msg = String(e?.message || e || '') @@ -75,7 +136,7 @@ export default function Uploader(){ setSel(arr) } - // When the global date changes, recompute per-file finalName (leave per-file date in place) + // When the global date changes, recompute per-file finalName useEffect(()=>{ setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)}))) }, [globalDate]) @@ -88,9 +149,12 @@ export default function Uploader(){ async function doUpload(){ if(!me) { setStatus('Not signed in'); return } + if(!selectedLib){ alert('Please select a Library to upload to.'); return } if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return } + setStatus('Starting upload…') setUploading(true) + try{ for(const s of sel){ // eslint-disable-next-line no-await-in-loop @@ -99,11 +163,16 @@ export default function Uploader(){ endpoint: '/tus/', chunkSize: 5*1024*1024, retryDelays: [0, 1000, 3000, 5000, 10000], - resume: false, // ignore any old http:// resume URLs - removeFingerprintOnSuccess: true, // keep storage clean going forward + // Avoid resuming old HTTP URLs: don't store fingerprints, and use a random fingerprint so nothing matches + storeFingerprintForResuming: false, + removeFingerprintOnSuccess: true, + fingerprint: (async () => `${Date.now()}-${Math.random().toString(36).slice(2)}-${s.file.name}`) as any, + metadata: { filename: s.file.name, - subdir: cwd || "", + // Server treats this as a subdirectory under the user's mapped root. + // When backend supports multiple libraries, it will also need the selected library. + subdir: subdir || "", date: s.date, // per-file YYYY-MM-DD desc: s.desc // server composes final }, @@ -136,27 +205,27 @@ export default function Uploader(){ opts.withCredentials = true const up = new tus.Upload(s.file, opts) - console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, cwd }) + console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, subdir }) up.start() }) } setStatus('All uploads complete') setSel([]) - await refresh(cwd) + await refresh(subdir) } finally { setUploading(false) } } 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 for files, or folder name):', oldp.split('/').pop()||''); if(!name) return try{ 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) + await refresh(subdir) } catch(e:any){ console.error('[Pegasus] rename error', e) alert(`Rename failed:\n${e?.message || e}`) @@ -167,7 +236,7 @@ export default function Uploader(){ if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return try{ await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' }) - await refresh(cwd) + await refresh(subdir) } catch(e:any){ console.error('[Pegasus] delete error', e) alert(`Delete failed:\n${e?.message || e}`) @@ -175,50 +244,111 @@ export default function Uploader(){ } function goUp(){ - const up = cwd.split('/').slice(0,-1).join('/') - setCwd(up); void refresh(up) + setSubdir(''); void refresh('') } + // Precompute a safely sorted copy for rendering (no in-place mutation) + const rowsSorted = useMemo(() => { + const copy = Array.isArray(rows) ? [...rows] : [] + return copy + .filter(r => r && typeof r.name === 'string') + .sort((a, b) => (Number(b.is_dir) - Number(a.is_dir)) || a.name.localeCompare(b.name)) + }, [rows]) + + // Restrict to one-level: only allow "Open" when we're at library root (subdir === '') + const canOpenDeeper = subdir === '' + return (<>
-
Signed in: {me?.username} · root: /{me?.root}
-
Destination: {destPath || '/(unknown)'}
+
Signed in: {me?.username}
+
Destination: {destPath || '/(select a library)'}
+ {/* Destination (Library + Subfolder) */}
+
+ + {libraries.length <= 1 ? ( +
+ {libraries[0] || '(none)'} +
+ ) : ( + + )} +
+
setGlobalDate(e.target.value)} />
+ {/* Pickers: mobile vs desktop */}
-
- - e.target.files && handleChoose(e.target.files)} - /> -
-
- - {/* set webkitdirectory/directory via ref to satisfy TS */} - e.target.files && handleChoose(e.target.files)} - /> -
+ {mobile ? ( + // ---- Mobile: show a Gallery picker (no capture) + optional Camera quick-capture + <> +
+ + browsers tend to open Photo/Media picker instead of camera or filesystem + accept="image/*,video/*" + onChange={e => e.target.files && handleChoose(e.target.files)} + /> +
+
+ + e.target.files && handleChoose(e.target.files)} + /> +
+ + ) : ( + // ---- Desktop: show both Files and Folder(s) pickers + <> +
+ + e.target.files && handleChoose(e.target.files)} + /> +
+
+ + {/* set webkitdirectory/directory via ref (done in useEffect) */} + e.target.files && handleChoose(e.target.files)} + /> +
+ + )} +
{(() => { - const uploadDisabled = !sel.length || uploading || sel.some(s=>!s.desc.trim()) + const uploadDisabled = + !selectedLib || !sel.length || uploading || sel.some(s=>!s.desc.trim()) let disabledReason = '' - if (!sel.length) disabledReason = 'Select at least one file.' + if (!selectedLib) disabledReason = 'Pick a library.' + else if (!sel.length) disabledReason = 'Select at least one file.' else if (uploading) disabledReason = 'Upload in progress…' else if (sel.some(s=>!s.desc.trim())) disabledReason = 'Add a short description for every file.' @@ -243,6 +373,7 @@ export default function Uploader(){ })()}
+ {/* Per-file editor */} {sel.length>0 && (

Ready to upload

@@ -287,35 +418,72 @@ export default function Uploader(){
{status}
+ {/* Destination details and subfolder management */}
-

Folder

- {rows.length === 0 ? ( +

Destination

+ + {/* Current subfolder header & actions (one level only) */} +
+
+ Library: {selectedLib || '(none)'} + {' · '} + Subfolder: {subdir ? subdir : '(root)'} +
+ +
+ + {/* Create subfolder (root only) */} + {!subdir && ( + { setSubdir(p); void refresh(p) }} + /> + )} + + {/* Listing */} + {rowsSorted.length === 0 ? (
- No items to show here. You’ll upload into {destPath}. + No items to show here. You’ll upload into {destPath || '/(select a library)'}.
- { setCwd(p); void refresh(p) }} />
) : ( -
- {cwd && ( - - )} - {rows.sort((a,b)=> (Number(b.is_dir)-Number(a.is_dir)) || a.name.localeCompare(b.name)).map(f=> -
-
{f.is_dir?'📁':'🎞️'} {f.name}
-
{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}
-
- {f.is_dir - ? (<>{e.preventDefault(); setCwd(f.path); void refresh(f.path)}}>Open · {e.preventDefault(); void rename(f.path)}}>Rename · {e.preventDefault(); void del(f.path,true)}}>Delete) - : (<>{e.preventDefault(); void rename(f.path)}}>Rename · {e.preventDefault(); void del(f.path,false)}}>Delete) - } +
+ {/* Only show "Open" when at library root to enforce one-level depth */} + {rowsSorted.map(f => +
+
{f.is_dir?'📁':'🎞️'} {f.name}
+
{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}
+
-
- )} -
+ )} +
)}
) @@ -326,7 +494,9 @@ function fmt(n:number){ if(n<1024) return n+' B'; const u=['KB','MB','GB','TB']; function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void }) { const [name, setName] = React.useState(''); async function submit() { - const clean = name.trim().replace(/[\/]+/g,'/').replace(/^\//,'').replace(/[^\w\-\s.]/g,'_'); + // single-level only + let clean = name.trim().replace(/[\/]+/g,'').replace(/[^\w\-\s.]/g,'_') + clean = clean.replace(/\s+/g,'_') if (!clean) return; const path = [cwd, clean].filter(Boolean).join('/'); console.log('[Pegasus] mkdir click', { path }) @@ -341,7 +511,7 @@ function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void } return (
- setName(e.target.value)} /> + setName(e.target.value)} />
);