/** * Shared attachment content-type configuration. * * By default a curated set of image/document/text/media types are allowed. Set the * `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a * comma-separated list of MIME types or wildcard patterns to expand the * allowed set for routes that use this allowlist. * * Examples: * PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf * PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/* * * Supported pattern syntax: * - Exact types: "application/pdf" * - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*" */ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ "image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", "application/pdf", "application/zip", "text/markdown", "text/plain", "application/json", "text/csv", "text/html", "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "video/mp4", "video/webm", "video/quicktime", "video/x-m4v", ]; export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream"; export const SVG_CONTENT_TYPE = "image/svg+xml"; export const GENERIC_ATTACHMENT_CONTENT_TYPES: readonly string[] = [ "application/octet-stream", "binary/octet-stream", "application/x-binary", ]; export const INLINE_ATTACHMENT_TYPES: readonly string[] = [ "image/*", "application/pdf", "text/plain", "text/markdown", "application/json", "text/csv", "video/mp4", "video/webm", "video/quicktime", "video/x-m4v", ]; /** * Parse a comma-separated list of MIME type patterns into a normalised array. * Returns the default image-only list when the input is empty or undefined. */ export function parseAllowedTypes(raw: string | undefined): string[] { if (!raw) return [...DEFAULT_ALLOWED_TYPES]; const parsed = raw .split(",") .map((s) => s.trim().toLowerCase()) .filter((s) => s.length > 0); return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES]; } /** * Check whether `contentType` matches any entry in `allowedPatterns`. * * Supports exact matches ("application/pdf") and wildcard / prefix * patterns ("image/*", "application/vnd.openxmlformats-officedocument.*"). */ export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean { const ct = contentType.toLowerCase(); return allowedPatterns.some((pattern) => { if (pattern === "*") return true; if (pattern.endsWith("/*") || pattern.endsWith(".*")) { return ct.startsWith(pattern.slice(0, -1)); } return ct === pattern; }); } export function normalizeContentType(contentType: string | null | undefined): string { const normalized = (contentType ?? "").trim().toLowerCase(); return normalized || DEFAULT_ATTACHMENT_CONTENT_TYPE; } export function inferOfficeAttachmentContentTypeFromFilename( filename: string | null | undefined, ): string | null { const lower = (filename ?? "").trim().toLowerCase(); if (lower.endsWith(".docx")) { return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; } if (lower.endsWith(".xlsx")) { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; } if (lower.endsWith(".pptx")) { return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; } if (lower.endsWith(".doc")) return "application/msword"; if (lower.endsWith(".xls")) return "application/vnd.ms-excel"; if (lower.endsWith(".ppt")) return "application/vnd.ms-powerpoint"; return null; } export function normalizeUploadAttachmentContentType(input: { contentType: string | null | undefined; originalFilename?: string | null; isAllowedContentType?: (contentType: string) => boolean; }): string { const normalized = normalizeContentType(input.contentType); if (!GENERIC_ATTACHMENT_CONTENT_TYPES.includes(normalized)) return normalized; const inferred = inferOfficeAttachmentContentTypeFromFilename(input.originalFilename); if (!inferred) return normalized; if (input.isAllowedContentType && !input.isAllowedContentType(inferred)) return normalized; return inferred; } export function isInlineAttachmentContentType(contentType: string): boolean { return matchesContentType(contentType, [...INLINE_ATTACHMENT_TYPES]); } // ---------- Module-level singletons read once at startup ---------- const allowedPatterns: string[] = parseAllowedTypes( process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES, ); /** Convenience wrapper using the process-level allowed list. */ export function isAllowedContentType(contentType: string): boolean { return matchesContentType(contentType, allowedPatterns); } /** * The one attachment size ceiling for this deployment. Every upload path — * assets, task attachments, cases, and company import — bounds itself by this * value, so an operator raises or lowers the limit in exactly one place. */ export const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; const ATTACHMENT_SIZE_UNITS: readonly string[] = ["KB", "MB", "GB"]; /** * Render a byte count the way a person reading an error message expects it: * 1024-based steps under the conventional consumer labels, at most one decimal * place, and no trailing ".0". The default cap renders as "10 MB" rather than * "10485760 bytes". Sub-kilobyte values stay in bytes so a tiny configured cap * does not collapse to "0 KB". */ export function formatAttachmentSize(bytes: number): string { // Defensive: the cap itself can never be negative or NaN (`Number(env) || default` // falls back on both), but never render "NaN bytes" at a user. if (!Number.isFinite(bytes) || bytes <= 0) return "0 bytes"; if (bytes < 1024) return bytes === 1 ? "1 byte" : `${bytes} bytes`; let value = bytes / 1024; let unitIndex = 0; while (value >= 1024 && unitIndex < ATTACHMENT_SIZE_UNITS.length - 1) { value /= 1024; unitIndex += 1; } // toFixed(1) then strip a trailing ".0": 10.5 -> "10.5", 10.0 -> "10". const rounded = value.toFixed(1).replace(/\.0$/, ""); return `${rounded} ${ATTACHMENT_SIZE_UNITS[unitIndex]}`; }