import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import { isReservedName, metaFile, safeJoin } from './paths.mjs'; const PREVIEWABLE = new Set([ '.html', '.htm', '.css', '.js', '.mjs', '.json', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf', '.txt', '.md', '.csv', '.xml', '.mp4', '.webm', '.mp3', '.wav', '.ogg', ]); export function isPreviewable(filePath) { return PREVIEWABLE.has(path.extname(filePath).toLowerCase()); } export function mimeFor(filePath) { const ext = path.extname(filePath).toLowerCase(); const map = { '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.zip': 'application/zip', '.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8', '.csv': 'text/csv; charset=utf-8', '.xml': 'application/xml; charset=utf-8', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', }; return map[ext] || 'application/octet-stream'; } export async function walkUploadDir(ctx, dir, base = dir) { const entries = await fsp.readdir(dir, { withFileTypes: true }); const files = []; const dirs = []; for (const entry of entries) { if (isReservedName(ctx, entry.name)) continue; const full = path.join(dir, entry.name); const rel = path.relative(base, full).split(path.sep).join('/'); if (entry.isDirectory()) { dirs.push(rel); const nested = await walkUploadDir(ctx, full, base); files.push(...nested.files); dirs.push(...nested.dirs); } else if (entry.isFile()) { const stat = await fsp.stat(full); files.push({ name: rel, size: stat.size, mtime: stat.mtimeMs, previewable: isPreviewable(full), }); } } return { files: files.sort((a, b) => b.mtime - a.mtime), dirs, }; } export async function loadMeta(ctx) { const file = metaFile(ctx); try { const stat = await fsp.stat(file); const raw = await fsp.readFile(file, 'utf8'); return { mtime: stat.mtimeMs, data: JSON.parse(raw) }; } catch { return { mtime: Date.now(), data: { files: {} } }; } } export async function saveMeta(ctx, data) { const file = metaFile(ctx); await fsp.mkdir(path.dirname(file), { recursive: true }); await fsp.writeFile(file, JSON.stringify(data, null, 2)); } export async function setFileMeta(ctx, relPath, meta) { const loaded = await loadMeta(ctx); loaded.data.files[relPath] = { ...(loaded.data.files[relPath] || {}), ...meta, updatedAt: Date.now(), }; await saveMeta(ctx, loaded.data); } export async function removeFileMeta(ctx, relPath) { const loaded = await loadMeta(ctx); delete loaded.data.files[relPath]; await saveMeta(ctx, loaded.data); } export async function renameFileMeta(ctx, fromRel, toRel) { const loaded = await loadMeta(ctx); if (loaded.data.files[fromRel]) { loaded.data.files[toRel] = { ...loaded.data.files[fromRel], updatedAt: Date.now() }; delete loaded.data.files[fromRel]; await saveMeta(ctx, loaded.data); } } export async function enrichFiles(ctx, files) { const loaded = await loadMeta(ctx); return files.map((f) => ({ ...f, origin: loaded.data.files[f.name] || null, })); } export function statEntry(ctx, rel) { const full = safeJoin(ctx.uploadDir, rel); if (!fs.existsSync(full)) return null; const stat = fs.statSync(full); return { full, stat, isDirectory: stat.isDirectory() }; }