All files / api/routes attachments.ts

92.59% Statements 25/27
80% Branches 16/20
100% Functions 3/3
100% Lines 23/23

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74            357x   357x 13x 13x 13x                   13x   12x 13x 13x           13x 13x     13x 13x                 357x 4x 4x 4x 4x                   4x   3x 4x 4x               357x    
import type Database from "better-sqlite3-multiple-ciphers";
import { Hono } from "hono";
import { decompressBuffer } from "../../storage/compression.js";
import { parseIntParam } from "../validation.js";
 
export function attachmentRoutes(getDb: () => Database.Database): Hono {
	const api = new Hono();
 
	api.get("/:attachmentId", (c) => {
		const attachmentId = parseIntParam(c, "attachmentId", c.req.param("attachmentId"));
		Iif (attachmentId instanceof Response) return attachmentId;
		const attachment = getDb()
			.prepare(
				`SELECT a.filename, a.content_type, b.data
				FROM attachments a
				JOIN blobs.attachment_blobs b ON b.content_hash = a.content_hash
				WHERE a.id = ?`,
			)
			.get(attachmentId) as
			| { filename: string | null; content_type: string | null; data: Buffer | null }
			| undefined;
		if (!attachment) return c.json({ error: "Attachment not found" }, 404);
 
		const contentType = attachment.content_type ?? "application/octet-stream";
		const rawName = attachment.filename ?? "attachment";
		const safeName = rawName
			.replace(/[/\\]/g, "_")
			.replace(/["\r\n]/g, "")
			.replace(/[^\x20-\x7E]/g, "_");
		// RFC 5987: if the original name contains non-ASCII characters, include
		// filename*=UTF-8''<pct-encoded> so modern browsers use the real name.
		const hasNonAscii = /[^\x20-\x7E]/.test(rawName);
		const contentDisposition = hasNonAscii
			? `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(rawName)}`
			: `attachment; filename="${safeName}"`;
		const data = decompressBuffer(attachment.data);
		return new Response(data ? new Uint8Array(data) : null, {
			headers: {
				"Content-Type": contentType,
				"Content-Disposition": contentDisposition,
			},
		});
	});
 
	// Serve inline images by Content-ID (for cid: URL resolution in HTML emails)
	api.get("/by-cid/:messageId/:contentId", (c) => {
		const messageId = parseIntParam(c, "messageId", c.req.param("messageId"));
		Iif (messageId instanceof Response) return messageId;
		const contentId = c.req.param("contentId");
		const attachment = getDb()
			.prepare(
				`SELECT a.content_type, b.data
				FROM attachments a
				JOIN blobs.attachment_blobs b ON b.content_hash = a.content_hash
				WHERE a.message_id = ? AND a.content_id = ?`,
			)
			.get(messageId, contentId) as
			| { content_type: string | null; data: Buffer | null }
			| undefined;
		if (!attachment) return c.json({ error: "Attachment not found" }, 404);
 
		const contentType = attachment.content_type ?? "application/octet-stream";
		const data = decompressBuffer(attachment.data);
		return new Response(data ? new Uint8Array(data) : null, {
			headers: {
				"Content-Type": contentType,
				"Cache-Control": "private, max-age=86400",
			},
		});
	});
 
	return api;
}