pegasus/frontend/src/uploader-utils.ts

142 lines
4.0 KiB
TypeScript
Raw Normal View History

2026-04-11 00:02:59 -03:00
import * as tus from 'tus-js-client'
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
type RowLike = {
name?: unknown
Name?: unknown
path?: unknown
Path?: unknown
is_dir?: unknown
IsDir?: unknown
isDir?: unknown
size?: unknown
Size?: unknown
mtime?: unknown
Mtime?: unknown
}
type NoResumeStorage = {
addUpload: (_u: any) => Promise<void>
removeUpload: (_u: any) => Promise<void>
listUploads: () => Promise<any[]>
findUploadsByFingerprint: (_fp: string) => Promise<any[]>
}
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() : '')
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) {
// Only allow a single path segment and collapse punctuation to keep mkdir/rename predictable.
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 stemOf(n: string) {
const i = n.lastIndexOf('.')
const stem = i > -1 ? n.slice(0, i) : n
// Keep the stem safe and avoid extra dots inside the composed upload name.
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'
return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}`
}
function clampOneLevel(p: string) {
if (!p) return ''
return p.replace(/^\/+|\/+$/g, '').split('/')[0] || ''
}
function normalizeRows(listRaw: unknown[]): FileRow[] {
return (Array.isArray(listRaw) ? listRaw : []).map((r: unknown) => {
const row = (r && typeof r === 'object' ? r : {}) as RowLike
return {
name: String(row.name ?? row.Name ?? ''),
path: String(row.path ?? row.Path ?? ''),
is_dir: Boolean(row.is_dir ?? row.IsDir ?? row.isDir ?? false),
size: Number(row.size ?? row.Size ?? 0),
mtime: Number(row.mtime ?? row.Mtime ?? 0),
}
})
}
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))
}
function isLikelyMobileUA(): boolean {
if (typeof window === 'undefined') return false
const ua = navigator.userAgent || ''
const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
return coarse || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
}
function fmt(n: number) {
if (n < 1024) return `${n} B`
const units = ['KB', 'MB', 'GB', 'TB']
let idx = -1
do {
n /= 1024
idx++
} while (n >= 1024 && idx < units.length - 1)
return `${n.toFixed(1)} ${units[idx]}`
}
function createNoResumeStorage(): NoResumeStorage {
return {
async addUpload(_u: any) {},
async removeUpload(_u: any) {},
async listUploads() {
return []
},
async findUploadsByFingerprint(_fp: string) {
return []
},
}
}
async function createNoResumeFingerprint() {
return `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
const uploaderUtils = {
sanitizeDesc,
sanitizeFolderName,
extOf,
stemOf,
composeName,
clampOneLevel,
normalizeRows,
isVideoFile,
isImageFile,
isDetailedError,
isLikelyMobileUA,
fmt,
createNoResumeStorage,
createNoResumeFingerprint,
}
export default uploaderUtils