SPB Git

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%
11.2 KB · 281 lines javascript
Raw Blame History
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