diff --git a/backend/main.go b/backend/main.go index b6675ed..fbb73bf 100644 --- a/backend/main.go +++ b/backend/main.go @@ -319,7 +319,7 @@ func main() { }) // 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,64})?\.[A-Za-z0-9]{1,8}$`) r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) { cl, err := internal.CurrentUser(r) if err != nil { @@ -641,10 +641,18 @@ func sanitizeDescriptor(s string) string { return string(b) } +// stemOf strips the final extension and cleans the base. +func stemOf(orig string) string { + base := strings.TrimSuffix(orig, filepath.Ext(orig)) + s := sanitizeDescriptor(strings.ReplaceAll(base, ".", "_")) + if s == "" { s = "file" } + return s +} + // composeFinalName mirrors the UI: YYYY.MM.DD.Description.ext // date is expected as YYYY-MM-DD; falls back to today if missing/bad. func composeFinalName(date, desc, orig string) string { - y, m, d := "", "", "" + var y, m, d string if len(date) >= 10 && date[4] == '-' && date[7] == '-' { y, m, d = date[:4], date[5:7], date[8:10] } else { @@ -654,9 +662,11 @@ func composeFinalName(date, desc, orig string) string { d = fmt.Sprintf("%02d", now.Day()) } clean := sanitizeDescriptor(desc) + if clean == "" { clean = "upload" } + stem := stemOf(orig) ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), ".")) if ext == "" { ext = "bin" } - return fmt.Sprintf("%s.%s.%s.%s.%s", y, m, d, clean, ext) + return fmt.Sprintf("%s.%s.%s.%s.%s.%s", y, m, d, clean, stem, ext) } // moveFromTus relocates the finished upload file from the TUS scratch directory to dst. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ef5bac9..f122d41 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,12 +7,18 @@ import { api } from './api' export default function App() { const [authed, setAuthed] = React.useState(undefined) - React.useEffect(()=>{ - api('/api/whoami').then(()=> setAuthed(true)).catch(()=> setAuthed(false)) + React.useEffect(() => { + api('/api/whoami') + .then(() => setAuthed(true)) + .catch(() => setAuthed(false)) }, []) - async function logout(){ - try { await api('/api/logout', { method:'POST' }) } finally { location.reload() } + async function logout() { + try { + await api('/api/logout', { method: 'POST' }) + } finally { + location.reload() + } } return ( @@ -25,7 +31,7 @@ export default function App() { {authed && } - {authed ? : } + {authed ? : setAuthed(true)} />} ) } diff --git a/frontend/src/Uploader.tsx b/frontend/src/Uploader.tsx index 39c49c6..57d4538 100644 --- a/frontend/src/Uploader.tsx +++ b/frontend/src/Uploader.tsx @@ -9,37 +9,54 @@ type Sel = { file: File; desc: string; date: string; finalName: string; progress console.log('[Pegasus] FE bundle activated at', new Date().toISOString()) // ---------- helpers ---------- -function sanitizeDesc(s:string){ - s = s.trim().replace(/\s+/g,'_').replace(/[^A-Za-z0-9._-]+/g,'_') - if(!s) s = 'upload' - return s.slice(0,64) +function sanitizeDesc(s: string) { + s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_') + if (!s) s = 'upload' + return s.slice(0, 64) } -function sanitizeFolderName(s:string){ +function sanitizeFolderName(s: string) { // 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-] - s = s.trim().replace(/[\/]+/g, '/').replace(/^\//,'').replace(/\/.*$/,'') - s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g,'_').replace(/_+/g,'_') - return s.slice(0,64) + s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '') + s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_') + return s.slice(0, 64) } -function extOf(n:string){ const i=n.lastIndexOf('.'); return i>-1 ? n.slice(i+1).toLowerCase() : '' } -function composeName(date:string, desc:string, orig:string){ - const d = date || new Date().toISOString().slice(0,10) - const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}` +function extOf(n: string) { + const i = n.lastIndexOf('.') + return i > -1 ? n.slice(i + 1).toLowerCase() : '' } -function clampOneLevel(p:string){ if(!p) return ''; return p.replace(/^\/+|\/+$/g,'').split('/')[0] || '' } -function normalizeRows(listRaw:any[]): FileRow[] { - return (Array.isArray(listRaw) ? listRaw : []).map((r:any)=>({ - name: r?.name ?? r?.Name ?? '', - path: r?.path ?? r?.Path ?? '', +function stemOf(n: string) { + const i = n.lastIndexOf('.') + const stem = i > -1 ? n.slice(0, i) : n + // keep safe charset and avoid extra dots within segments + return sanitizeDesc(stem.replace(/\./g, '_')) || 'file' +} +function composeName(date: string, desc: string, orig: string) { + const d = date || new Date().toISOString().slice(0, 10) + const [Y, M, D] = d.split('-') + const sDesc = sanitizeDesc(desc) + const sStem = stemOf(orig) + const ext = extOf(orig) || 'bin' + // New pattern: YYYY.MM.DD.Description.OriginalStem.ext + return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}` +} +function clampOneLevel(p: string) { + if (!p) return '' + return p.replace(/^\/+|\/+$/g, '').split('/')[0] || '' +} +function normalizeRows(listRaw: any[]): FileRow[] { + return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({ + name: r?.name ?? r?.Name ?? '', + path: r?.path ?? r?.Path ?? '', is_dir: Boolean(r?.is_dir ?? r?.IsDir ?? r?.isDir ?? false), - size: Number(r?.size ?? r?.Size ?? 0), + size: Number(r?.size ?? r?.Size ?? 0), mtime: Number(r?.mtime ?? r?.Mtime ?? 0), })) } -const videoExt = new Set(['mp4','mkv','mov','avi','m4v','webm','mpg','mpeg','ts','m2ts']) -const imageExt = new Set(['jpg','jpeg','png','gif','heic','heif','webp','bmp','tif','tiff']) -const extLower = (n:string)=> (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '') -const isVideoFile = (f:File)=> f.type.startsWith('video/') || videoExt.has(extLower(f.name)) -const isImageFile = (f:File)=> f.type.startsWith('image/') || imageExt.has(extLower(f.name)) +const videoExt = new Set(['mp4', 'mkv', 'mov', 'avi', 'm4v', 'webm', 'mpg', 'mpeg', 'ts', 'm2ts']) +const imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff']) +const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '') +const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name)) +const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name)) function isDetailedError(e: unknown): e is tus.DetailedError { return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any)) @@ -53,7 +70,9 @@ function isLikelyMobileUA(): boolean { } function useIsMobile(): boolean { const [mobile, setMobile] = React.useState(false) - React.useEffect(()=>{ setMobile(isLikelyMobileUA()) }, []) + React.useEffect(() => { + setMobile(isLikelyMobileUA()) + }, []) return mobile } @@ -62,44 +81,55 @@ const NoResumeUrlStorage: any = { addUpload: async (_u: any) => {}, removeUpload: async (_u: any) => {}, listUploads: async () => [], - // compatibility: - findUploadsByFingerprint: async (_fp: string) => [] + findUploadsByFingerprint: async (_fp: string) => [], } const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}` // ---------- thumbnail ---------- function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) { const [url, setUrl] = React.useState() - React.useEffect(()=>{ - const u = URL.createObjectURL(file); setUrl(u) - return () => { try { URL.revokeObjectURL(u) } catch {} } + React.useEffect(() => { + const u = URL.createObjectURL(file) + setUrl(u) + return () => { + try { + URL.revokeObjectURL(u) + } catch {} + } }, [file]) if (!url) return null const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 } if (isImageFile(file)) return if (isVideoFile(file)) return