/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/preview/handlers.mjs * Purpose : Preview endpoints shared by the app API and public share pages * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { createReadStream, existsSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; import sharp from 'sharp'; import exifReader from 'exif-reader'; import { blobPath } from '../storage/blobs.mjs'; import { previewStrategy, extOf } from './router.mjs'; import { getThumb, probeMedia } from './thumbs.mjs'; import { isWebSafeVideo, startTranscode, transcodeState, getAudioPeaks } from './transcode.mjs'; import { convertToPdf, officeState, sofficeBin } from './office.mjs'; import { listArchive, extractMember } from './archive.mjs'; import { highlightCode, renderMarkdown } from './code.mjs'; import { sendBlob, parseRange, apiError } from '../web/http-helpers.mjs'; const TEXT_LIMIT = 4 * 1024 * 1024; /** * Preview descriptor — everything the client viewer needs to pick a renderer. * `base` is the URL prefix for follow-up calls ('/api/v1/preview/123' or * '/s//preview/456'). */ export async function describePreview(node, base) { const { strategy, ext } = previewStrategy(node); const out = { strategy, ext, id: node.id, name: node.name, size: node.size, mime: node.mime, modified: node.modified, sha: node.blob_sha, }; if (strategy === 'video') { const probe = await probeMedia(node.blob_sha); out.probe = probe; out.webSafe = isWebSafeVideo(probe, node.mime); if (!out.webSafe) { const state = transcodeState(node.blob_sha); out.transcode = state.state; if (state.state === 'none') { startTranscode(node.blob_sha); // fire and forget; client polls out.transcode = 'queued'; } } } if (strategy === 'audio') { out.probe = await probeMedia(node.blob_sha); } if (strategy === 'office') { out.office = (await sofficeBin()) ? officeState(node.blob_sha).state : 'unsupported'; if (out.office === 'none') { convertToPdf(node.blob_sha, extOf(node.name)).catch(() => {}); out.office = 'queued'; } } if (strategy === 'heic') { out.heicSupported = await heicSupported(); } out.base = base; return out; } let heicOk = null; async function heicSupported() { if (heicOk !== null) return heicOk; heicOk = Boolean(sharp.format?.heif?.input?.buffer || sharp.format?.heif?.input?.file); return heicOk; } /** GET …/text — shiki-highlighted HTML for code/text files. */ export async function handleText(node, req, reply) { if (node.size > TEXT_LIMIT) return apiError(reply, 413, 'too_large', 'File too large for text preview'); const raw = await readFile(blobPath(node.blob_sha), 'utf8'); const { html, lang, clipped } = await highlightCode(raw, node.name); return reply.send({ html, lang, clipped, raw: raw.length < 512 * 1024 ? raw : null }); } /** GET …/markdown — rendered GFM + raw source. */ export async function handleMarkdown(node, req, reply) { if (node.size > TEXT_LIMIT) return apiError(reply, 413, 'too_large', 'File too large'); const raw = await readFile(blobPath(node.blob_sha), 'utf8'); return reply.send({ html: renderMarkdown(raw), raw }); } /** GET …/raw — plain bytes as text/plain (csv/json/notebook parsing client-side). */ export async function handleRawText(node, req, reply) { const path = blobPath(node.blob_sha); const size = (await stat(path)).size; if (size > 64 * 1024 * 1024) return apiError(reply, 413, 'too_large', 'File too large'); reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Content-Length', size); reply.type('text/plain; charset=utf-8'); return reply.send(createReadStream(path)); } /** GET …/archive — member listing. */ export async function handleArchive(node, req, reply) { const listing = await listArchive(node.blob_sha, node.name); if (!listing) return apiError(reply, 422, 'unsupported', 'Archive format needs the 7z binary'); return reply.send(listing); } /** GET …/archive/member?path=… — stream one member (inline preview or download). */ export async function handleArchiveMember(node, req, reply) { const memberPath = String(req.query.path ?? ''); const member = await extractMember(node.blob_sha, node.name, memberPath); if (!member) return apiError(reply, 404, 'not_found', 'Member not found or too large'); const filename = memberPath.split('/').pop() || 'member'; const download = req.query.download === '1'; reply.header('X-Content-Type-Options', 'nosniff'); reply.header( 'Content-Disposition', `${download ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(filename)}`, ); if (member.size) reply.header('Content-Length', member.size); const mime = await import('mime').then((m) => m.default.getType(filename)); const safe = mime && /^(image\/(?!svg)|application\/pdf|text\/plain)/.test(mime); reply.type(download ? (mime ?? 'application/octet-stream') : safe ? mime : 'text/plain; charset=utf-8'); return reply.send(member.stream); } /** GET …/pdf — LibreOffice-converted PDF for office docs (Range-aware). */ export async function handleOfficePdf(node, req, reply) { const pdf = await convertToPdf(node.blob_sha, extOf(node.name)).catch(() => null); if (!pdf || !existsSync(pdf)) { return apiError(reply, 422, 'conversion_unavailable', 'LibreOffice conversion unavailable'); } const size = (await stat(pdf)).size; reply.header('Accept-Ranges', 'bytes'); reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Cache-Control', 'private, max-age=3600'); reply.type('application/pdf'); const range = parseRange(req.headers.range, size); if (range) { reply.code(206); reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`); reply.header('Content-Length', range.end - range.start + 1); return reply.send(createReadStream(pdf, range)); } reply.header('Content-Length', size); return reply.send(createReadStream(pdf)); } /** GET …/video — transcode status; …/video/file streams the cached mp4. */ export async function handleVideoStatus(node, req, reply) { const state = transcodeState(node.blob_sha); if (state.state === 'none') { startTranscode(node.blob_sha); return reply.send({ state: 'queued' }); } return reply.send({ state: state.state }); } export async function handleVideoFile(node, req, reply) { const state = transcodeState(node.blob_sha); if (state.state !== 'ready') return apiError(reply, 409, 'not_ready', 'Transcode not ready'); const size = (await stat(state.path)).size; reply.header('Accept-Ranges', 'bytes'); reply.type('video/mp4'); const range = parseRange(req.headers.range, size); if (range) { reply.code(206); reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`); reply.header('Content-Length', range.end - range.start + 1); return reply.send(createReadStream(state.path, range)); } reply.header('Content-Length', size); return reply.send(createReadStream(state.path)); } /** GET …/peaks — waveform peaks for the audio player. */ export async function handlePeaks(node, req, reply) { const peaks = await getAudioPeaks(node.blob_sha); return reply.send({ peaks: peaks ?? [] }); } /** GET …/exif — image metadata panel (dimensions, camera, date, GPS). */ export async function handleExif(node, req, reply) { try { const meta = await sharp(blobPath(node.blob_sha), { failOn: 'none' }).metadata(); const out = { width: meta.width ?? null, height: meta.height ?? null, format: meta.format ?? null, space: meta.space ?? null, density: meta.density ?? null, pages: meta.pages ?? null, }; if (meta.exif) { try { const exif = exifReader(meta.exif); out.camera = [exif?.Image?.Make, exif?.Image?.Model].filter(Boolean).join(' ') || null; out.lens = exif?.Photo?.LensModel ?? null; out.iso = exif?.Photo?.ISOSpeedRatings ?? null; out.exposure = exif?.Photo?.ExposureTime ?? null; out.fnumber = exif?.Photo?.FNumber ?? null; out.focal = exif?.Photo?.FocalLength ?? null; out.taken = exif?.Photo?.DateTimeOriginal ?? exif?.Image?.DateTime ?? null; const gps = exif?.GPSInfo; if (gps?.GPSLatitude && gps?.GPSLongitude) { const toDec = (dms, ref) => (dms[0] + dms[1] / 60 + dms[2] / 3600) * (ref === 'S' || ref === 'W' ? -1 : 1); out.gps = { lat: toDec(gps.GPSLatitude, gps.GPSLatitudeRef), lon: toDec(gps.GPSLongitude, gps.GPSLongitudeRef), }; } } catch { /* EXIF parse is best-effort */ } } return reply.send(out); } catch { return reply.send({}); } } /** GET …/heic — HEIC converted to JPEG on the fly (when libheif available). */ export async function handleHeic(node, req, reply) { if (!(await heicSupported())) return apiError(reply, 422, 'unsupported', 'HEIC not supported'); reply.type('image/jpeg'); reply.header('Cache-Control', 'private, max-age=3600'); const buf = await sharp(blobPath(node.blob_sha), { failOn: 'none' }) .rotate() .jpeg({ quality: 88 }) .toBuffer(); return reply.send(buf); } /** GET /thumb/:id — cached webp thumbnail (or 404 → client shows type icon). */ export async function handleThumb(node, req, reply, kind) { let effectiveKind = kind; let sha = node.blob_sha; if (kind === 'office') { const state = officeState(sha); if (state.state !== 'ready') return apiError(reply, 404, 'no_thumb', 'No thumbnail'); // Thumbnail the converted PDF: getThumb reads from blobPath, so pdf kind // is handled by a direct pdftoppm call on the cache file instead. effectiveKind = 'pdf-cache'; } const file = effectiveKind === 'pdf-cache' ? await getOfficeThumb(sha, req.query.size) : await getThumb(sha, effectiveKind, req.query.size); if (!file || !existsSync(file)) return apiError(reply, 404, 'no_thumb', 'No thumbnail'); const size = (await stat(file)).size; reply.type('image/webp'); reply.header('Cache-Control', 'private, max-age=86400'); reply.header('Content-Length', size); return reply.send(createReadStream(file)); } async function getOfficeThumb(sha, size) { const { getThumbFromFile } = await import('./office-thumb.mjs'); return getThumbFromFile(sha, size); } /** GET /stream/:id or /dl/:id (svg goes through the sandboxed sender). */ export async function handleFileSend(node, req, reply, { download }) { const { strategy } = previewStrategy(node); if (!download && strategy === 'svg') { const { sendSandboxedSvg } = await import('../web/http-helpers.mjs'); return sendSandboxedSvg(req, reply, { sha: node.blob_sha, filename: node.name }); } return sendBlob(req, reply, { sha: node.blob_sha, mime: node.mime, filename: node.name, download, }); }