spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/preview/handlers.mjs8 * Purpose : Preview endpoints shared by the app API and public share pages9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { createReadStream, existsSync } from 'node:fs';14import { readFile, stat } from 'node:fs/promises';15import sharp from 'sharp';16import exifReader from 'exif-reader';17import { blobPath } from '../storage/blobs.mjs';18import { previewStrategy, extOf } from './router.mjs';19import { getThumb, probeMedia } from './thumbs.mjs';20import { isWebSafeVideo, startTranscode, transcodeState, getAudioPeaks } from './transcode.mjs';21import { convertToPdf, officeState, sofficeBin } from './office.mjs';22import { listArchive, extractMember } from './archive.mjs';23import { highlightCode, renderMarkdown } from './code.mjs';24import { sendBlob, parseRange, apiError } from '../web/http-helpers.mjs';2526const TEXT_LIMIT = 4 * 1024 * 1024;2728/**29 * Preview descriptor — everything the client viewer needs to pick a renderer.30 * `base` is the URL prefix for follow-up calls ('/api/v1/preview/123' or31 * '/s/<token>/preview/456').32 */33export async function describePreview(node, base) {34 const { strategy, ext } = previewStrategy(node);35 const out = {36 strategy,37 ext,38 id: node.id,39 name: node.name,40 size: node.size,41 mime: node.mime,42 modified: node.modified,43 sha: node.blob_sha,44 };45 if (strategy === 'video') {46 const probe = await probeMedia(node.blob_sha);47 out.probe = probe;48 out.webSafe = isWebSafeVideo(probe, node.mime);49 if (!out.webSafe) {50 const state = transcodeState(node.blob_sha);51 out.transcode = state.state;52 if (state.state === 'none') {53 startTranscode(node.blob_sha); // fire and forget; client polls54 out.transcode = 'queued';55 }56 }57 }58 if (strategy === 'audio') {59 out.probe = await probeMedia(node.blob_sha);60 }61 if (strategy === 'office') {62 out.office = (await sofficeBin()) ? officeState(node.blob_sha).state : 'unsupported';63 if (out.office === 'none') {64 convertToPdf(node.blob_sha, extOf(node.name)).catch(() => {});65 out.office = 'queued';66 }67 }68 if (strategy === 'heic') {69 out.heicSupported = await heicSupported();70 }71 out.base = base;72 return out;73}7475let heicOk = null;76async function heicSupported() {77 if (heicOk !== null) return heicOk;78 heicOk = Boolean(sharp.format?.heif?.input?.buffer || sharp.format?.heif?.input?.file);79 return heicOk;80}8182/** GET …/text — shiki-highlighted HTML for code/text files. */83export async function handleText(node, req, reply) {84 if (node.size > TEXT_LIMIT) return apiError(reply, 413, 'too_large', 'File too large for text preview');85 const raw = await readFile(blobPath(node.blob_sha), 'utf8');86 const { html, lang, clipped } = await highlightCode(raw, node.name);87 return reply.send({ html, lang, clipped, raw: raw.length < 512 * 1024 ? raw : null });88}8990/** GET …/markdown — rendered GFM + raw source. */91export async function handleMarkdown(node, req, reply) {92 if (node.size > TEXT_LIMIT) return apiError(reply, 413, 'too_large', 'File too large');93 const raw = await readFile(blobPath(node.blob_sha), 'utf8');94 return reply.send({ html: renderMarkdown(raw), raw });95}9697/** GET …/raw — plain bytes as text/plain (csv/json/notebook parsing client-side). */98export async function handleRawText(node, req, reply) {99 const path = blobPath(node.blob_sha);100 const size = (await stat(path)).size;101 if (size > 64 * 1024 * 1024) return apiError(reply, 413, 'too_large', 'File too large');102 reply.header('X-Content-Type-Options', 'nosniff');103 reply.header('Content-Length', size);104 reply.type('text/plain; charset=utf-8');105 return reply.send(createReadStream(path));106}107108/** GET …/archive — member listing. */109export async function handleArchive(node, req, reply) {110 const listing = await listArchive(node.blob_sha, node.name);111 if (!listing) return apiError(reply, 422, 'unsupported', 'Archive format needs the 7z binary');112 return reply.send(listing);113}114115/** GET …/archive/member?path=… — stream one member (inline preview or download). */116export async function handleArchiveMember(node, req, reply) {117 const memberPath = String(req.query.path ?? '');118 const member = await extractMember(node.blob_sha, node.name, memberPath);119 if (!member) return apiError(reply, 404, 'not_found', 'Member not found or too large');120 const filename = memberPath.split('/').pop() || 'member';121 const download = req.query.download === '1';122 reply.header('X-Content-Type-Options', 'nosniff');123 reply.header(124 'Content-Disposition',125 `${download ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(filename)}`,126 );127 if (member.size) reply.header('Content-Length', member.size);128 const mime = await import('mime').then((m) => m.default.getType(filename));129 const safe = mime && /^(image\/(?!svg)|application\/pdf|text\/plain)/.test(mime);130 reply.type(download ? (mime ?? 'application/octet-stream') : safe ? mime : 'text/plain; charset=utf-8');131 return reply.send(member.stream);132}133134/** GET …/pdf — LibreOffice-converted PDF for office docs (Range-aware). */135export async function handleOfficePdf(node, req, reply) {136 const pdf = await convertToPdf(node.blob_sha, extOf(node.name)).catch(() => null);137 if (!pdf || !existsSync(pdf)) {138 return apiError(reply, 422, 'conversion_unavailable', 'LibreOffice conversion unavailable');139 }140 const size = (await stat(pdf)).size;141 reply.header('Accept-Ranges', 'bytes');142 reply.header('X-Content-Type-Options', 'nosniff');143 reply.header('Cache-Control', 'private, max-age=3600');144 reply.type('application/pdf');145 const range = parseRange(req.headers.range, size);146 if (range) {147 reply.code(206);148 reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);149 reply.header('Content-Length', range.end - range.start + 1);150 return reply.send(createReadStream(pdf, range));151 }152 reply.header('Content-Length', size);153 return reply.send(createReadStream(pdf));154}155156/** GET …/video — transcode status; …/video/file streams the cached mp4. */157export async function handleVideoStatus(node, req, reply) {158 const state = transcodeState(node.blob_sha);159 if (state.state === 'none') {160 startTranscode(node.blob_sha);161 return reply.send({ state: 'queued' });162 }163 return reply.send({ state: state.state });164}165166export async function handleVideoFile(node, req, reply) {167 const state = transcodeState(node.blob_sha);168 if (state.state !== 'ready') return apiError(reply, 409, 'not_ready', 'Transcode not ready');169 const size = (await stat(state.path)).size;170 reply.header('Accept-Ranges', 'bytes');171 reply.type('video/mp4');172 const range = parseRange(req.headers.range, size);173 if (range) {174 reply.code(206);175 reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);176 reply.header('Content-Length', range.end - range.start + 1);177 return reply.send(createReadStream(state.path, range));178 }179 reply.header('Content-Length', size);180 return reply.send(createReadStream(state.path));181}182183/** GET …/peaks — waveform peaks for the audio player. */184export async function handlePeaks(node, req, reply) {185 const peaks = await getAudioPeaks(node.blob_sha);186 return reply.send({ peaks: peaks ?? [] });187}188189/** GET …/exif — image metadata panel (dimensions, camera, date, GPS). */190export async function handleExif(node, req, reply) {191 try {192 const meta = await sharp(blobPath(node.blob_sha), { failOn: 'none' }).metadata();193 const out = {194 width: meta.width ?? null,195 height: meta.height ?? null,196 format: meta.format ?? null,197 space: meta.space ?? null,198 density: meta.density ?? null,199 pages: meta.pages ?? null,200 };201 if (meta.exif) {202 try {203 const exif = exifReader(meta.exif);204 out.camera = [exif?.Image?.Make, exif?.Image?.Model].filter(Boolean).join(' ') || null;205 out.lens = exif?.Photo?.LensModel ?? null;206 out.iso = exif?.Photo?.ISOSpeedRatings ?? null;207 out.exposure = exif?.Photo?.ExposureTime ?? null;208 out.fnumber = exif?.Photo?.FNumber ?? null;209 out.focal = exif?.Photo?.FocalLength ?? null;210 out.taken = exif?.Photo?.DateTimeOriginal ?? exif?.Image?.DateTime ?? null;211 const gps = exif?.GPSInfo;212 if (gps?.GPSLatitude && gps?.GPSLongitude) {213 const toDec = (dms, ref) =>214 (dms[0] + dms[1] / 60 + dms[2] / 3600) * (ref === 'S' || ref === 'W' ? -1 : 1);215 out.gps = {216 lat: toDec(gps.GPSLatitude, gps.GPSLatitudeRef),217 lon: toDec(gps.GPSLongitude, gps.GPSLongitudeRef),218 };219 }220 } catch { /* EXIF parse is best-effort */ }221 }222 return reply.send(out);223 } catch {224 return reply.send({});225 }226}227228/** GET …/heic — HEIC converted to JPEG on the fly (when libheif available). */229export async function handleHeic(node, req, reply) {230 if (!(await heicSupported())) return apiError(reply, 422, 'unsupported', 'HEIC not supported');231 reply.type('image/jpeg');232 reply.header('Cache-Control', 'private, max-age=3600');233 const buf = await sharp(blobPath(node.blob_sha), { failOn: 'none' })234 .rotate()235 .jpeg({ quality: 88 })236 .toBuffer();237 return reply.send(buf);238}239240/** GET /thumb/:id — cached webp thumbnail (or 404 → client shows type icon). */241export async function handleThumb(node, req, reply, kind) {242 let effectiveKind = kind;243 let sha = node.blob_sha;244 if (kind === 'office') {245 const state = officeState(sha);246 if (state.state !== 'ready') return apiError(reply, 404, 'no_thumb', 'No thumbnail');247 // Thumbnail the converted PDF: getThumb reads from blobPath, so pdf kind248 // is handled by a direct pdftoppm call on the cache file instead.249 effectiveKind = 'pdf-cache';250 }251 const file = effectiveKind === 'pdf-cache'252 ? await getOfficeThumb(sha, req.query.size)253 : await getThumb(sha, effectiveKind, req.query.size);254 if (!file || !existsSync(file)) return apiError(reply, 404, 'no_thumb', 'No thumbnail');255 const size = (await stat(file)).size;256 reply.type('image/webp');257 reply.header('Cache-Control', 'private, max-age=86400');258 reply.header('Content-Length', size);259 return reply.send(createReadStream(file));260}261262async function getOfficeThumb(sha, size) {263 const { getThumbFromFile } = await import('./office-thumb.mjs');264 return getThumbFromFile(sha, size);265}266267/** GET /stream/:id or /dl/:id (svg goes through the sandboxed sender). */268export async function handleFileSend(node, req, reply, { download }) {269 const { strategy } = previewStrategy(node);270 if (!download && strategy === 'svg') {271 const { sendSandboxedSvg } = await import('../web/http-helpers.mjs');272 return sendSandboxedSvg(req, reply, { sha: node.blob_sha, filename: node.name });273 }274 return sendBlob(req, reply, {275 sha: node.blob_sha,276 mime: node.mime,277 filename: node.name,278 download,279 });280}281