diff --git a/bun.lock b/bun.lock index 9ab148e..d24d92a 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "remark-rehype": "^11.1.2", "resend": "^6.12.2", "serwist": "^9.5.11", + "sharp": "^0.34.5", "web-haptics": "^0.0.6", }, "devDependencies": { diff --git a/lib/image-processing.ts b/lib/image-processing.ts new file mode 100644 index 0000000..f5ed3d3 --- /dev/null +++ b/lib/image-processing.ts @@ -0,0 +1,98 @@ +import sharp from "sharp" + +const MAX_WIDTH = 1920 +const MAX_HEIGHT = 1080 +const QUALITY_HIGH = 80 +const QUALITY_FLOOR = 75 +const MAX_SIZE_BYTES = 400 * 1024 // 400 KB +const FALLBACK_WIDTH = 1280 +const FALLBACK_HEIGHT = 720 + +const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"] + +// Magic byte signatures for file type validation +const MAGIC_BYTES: Record = { + "image/jpeg": [0xff, 0xd8, 0xff], + "image/png": [0x89, 0x50, 0x4e, 0x47], + "image/webp": [0x52, 0x49, 0x46, 0x46], // RIFF header (WEBP container) +} + +export function validateMagicBytes(buffer: Buffer, declaredMime: string): boolean { + const expected = MAGIC_BYTES[declaredMime] + if (!expected) return false + if (buffer.length < expected.length) return false + return expected.every((byte, i) => buffer[i] === byte) +} + +export function isAllowedMimeType(mimeType: string): boolean { + return ALLOWED_MIME_TYPES.includes(mimeType) +} + +export interface ProcessedImage { + buffer: Buffer + width: number + height: number + mimeType: string // Always "image/jpeg" after processing + size: number +} + +/** + * Process a screenshot for storage: + * 1. Validate format via magic bytes + * 2. Resize to max 1920×1080, maintain aspect ratio + * 3. Convert to progressive JPEG + * 4. If size > 400KB, reduce quality to 75 + * 5. If still > 400KB at quality 75, resize to 1280×720 and try again + * 6. Strip ALL EXIF data + */ +export async function processScreenshot( + inputBuffer: Buffer, + declaredMime: string, +): Promise { + if (!isAllowedMimeType(declaredMime)) { + throw new Error(`Invalid MIME type: ${declaredMime}`) + } + + if (!validateMagicBytes(inputBuffer, declaredMime)) { + throw new Error("File content does not match declared type") + } + + let pipeline = sharp(inputBuffer, { animated: false }) + .rotate() // Auto-rotate based on EXIF orientation + .resize(MAX_WIDTH, MAX_HEIGHT, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: QUALITY_HIGH, progressive: true, mozjpeg: true }) + // Metadata is stripped by default (no .withMetadata() / .keepMetadata() call) + + let outputBuffer = await pipeline.toBuffer() + let metadata = await sharp(outputBuffer).metadata() + + // If still over 400KB, reduce quality to floor + if (outputBuffer.length > MAX_SIZE_BYTES) { + pipeline = sharp(inputBuffer, { animated: false }) + .rotate() + .resize(MAX_WIDTH, MAX_HEIGHT, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: QUALITY_FLOOR, progressive: true, mozjpeg: true }) + + outputBuffer = await pipeline.toBuffer() + metadata = await sharp(outputBuffer).metadata() + } + + // If STILL over 400KB, downscale to 1280×720 + if (outputBuffer.length > MAX_SIZE_BYTES) { + pipeline = sharp(inputBuffer, { animated: false }) + .rotate() + .resize(FALLBACK_WIDTH, FALLBACK_HEIGHT, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: QUALITY_FLOOR, progressive: true, mozjpeg: true }) + + outputBuffer = await pipeline.toBuffer() + metadata = await sharp(outputBuffer).metadata() + } + + return { + buffer: outputBuffer, + width: metadata.width ?? FALLBACK_WIDTH, + height: metadata.height ?? FALLBACK_HEIGHT, + mimeType: "image/jpeg", + size: outputBuffer.length, + } +} \ No newline at end of file diff --git a/package.json b/package.json index 55bed21..a943c11 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "remark-rehype": "^11.1.2", "resend": "^6.12.2", "serwist": "^9.5.11", + "sharp": "^0.34.5", "web-haptics": "^0.0.6" }, "devDependencies": {