Files
SnapOtter/apps/api/src/routes/file-preview.ts
T
SnapOtter 7a3b4e6b3b fix(security): basename-sanitize file-preview paths (CodeQL js/path-injection)
The resolve()+startsWith containment check was correct but CodeQL did not
recognize it. Apply path.basename() to the name in resolveWithinPreviewDir -- a
sanitizer CodeQL recognizes -- so every id-derived preview path is provably a
single filename inside the preview dir. Behaviour is unchanged for valid ids
(already charset-validated); containment check kept as a backstop.
2026-06-21 14:32:16 +08:00

384 lines
12 KiB
TypeScript

/**
* GET /api/v1/files/:id/preview
*
* Server-side preview generation for non-native video/audio formats
* and document files. Generates browser-playable H.264 MP4 (video),
* MP3 (audio), or PDF (documents) previews and caches them on disk.
*/
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { access, copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join, resolve, sep } from "node:path";
import { convertDocument, sofficeAvailable } from "@snapotter/doc-engine";
import { runFfmpeg } from "@snapotter/media-engine";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { getStoredFilePath } from "../lib/file-storage.js";
import { hasEffectivePermission } from "../permissions.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js";
const PREVIEW_DIR = ".previews";
let previewDirReady = false;
function previewDirPath(): string {
return join(env.FILES_STORAGE_PATH, PREVIEW_DIR);
}
async function ensurePreviewDir(): Promise<void> {
if (previewDirReady) return;
await mkdir(previewDirPath(), { recursive: true });
previewDirReady = true;
}
/**
* Resolve a name inside the preview directory and verify the result cannot
* escape it. The id is already charset-validated at the route; this containment
* check is the authoritative path-traversal barrier for every preview path.
*/
function resolveWithinPreviewDir(name: string): string {
// basename() strips any directory component, so the result can only ever be a
// single filename inside the preview dir; the containment check is a
// defense-in-depth backstop.
const base = resolve(previewDirPath());
const resolved = join(base, basename(name));
if (resolved !== base && !resolved.startsWith(base + sep)) {
throw new Error("Preview path escapes the preview directory");
}
return resolved;
}
function previewPath(fileId: string, ext: string): string {
return resolveWithinPreviewDir(`${fileId}${ext}`);
}
async function fileExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
const OFFICE_MIMES = new Set([
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.presentation",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
]);
export async function filePreviewRoutes(app: FastifyInstance): Promise<void> {
app.get(
"/api/v1/files/:id/preview",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const { id } = request.params;
// Confine id to a safe charset (no path separators or dots) before it is
// interpolated into filesystem paths below -- prevents path traversal via
// the URL param. File ids are generated server-side (randomUUID).
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
return reply.status(400).send({ error: "Invalid file id" });
}
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
if (
!file ||
(file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))
) {
return reply.status(404).send({ error: "File not found" });
}
const isVideo = file.mimeType.startsWith("video/");
const isAudio = file.mimeType.startsWith("audio/");
const isPdf = file.mimeType === "application/pdf";
const isOfficeDoc = OFFICE_MIMES.has(file.mimeType);
if (!isVideo && !isAudio && !isPdf && !isOfficeDoc) {
return reply.status(400).send({ error: "Preview not supported for this file type" });
}
// PDF: stream the original file directly
if (isPdf) {
const inputPath = getStoredFilePath(file.storedName);
return reply
.header("Content-Type", "application/pdf")
.header("Cache-Control", "public, max-age=86400, immutable")
.send(createReadStream(inputPath));
}
// Office documents: convert to PDF via LibreOffice
if (isOfficeDoc) {
const cachedPath = previewPath(id, ".pdf");
if (await fileExists(cachedPath)) {
return reply
.header("Content-Type", "application/pdf")
.header("Cache-Control", "public, max-age=86400, immutable")
.send(createReadStream(cachedPath));
}
if (!sofficeAvailable()) {
return reply
.status(422)
.send({ error: "LibreOffice is not available for document preview" });
}
await ensurePreviewDir();
const inputPath = getStoredFilePath(file.storedName);
// Copy to a temp file with the original extension so LibreOffice
// can detect the format correctly from the extension.
// Restrict to an alphanumeric extension (no path separators) -- the
// original filename is user-controlled and feeds a filesystem path.
const origExt = file.originalName.match(/\.[a-zA-Z0-9]+$/)?.[0] ?? "";
const tempInput = resolveWithinPreviewDir(`${id}-input${origExt}`);
await copyFile(inputPath, tempInput);
try {
await convertDocument(tempInput, previewDirPath(), "pdf", {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
// convertDocument outputs next to the temp file; rename to cached path
const producedPath = resolveWithinPreviewDir(`${id}-input.pdf`);
await rename(producedPath, cachedPath);
} catch (err) {
request.log.error({ err, fileId: id }, "Document preview generation failed");
return reply.status(422).send({ error: "Could not generate document preview" });
} finally {
// Clean up temp input copy
await rm(tempInput, { force: true }).catch(() => {});
}
return reply
.header("Content-Type", "application/pdf")
.header("Cache-Control", "public, max-age=86400, immutable")
.send(createReadStream(cachedPath));
}
// Video / Audio preview via FFmpeg
const previewExt = isVideo ? ".mp4" : ".mp3";
const contentType = isVideo ? "video/mp4" : "audio/mpeg";
const cachedPath = previewPath(id, previewExt);
// Serve from cache if available
if (await fileExists(cachedPath)) {
return reply
.header("Content-Type", contentType)
.header("Cache-Control", "public, max-age=86400, immutable")
.send(createReadStream(cachedPath));
}
// Generate preview via FFmpeg
await ensurePreviewDir();
const inputPath = getStoredFilePath(file.storedName);
try {
if (isVideo) {
await runFfmpeg([
"-i",
inputPath,
"-t",
"30",
"-vf",
"scale='min(720,iw)':-2",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"28",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-y",
cachedPath,
]);
} else {
await runFfmpeg([
"-i",
inputPath,
"-t",
"60",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
"-y",
cachedPath,
]);
}
} catch (err) {
request.log.error({ err, fileId: id }, "Preview generation failed");
return reply.status(422).send({ error: "Could not generate preview" });
}
return reply
.header("Content-Type", contentType)
.header("Cache-Control", "public, max-age=86400, immutable")
.send(createReadStream(cachedPath));
},
);
// ── On-demand preview for uploaded (non-stored) media files ─────
app.post(
"/api/v1/preview/generate",
{
config: { rateLimit: { max: 60, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
// Optional auth -- the preview is for the user's own uploaded file
getAuthUser(request);
const parts = request.parts();
let fileBuffer: Buffer | null = null;
let filename = "input";
for await (const part of parts) {
if (part.type !== "file") continue;
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = part.filename ?? "input";
break; // only process the first file
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No file provided" });
}
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const videoExts = new Set([
"avi",
"mkv",
"wmv",
"flv",
"mov",
"mpg",
"mpeg",
"m4v",
"3gp",
"3g2",
"ts",
"mts",
"m2ts",
"vob",
"divx",
"asf",
"rm",
"rmvb",
"f4v",
"ogv",
"mp4",
"webm",
"ogg",
]);
const audioExts = new Set([
"wav",
"flac",
"aac",
"wma",
"ogg",
"oga",
"opus",
"m4a",
"aiff",
"aif",
"amr",
"ape",
"ac3",
"dts",
"mp3",
]);
const isVideo = videoExts.has(ext);
const isAudio = audioExts.has(ext);
if (!isVideo && !isAudio) {
return reply.status(400).send({ error: "Unsupported file type for preview" });
}
const id = randomUUID();
const inputPath = join(tmpdir(), `snapotter-preview-${id}.${ext}`);
const outputExt = isVideo ? "mp4" : "mp3";
const outputPath = join(tmpdir(), `snapotter-preview-${id}-out.${outputExt}`);
try {
await writeFile(inputPath, fileBuffer);
if (isVideo) {
await runFfmpeg([
"-i",
inputPath,
"-t",
"30",
"-vf",
"scale='min(720,iw)':-2",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"28",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-y",
outputPath,
]);
} else {
await runFfmpeg([
"-i",
inputPath,
"-t",
"60",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
"-y",
outputPath,
]);
}
const outputBuffer = await readFile(outputPath);
const contentType = isVideo ? "video/mp4" : "audio/mpeg";
return reply
.header("Content-Type", contentType)
.header("Content-Length", outputBuffer.length)
.send(outputBuffer);
} catch (err) {
request.log.error({ err, filename }, "On-demand preview generation failed");
return reply.status(422).send({ error: "Could not generate preview" });
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
},
);
app.log.info("File preview routes registered");
}