import { createRouter } from '@@/create-router'; import { resolve, dirname, join, parse as parsePath } from 'node:path'; import { readdir, stat, mkdir, rm, rename, readFile, cp, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path'; import * as errors from '@@/custom-errors'; import { readTtsConfig } from '@@/api/server-settings/tts'; import { readSttConfig } from '@@/api/server-settings/stt'; import { readOcrConfig } from '@@/api/server-settings/ocr'; const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Video', 'Pictures', 'Onboarding']; const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video']; const ONBOARDING_SEED = join(DATA_PATH, 'Onboarding'); async function cleanOldCacheDirs(userDataDir: string) { for (const dir of OLD_CACHE_DIRS) { const target = join(userDataDir, dir); if (existsSync(target)) await rm(target, { recursive: true, force: true }); } } async function seedHomeDir(homeDir: string) { for (const dir of DEFAULT_HOME_DIRS) { const target = join(homeDir, dir); if (existsSync(target)) continue; if (dir === 'Onboarding' && existsSync(ONBOARDING_SEED)) { await cp(ONBOARDING_SEED, target, { recursive: true }); } else { await mkdir(target, { recursive: true }); } } } export const router = createRouter(); type UserCtx = { email: string; role: string | null }; function getUserDataDir(email: string): string { return join(DATA_PATH, email); } function getRootDir(user: UserCtx, root?: string): string { if (!root || root === 'home') return getHomeDir(user.email); if (root === 'user-data') return getUserDataDir(user.email); if (user.role !== 'Super Admin') throw errors.FORBIDDEN('Only Super Admin can access this root'); if (root === '~') return homedir(); if (root === 'officer.dev') return resolve(process.cwd(), '..'); throw errors.BAD_REQUEST(`Invalid root: ${root}`); } function resolveUserPath(rootDir: string, relPath: string): string { const resolved = resolve(rootDir, relPath.replace(/^\/+/, '')); if (!resolved.startsWith(rootDir)) throw errors.FORBIDDEN('Path outside root directory'); return resolved; } // List directory entries router.get('/ls', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, ''); const absPath = resolveUserPath(rootDir, relPath); // Auto-create dir if missing (only for user home root) if (!ctx.req.query('root') || ctx.req.query('root') === 'home') { await seedHomeDir(rootDir); await mkdir(absPath, { recursive: true }); } // Remove old top-level cache dirs (migrated to cache/ prefix) if (ctx.req.query('root') === 'user-data') { await cleanOldCacheDirs(rootDir); } let names: string[]; try { names = await readdir(absPath); } catch { return ctx.json({ path: '/', entries: [], reset: true }); } const entries = await Promise.all( names.map(async (name) => { const fullPath = resolve(absPath, name); // Skip entries that escape the home dir (shouldn't happen but be safe) if (!fullPath.startsWith(rootDir)) return null; const s = await stat(fullPath).catch(() => null); if (!s) return null; return { name, type: s.isDirectory() ? 'directory' : 'file', size: s.size, modifiedAt: s.mtimeMs, }; }), ); const path = '/' + absPath.slice(rootDir.length).replace(/^\/+/, ''); return ctx.json({ path, rootDir, entries: entries.filter(Boolean) }); }); // Read file contents router.get('/read', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read a directory'); const MAX_TEXT_SIZE = 5 * 1024 * 1024; // 5 MB if (s.size > MAX_TEXT_SIZE) throw errors.BAD_REQUEST('File too large to read (max 5 MB)'); const content = await readFile(absPath, 'utf-8'); return ctx.json({ content, size: s.size }); }); // Write file contents router.post('/write', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { path, content } = ctx.get('body') as { path: string; content: string }; if (!path) throw errors.BAD_REQUEST('path is required'); if (typeof content !== 'string') throw errors.BAD_REQUEST('content must be a string'); const MAX_SIZE = 5 * 1024 * 1024; // 5 MB if (new TextEncoder().encode(content).length > MAX_SIZE) throw errors.BAD_REQUEST('Content too large (max 5 MB)'); const absPath = resolveUserPath(rootDir, path); await mkdir(dirname(absPath), { recursive: true }); await Bun.write(absPath, content); return ctx.json({ ok: true }); }); // Create directory router.post('/mkdir', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { path } = ctx.get('body') as { path: string }; if (!path) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, path); await mkdir(absPath, { recursive: true }); return ctx.json({ ok: true }); }); // Upload files router.post('/upload', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, ''); const targetDir = resolveUserPath(rootDir, relPath); await mkdir(targetDir, { recursive: true }); const body = ctx.get('body') as Record; const raw = body['file']; const files = Array.isArray(raw) ? raw : raw ? [raw] : []; for (const file of files) { if (!(file instanceof File)) continue; const filePath = resolve(targetDir, file.name); if (!filePath.startsWith(rootDir)) continue; await mkdir(dirname(filePath), { recursive: true }); await Bun.write(filePath, file); } return ctx.json({ ok: true }); }); // Rename file or directory router.post('/rename', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { path: filePath, newName } = ctx.get('body') as { path: string; newName: string }; if (!filePath || !newName) throw errors.BAD_REQUEST('path and newName are required'); if (newName.includes('/')) throw errors.BAD_REQUEST('newName must not contain /'); const absPath = resolveUserPath(rootDir, filePath); if (absPath === rootDir) throw errors.FORBIDDEN('Cannot rename home directory'); const newPath = resolve(dirname(absPath), newName); if (!newPath.startsWith(rootDir)) throw errors.FORBIDDEN('Path outside home directory'); await rename(absPath, newPath); return ctx.json({ ok: true }); }); // Serve raw file (binary-safe, for audio/video/images/download) // Supports Range requests for audio/video seeking router.get('/raw', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot serve a directory'); const file = Bun.file(absPath); const contentType = file.type || 'application/octet-stream'; const total = s.size; const rangeHeader = ctx.req.header('range'); if (rangeHeader) { const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); if (match) { const start = match[1] ? parseInt(match[1], 10) : 0; const end = match[2] ? parseInt(match[2], 10) : total - 1; const chunkSize = end - start + 1; const slice = file.slice(start, end + 1); return new Response(slice, { status: 206, headers: { 'Content-Type': contentType, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(chunkSize), 'Accept-Ranges': 'bytes', }, }); } } return new Response(file, { headers: { 'Content-Type': contentType, 'Content-Length': String(total), 'Accept-Ranges': 'bytes', }, }); }); // Transcode video via ffmpeg with caching — outputs a seekable MP4 file router.get('/transcode', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory'); const userDataDir = getUserDataDir(user.email); const { dir, name } = parsePath(relPath); const cacheRel = dir ? `cache/video/${dir}/${name}.mp4` : `cache/video/${name}.mp4`; const cacheAbs = resolve(userDataDir, cacheRel); if (!existsSync(cacheAbs)) { await mkdir(dirname(cacheAbs), { recursive: true }); const tmpPath = cacheAbs + '.tmp'; const proc = Bun.spawn( [ 'ffmpeg', '-i', absPath, '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', '-y', tmpPath, ], { stdout: 'ignore', stderr: 'pipe' }, ); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); await unlink(tmpPath).catch(() => {}); throw errors.BAD_REQUEST(stderr.trim() || 'Video transcoding failed'); } await rename(tmpPath, cacheAbs); } const file = Bun.file(cacheAbs); const total = file.size; const rangeHeader = ctx.req.header('range'); if (rangeHeader) { const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); if (match) { const start = match[1] ? parseInt(match[1], 10) : 0; const end = match[2] ? parseInt(match[2], 10) : total - 1; const chunkSize = end - start + 1; return new Response(file.slice(start, end + 1), { status: 206, headers: { 'Content-Type': 'video/mp4', 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(chunkSize), 'Accept-Ranges': 'bytes', }, }); } } return new Response(file, { headers: { 'Content-Type': 'video/mp4', 'Content-Length': String(total), 'Accept-Ranges': 'bytes', }, }); }); // Transcode audio via ffmpeg for universal playback (outputs MP3) router.get('/transcode-audio', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory'); const proc = Bun.spawn( ['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'], { stdout: 'pipe', stderr: 'ignore' }, ); return new Response(proc.stdout as ReadableStream, { headers: { 'Content-Type': 'audio/mpeg', 'Transfer-Encoding': 'chunked', }, }); }); // Save a cached result (ocr/tts/transcriptions/audio) next to the original file const CACHE_PREFIXES = ['cache/ocr/', 'cache/tts/', 'cache/transcriptions/', 'cache/audio/', 'cache/video/']; router.post('/save-result', async (ctx) => { const user = ctx.get('user'); const { path: cachedPath } = ctx.get('body') as { path: string }; if (!cachedPath) throw errors.BAD_REQUEST('path is required'); const prefix = CACHE_PREFIXES.find((p) => cachedPath.startsWith(p)); if (!prefix) throw errors.BAD_REQUEST('Not a cached result path'); let relativePath = cachedPath.slice(prefix.length); // Strip any nested cache prefixes (e.g. tts/ocr/photos/file.mp3 → photos/file.mp3) let nested: string | undefined; while ((nested = CACHE_PREFIXES.find((p) => relativePath.startsWith(p)))) { relativePath = relativePath.slice(nested.length); } const userDataDir = getUserDataDir(user.email); const srcAbs = resolve(userDataDir, cachedPath); if (!existsSync(srcAbs)) throw errors.BAD_REQUEST('Cached file not found'); const homeDir = getHomeDir(user.email); const destAbs = resolve(homeDir, relativePath); if (!destAbs.startsWith(homeDir)) throw errors.FORBIDDEN('Path outside home directory'); await mkdir(dirname(destAbs), { recursive: true }); await cp(srcAbs, destAbs); const destPath = '/' + destAbs.slice(homeDir.length).replace(/^\/+/, ''); return ctx.json({ savedPath: destPath }); }); // Text-to-speech with caching router.post('/tts', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory'); const userDataDir = getUserDataDir(user.email); const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); const cacheRel = dir ? `cache/tts/${dir}/${name}.mp3` : `cache/tts/${name}.mp3`; const cacheAbs = resolve(userDataDir, cacheRel); if (existsSync(cacheAbs)) { return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); } const ttsConfig = await readTtsConfig(); if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech'); const content = await readFile(absPath, 'utf-8'); const headers: Record = { 'Content-Type': 'application/json' }; let res: Response; if (ttsConfig.provider === 'elevenlabs') { if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured'); res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(ttsConfig.voice)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey }, body: JSON.stringify({ text: content, model_id: ttsConfig.model }), }); } else { if (ttsConfig.apiKey) headers['Authorization'] = `Bearer ${ttsConfig.apiKey}`; res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, { method: 'POST', headers, body: JSON.stringify({ model: ttsConfig.model, input: content, voice: ttsConfig.voice, response_format: 'mp3' }), }); } if (!res.ok) throw errors.BAD_REQUEST('TTS request failed'); await mkdir(dirname(cacheAbs), { recursive: true }); const buffer = await res.arrayBuffer(); await Bun.write(cacheAbs, buffer); return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); }); // OCR image via vision model with caching router.post('/ocr', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot OCR a directory'); const userDataDir = getUserDataDir(user.email); const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); const cacheRel = dir ? `cache/ocr/${dir}/${name}.md` : `cache/ocr/${name}.md`; const cacheAbs = resolve(userDataDir, cacheRel); if (existsSync(cacheAbs)) { const text = await readFile(cacheAbs, 'utf-8'); return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: true }); } const ocrConfig = await readOcrConfig(); if (!ocrConfig) throw errors.BAD_REQUEST('OCR not configured — set it up in Settings → OCR'); const imageBytes = await Bun.file(absPath).arrayBuffer(); const base64 = Buffer.from(imageBytes).toString('base64'); const ext = absPath.split('.').pop()?.toLowerCase() ?? 'png'; const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : `image/${ext}`; const res = await fetch(`${ocrConfig.url.replace(/\/+$/, '')}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: ocrConfig.model, messages: [ { role: 'system', content: [ 'You are an OCR assistant. Extract meaningful text content from images.', 'Rules:', '- Output ONLY the extracted text, no commentary or explanations.', '- For documents, articles, books: preserve the original text, paragraphs, and structure as markdown.', '- For tables: use markdown table format.', '- For code/terminal screenshots: use fenced code blocks.', '- For social media posts/threads: extract as clean conversation. Format as:', ' **username** says: "their text"', ' **replier** replies: "their text"', ' Strip all UI chrome (follow buttons, timestamps, like counts, avatars, "Everybody can reply", etc).', ' Keep only usernames and what they actually wrote.', '- For memes/image macros: describe the image briefly, then extract any text.', '- For handwriting: transcribe as accurately as possible.', '- For receipts/invoices: extract as structured text with line items.', '- Strip all UI elements, navigation, ads, watermarks, and other noise.', '- Preserve the original language of the text.', ].join('\n'), }, { role: 'user', content: [ { type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } }, { type: 'text', text: 'Extract the text from this image.' }, ], }, ], }), }); if (!res.ok) throw errors.BAD_REQUEST('OCR request failed'); const json = (await res.json()) as { choices: { message: { content: string } }[] }; const text = json.choices[0]?.message?.content ?? ''; await mkdir(dirname(cacheAbs), { recursive: true }); await Bun.write(cacheAbs, text); return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: false }); }); // Extract audio from video via ffmpeg with caching router.post('/extract-audio', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract audio from a directory'); const userDataDir = getUserDataDir(user.email); const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); const cacheRel = dir ? `cache/audio/${dir}/${name}.mp3` : `cache/audio/${name}.mp3`; const cacheAbs = resolve(userDataDir, cacheRel); if (existsSync(cacheAbs)) { return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: true }); } await mkdir(dirname(cacheAbs), { recursive: true }); const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-vn', '-codec:a', 'libmp3lame', '-q:a', '2', '-y', cacheAbs], { stdout: 'ignore', stderr: 'pipe', }); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); throw errors.BAD_REQUEST(stderr.trim() || 'Audio extraction failed'); } return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: false }); }); // Extract archive (zip, tar, 7z, rar) into a sibling folder router.post('/extract', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract a directory'); const fileName = absPath.split('/').pop()!; const lower = fileName.toLowerCase(); // Determine archive type and build command type ArchiveType = 'tar' | 'zip' | '7z' | 'rar'; let archiveType: ArchiveType; if ( lower.endsWith('.tar') || lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar.bz2') || lower.endsWith('.tbz2') || lower.endsWith('.tar.xz') || lower.endsWith('.txz') || lower.endsWith('.tar.zst') || lower.endsWith('.gz') || lower.endsWith('.bz2') || lower.endsWith('.xz') || lower.endsWith('.zst') ) { archiveType = 'tar'; } else if (lower.endsWith('.zip')) { archiveType = 'zip'; } else if (lower.endsWith('.7z')) { archiveType = '7z'; } else if (lower.endsWith('.rar')) { archiveType = 'rar'; } else { throw errors.BAD_REQUEST('Unsupported archive format'); } // Compute destination folder name (strip archive extension) const stripArchiveExt = (name: string): string => { const l = name.toLowerCase(); for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) { if (l.endsWith(compound)) return name.slice(0, -compound.length); } const dotIdx = name.lastIndexOf('.'); return dotIdx > 0 ? name.slice(0, dotIdx) : name; }; const baseName = stripArchiveExt(fileName); const destPath = resolve(dirname(absPath), baseName); const finalDest = await resolveCollision(destPath); await mkdir(finalDest, { recursive: true }); let cmd: string[]; switch (archiveType) { case 'tar': cmd = ['tar', 'xf', absPath, '-C', finalDest]; break; case 'zip': cmd = ['unzip', '-q', absPath, '-d', finalDest]; break; case '7z': cmd = ['7z', 'x', absPath, `-o${finalDest}`, '-y']; break; case 'rar': cmd = ['unrar', 'x', '-o+', absPath, `${finalDest}/`]; break; } const proc = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' }); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); // Clean up the empty directory on failure await rm(finalDest, { recursive: true, force: true }).catch(() => {}); throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed'); } const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, ''); return ctx.json({ extractedPath }); }); // Transcribe audio via Whisper with caching // Workflow: detect language → check user's spoken languages → translate if needed → transcribe router.post('/transcribe', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcribe a directory'); const userDataDir = getUserDataDir(user.email); const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); const cacheRel = dir ? `cache/transcriptions/${dir}/${name}.md` : `cache/transcriptions/${name}.md`; const cacheAbs = resolve(userDataDir, cacheRel); if (existsSync(cacheAbs)) { return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: true }); } const sttConfig = await readSttConfig(); const whisperUrl = sttConfig?.url; if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured — set it up in Settings → Speech to Text'); const audioFile = Bun.file(absPath); // Step 1: Detect language const detectForm = new FormData(); detectForm.append('file', audioFile); detectForm.append('temperature', '0.0'); detectForm.append('response_format', 'verbose_json'); detectForm.append('detect_language', 'true'); const detectRes = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: detectForm }); if (!detectRes.ok) throw errors.BAD_REQUEST('Language detection failed'); const detectJson = (await detectRes.json()) as { language?: string }; const detectedLang = detectJson.language ?? 'en'; // Step 2: Check user's spoken languages to decide if translation is needed let shouldTranslate = false; const settingsFile = Bun.file(getUserSettingsFile(user.email)); if (await settingsFile.exists()) { const settings = (await settingsFile.json()) as { languages?: { spoken?: string[] } }; const spokenLanguages = settings.languages?.spoken ?? []; if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) { shouldTranslate = true; } } // Step 3: Full transcription const transcribeForm = new FormData(); transcribeForm.append('file', audioFile); transcribeForm.append('temperature', '0.0'); transcribeForm.append('temperature_inc', '0.2'); transcribeForm.append('response_format', 'text'); transcribeForm.append('language', detectedLang); if (shouldTranslate) { transcribeForm.append('translate', 'true'); } const res = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: transcribeForm }); if (!res.ok) throw errors.BAD_REQUEST('Transcription request failed'); const text = (await res.text()).trim(); await mkdir(dirname(cacheAbs), { recursive: true }); await Bun.write(cacheAbs, text); return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: false }); }); // Search files by name router.get('/search', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const query = (ctx.req.query('q') || '').trim().toLowerCase(); if (!query) throw errors.BAD_REQUEST('q is required'); const MAX_RESULTS = 50; const results: { path: string; name: string; type: string; size: number; modifiedAt: number }[] = []; async function walk(dir: string) { if (results.length >= MAX_RESULTS) return; const names = await readdir(dir).catch(() => [] as string[]); for (const name of names) { if (results.length >= MAX_RESULTS) break; const fullPath = resolve(dir, name); if (!fullPath.startsWith(rootDir)) continue; const s = await stat(fullPath).catch(() => null); if (!s) continue; if (name.toLowerCase().includes(query)) { const relPath = '/' + fullPath.slice(rootDir.length).replace(/^\/+/, ''); results.push({ path: relPath, name, type: s.isDirectory() ? 'directory' : 'file', size: s.size, modifiedAt: s.mtimeMs, }); } if (s.isDirectory()) await walk(fullPath); } } await walk(rootDir); return ctx.json({ results }); }); // Resolve a destination path, appending " (copy)", " (copy 2)", etc. if it already exists async function resolveCollision(destPath: string): Promise { try { await stat(destPath); } catch { return destPath; } const dir = dirname(destPath); const base = destPath.split('/').pop()!; const dotIdx = base.lastIndexOf('.'); const name = dotIdx > 0 ? base.slice(0, dotIdx) : base; const ext = dotIdx > 0 ? base.slice(dotIdx) : ''; let n = 1; while (true) { const suffix = n === 1 ? ' (copy)' : ` (copy ${n})`; const candidate = resolve(dir, `${name}${suffix}${ext}`); try { await stat(candidate); n++; } catch { return candidate; } } } type CopyMoveItem = { source: string; destination: string }; type CopyMoveBody = CopyMoveItem | { items: CopyMoveItem[] }; function parseCopyMoveBody(body: CopyMoveBody): CopyMoveItem[] { if ('items' in body && Array.isArray(body.items)) return body.items; return [body as CopyMoveItem]; } // Copy file or directory router.post('/copy', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const body = ctx.get('body') as CopyMoveBody; const items = parseCopyMoveBody(body); if (items.length === 0) throw errors.BAD_REQUEST('No items provided'); const results: { source: string; destination: string; error?: string }[] = []; for (const item of items) { if (!item.source || !item.destination) { results.push({ source: item.source, destination: item.destination, error: 'source and destination are required', }); continue; } try { const srcAbs = resolveUserPath(rootDir, item.source); const destAbs = resolveUserPath(rootDir, item.destination); await mkdir(dirname(destAbs), { recursive: true }); const finalDest = await resolveCollision(destAbs); await cp(srcAbs, finalDest, { recursive: true }); const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, ''); results.push({ source: item.source, destination: relDest }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unknown error'; results.push({ source: item.source, destination: item.destination, error: msg }); } } return ctx.json({ ok: true, results }); }); // Move file or directory router.post('/move', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const body = ctx.get('body') as CopyMoveBody; const items = parseCopyMoveBody(body); if (items.length === 0) throw errors.BAD_REQUEST('No items provided'); const results: { source: string; destination: string; error?: string }[] = []; for (const item of items) { if (!item.source || !item.destination) { results.push({ source: item.source, destination: item.destination, error: 'source and destination are required', }); continue; } try { const srcAbs = resolveUserPath(rootDir, item.source); const destAbs = resolveUserPath(rootDir, item.destination); // Prevent moving a directory into itself if (destAbs.startsWith(srcAbs + '/')) { throw new Error('Cannot move a directory into itself'); } await mkdir(dirname(destAbs), { recursive: true }); const finalDest = await resolveCollision(destAbs); await rename(srcAbs, finalDest); const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, ''); results.push({ source: item.source, destination: relDest }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unknown error'; results.push({ source: item.source, destination: item.destination, error: msg }); } } return ctx.json({ ok: true, results }); }); // Download video via yt-dlp router.post('/download-video', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { url, path, audioOnly } = ctx.get('body') as { url: string; path: string; audioOnly?: boolean }; if (!url) throw errors.BAD_REQUEST('url is required'); if (!path) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, path); const args = ['yt-dlp', '-o', '%(title)s.%(ext)s']; if (audioOnly) args.push('-x', '--audio-format', 'mp3'); args.push(url); const proc = Bun.spawn(args, { cwd: absPath, stdout: 'pipe', stderr: 'pipe' }); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); throw errors.BAD_REQUEST(stderr.trim() || 'yt-dlp download failed'); } return ctx.json({ ok: true }); }); // Git clone a repository into a directory router.post('/git-clone', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { url, path } = ctx.get('body') as { url: string; path: string }; if (!url) throw errors.BAD_REQUEST('url is required'); if (!path) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, path); await mkdir(absPath, { recursive: true }); const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'pipe', stderr: 'pipe' }); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); throw errors.BAD_REQUEST(stderr.trim() || 'git clone failed'); } return ctx.json({ ok: true }); }); // Download file or directory (directories are zipped on-the-fly) router.get('/download', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const s = await stat(absPath); const name = absPath.split('/').pop()!; if (s.isDirectory()) { const proc = Bun.spawn(['zip', '-r', '-', name], { cwd: dirname(absPath), stdout: 'pipe', stderr: 'ignore', }); return new Response(proc.stdout as ReadableStream, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${name}.zip"`, }, }); } const file = Bun.file(absPath); return new Response(file, { headers: { 'Content-Type': file.type || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${name}"`, 'Content-Length': String(s.size), }, }); }); // Download multiple items as a single zip router.post('/download', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { paths } = ctx.get('body') as { paths: string[] }; if (!Array.isArray(paths) || paths.length === 0) throw errors.BAD_REQUEST('paths is required'); const items: string[] = []; for (const p of paths) { const relPath = p.replace(/^\/+/, ''); const absPath = resolveUserPath(rootDir, relPath); await stat(absPath); // throws if not found items.push(relPath); } const proc = Bun.spawn(['zip', '-r', '-', ...items], { cwd: rootDir, stdout: 'pipe', stderr: 'ignore', }); return new Response(proc.stdout as ReadableStream, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="download.zip"', }, }); }); // Delete file or directory router.delete('/rm', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const body = await ctx.req.json<{ path: string }>(); const { path } = body; if (!path) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, path); // Prevent deleting the home dir itself if (absPath === rootDir) throw errors.FORBIDDEN('Cannot delete home directory'); await rm(absPath, { recursive: true, force: true }); return ctx.json({ ok: true }); });